From 73f3257ddca75404646d33cbfd3d22b8b7510d0d Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Mon, 7 Sep 2026 22:51:24 +0200 Subject: [PATCH] docs(agent): preserve pico2 spike design for review --- packages/agent/docs/pico2.md | 987 +++++++++++++++++++++++++++++++++++ 1 file changed, 987 insertions(+) create mode 100644 packages/agent/docs/pico2.md diff --git a/packages/agent/docs/pico2.md b/packages/agent/docs/pico2.md new file mode 100644 index 000000000..7e2132dff --- /dev/null +++ b/packages/agent/docs/pico2.md @@ -0,0 +1,987 @@ +# pico v2 + +A harness for running agents. This document is the design: read it and build it. + +Assumed knowledge: pi-ai (`Models`, `streamSimple`, tool definitions, thinking levels, deferred +handles, `AssistantMessage`/`UserMessage`/`ToolResultMessage`). Everything else is defined here. +Code is TypeScript; a snippet is a shape unless it says otherwise. Normative words: MUST, MUST NOT, +SHOULD, MAY. + +History: `pico.md` (v1) modelled a session as one resident tree of nodes; `pico-v2-tree-draft.md` +tried to page that tree; `pico-v2-folds-draft.md` replaced the tree with a transcript and used +"folds" to bound the model's context. This version keeps v1's driver, kinds, scratch, line, hooks, +vars and watch, and replaces both the tree and the folds with three separately-lived things and an +explicit context list. + +--- + +## 0. In one page + +A **session** is one **journal** (an append-only log of small records) and a set of +**conversations**. A conversation is three things: + +1. a **transcript**: an append-only list of immutable **entries** (a user message, an assistant + message, a tool result, a summary): what happened; +2. **tasks**: small mutable records with a status and code (a generation in progress, a tool + executing, a background job, a subagent, an approval waiting): what the driver runs; +3. **keyed values**: named settings and plugin state written as immutable entries at transcript + positions: what forks and rewinds read as of a position. + +The model does not read the transcript. It reads the conversation's **context**: a short list of +entry ids, edited by three ops (`append`, `replace`, `reset`), that always has the shape +"one head entry (a summary or a handoff), then selected entries in transcript order". + +A **kind** defines what an entry, a task or a value is and, for tasks, the code that runs, +recovers and aborts it. A **read model** (tables for SQLite; rebuilt in memory for JSONL) answers +"what is live", "what is this task now", "the newest value of key K before position P" and pages +the transcript, without replaying the journal. Memory holds each live conversation's context +entries and its live tasks; everything else is read on demand. A **subagent** is a task that +owns a conversation; the only nesting is `conversation ⊃ task ⊃ conversation`. Clients **watch** a +conversation and receive a transcript page, the context list, live tasks, and then journal records +as they commit. + +--- + +## 1. Definitions + +- **seq**: the position of a record in the journal; a positive integer assigned at append. Every + entry, task and conversation is identified by the seq of the journal record that created + it. There are no other ids. A caller-supplied idempotency id is a keyed attribute. +- **conversation**: an identity (its creation seq) with a mutable **conversation state** (§4.1), a + transcript, work, keyed values, and a context list. The **root** is the conversation with no parent. +- **entry**: an immutable transcript element (§2). Written once; never patched; never reordered. +- **task**: a mutable element with a lifecycle (§3): a generation, a tool execution, a job, a subagent, a collapse, an approval. Patched by `set`. +- **keyed value**: an entry with a `key`; read as of a position (§5). +- **transcript order**: the order of a conversation's entries by id (seq). There is no other order. +- **context**: the ordered list of entry ids the model reads (§6). Lives in the conversation state. +- **live**: a task whose status role is not `terminal` (§3.2). A conversation is live if it has live + tasks, a live owner task, or an attached watcher/handle. +- **the line**: the session's single command queue (§7). +- **scratch**: durable working storage per live task, off-journal (§3.5). +- **projection**: the messages the model receives, built from the context list (§6.4). +- **canEditContext** (§8.1): no generation in flight and no unresolved foreground calls; the precondition for context edits, forks, and `next()`. + +--- + +## 2. Transcript + +### 2.1 Entry + +```ts +interface Entry { + id: number; // seq of its journal record; also its transcript order + conv: number; // owning conversation + kind: string; // "user" | "assistant" | "tool_result" | "summary" | "value" | plugin content kinds + by?: number; // the task that wrote it, if any + callIndex?: number; // tool results: index of the call within the assistant message they answer + through?: number; // summaries: the last entry the summary replaced (§6.2) + key?: string; // keyed values (§5) + meta: JsonObject; // small resident fields: timestamp, preview, sizes +} +// the payload (the message, the summary text, the value) is in the journal record at `id` +``` + +Entries are immutable and the transcript is append-only: no journal record targets an existing +entry and nothing is inserted before an existing one. An entry is reconstructed by one addressed +read of its journal record. + +### 2.2 Content kinds + +```ts +defineContent({ kind, meta: M, payload: P, keyed?: true, context: "select" | "none" }, + { project(entry, ctx): Promise }) +``` +- `context`: whether inserting an entry of this kind also appends its id to the conversation's + context list (§6.1). Core: `user`, `assistant`, `tool_result` → `select`; `summary`, `value`, + `custom` → `none` (a summary enters the context only through the `ctx_replace` issued by the + command that creates it; a reset's bootstrap enters through `ctx_reset`). There is no per-entry override. +- `project`: what a selected entry contributes to the model's messages. May read the payload + (`ctx.payload(id)`) and nothing else. A selected entry MAY project nothing. + +--- + +## 3. Tasks + +### 3.1 Task + +```ts +interface Task { + id: number; // seq of its ins_task + conv: number; // owning conversation + kind: string; // "generation" | "tool" | "job" | "subagent" | "collapse" | "approval" | plugin work kinds + at: number; // transcript position (entry id) it started from + for?: number; // the task that spawned it (a tool → its generation) + status: string; + state: JsonObject; // small resident fields: attempt, model, call args, pid, produced, calls, … + owns?: number; // subagent: the conversation it owns +} +``` + +### 3.2 Statuses and roles + +| role | meaning | driver action when the task is not owned | +|---|---|---| +| `start` | not begun, or must begin again | run `effect` | +| `inflight` | someone was acting; unowned means interrupted | run `recover` | +| `waiting` | waiting on the outside world (a human, a process, a child conversation, time) | nothing (one `recover` per process, so a kind can re-arm) | +| `terminal` | finished | nothing | + +`transitions` lists the allowed next statuses per non-terminal status. A `start` status MAY carry +`state.notBefore`; the driver arms a timer. + +### 3.3 Task kinds + +```ts +defineTask({ kind, state: S, statuses: St, roles, transitions, initial, + concurrency: "parallel" | "exclusive", ownsConversation?: true }, + { effect, recover, abort, next? }) +``` +- `effect(task, ctx)`: MUST write an inflight status before any external action; MUST settle with + one command carrying everything that belongs together (produced entries, spawned tasks, context + appends). A task's result is an entry (a result message, a summary, a job's tail as a `value`). Only the external call is caught; storage/invariant failures propagate. +- `recover(task, ctx)`: the task is inflight and unowned. Idempotent; decides from scratch and the + kind's replay rule. +- `abort(task, ctx)`: MUST reach a terminal status and release external resources. An aborted + generation with calls MUST write error `tool_result` entries for them (§6.3), so an exchange is + always complete. +- `next?(conv, ctx)`: for kinds with `ownsConversation` (§8.2). +- `concurrency` per kind per conversation. Core: generation, collapse exclusive; tool parallel + (`toolExecution: "sequential"` makes it exclusive); job, subagent, approval parallel. + +Core statuses (role in parentheses): +`generation`: pending(start) streaming(inflight) retry_wait(start) deferred(start) polling(inflight) done aborted failed; +`tool`: planned(start) truncated(start) running(inflight) waiting(waiting) done aborted; +`collapse`: pending(start) summarizing(inflight) retry_wait(start) done failed; +`job`: planned(start) spawning(inflight) running(waiting) exited killed lost; +`subagent`: planned(start) running(waiting) done aborted failed; +`approval`: pending(start) waiting(waiting) granted denied cancelled. + +### 3.4 Context handed to kinds + +```ts +interface Ctx { + signal: AbortSignal; + conv: ConversationView; // §4.2, all reads async + set(id, patch: Partial & { status?: St }): Promise; + settle(id, patch: Partial & { status: St }): Promise; + commit(plan: (view) => Op[] | undefined): Promise; // several ops, one command + ops: { entry(kind, meta, payload?, opts?: { by?, callIndex?, through?, key? }): Op; + task(kind, state, opts?: { for?, at? }): Op; value(key, value): Op; + set(id, patch): Op; settle(id, patch): Op; + ctxAppend(ids: Ref[]): Op; ctxReplace(through: number, withRef: Ref, expectPrefixVersion: number): Op; ctxReset(ids: Ref[]): Op; + ref(opIndex: number): Ref }; // refer to an entry created earlier in the same command + scratch(id): Scratch; payload(id): Promise; + sleep(ms): Promise; emit(delta): Promise; + hooks; vars; models; tools; resources; retry; collapse; +} +``` +Writes happen only through `ctx`, and every write is a command on the line (§7). Ids are seqs; a +plan refers to records it creates in the same command by op index. + +### 3.5 Scratch + +Per live task: an append-only log plus a small key/value map, on disk beside the journal +(JSONL: one file per task; SQLite: a table), never in the journal, never replayed. The kind +declares `reduce(entries) → value` (a partial assistant message from frames; a tool's output so +far; a job's tail). Read by `recover`, `abort` and watch. Retired by settlement; orphans retired on +open. Appends are announced to watchers as ephemeral deltas (§9). + +--- + +## 4. Journal, conversation state, read model + +### 4.1 Journal records + +``` +ins_conv { parent?: {conv, at, asOf}, config, context: number[] } // at: parent entry id; asOf: parent seq +set_conv { id, patch } // inbox, abort, config, label +del_conv { id } +ins_entry { conv, kind, by?, callIndex?, through?, key?, meta, payload? } +ins_task { conv, kind, at, for?, state } +set { id, patch } // tasks only +settle { id, patch (status terminal) } // tasks only; a task's result is an entry +ctx_append { conv, ids } +ctx_replace { conv, through, with, expectPrefixVersion } +ctx_reset { conv, ids } +``` +JSONL: one line per **commit** (every record of one command; atomic; a torn tail is truncated on +open). SQLite: one row per journal record, one transaction per commit. Payloads live only in the +journal, addressed by seq. Nothing is rewritten. + +**Conversation state** (derived; in memory; in a table for SQLite): +```ts +interface ConversationState { + id: number; + parent?: { conv: number; at: number; asOf: number }; + config: { model, thinkingLevel, seedTools }; // sticky vars (§5) + inbox: InboxItem[]; // steer | followUp | nextRun, each with its message entry id + abort?: true; + label?: string; + context: number[]; // §6 + prefixVersion: number; // bumped by ctx_replace / ctx_reset only + steeringMode?, followUpMode?; +} +``` + +### 4.2 Read model + +Maintained by the append's transaction (SQLite) or rebuilt by one header-only scan of the journal +on open (JSONL; cost proportional to the file, accepted). Tables and the queries they serve: + +``` +entries(id, conv, kind, key, by, through, meta) index (conv, id); (conv, kind, key, id) +tasks(id, conv, kind, at, for, status, role, state, owns, last_seq) index (role) where role <> 'terminal'; (conv, kind); (owns) +conversations(id, parent_conv, parent_at, parent_asof, state) index (parent_conv) + +ConversationView (async, bounded): + live() → tasks where role <> 'terminal' (session-wide) + entry(id) / payload(id) / task(id) / conversation(id) + entries(conv, { before?: id, after?: id, limit }) → a transcript page in transcript order + lastEntry(conv) / lastEntryWhere(conv, pred, { limit }) + findValue(conv, key, { before: id, asOf?: seq }) → newest keyed value before a position (and seq) + find(conv, kind, key?, { limit }) + tasks(conv, { kind?, live?: true }) + conversations({ parent?: conv }) +``` +The read model is the **backend's**: SQLite keeps these tables on disk; memory and JSONL keep them +in memory and rebuild them by the open scan. The harness asks it questions (`ReadIndex`) and never +depends on how it is kept. The journal is the authority; if they disagree the read model is rebuilt. + +### 4.3 Backends + +- **SQLite**: journal + read-model tables in one file, one transaction per commit (WAL, + `synchronous = NORMAL`). Open = three queries. For long-lived sessions. +- **JSONL**: the journal (one line per commit) plus scratch sidecars and the usage ledger, nothing + else on disk; no index files. In memory it is the memory backend: records and read model, rebuilt + by one scan on open (torn last line truncated). For portable, inspectable, medium sessions; its + memory grows with the journal, by decision. +- **Memory**: tests. +Conformance: read model equals replay at every commit; pages/find/findValue equal a scan; scratch +survival and retirement; torn-tail tolerance. + +### 4.4 Memory + +```ts +conversations: Map // live conversations only +tasks: Map // live tasks, plus each conversation's newest generation (§8.1 reads it) +entries: Map // every id on a live conversation's context list +history: LRU // pages a UI asked for; bounded +``` +This is **residency** and it is the same on every backend; it is not the read model (§4.2) and +does not depend on how the backend indexes. After each commit the harness keeps exactly the entries +some context list names and the tasks that are live or a newest generation, and drops the rest; a +commit that puts an old entry on a list (a fork) loads it first. +Objects are values (a write yields a new object). All reads are async; a hit is a microtask, a +miss goes to the read model or the journal. Residency is proportional to (live conversations × +context size) + live tasks, independent of the journal's length on every backend; the process's +total memory additionally includes the backend's read model where the backend keeps it in memory +(memory, JSONL) and nothing else with SQLite. + +--- + +## 5. Keyed values (vars) and plugin state + +`declareVar(name, { rewind: boolean, inherit: boolean })`. +- `rewind: true`: written as a `value` entry (`key = name`, one bounded immutable value per write); + read by `findValue(conv, name, { before })`: one index read. A fork reads as of its `{at, asOf}`. + Examples: `plan.mode`; `activeTools` (written by the tool kind when a result adds tools). +- `rewind: false`: a field of `config`; latest wins; forks copy it. Examples: `model`, + `thinkingLevel`; `seedTools` seeds `activeTools`. +- `inherit`: default for seeding a child; the creating kind MAY override. + +Plugin state has two shapes and no third: keyed value entries (the default: a board is +`board.state` written whole on each change, found by `find(conv, kind, key)` without any lifecycle) +or a task for an actual lifecycle. A sequence (a list) is repeated entries under one key; +reading it as of a position is `find(conv, kind, key, { before, limit })`. Replicated plugin state +(§9.1) is a view over such entries. Scratch is for un-journaled live preview only. + +--- + +## 6. Context + +### 6.1 The list and its three ops + +`context: number[]` on the conversation state. Edited only by: +- `ctx_append { ids }`: emitted by the insert of a `select` kind, in the same command, in + transcript order. Never called directly. +- `ctx_replace { through, with, expectPrefixVersion }`: remove the prefix up to and including + entry `through`, put entry `with` (a summary) at the head. Rejected if `prefixVersion` moved. +- `ctx_reset { ids }`: replace the whole list with a bootstrap: `[]` (a user's `/clear`: waits for + input), a `user` entry written by the model's `new_context` tool (the handoff: the tail is a user + message, so generation continues), or a `summary`-kind note (text without a trigger). + +Both `replace` and `reset` bump `prefixVersion`; `append` does not. Consequently the list always has +the shape **[head?, then selected entries in transcript order]**, and a summarization that started +earlier survives appends (they land after `through`) but is rejected by a competing replace or +reset. Validation (§7.2) checks, on every `ctx_*` op, that every id exists in this conversation or +in the inherited prefix, that the resulting list has that shape, and that `through` does not split +an exchange: no `tool_result` after `through` may answer an assistant entry at or before it. +The context as of an earlier commit is reconstructed by replaying `ctx_*` records (§6.6). + +### 6.2 Summaries and handoffs + +A `summary` entry is appended by the `collapse` task and carries `through`. A handoff is a `user` +entry appended by the model's `new_context` tool and made the sole context by `ctx_reset`. Both +are ordinary transcript entries at their append position; their place *in the context* is the +head. UIs draw a summary immediately after its `through` (so retained turns follow it) and a +handoff at its own position (§9.2). + +### 6.3 Boundaries + +Context edits (`replace`, `reset`), forks and requests happen only when the conversation is +quiescent (§8.1), so a request never contains an assistant message whose tool results are still +pending. Settlements complete exchanges: an aborted or failed generation writes error results for +any calls it produced; a deferred generation has produced nothing yet. Timing alone does not make a +cut valid: `through` may not split an exchange (§6.1 validation), so a replacement never leaves a +tool result whose call was summarized away. A reset drops whole exchanges by construction. + +### 6.4 Projection + +``` +messages := for each id in conv.context: entries[id].kind.project(entry) (tool results ordered by callIndex within their message) + then before_request hooks edit the request +``` +Nothing else. Projections never write. + +### 6.5 Collapse policy + +`next()` starts a `collapse` task when the last generation's usage exceeds `threshold × contextWindow` +and more than `keepRecent` turns are on the list; also on provider overflow (the generation parks +in `retry_wait`; the collapse runs first; the generation retries once); also manually. The decision +(`before_collapse` hooks) is taken before the task is inserted; declined publishes nothing. The +task captures `through` (the cut, on a user boundary before the kept tail) and `prefixVersion`, +summarizes the projection of the prefix (which contains the previous summary, so summaries subsume), +and settles with `ins_entry summary {through} + ctx_replace`. Repeated compaction never accumulates +summaries. + +### 6.6 Context as of a commit + +The context as of a committed boundary `asOf` is reconstructed from the conversation's `ctx_*` +records: find the newest `ctx_replace` or `ctx_reset` with `seq ≤ asOf`; the list is its head +(`with` / `ids`) followed by every `ctx_append` id greater than `through` (for a replace) with +`seq ≤ asOf`, minus nothing (later replaces are by definition after `asOf`). Replay is therefore +bounded by compaction cadence, not by the session, and needs no checkpoint. The read model indexes +`ctx_*` by `(conv, seq)`; only `fork` (§8.4) and replicated-state hydration (§9.1) use this. +`asOf` MUST be the seq of a commit's last record; a seq inside a multi-record commit is rejected, so +a state that was never externally visible cannot be selected. + +--- + +## 7. The line + +One command queue per session. A command is `plan(view) → ops | undefined`: the plan reads (async +allowed), returns ops; the line loads the rows the ops target, validates (§7.2), appends the ops as +one commit, updates the read model, updates memory (§4.4), delivers to listeners, resolves the +caller. Nothing else writes. + +### 7.1 Guarantees +1. No two commands interleave; a plan sees the previous command's state. +2. A caller resolves after delivery; a listener sees the state its records describe; a listener + MUST NOT await a command on the same session inside its callback; a throwing listener is + reported as `handler_error` and does not affect the commit. +3. A settlement retires its record's scratch. + +### 7.2 Validation (rejects the whole commit) +- `ins_entry`: the conversation exists; `through` (if any) is an entry of it. +- `ctx_replace` / `ctx_reset`: the resulting list (§6.1) has the head+ordered-suffix shape; `through` + does not split an exchange; `expectPrefixVersion` matches. +- `ins_task`: the conversation exists; `at` is one of its entries; `for` is a task of it; + the status is `initial` or a `start` status. +- `set`/`settle`: target exists; keys are the kind's; a status change is an allowed transition; + `settle` reaches a terminal status; a terminal task accepts no patch. +- `ctx_*`: every id exists in this conversation or its inherited prefix. +- `del_conv`: every live task in the conversation (transitively through owned conversations) was + aborted in the same command. + +### 7.3 Faults +A failure after a plan's decision faults the session: later commands reject; the process should +exit and reopen. Rejections (§19) are not faults. + +--- + +## 8. Driver and conversations + +### 8.1 Loop + +``` +tick: + for each live task not owned: + role start → if concurrency allows in its conversation: own, run effect + role inflight → own, run recover + role waiting → nothing (once per process: recover, to re-arm) + for each live conversation with an abort marker: abort its live tasks (transitively), clear the marker + for each live conversation with canEditContext (§8.1): kind(conv).next(conv) + if nothing owned: no live tasks → "idle"; only waiting → "suspended" + await any completion | timer | commit | signal (events only wake the loop; every pass re-reads state) +``` +The live set is in memory; the tick scans nothing. One loop per session; `conv.drive()` is a join +handle that resolves when that conversation is quiescent (the driver emits `quiescent(conv)` after a +`next()` that changed nothing) or when the loop returns. + +Three facts about a conversation, each derivable from the live set and the conversation state, +decide scheduling; nothing is derived from "the last transcript entry": +- `canEditContext`: no `generation` of this conversation is in flight (`streaming`, `polling`) and + every task spawned by the newest generation (`state.calls`) is terminal. Context edits + (`replace`, `reset`), forks and rewinds require it. A generation in `pending`, `retry_wait` or + `deferred` does not block it (the overflow path edits the context while its generation waits). +- `hasPendingGeneration`: a `generation` of this conversation is live in any status (`pending`, + `streaming`, `retry_wait`, `deferred`, `polling`). While true, `next()` never creates another + generation, whatever the context's tail says (a deferred generation with `pollDeferred: false` + simply waits; the drive returns `suspended`). +- `needsGeneration`: `canEditContext && !hasPendingGeneration` and the conversation has an + **unconsumed continuation**: the context's tail is a user message or a tool result of a + generation without open calls, **and the run has not ended**. The run has ended when the newest + generation answers the current tail and settled `failed` or `aborted`, or one of its tool results + carries `terminate` or `aborted` (settlements write those on the entries: an assistant entry + carries `calls`, a tool result carries `answers`, `terminate`, `aborted`). Nothing stores "run + ended": a new user entry moves the tail past that generation, which is what a later `accept`, + steer, follow-up or `on_yield` continuation does. So a final failure does not re-trigger with a + fresh retry budget, and `terminate` ends the run even though the tail is a tool result. +Background tasks (jobs, older approvals) and keyed-value writes affect none of the three. + +### 8.2 `next(conv)` + +Reference behaviour for `session`, inherited by `subagent` and `fork`; called when +`canEditContext`; reads the tail of the **context list**, never the transcript: +1. abort marker → return. +2. steers in the inbox → insert as user entries (mode `all` | `one-at-a-time`); return. +3. `needsGeneration` → collapse decision (§6.5); else insert a `generation` + `{ at: , model, thinking, tools }` with tools from + `findValue(conv, "activeTools")` or `seedTools`. +4. the last selected entry is an assistant message of a settled generation without calls → + follow-ups in the inbox → insert as user entries; else `on_yield` hooks: `continue(message)` + inserts a user entry; else quiescent: a `subagent` settles its task with the last assistant + message as `result`; the root does nothing. +5. after a reset the tail is whatever the bootstrap was: a user handoff continues; an empty list + waits for the next `accept`. +6. `nextRun` items are consumed by the next `accept`. + +### 8.3 Subagents and jobs + +A subagent is a `subagent` task with `owns`: its effect creates the conversation (`ins_conv` with +`parent: {conv, at, asOf}`, config seeded per var flags, an empty or inherited context per +`context: "fresh" | "inherit"`) and inserts the prompt as the child's first user entry; it sits in +`running` while the child works; the child's `next()` settles it with `result`. A tool that starts +one either waits for the task to be terminal (foreground) and writes a result entry, or settles +at once with the task id (background). `abort` on a conversation reaches owned conversations. + +A job: effect `set spawning` → spawn or adopt a process whose output goes to a durable log file +owned by the job (`state.logPath`) → `set running {pid}`; output is tailed into scratch; exit → +`settle exited {code}` with the tail as payload. `recover`: pid alive → adopt by `logPath`; else +`settle lost`. `abort`: kill. Scheduled: the effect ends with `set { status: planned, notBefore }`; +a kind MAY model each run as its own task `for: job`. + +### 8.4 Forks and rewind + +A fork is a conversation with `parent: {conv, at, asOf}` and `context: "inherit"`. `asOf` is the +commit boundary that **contains** entry `at` (the seq of the last record of the commit that +inserted it), so everything that commit did is visible as a unit: a tool settlement that appended +a result and wrote `activeTools` in one command is inherited whole. At creation the fork copies the +parent's context as of `asOf` (§6.6), restricted to ids `≤ at` and trimmed to the last complete +exchange, and reads keyed values with `{ asOf }` (position is implied: a value written by that +commit or earlier is visible, a later one is not). Its projection is its own context list (the +copied head + its own appends); nothing about the parent's later edits reaches it. `rewind(conv, at)` += abort every task of `conv` with `at > at` (default; `keepRunning` opts out), then fork. +`/tree` = `conversations({ parent })`. + +### 8.5 Open, resume, close, delete + +- `open`: conversation states, live tasks, the entries on live conversations' context lists, scratch + of live tasks. Start nothing; report live tasks by role. +- `resume(opts)`: the loop. `pollDeferred`. +- `abort(conv)`: `set_conv {abort}`, drain steer/followUp (keep nextRun); the driver aborts live + tasks transitively and clears the marker. A no-op when nothing is live. +- `close()`: stop admitting commands; cancel in-process signals; live tasks stay live; jobs keep running. +- `delete(conv)`: report live tasks; on confirm, abort them, `del_conv`. + +--- + +## 9. Observation + +### 9.1 Watch + +`watch(conv, { limit })` runs on the line: it registers the listener and reads the base in one +step, so there is no gap. Base: +``` +{ asOf: seq, + conversation: ConversationState, // includes context and prefixVersion + entries: [ last `limit` transcript entries in transcript order, with payloads ], + tasks: [ live tasks ], + scratch: { [taskId]: reduced } } +``` +Then, in order, each with its seq: every journal record concerning the conversation (`ins_entry` +with payload, `ins_task`, `set`, `settle`, `set_conv`, `ctx_*`; owned conversations if requested), +and ephemeral deltas `{ frame | progress | kind-emitted, taskId }`, plus `quiescent` and `fault`. +Older pages: `entries(conv, { before, limit })`, each with its own `asOf`; the client ignores stream +records with `seq ≤ asOf` for entries it paged in. Remote clients RPC into the host, so the same +one-step base applies to them. + +**Replicated plugin state** (`ReplicatedState`) is a view over keyed entries with an adapter +contract: +- **Two sequences.** Hydration carries `{ journalThrough, stateSequence, ops }`: the journal + watermark (for catch-up and dedupe) and the state stream's own consecutive sequence (deltas since + the base), because Chord's replica requires consecutive updates and a journal filtered by key has + gaps. Each delta entry carries its `stateSequence`. +- **Read-modify-write on the line.** `state.update(fn)` is `line.commit(view => { const draft = + hydrate(view); fn(draft); return encode(draft) })`: the current value is read and the ops are + derived *inside* the planner, so two concurrent updates serialize instead of both computing from + the same base. Publication to the local replica happens on delivery; a failed commit publishes + nothing. Preparation done outside the line (a summarizer) is revalidated inside the planner. +- **Binding.** A replica is bound to one conversation and one history. Switching to a fork or a + rewound history is the same one-step operation as `watch` (§9.1): on the line, drop the old + binding, read the hydration, register for the new binding, with no commit in between. Deliveries + still arriving for the old binding are rejected by binding id. `stateSequence` is monotonic + within a binding across periodic bases; it restarts only with an explicit hydration. +- State values are byte-capped (one bounded immutable value per write); base cadence bounds hydration length. + +### 9.2 Rendering + +Transcript order, with one rule: a `summary` is drawn immediately after its `through` (at the top +of the loaded page if `through` isn't loaded); everything else where it is. The context list marks +what the model sees (dim the rest). +A client-side `toLaneSnapshot(base)` (transcript, streaming message from the generation's reduced +scratch, running tools from live tool tasks, queues from the inbox) serves renderers that want the +old shape. + +### 9.3 Events + +Subscriptions over journal records by kind and transition; no separate event vocabulary. + +--- + +## 10. Hooks + +Points and decisions: `before_request` (edit), `after_response` (observe), `before_tool` +(`allow | block(text) | hold`), `after_tool`, `on_yield` (`pass | continue(message)`), +`before_collapse` (`decline | {meta}`). `hooks.on(point, handler, { priority })`, ascending; first +`block`/`hold`/`decline` wins; first `continue` wins. Hooks own no state. + +--- + +## 11. Reference behaviours (informative) + +- **generation.effect**: `set streaming`; projection (§6.4) + `before_request`; if aborted return; + stream (frames → scratch, frame deltas); settle: overflow → `set retry_wait{overflow}` + collapse + decision; deferred → `set deferred{handle}`; error → `retry_wait`/`failed`; else one command: + `ins_entry assistant {by}` (+ `ctx_append`) + `settle done {produced, calls}` + one `ins_task tool + {for, at: produced, callIndex, args}` per call (`truncated` if the stop reason is not toolUse). +- **generation.recover**: polling → deferred; frames → settle aborted with the partial and error + results for its calls; none → retry or fail. +- **tool.effect**: truncated/missing → error result; `before_tool` unless `approved`: block → error + result, hold → `set waiting`; `set running`; execute with the `ToolSink` (scratch; caps; details, + usage, addTools, terminate, progress, memo); settle: `ins_entry tool_result {by, callIndex}` + (+ `ctx_append`) + `settle done|aborted` (+ `value activeTools` when tools were added). +- **tool.recover**: replay-safe → run again; else an interrupted result with the last checkpoint. +- **collapse.effect**: capture `through` and `prefixVersion`; `set summarizing`; summarize the + projection of the prefix; settle: `ins_entry summary {through}` + `ctx_replace {through, with, + expectPrefixVersion}` + `settle done` in one command; a version mismatch fails the record. +- **reset by a tool** (`new_context`): the tool's settlement is one command: its result entry + (transcript only), `settle done`, `ins_entry user {by}`, `ctx_reset [handoff]`. The + `canEditContext` precondition is evaluated against the state *after* the settlement in the same + command, so the tool's own task does not block it; if sibling calls of the same generation are + still unresolved, the tool instead records `state.pendingReset = { handoff }` on the + conversation and settles; `next()` applies the reset in the command that runs after the last + sibling settles. A user's `/clear` is `ctx_reset []` and requires `canEditContext`. + +--- + +## 12. Public API + +```ts +Harness.open(storage, { kinds, hooks, models, tools, resources, retry, collapse, toolExecution }) // starts nothing +harness.root() / conversation(id) / conversations() / inspect() +harness.watch(conv, { limit }, listener) / entries(conv, opts) / entry(id) / payload(id) / task(id) / find(conv, kind, key?) +harness.close() + +conv.accept(input, { id? }) → { entryId } +conv.drive({ pollDeferred? }) → "idle" | "suspended" +conv.prompt(input) → AssistantMessage | undefined +conv.result(entryId) → pending | settled { entryId, message } +conv.steer / followUp / nextRun / cancelQueued / abort / waitForIdle / runWhenIdle +conv.collapse({ instructions? }) / resetContext(bootstrapIds) / fork({ at, context, label?, summary? }) / rewind(at, { keepRunning? }) +conv.insert(kind, meta, payload?) / conv.task(kind, state) / conv.set(taskId, patch) +conv.setConfig(partial) / conv.state +harness.delete(conv, { confirm }) +vars.declare / get / set; hooks.on(point, handler, { priority }) +``` + +--- + +## 13. Invariants and tests + +1. The read model equals a replay at every commit, on every backend. +2. Every effect's first write is an inflight status; every settlement is one command. +3. Every non-terminal status has a driver action; an unknown status fails at open. +4. After `abort(conv)` and one drive, no live task remains under `conv` (transitively); scratch retired. +5. Delivery order and the listener rule (§7.1). +6. Projections never write; the driver never reads a payload. +7. Entries are never patched or reordered; a terminal task accepts no patch. +8. The context list is always `[head?, then entries in transcript order]`; every id on it exists; `through` never splits an exchange; `ctx_replace` with a stale `prefixVersion` rejects; a summarization survives appends and is rejected by a competing replace/reset; the context as of any commit boundary is reconstructible by replaying `ctx_*` records from the newest reset. +9. **Residency**: after open, memory holds live conversation states, live tasks, their scratch, and the entries on live conversations' context lists; nothing else. +10. **Bound**: resident entries = Σ context lists; resident tasks = |live| + one newest generation per conversation; independent of journal length on every backend. Measured at ≥100k turns with a cold reopen at every sample (on memory/JSONL the process heap also carries the backend's read model, by decision). +11. A fork's context never changes when its parent summarizes or resets later. +12. A task older than any summary survives a cold open, is recovered, settles, and is readable by id. +13. Watch has no gap: base and subscription are one line step; a paged-in entry never regresses a streamed update (`asOf`). + +Scenarios: the v1 suite re-expressed; the walkthroughs in §15; the adversarial list: rewind after +mutating an old keyed value; a plugin writing thousands of unselected values (memory flat); compact +repeatedly (one head, never a chain); an ancient job waiting across many summaries; fork before a +later parent summary; a reset while a summarization runs (summary rejected); page while updates +arrive (`asOf`); render a summary with a retained tail at `limit: 3` (§15.6). + +--- + +## 14. Contrast + +| | runtime/ (lanes) | dom/ and pico v1 | pico v2 | +|---|---|---|---| +| unit | lane over an entry branch | node tree | conversation = transcript + tasks + values, with a context list | +| in-flight state | one 13-leaf op record per lane | status on nodes | status on tasks; one live set | +| model context | scan back to the compaction entry | collapse node / folds | explicit list: head + suffix; three ops | +| memory | window per lane | whole tree (v1) | context entries + live tasks | +| history | branch index, `getEntry` | resident tree | transcript pages by id, `findValue`, `find` | +| subagents | child lanes | nodes | tasks owning conversations | +| plugin state | closures + custom entries | nodes | keyed value entries | +| rewind | move a cursor | fork node | fork with `{at, asOf}`, copied context, tasks aborted by `at` | +| storage | entries + branch index | ops + checkpoints | journal + read model; JSONL rebuilds by scan, SQLite keeps tables | + +--- + +## 15. Walkthroughs + +Notation: `E` entry, `T` task, `set`/`settle` patches, `ctx` context ops; seq = id; live set after +each step; `C.context` shown when it changes. `at` on a task = the entry it started from; `by` on an +entry = the task that wrote it; `for`/`calls` are the explicit dependencies. + +### 15.1 Prompt → generation with a tool call → settle + +``` + 1 E user C "add auth" live {} ctx [1] + 2 T gen C {pending, at:1} {2} + 3 set 2 {streaming} (frames → scratch(2); frame deltas) + 4 E assistant C msg(text + call echo) by:2 ctx [1,4] ┐ one commit + 5 settle 2 {done, produced:4, calls:[6]} {6} │ + 6 T tool C {planned, for:2, at:4, callId:x, name:echo} ┘ + 7 set 6 {running} (output → scratch(6); progress deltas) + 8 E tool_result C result by:6 callIndex:0 ctx [1,4,8] ┐ + 9 settle 6 {done, produced:8} {} ┘ +``` +Tick: C's newest generation (2) is terminal and its call (6) is terminal → quiescent → `next`: +last entry is a tool result of a generation without open calls → generation: +``` +10 T gen C {pending, at:8} … 12 E assistant C "done" by:10 ctx [1,4,8,12] 13 settle 10 {done, produced:12, calls:[]} +``` +`next`: settled generation without calls, no follow-ups, `on_yield` passes → `quiescent(C)`; +`prompt()` resolves; `result(1)` = entry 12. Projection for 10 was `[1, 4, 8]` by list; the live +set never exceeded one id; a crash after 7 reopens with `{6}` live and recovers it. + +### 15.2 Parallel calls, out of order, one blocked + +``` + 5 settle 2 {done, produced:4, calls:[6,7,8]} 6,7,8 T tool {planned, for:2} {6,7,8} + 9 E tool_result "write is not allowed in plan mode" by:8 callIndex:2 10 settle 8 {done, blocked} {6,7} (before_tool blocked it) +11 set 7 {running} 12 set 6 {running} +13 E tool_result by:6 callIndex:0 14 settle 6 {done} {7} +15 E tool_result by:7 callIndex:1 16 settle 7 {done} {} +``` +`C.context = [1, 4, 9, 13, 15]` (append order); projection orders the three results by `callIndex` +within message 4: 13, 15, 9. Quiescent after 16 → generation. Sequential mode: `tool` exclusive. + +### 15.3 Held call, released from a UI, crash in between + +``` + 6 T tool C {planned, for:2, name:deploy} 7 set 6 {waiting} {6} (before_tool → hold) +``` +Not quiescent (call 6 is not terminal) → no generation. Every watcher shows a waiting tool with its +call. Crash; reopen: live `{6}`, waiting → reported, not started. UI: `set 6 {planned, approved:true}` +→ effect skips the gate → runs → `E tool_result` + `settle done`. Abort instead: the driver aborts +6 → error result + `settle aborted`; the exchange is complete; the turn ends (an aborted call means +no new generation). + +### 15.4 Subagent, foreground and background + +``` + 6 T tool C {planned, for:2, name:subagent, args:{prompt}} 7 set 6 {running} {6} + 8 T subagent C {planned, for:6, at:4, prompt, context:"fresh"} {6,8} + 9 ins_conv S {parent:{conv:C, at:4, asOf:8}, config, context:[]} +10 set 8 {running, owns:S} +11 E user S "explore" by:8 S.context [11] +``` +S is live (its owner 8 is live) and quiescent with a user entry last → `next(S)` inserts generation +12 … which proceeds as §15.1 inside S: +``` +… 20 settle 12 {done, produced:19, calls:[]} S.context [11, 19] +21 settle 8 {done, result: } {6} +``` +Foreground: the tool effect was awaiting "8 terminal" → `22 E tool_result C "child said: …" by:6` + +`23 settle 6 {done}` → `C.context [1, 4, 22]`. Background: the tool settles right after 10 with +"started subagent 8"; 8 stays live on its own; a later `subagent_status` tool reads task 8 by id or +S's transcript. C's projection never contains S's transcript. `watch(S)` shows S. Abort S only: +`set_conv S {abort}` → 8 settles aborted → 6 (foreground) writes an error result. Crash while S's +generation streams: reopen → `{6, 8, 12}` live → 12 recovers (partial or retry), 6 re-awaits 8. +Nested: S's tool can start T; `conversations({parent})` lists C, S, T. + +### 15.5 Background job across a restart, consumed later + +``` + 6 T tool C {planned, for:2, name:bash, args:{cmd, background:true}} {6} + 7 T job C {planned, at:4, cmd, for:6} {6,7} + 8 set 7 {spawning} 9 set 7 {running, pid:4242, logPath} (log tail → scratch(7); progress deltas) +10 E tool_result C "started job 7" by:6 11 settle 6 {done} {7} C.context [1,4,10] +``` +Generation 2's turn is quiescent (its call is done); job 7 is live but not a dependency → `next` +proceeds. Twenty turns and one summary later (`ctx_replace` moved the head), job 7 is untouched: +tasks are never on the list. Crash; reopen: `{7}` running, unowned → `job.recover`: pid alive → +adopt by `logPath`; dead → `settle lost {tail}`. Exit: `95 settle 7 {exited, code:0} payload{tail}`. +Turn 30: `bash_result 7` reads task 7 by id (one row) and its payload; writes a result entry. Its +`at: 4` was folded long ago; nobody needed it. + +### 15.6 Collapse, speculative, subsuming, and the rendering at `limit: 3` + +Transcript `u1 a1 u2 a2` (ids 1..4), context `[1,2,3,4]`, budget exceeded, `keepRecent` keeps `u2 a2`: +``` +20 T collapse C {pending, through:2, prefixVersion:0} 21 set 20 {summarizing} + — meanwhile generation 22 runs and appends 23 (u3), 25 (a3): ctx [1,2,3,4,23,25] — +26 E summary C "…" through:2 by:20 (context: "none": not auto-appended) ┐ one commit +27 ctx_replace {through:2, with:26, expectPrefixVersion:0} → ctx [26,3,4,23,25] │ prefixVersion 1 +28 settle 20 {done, produced:26} ┘ +``` +A reset that had landed between 21 and 26 would have bumped `prefixVersion` and 27 would reject; +20 would settle `failed`. A second collapse later captures `through: 23` and summarizes the prefix +`[26, 3, 4, 23]`, which includes summary 26: the new summary subsumes it; the list stays one head +plus a suffix. + +Rendering: transcript order is `u1 a1 u2 a2 u3 a3 S` (S = 26 was appended last). A watch with +`limit: 3` receives `[u3, a3, S]`; S is drawn after its `through` (a1), which isn't loaded, so at the +top: `S u3 a3`. Paging `before: u3` returns `[u2, a2]`, drawn below S because their positions are +after a1: `S u2 a2 u3 a3`. Paging again returns `[u1, a1]`: `u1 a1 S u2 a2 u3 a3`. Pagination uses +transcript order as stored; only the summary's display position is relocated. + +### 15.7 Reset (Codex-style) + +The model calls `new_context` with a handoff note. If it is the generation's only unresolved call, +its settlement carries the reset (the precondition is checked against the post-settlement state); +otherwise the reset is recorded as pending and applied when the last sibling settles: +``` +40 E user C "" by: 41 ctx_reset {ids:[40]} ctx [40]; prefixVersion 2 + (the tail is a user message → the next generation projects [40]; a user /clear resets to [] and waits) +``` +Transcript unchanged; jobs unchanged; keyed values unchanged. The next generation projects `[40]` +plus subsequent appends. A watcher draws the handoff at its position and dims everything above it. + +### 15.8 Fork and rewind + +Fork at `a1` (entry 2) after §15.6 published: the fork copies C's context restricted to ids `≤ 2`: +`[26]`? No: 26 has `through: 2`, its head stands in for `1..2`; restricted to `≤ 2` that head is not +applicable (it was published after the fork point and the fork's `asOf` is earlier), so the fork +reads C's context **as of `asOf`** = `[1, 2]`, trims to the last complete exchange, and starts from +`[1, 2]`. Later parent summaries never reach it (invariant 11). `rewind(C, at: 2)` while tool 7 runs +under generation 2: abort work with `at > 2` (tool 7 → error result, exchange complete), then fork. +Keyed values in the fork: `findValue(F, key, { asOf })`, where `asOf` is the commit that inserted entry 2. + +### 15.9 Plugin state and replicated state + +Plan mode on at turn 12: `E value C key:"plan.mode" {on:true, file}` (unselected). `before_request` +at turn 20: `findValue(C, "plan.mode", { before })` → strips write tools. Fork at turn 10: same read +with `before: 10` → off. A todo board: `E value key:"todo.board" {items}` on each change; found by +`find(C, "value", "todo.board")`; no lifecycle, no residency. A canvas: `E value key:"canvas.base"` +at creation and every N strokes; `E value key:"canvas.delta"` (chord ops) per stroke batch; a new +client hydrates with the newest base before `asOf` plus the deltas after it, then applies stream +records; rewind to P reads the same with `before: P`. Un-journaled preview (cursors) is scratch on +the canvas's live task, if it has one. + +### 15.10 Open, in general + +Read conversation states; read live tasks (`role <> 'terminal'`); for each live conversation read +the entries on its context list (one batched read); read scratch of live tasks. Then `resume()`. +Cost is independent of the journal's length; JSONL pays a header scan first. + +--- + +# Part II — the complete contract + +Parts I (§0–§15) defined the model. This part specifies every surface an implementation needs to get +the edge cases right. Templates and skills are a layer above the harness and are not specified here. + +## 16. Tools + +```ts +interface Tool { + name: string; description: string; parameters: JsonSchema; + replay?: "safe" | "never"; // default "never": an interrupted execution is not re-run + output?: { retain?: "head" | "tail"; maxBytes?: number }; // default tail, 64 KiB + execute(args: JsonObject, signal: AbortSignal, out: ToolSink, resources: Resources): Promise; +} +interface ToolSink { + write(text: string): void; // appended to scratch; capped per `output`; truncation is marked in the result + details(json: JsonValue): void; // result details, replaced on repeat + usage(u: Usage): void; // recorded to the usage ledger at settlement + addTools(names: string[]): void; // becomes a `value activeTools` entry at settlement; unknown names are dropped + terminate(v: boolean): void; // the run stops after this exchange; the assistant's other calls still settle + progress(partial: JsonObject): Promise; // durable checkpoint in scratch; a `progress` delta to watchers; reported by recovery + memo: { get(key): Promise; set(key, v): Promise }; // durable per-execution memos, retired at settlement +} +``` +Rules. A tool's throw becomes an error result (`isError: true`, the message text); a throw after +`signal.aborted` becomes an aborted result. Only `execute` is inside the harness's try; a storage +failure while settling propagates (§7.3). Results are `tool_result` entries `{ callIndex, isError, +bytes }` with the capped text (`[truncated: N bytes total]` appended when capped), details, and +images. A call for a tool not in `tools` settles with `not found`; a `truncated` call (stop reason +other than toolUse) settles with `not executed` and never runs. `addTools` names not present in +`tools` are ignored. `terminate` ends the run after the current exchange: no new generation, the +turn is quiescent, `drive` returns `idle`. Concurrency: `tool` is parallel per conversation unless +`toolExecution: "sequential"`, in which case calls run in `callIndex` order and a `hold` blocks the +ones after it. + +## 17. Generation + +**Request**: `systemPrompt` (option, static or `() => string`) · messages = projection (§6.4) · +tools = the definitions of `activeTools ∩ tools` · `streamOptions` (option, merged with the call's) +· `reasoning` from `thinkingLevel` (`off` → none). `before_request` hooks run after `set streaming` +and before `streamSimple`; a hook that throws fails the generation (`failed`, `errorMessage`); the +abort marker is re-checked after the hooks and before the provider call. + +**Settlement classification**, in order: aborted (signal) → `aborted` with the partial from frames +and an error result per call; `stopReason: "deferred"` → `deferred {handle, poll: 0, notBefore}`; +`stopReason: "error"` and overflow (provider message matches the overflow patterns, and this attempt +is not already an overflow retry) → `retry_wait {overflow, notBefore: 0}` and a collapse decision; +if the collapse is declined or nothing can be folded → `failed`; `stopReason: "error"` otherwise → +`retry_wait {attempt+1, notBefore: now + base × 2^(attempt−1)}` while `attempt < maxAttempts`, else +`failed`; anything else → `done`: one command with the assistant entry (+ `ctx_append`), `settle +{produced, calls, usage, stop}`, one `tool` record per call (`truncated` if the stop reason is not +toolUse). Usage is recorded to the ledger keyed by the generation id. + +**Deferred**: `deferred` is a `start` status honoured only when `pollDeferred` is true; the effect +`set polling {poll+1}` → `fetchDeferred(handle)` → final message → the same classification; still +deferred → `set deferred {notBefore: now + pollAfterMs}`; fetch error → `deferred` with backoff. +`cancelDeferred(handle)` is attempted on abort (best effort). `pollDeferred: false` leaves such +generations alone and `drive` returns `suspended`. + +**Retry** applies to generation and collapse: `attempt` starts at 1; `retry.maxAttempts` bounds it; +`retry.baseDelayMs` is the base of the backoff; `retry_wait` is a `start` status with `notBefore`; +an abort during `retry_wait` settles `aborted` immediately (the timer is not waited out). + +**Recovery**: `streaming` unowned → frames present → `aborted` with the partial and error results +for any calls in it; none → `retry_wait {attempt+1}` or `failed`. `polling` unowned → `deferred`. + +## 18. Queues + +Inbox items: `{ id, kind: "steer" | "followUp" | "nextRun", entry }` where `entry` is the message +stored as an unselected `value`-like entry (`key: "inbox"`) so the text is journaled once. +- `accept(input)` on an idle conversation (`canEditContext && !hasPendingGeneration` and no + unanswered selected user entry; background tasks do not count): any `nextRun` items are inserted + as user entries first, then the prompt; the run starts. +- `accept` on a busy conversation queues a `followUp`. `steer(text)` queues a steer; `followUp` + and `nextRun` queue their kinds. All return the item id. +- `steeringMode` / `followUpMode` (`all` | `one-at-a-time`, per conversation, default `all`): + `next()` drains steers before a generation, follow-ups when the turn would otherwise end; in + `one-at-a-time` only the oldest is served per boundary. +- `cancelQueued(id)` → `cancelled` | `already_consumed` | `not_found`. +- `abort` drains steer and followUp items and returns them to the caller; `nextRun` items survive. +- `result(entryId)` = the first assistant entry after the input entry whose generation settled with + no calls; `pending` while the conversation has live tasks or the input is still queued; + `unknown` for an id that is neither. + +## 19. Outcomes, reports, errors + +- `drive(opts)` returns `idle` (no live tasks), `suspended` (only waiting tasks, or deferred with + `pollDeferred: false`), `closed`. Waiting for an outcome is a state predicate re-checked after + every commit and driver pass; events are wake-ups, never the source of truth. `conv.drive()` joins the session loop and resolves on `quiescent(conv)` or when the loop + returns; two callers joining the same conversation await the same condition. +- `inspect()` → `{ start: Task[], inflight: Task[], waiting: Task[] }` from the live set; `open()` + reports the same and starts nothing. +- Errors: `Closed` (command after `close`), `Faulted` (command after a fault; `.reason`), + `Rejected` with a code (`invariant`, `busy`, `unknown_target`, `version_mismatch`, `not_found`); + none of these is a fault. A listener that throws is reported through the `handler_error` signal. +- `close()` waits for in-process effects to observe cancellation (each `abort` signal fires; effects + return), does not settle anything, does not wait for jobs. + +## 20. Hooks, precisely + +| point | input | decision | combination | +|---|---|---|---| +| `before_request` | `{ conv, generation, request: { systemPrompt, messages, tools, streamOptions } }` | `request` | applied in order | +| `after_response` | `{ generation, message }` | — | all run | +| `before_tool` | `{ call: Task, args }` | `allow` \| `block(text)` \| `hold` | first block/hold wins; later hooks do not run | +| `after_tool` | `{ call, result }` | — | all run | +| `on_yield` | `{ conv, turn: Entry }` | `pass` \| `continue(message)` | first continue wins | +| `before_collapse` | `{ conv, through, prefixVersion }` | `decline` \| `{ meta }` | first decline wins; metas merged | +A hook that throws: `before_request` fails the generation; `before_tool` blocks with the error text; +`before_collapse` declines; `on_yield` passes; observers are reported as `handler_error` and ignored. Hooks are called by effects, outside the line, so a hook +MAY `await ctx.commit(...)` (append entries or keyed values, insert work); those commands interleave +with other actors under §24. A `before_request` hook affects *this* request only through the +request it returns; an entry it appends is projected from the next generation on. Only listeners +(watch/`on`) are forbidden from issuing commands inside their callback. + +## 21. Watch wire shapes + +``` +base { type: "base", asOf, conversation: ConversationState, entries: Entry&{payload}[], tasks: Task[], scratch: { [taskId]: JsonValue } } +record { type: "record", seq, record: ins_entry&{payload} | ins_task | set | settle | set_conv | ctx_append | ctx_replace | ctx_reset | ins_conv | del_conv } +delta { type: "delta", taskId, kind: "frame" | "progress" | string, payload } +signal { type: "quiescent", conv } | { type: "fault", message } | { type: "handler_error", taskId?, message } +page entries(conv, { before | after, limit }) → { asOf, entries: Entry&{payload}[] } +``` +Base and subscription are one line step; every record after the base has `seq > asOf`; a page has +its own `asOf` and records with `seq ≤ asOf` are ignored for its entries. `del_conv` on the watched +conversation ends the watch. A `ctx_replace`/`ctx_reset` record is the client's cue to dim or drop. + +## 22. Usage ledger and telemetry + +**Usage**: one row per settlement that carried usage (`generation`, `collapse`, `tool` via the +sink): `{ taskId, conv, seq, usage }`; append-only; queried by conversation or session; never +rewritten; summed by clients. Not in the journal. + +**Telemetry** uses pi's callback `TelemetryContext` (typed schemas, no second contract). Spans and +their parents follow the procedure nesting: +``` +harness.open · harness.drive (per pass) + conversation.next + task.effect / task.recover / task.abort (attributes: kind, taskId, status from→to, attempt) + ai.request (model, usage, stop reason, durations; never prompts or completions) + tool.execute (name, callIndex, bytes, isError; never args or results) + hook (point, name, decision) + line.command (op count, seqs, duration) ← parent: the command's caller + watch.deliver (listener count, duration) +``` +A pre-aborted signal starts no span. Attributes are ids, names, counts, durations, statuses and +usage; never message text, arguments, results, file contents, provider payloads, headers or handles. +Each command and each effect carries its own telemetry parent and abort signal; cancellation ends +only that caller's observation. + +## 23. Storage conformance + +Each backend passes: +1. `append` is atomic per commit and ordered; seqs are dense and monotone. +2. Read model equals a replay of the journal after every commit (entries, tasks, conversations, + context lists, `prefixVersion`). +3. `entries(conv, {before|after, limit})` equals a scan; ranks are correct; `findValue`/`find` + equal a scan. +4. Scratch: append/read/set/get per task id; survives a process crash; retired by settlement; + orphans removed on open; a crash between settlement and retirement is tolerated. +5. JSONL: one line per commit; a torn final line is truncated on open; rebuild-by-scan yields the + same read model as SQLite's tables for the same journal. +6. Usage rows survive and are queryable. +7. Open on an empty store creates the root conversation once; open on a non-empty store writes nothing. + +## 24. Race catalog (each has exactly two durable orders; tests assert both) + +| race | orders | +|---|---| +| `accept` vs `accept` on an idle conversation | first is the prompt; second queues as followUp | +| `accept` vs process loss before `drive` | entry absent → caller retries by id (idempotent); present → `drive` runs it | +| `drive` vs `drive` | both join one pass | +| `abort` vs generation settlement | marker first → `aborted` with partial and error results; settlement first → `done`; a later abort is a no-op (stale) | +| `abort` vs tool outcome | marker first → the effect's signal fires and it settles `aborted`; outcome first → `done`, result preserved | +| `abort` vs `retry_wait` | settles `aborted` without waiting the timer | +| `abort` vs collapse settlement | marker first → collapse `failed`, list unchanged; settlement first → list replaced | +| `abort` vs `on_yield` continuation | marker first → the continuation entry is not inserted; entry first → the run continues and abort settles it | +| `cancelQueued` vs boundary consumption | `cancelled` | `already_consumed` | +| `setConfig` vs generation start | old config or new; the generation records what it used | +| `nextRun` vs `accept` | captured by this prompt or stays for the next | +| collapse settlement vs `ctx_reset` | reset first → `version_mismatch`, collapse `failed`; settlement first → the reset applies to the new list | +| collapse settlement vs appends | appends survive either order | +| frame/progress write vs settlement | settlement awaits pending scratch writes, then retires scratch; a crash between leaves orphans for open | +| watcher registration vs commit | one line step: old base + all later records, or new base | +| `close` vs settlement | settlement committed before close, or the task stays inflight and recovers next open | +| fork vs parent summary | the fork copied its list as of `asOf`; the summary is not applied to it |