# pico Durable agent harness. One session file, any number of conversations, every piece of work is a task that survives a crash, and a view a UI renders directly. This guide shows how to use it. `pico-handoff-v2.md` is the implementation specification. - [Mental model](#mental-model) - [Quick start](#quick-start) - [Opening, closing, recovery](#opening-closing-recovery) - [Sending input](#sending-input) - [Configuration](#configuration) - [System prompt](#system-prompt) - [Watching](#watching) - [Forks, compaction, reset](#forks-compaction-reset) - [Plugin state](#plugin-state) - [Writing tools](#writing-tools) - [Hooks](#hooks) - [Writing entry kinds](#writing-entry-kinds) - [Writing task kinds](#writing-task-kinds) - [Subagents and jobs](#subagents-and-jobs) - [Storage backends](#storage-backends) ## Mental model A **session** holds **conversations**. A conversation has: - a **transcript**: append-only immutable **entries** (user, assistant, tool result, summary, notice, ...); - **tasks**: units of work (generate, run a tool, compact, run a process). Each survives a crash; - **state**: keyed values and lists (model in use, plan mode, whatever a plugin needs). The model **context** is derived from entries, not stored: the newest head entry says where context starts, edits omit or replace earlier messages. Compaction appends a summary with a head; nothing is rewritten. All writes go through **commits**: one callback, one atomic batch. Tasks run concurrently, commits serialize. Every async call takes a Chord `Context` last (`ctx`). It carries cancellation and telemetry. Cancelling a wait removes the waiter; it never cancels durable work. ## Quick start ```typescript import { Harness, JsonlStorage, systemSections } from '@earendil-works/pi-agent/pico'; import { builtinModels } from '@earendil-works/pi-ai/providers/all'; import { BACKGROUND_CONTEXT as ctx } from '@earendil-works/chord/context'; const storage = await JsonlStorage.open('./session.jsonl', ctx); const h = await Harness.open(storage, { models: builtinModels(), // no `tools`: the ordinary tool API is not settled yet (see Writing tools); this is a tool-free agent root: { // applied once, when the session file is new config: { model: { provider: 'anthropic', modelId: 'claude-opus-5' }, thinkingLevel: 'high', }, }, }, ctx); const c = await h.root(ctx); // System prompt: edit typed sections before each request. Stored when it changes, replayed after a restart. // Registered on this conversation only: subagents get their own identity (see Subagents). c.hook.systemInstructions(({ sections }) => { sections.set(systemSections.identity, 'You are a careful engineer working in this repository.'); sections.set(systemSections.environment, { cwd: process.cwd() }); }); // Watch: `w.view` is always current; each delivery is one commit's events. const w = await c.watch(ctx); w.start((d) => { if (d.type !== 'commit') return; for (const e of d.events) { if (e.type === 'task_output') process.stdout.write(renderStreaming(w.view.taskOutputs)); if (e.type === 'entry') console.log(`\n[${e.entry.kind} ${e.entry.id}]`); if (e.type === 'config' && e.key === 'model') statusLine.set(e.value); // typed: ModelRef | undefined } }); // Send: durable, returns at once. Wait for this input's answer. const input = await c.send({ requestId: 'cli-1', content: 'List the public API of the parser' }, ctx); const result = await input.wait(ctx); if (result.status === 'done' && result.answer) console.log(result.answer.message); // Change settings any time; the next request uses them. await c.config.model.set({ provider: 'openai', modelId: 'gpt-5.6' }, ctx); w.unsubscribe(); await h.close(ctx); ``` Kill it mid-run and run it again: the second run recovers the in-flight generation (or job) and continues; tools will recover the same way once their API lands. ## Opening, closing, recovery ```typescript const h = await Harness.open(storage, options, ctx); // inert: nothing runs yet await h.resume(ctx); // start/recover every eligible task ``` `send`, `wait`, `waitForIdle`, `collapse` and abort operations call `resume` for you. Reads, inspection and watch do not. After a crash, `open` + `resume` is the whole recovery procedure: | was running | on resume | |---|---| | generation | partial output is restored; request retried or failed within budget | | tool with `replay: 'safe'` | runs again (once the tool API lands) | | other tool | "interrupted" error result; the turn continues (once the tool API lands) | | tool waiting on a child | waits again on the same child (once the mediated child API lands) | | job | asks the process host for the run's status; if the host still knows it, continues; if not, reruns under the same key if `rerun` (possibly duplicating the effect), else `interrupted`. `nodeProcessHost()` forgets runs on restart | ```typescript const { pending, running, orphaned, unknownEntryKinds } = await h.inspect(ctx); ``` `orphaned` are live tasks whose kind was not registered at open; they were terminalized. Register all kinds before opening. ```typescript await h.close(ctx); // stop, write nothing; everything resumes next open await h.shutdown(ctx); // abort every live task cleanly, keep queued input, then close ``` Options: | option | | |---|---| | `models` | pi-ai models; `generationKind.config.model` is resolved against it | | `tools` | tool declarations the model may call; not usable yet, the execution API is gated (see Writing tools) | | `taskKinds`, `entryKinds`, `sections` | plugin kinds and system section definitions | | `root.config` | initial built-in config (`model`, `thinkingLevel`, `selectedTools`, ...) for a fresh session; ignored on reopen | | `root.values`, `root.sections`, `rootValues` | advanced: arbitrary initial values / section payloads for the fresh root | | `processHost` | how `pi.job` runs processes; `nodeProcessHost()` for local jobs (in-memory: forgets runs on restart); omitted means every job fails with reason `spawn` | Registries supply implementations, not selections: registering `bash` does not select it. Select through `c.config.selectedTools`. `h.tools`, `h.taskKinds`, `h.entryKinds`, `h.sections` have `register`/`replace`/`remove` for changes after open; replacing or removing a task kind with live tasks rejects. Hooks are registered on `h.hooks` or `c.hooks` (below), not in open options. ## Sending input ```typescript const input = await c.send({ content: 'Inspect the parser', requestId: 'req-1' }, ctx); input.id; // stable input id await input.result(ctx); // queued | placed | done | unanswered, no waiting const r = await input.wait(ctx); // done | unanswered await input.abort(ctx); // aborted | already_placed | not_found if (r.status === 'done') { r.input; // UserEntry: the placed user entry r.answer?.entry; // AssistantEntry, when an answer was owed r.answer?.message; // the stored AssistantMessage (entry.model[0]); text, thinking, tool calls, usage } else { r.reason; // terminated | aborted | failed | stale r.input; // present if the input had been placed before it went unanswered } ``` Outcomes come with their entries attached; there is nothing to look up afterwards. `message` is the message as stored, not the current context projection, so a later edit or compaction does not change what `wait` returned. `requestId` makes `send` idempotent for the session: retrying returns the original handle and writes nothing. `h.input(requestId, ctx)` finds it later. While the conversation is busy (a generation or tool is live), input is queued: ```typescript await c.send({ content: 'then write tests' }, ctx); // followUp: after the answer await c.send({ content: 'focus on the tokenizer', whenBusy: 'steer' }, ctx); // steer: at the next tool boundary await c.send({ content: '...', whenBusy: 'reject' }, ctx); // throws ConversationBusy ``` Steer joins the current turn after the running tools finish. FollowUp starts a new turn after the final answer. Both survive restarts. Passive content (a notice the model should see next time) uses `write`: ```typescript await c.commit((tx) => tx.write(noticeKind, { model: [{ role: 'user', content: 'CI finished: 3 failures', timestamp: Date.now() }], }), ctx); ``` `write` appends immediately if idle, otherwise queues for the next boundary. It never asks for an answer. There is no "queue this for the next run" mode. If you accumulate context that should not trigger a turn yet (observations, partial instructions), keep it in your own value or list and fold it into a single `send` when you do want an answer; use `write` only when a passive transcript entry is the point. ```typescript await c.waitForIdle(ctx); // this conversation's foreground work await h.waitForIdle(ctx); // whole session await c.abort(ctx); // abort this conversation's foreground cancellation set (turn, foreground tasks, owned subagents); withdraws queued steer/followUp await h.abortTask(id, ctx); // one task; queued input is kept ``` ## Configuration Built-in configuration is a set of typed properties on `c.config`, one per value, plus a batched setter: ```typescript await c.config.model.set({ provider: 'openai', modelId: 'gpt-5.6' }, ctx); await c.config.thinkingLevel.set('high', ctx); await c.config.selectedTools.set(['read', 'grep'], ctx); await c.config.followUpMode.set('all', ctx); const model = await c.config.model.get(ctx); // current (or the definition's default) const then = await c.config.model.get(entryId, ctx); // as of an entry (rewindable values only) const mode = await c.config.followUpMode.get(ctx); // sticky: current only // several at once, one commit; keys and value types are exact await c.config.set({ model: { provider: 'openai', modelId: 'gpt-5.6' }, thinkingLevel: 'low', followUpMode: 'all' }, ctx); const all = await c.config.get(ctx); ``` | property | type | default | rewindable | |---|---|---|---| | `model` | `ModelRef` (`{ provider, modelId }`) | none: generation fails `no_model` | yes | | `thinkingLevel` | `ThinkingLevel` (`off` … `xhigh`, `max`) | `'off'` | yes | | `selectedTools` | `string[]` | `[]` | yes | | `profile` | `string` | `'default'` | yes | | `retry` | pi-ai `RetryPolicy` | `{ enabled: true, maxRetries: 3, baseDelayMs: 2000, maxAgentDelayMs: 60000 }` | no | | `steeringMode`, `followUpMode` | `'all' \| 'one-at-a-time'` | `'one-at-a-time'` | no | | `threshold` | `number` (context tokens; 0 = never) | `0` | yes | | `keepRecent` | `number` (tokens kept after compaction) | `20000` | yes | Defaults live on the definitions and are what `get`, `view.config` and `config` events return when nothing is set; `delete` restores them. The types say so: `c.config.followUpMode.get(ctx)` is `'all' | 'one-at-a-time'`, never `undefined`; only `model` (no default) can be `undefined`. These are the same definitions the built-in tasks read (`generationKind.config.model` and friends). Each kind exports a typed bundle of reusable conversation-value definitions; `c.config` is assembled from all of them, so there is no second schema, and a name clash between kinds or with `set`/`get` is a compile error. The definitions carry no conversation ID; the handle binds them, and you can pass them anywhere a conversation value is accepted (`c.value(generationKind.config.model)`, `root.values`, child seeds). Plugin configuration is not in `c.config`; plugins declare their own values and use `c.value(token)`. Rewindable means a fork at yesterday's answer gets yesterday's model. Queue modes are sticky: they describe how you want the present handled, not history. The next generation reads current values; nothing is cached. ## System prompt Pico sends pi-ai `{ messages }` only. Instructions and the tool loadout are system messages inside the transcript at the position they took effect, so the model's cached prefix survives a change and a fork sees exactly what its source saw. Sections are typed tokens with a renderer. A `systemInstructions` hook edits a draft before each request: ```typescript import { defineSystemSection, systemSections } from '@earendil-works/pi-agent/pico'; import { generationKind } from '@earendil-works/pi-agent/pico/kinds'; const rules = defineSystemSection({ key: 'myplugin.rules', render: (r) => r.map((x) => `- ${x}`).join('\n'), }); await h.sections.register(rules, ctx); // host, harness-wide: sets the authoritative base every time, so it runs first h.hooks.on(generationKind, generationKind.hooks.systemInstructions, ({ sections }) => { sections.set(systemSections.identity, 'You are a coding assistant.'); sections.set(systemSections.skills, skillsCache.current); sections.set(rules, ['Run relevant tests.']); }); // plugin, on this conversation: runs after the host and modifies typed payloads, never parses prose c.hook.systemInstructions(({ sections }) => { sections.set(rules, [...(sections.get(rules) ?? []), 'Check migration safety.']); sections.wrap(rules, (text) => `Repository policy:\n${text}`); }); // rare: override the loadout for this request instead of c.config.selectedTools c.hook.systemInstructions(({ tools }) => ({ tools: tools.filter((t) => t.name !== 'bash') })); ``` Handlers run harness-wide first, then outer conversation to inner, on one draft; the plugin above therefore sees the host's base. Registration is synchronous and process-local (it returns an unsubscribe function); `h.sections.register` is asynchronous because the section registry is shared state. `get` returns a copy; `set` keeps an existing key's position; `delete` is explicit (null is a value); `wrap` transforms rendered text and is not stored. A throwing handler loses only its own edits. The tool loadout is `c.config.selectedTools` resolved against `h.tools`; a handler that returns `tools` overrides it for that request. After the hooks, the draft is compared to what was last stored. No change: nothing is written. A change: one `pi.system` entry with the changed sections' payload and rendered text, and a system message with `toolsRemoved`/`toolsAdded`. After a compaction or reset, the next request gets a fresh complete baseline. A plugin that disappears leaves its rendered text in place until someone deletes the section. Because handlers rerun on every preparation, a handler that appends to what it reads accumulates unless an earlier handler resets the base. Make the chain idempotent as a set. ## Watching ```typescript const w = await c.watch({ values: [planMode], lists: [moves] }, ctx); // or c.watch(ctx); === h.watchConversation(c.id, ...) w.view; // ConversationView, captured atomically with the subscription w.start((d) => { // one delivery per commit: { type: 'commit', first, last, events } | { type: 'closed', reason } if (d.type === 'commit') for (const e of d.events) { /* w.view is already updated */ } }); w.unsubscribe(); ``` ```typescript // abbreviated sketch; the exact readonly declarations are in the handoff, section 14 interface ConversationView { conversation: Conversation; entries: Entry[]; // the active transcript in order: everything from the newest head's boundary through now context: Id[]; // entry ids the model currently sees, derived from entries tasks: Task[]; // live tasks in this conversation taskOutputs: { id: Id; kind: string; value: OutputState }[]; // streaming assistant text, tool progress inbox: Element[]; config: { model?, thinkingLevel, selectedTools, profile, retry, steeringMode, followUpMode, threshold, keepRecent }; // defaults resolved: every key except model is present and never undefined values: { address: Value; value?: JsonValue }[]; // only what you asked for in { values: [...] } lists: { address: List; elements: Element[] }[]; // only what you asked for in { lists: [...] } readAt: Seq; } ``` A requested list is captured whole and then kept current by `list` events (`append`/`remove`/`clear` ops in commit order, already folded when the listener runs). Built-in config cannot be requested as a value; it is always in `view.config`. `view.config` has the same properties as `c.config`, so a status line reads `view.config.model` and a settings panel binds to it directly; nothing needs to be requested. `values` is for your own state (`planMode` above). Built-in config changes arrive as their own event, typed by key: ```typescript w.start((d) => { if (d.type !== 'commit') return; for (const e of d.events) { if (e.type === 'config') { // e.key narrows e.value / e.previous if (e.key === 'model') statusLine.setModel(e.value); // ModelRef | undefined if (e.key === 'selectedTools') toolbar.set(e.value); // readonly string[]: defaulted, never undefined } if (e.type === 'value') plugins.update(e.value.address, e.value.value); // your requested values if (e.type === 'list') board.render(w.view.lists); // ops already folded } }); ``` `c.config.set({ model, followUpMode })` yields two `config` events in the same delivery; `w.view.config` already reflects both when the listener runs. Read the initial state from `w.view.config`, then follow events. The listener is a wake-up: `w.view` is already updated when it runs. A renderer that only looks at the view is correct. `entries` is what the conversation is currently working with, in transcript order, not a fixed number of recent entries. It is captured once and then maintained incrementally: an ordinary entry appends; an entry that carries a `head` (summary, reset, handoff) first drops the entries before its retained boundary and then appends itself, all within the same delivery. A summary therefore sits after the entries it retains (the model sees it first; that is context projection, not the view). A reset or handoff points at itself, so it clears the list and becomes the first entry. The older transcript is still in storage and readable through `h.entries({ conversationId: c.id, limit: 50 }, ctx)` (newest first, paged) and `h.getEntry`, it just is not part of the live view. There is no window size to tune. | you want | look at | |---|---| | in a turn | `view.tasks` has a `pi.generation`/`pi.tool`/`pi.post_tools` | | streaming text | `view.taskOutputs` entry for the generation's `output.id` | | a tool running | `pi.tool` task; its `output` for progress | | retrying | generation `checkpoint.phase === 'retrying'` | | current model / tools / thinking | `view.config.model`, `.selectedTools`, `.thinkingLevel`; changes: `config` event with `key`/`value`/`previous` | | compacting | `pi.collapse` task present | | queued input | `view.inbox` | Overflow (listener too slow, 256 commits buffered) closes the watch with `{ type: 'closed', reason: 'overflow' }`. Open a new one; its view is the fresh truth. Remote UIs: ship `w.view` once, then every delivery. `applyConversationCommit(view, commit)` is exported and needs no kinds. ```typescript const sw = await h.watchSession({ values: [sessionName] }, ctx); // conversation list, session values ``` ### Plugin state in a UI Requested Pico values and lists (as in the watch above) are for the process that owns the Harness: the plugin's session-side facet, or a host renderer in the same process. Pico does not push plugin state to remote UIs. A plugin that has something to show elsewhere follows the Chord facet pattern used by `coding-agent/examples/plugins/pi-example-plugin` and the experimental `models-provider` / `transcript-provider` services: ```typescript // contract.ts: what the UI may see and call export interface PlanService { readonly state: ReplicatedState<{ active: boolean; steps: string[] }>; toggle(input: { active: boolean }, ctx: Context): Promise; } export const PlanService = defineService('myplugin.plan'); // session.ts: runs beside the Harness; Pico is the durable truth, the replicated state is a projection import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context'; export default defineFacet({ id: 'myplugin/session', setup(env) { const state = env.replicatedState({ active: false, steps: [] }); env.provide(PlanService, { state, async toggle({ active }, ctx) { await c.value(planMode).set(active, ctx); // durable state.state.active = active; state.publish(ctx); // live }, }); env.onActivate(async () => { // reopen: rebuild the projection from Pico state.state.active = (await c.value(planMode).get(BACKGROUND_CONTEXT)) ?? false; state.publish(BACKGROUND_CONTEXT); }); }, }); // tui.ts: never sees the Harness or addresses export default defineFacet({ id: 'myplugin/tui', setup(env) { const plan = env.use(PlanService); env.onActivate(() => env.own(plan.state.subscribe((value) => badge.set(value.active)))); // (value, context, delivery) => void }, }); ``` Built-in config is the exception and is automatic (`view.config`); everything else a UI should see is a service DTO the plugin defines. ## Forks, compaction, reset ```typescript const alt = await c.fork({ at: entryId }, ctx); // shares history through entryId; independent after const alt2 = await c.fork({ at: 'start' }, ctx); // empty transcript, same session const back = await c.fork({ at: earlier, abort: true }, ctx); // "go back": abort the source's turn first await alt.send({ content: 'try recursive descent instead' }, ctx); ``` Forks inherit rewindable state (model, tools) and the system messages visible at the fork point. They inherit no tasks and no queued input. Nothing in the source changes. ```typescript const taskId = await c.collapse({ instructions: 'keep API decisions verbatim' }, ctx); await h.getTask(taskId, ctx); ``` Manual compaction runs in the background while the model keeps working; it declines with `NothingToCollapse` if everything already fits in `keepRecent`. Threshold compaction is created by generation automatically when the context estimate crosses `threshold` and runs in the foreground (aborting the conversation aborts it too). Both append a `pi.summary` entry with a head; the transcript keeps everything. ```typescript await c.reset({ handoff: 'We settled on recursive descent; continue from the lexer.' }, ctx); await c.reset(ctx); // /clear ``` Reset appends a `pi.handoff` or `pi.reset` entry with `head: self`. If the conversation is busy it is queued for the next boundary; either way `reset` resolves once the request is durable, and the entry shows up in the watch when it lands. ## Plugin state ```typescript import { conversationValue, conversationList, sessionValue } from '@earendil-works/pi-agent/pico'; type Move = { readonly x: number; readonly y: number }; const planMode = conversationValue('plan.mode', { rewind: true }); const moves = conversationList('game.moves', { rewind: true }); const expanded = conversationValue('ui.expanded', { rewind: false }); const name = sessionValue('app.name'); await c.value(planMode).set(true, ctx); // c binds it to its own conversation await c.value(planMode).get(ctx); await c.value(planMode).get(entryId, ctx); // rewindable only const id = await c.list(moves).append({ x: 1, y: 2 }, ctx); await c.list(moves).read(entryId, ctx); await c.list(moves).remove(id, ctx); await h.value(name).set('parser work', ctx); planMode.bind(otherConversationId) // explicit Value when you need one ``` A conversation address is declared once and bound when used. It carries rewind policy and payload type. Your definitions work everywhere: `c.value`, commits, `root.values`, child seeds, watch `values`. Pico's built-in config tokens (`generationKind.config.model` and friends) are exported and work the same way. Pico's internals (inbox, input results, request receipts, task output) are not addressable through any generic handle; they surface only as `view.inbox`, `InputHandle` and `view.taskOutputs`, and you cannot define values in those namespaces. Anything that must land together goes in one commit (assumes `noteKind` and `reminderKind` are registered, see below; a kind object that is not the registered one is rejected as `StaleDefinition`): ```typescript const id = await c.commit(async (tx) => { const current = await tx.value(planMode).get(); // reads first tx.value(planMode).set(!current); // rewindable state before entries const note = await tx.write(noteKind, { data: { text: 'toggled' } }); tx.task(reminderKind, { background: true, input: { about: note, at: Date.now() + 3600_000 } }); return note; }, ctx); ``` Rules inside a commit: reads before the first write (`ReadAfterWrite` otherwise); rewindable writes before entry appends; no awaiting anything but `tx` reads; a throw discards everything. ## Writing tools **The ordinary tool API is not settled.** This is the one deliberate gap in Pico v1: what a tool's `execute` receives and how it streams progress is still being decided, so nothing in this section is a compile contract. What *is* settled: - A tool declares `name`, `description`, `parameters` (TypeBox schema), `replay: 'safe' | 'unsafe'` (may the call be rerun after a crash) and `output` bounds (`maxBytes`, `maxLines`, `retain`). - A tool returns a `ToolResult`: `content` (text/images the model reads), typed `details` for UIs, `isError`, `usage`, `diagnostics` (commentary about the call, rendered after the output), and optional `control`. - `control`: `{ terminate: true }` ends the turn after this exchange; `{ handoff: message }` ends it and resets context with that message (what `new_context` does); `{ addTools: [...] }` adds tools to `selectedTools` so the very next model request (normally the continuation right after this tool round) already offers them. - Pico enforces the output bounds, reports truncation, stores the exact model message in the result entry and the rest in its data, and streams the tool's live progress as the tool task's output (`view.taskOutputs`). - A throw becomes an `isError` result; it never faults the session. Tools cannot touch the transcript, the task or the inbox, and get no commit authority. - Not yet available: durable per-call memos, starting a background job from a tool, starting a subagent from a tool. Requirements are fixed (keyed and idempotent across a crash, cleanup policy recorded before the tool returns, a wait timeout never cancels the child, a job is a task and a subagent is a conversation) but the API shape is not. Until it is, the built-in `bash` cannot background and there is no `subagent` tool. Illustration only (the `execute` signature and the `tool` facade are the gated part): ```typescript // ILLUSTRATIVE, not the final API const params = Type.Object({ path: Type.String(), pattern: Type.Optional(Type.String()) }); export const countLines = { name: 'count_lines', description: 'Count lines in a file', parameters: params, replay: 'safe', output: { maxBytes: 64_000, maxLines: 200, retain: 'head' }, async execute(args /* Static */, tool /* gated facade */, ctx) { const lines = (await fs.readFile(args.path, 'utf8')).split('\n'); const matching = args.pattern ? lines.filter((l) => l.includes(args.pattern!)) : lines; return { content: [{ type: 'text', text: matching.join('\n') }], details: { lines: lines.length }, diagnostics: matching.length > 200 ? [{ severity: 'warn', message: `showing 200 of ${matching.length}` }] : [], }; }, }; ``` ## Hooks Hooks belong to the task kind that runs them; each kind declares its own typed points. The built-in points are flattened onto `c.hook`, one method each: `systemInstructions`, `beforeRequest`, `afterResponse`, `onYield`, `beforeTool`, `afterTool`, `afterTools`, `beforeCollapse`. Each is the generic `c.hooks.on(kind, kind.hooks.point, handler, { subtree })` with kind and point bound; `h.hooks.on(...)` registers harness-wide. Plugin kinds use the generic form with their own point tokens. Input, output and failure behaviour come from the point: ```typescript import { toolKind } from '@earendil-works/pi-agent/pico/kinds'; // only for the generic form // conversation-scoped, named c.hook.beforeTool(async ({ call }, { conversationId }, ctx) => { // a `block` from an earlier handler already stopped the chain if (call.name === 'write' && await c.config.profile.get(ctx) === 'plan') return { block: { reason: 'plan mode: no edits' } }; if (call.name === 'bash' && !(await ui.approve(call.arguments, ctx))) return { block: { reason: 'denied by user' } }; return { call: { ...call, arguments: normalize(call.arguments) } }; // may rewrite arguments }, { subtree: true }); // shared policy: applies to subagents this conversation owns too c.hook.afterTool(async ({ call, result }) => { metrics.record(call.name); }); c.hook.beforeRequest(async ({ request }) => ({ request: withTracing(request) })); c.hook.onYield(async ({ answer }, { conversationId }, ctx) => { if (await goalIncomplete(conversationId, ctx)) return { continue: 'The goal is not met yet; continue.' }; }); c.hook.beforeCollapse(async ({ reason, entries }) => { if (reason === 'manual' && entries.length < 10) return { decline: true }; }); // harness-wide, generic h.hooks.on(toolKind, toolKind.hooks.beforeTool, handler); // same as c.hook.beforeTool, for every conversation h.hooks.on(myKind, myKind.hooks.myPoint, handler); // your own kinds' points, by token ``` | kind | point | returns | several handlers | on throw | |---|---|---|---|---| | generation | `systemInstructions` | draft edits; `{ tools? }` | all run | its edits dropped, skipped | | generation | `beforeRequest` | `{ request? }` | chained: each output is shallow-merged into the input the next handler sees | skipped | | generation | `onYield` | `{ continue: string }` or nothing | first non-void answer wins | skipped | | generation | `afterResponse` | nothing | all run | reported | | tool | `beforeTool` | `{ call? }` or `{ block }` | chained (patch); `block` stops the chain | **blocks the tool** | | tool | `afterTool` | `{ result? }` | chained (patch) | skipped | | post_tools | `afterTools` | nothing | all run | reported | | collapse | `beforeCollapse` | `{ decline }` or `{ instructions?, summary? }` | first answer wins | skipped | Hooks run outside the commit line and may take as long as they like (a human approval is a hook that waits). They may run again after a crash; key external side effects. Cancellation always propagates. `onYield`'s `continue` appends a user entry and starts another generation for the same input; if the user typed something meanwhile, that wins and the yield decision is dropped. Your own task kinds declare points the same way (see below); `h.hooks.on(myKind, myKind.hooks.myPoint, ...)` then type-checks against your declaration. ## Writing entry kinds ```typescript import { defineEntry, type EntryBase, type EntryData, type ModelProjection } from '@earendil-works/pi-agent/pico'; type NoteEntry = EntryBase & EntryData<{ text: string }>; export const noteKind = defineEntry('myplugin.note'); await h.entryKinds.register(noteKind, ctx); // or pass it in Harness.open({ entryKinds: [noteKind] }) // Data-only: the model sees nothing. `write` resolves with its admission/input id, not the later entry id; // the committed entry appears in the watch. await c.commit((tx) => tx.write(noteKind, { data: { text: 'plan accepted' } }), ctx); type PinnedEntry = EntryBase & EntryData<{ text: string }> & ModelProjection; export const pinnedKind = defineEntry('myplugin.pinned'); await h.entryKinds.register(pinnedKind, ctx); const text = 'Always run the tests before claiming a fix works.'; await c.commit((tx) => tx.write(pinnedKind, { data: { text }, model: [{ role: 'user', content: `${text}`, timestamp: Date.now() }], }), ctx); // later, from a watch event or an entry scan: const entry = await h.getEntry(entryId, ctx); if (noteKind.is(entry)) console.log(entry.data.text); ``` An entry may also carry `head` (context starts here) and `edits` (omit/replace earlier messages). A missing plugin loses typed access, not context behaviour: facets are stored. ## Writing task kinds ```typescript import { defineTask } from '@earendil-works/pi-agent/pico'; type Input = { readonly about: Id; readonly at: number }; type Checkpoint = { readonly phase: 'firing'; readonly key: string }; export const reminderKind = defineTask()({ kind: 'myplugin.reminder', async execute(task, rt, ctx) { if (task.input.at > rt.now()) await rt.sleep(task.input.at, ctx); await rt.commit((tx) => tx.checkpoint({ phase: 'firing', key: `reminder:${task.id}` }), ctx); await notifications.fire(task.input.about, `reminder:${task.id}`, ctx); return () => ({ status: 'completed', result: { fired: true } }); }, async recover(task, rt, ctx) { if (!task.checkpoint) return reminderKind.execute(task, rt, ctx); // crashed before the checkpoint: nothing external happened // the checkpoint proves we intended to fire; the call is keyed, so firing again is safe if it did not land if (!(await notifications.check(task.checkpoint.key, ctx))) { await notifications.fire(task.input.about, task.checkpoint.key, ctx); } return () => ({ status: 'completed', result: { fired: true } }); }, async abort() { return () => null; }, }); await h.taskKinds.register(reminderKind, ctx); // fine for a fresh session. Once tasks of this kind can exist across restarts, // pass it in Harness.open({ taskKinds: [reminderKind] }) on every open: open orphans // live tasks whose kind is not registered *at open*, and nothing resurrects them const id = await c.commit((tx) => tx.task(reminderKind, { background: true, input: { about: entryId, at: Date.now() + 3600_000 } }), ctx); const task = await h.getTask(id, ctx); ``` The contract: 1. Pico marks the task `running` before calling `execute`. Commit a checkpoint before any external effect you would need to reconcile after a crash. 2. `execute`/`recover` do their effects, then return a closure. Pico runs the closure on the commit line; its writes, the outcome and scratch cleanup commit atomically. Do not perform effects in the closure. 3. `recover` is called instead of `execute` when a running task is found at open. Read `task.checkpoint` and decide. 4. On abort, the old invocation is signalled through `ctx` and its later writes rejected. `abort` then runs as a fresh call for cleanup and returns its own closure. 5. `after: [ids]` runs the task after those are terminal (any outcome). `rt.waitForTask(id, ctx)` observes. 6. Configuration lives in scoped values, not in task input. Task input is what recovery needs. 7. A task may `tx.write(...)` a passive entry (a notice) in any commit or in its terminal closure. It lands now if the conversation is idle, at the next boundary if busy. Tasks never append directly and never ask for an answer; that is `send`. A kind may declare hook points. It decides when to run them; handlers registered on `h.hooks`/`c.hooks` for this kind and point are invoked outside the line: ```typescript import { defineHookPoint } from '@earendil-works/pi-agent/pico'; export const deployKind = defineTask()({ kind: 'myplugin.deploy', hooks: { beforeDeploy: defineHookPoint<{ target: string }, { approve: false; reason: string } | { approve: true }>({ fold: 'first', onThrow: 'abort', }), }, async execute(task, rt, ctx) { const decision = await rt.hooks.run(deployKind.hooks.beforeDeploy, { target: task.input.target }, ctx); if (decision.threw || decision.output?.approve === false) return () => ({ status: 'failed', failure: { message: 'deployment declined' } }); ... }, ... }); await h.taskKinds.register(deployKind, ctx); h.hooks.on(deployKind, deployKind.hooks.beforeDeploy, async ({ target }, info, ctx) => (await ui.approve(`deploy to ${target}?`, ctx)) ? { approve: true } : { approve: false, reason: 'denied' }); ``` `fold` says how several handlers combine (`collect` all outputs, `first` non-void wins, `chain` shallow-merges each handler's patch into the input the next handler sees). `onThrow` says whether a throwing handler is skipped or stops the point. Kinds declaring `output` get `rt.output` (read/mutate/replace) whose deltas stream to watchers: ```typescript // abbreviated: recover/abort omitted type Progress = { pct: number; log: string[] }; const progressKind = defineTaskOutput('myplugin.progress'); defineTask<{ readonly steps: number }, never, { readonly done: true }, { readonly message: string }, null, Progress>()({ kind: 'myplugin.long', output: { kind: progressKind, initial: () => ({ pct: 0, log: [] }) }, async execute(task, rt, ctx) { await rt.output.mutate((o) => { o.pct = 50; o.log.push('halfway'); }, ctx); return () => ({ status: 'completed', result: { done: true } }); }, // ... }); ``` ## Subagents and jobs A subagent is a conversation owned by the task that created it. There is no subagent task and no subagent object; the model will get one tool, the API gets a conversation handle. The tool-side operation is not yet specified (see Writing tools). What is settled: a child never inherits the parent's `c.config` implicitly. Its creator seeds `model`, `selectedTools`, `profile` and any other definitions it wants, plus initial system section values, in the same commit that creates the child and accepts its first input, so the first generation already sees them. A child created without a first input is inert; register its hooks and set its config, then `send`. From the host a subagent is just another conversation: `h.conversation(id, ctx)`, `h.conversations({ owner, limit: 100 }, ctx)`. Its own `systemInstructions` hooks (registered on the child handle or inherited via `subtree`) give it its own identity. Aborting the owning tool call aborts it unless the (gated) child API created it with `abortWithTool: false`. A job is a process: ```typescript // Harness.open(storage, { ..., processHost: nodeProcessHost() }, ctx) -- without a host, jobs fail with reason 'spawn' const id = await c.commit((tx) => tx.task(jobKind, { background: true, input: { command: 'npm', args: ['run', 'dev'], cwd, notify: false, rerun: true }, }), ctx); await h.abortTask(id, ctx); // SIGTERM, then SIGKILL ``` `nodeProcessHost()` keeps runs in memory only: after a restart it no longer knows them, so a job either reruns (`rerun: true`, same key, possibly repeating the effect) or fails `interrupted`. A supervising host can do better; that is the host's contract, not Pico's. Its stdout/stderr tail (and how many bytes were dropped) is in its task output; `notify: true` appends a `pi.notice` when it finishes (per occurrence for a schedule) so the model sees it on its next request (which may be the continuation of the current turn). ## Storage backends ```typescript MemoryStorage.create() // tests await JsonlStorage.open('./session.jsonl', ctx) // local; one file plus live sidecars await SqliteStorage.open('./sessions.db', { session: 'parser' }, ctx) // many sessions, servers ``` Same behaviour everywhere. JSONL and SQLite take a cross-process lock; a second writer fails rather than racing.