diff --git a/packages/agent/docs/pico/pico-handoff.md b/packages/agent/docs/pico/pico-handoff.md deleted file mode 100644 index c013dc8f6..000000000 --- a/packages/agent/docs/pico/pico-handoff.md +++ /dev/null @@ -1,220 +0,0 @@ -# Handoff: outstanding pico decisions - -Remaining additions for `packages/agent/docs/pico/`, to reconcile with `pico-v3.md`, -`pico-usage-guide.md` and `pico-work.md` before implementation. Incorporated decisions and rejected -or superseded proposals are omitted. Two parts: harness additions, then presentation additions -that also require task provenance and typed preview access. - ---- - -# Part 1 — Harness - -## 1.1 Preview delivery is coalesced - -Scratch commits are never delayed, but `task_output` delivery to watchers is coalesced per task to -a frame interval (the rules in `docs/mobile-handoff/01-harness/04-tool-output/rate-limiting.md`), -so a fast stream produces one event per frame instead of one per token. Without this a TUI redraws -per token. - -Where: §9.4, next to the tracker paragraph. - -## 1.2 Hook handlers receive the task's scratch - -A handler that waits on a person (approval, a question) must be able to memoize its answer durably, -or a crash mid-wait re-asks and a crash after the answer asks again. Give handlers `scratch` and -the task id in their context, and state the pattern: read-then-set in one scratch commit, first -writer wins; a crash before the answer asks again, a crash after replays to the same answer; the -memo retires with the task. This replaces `plugins.md`'s `memoOnce` and invocation-memo API, and it -is the same shape the question extension needs. - -```typescript -h.hooks.on(toolKind, 'before_tool', async ({ toolName, args, scratch, taskId }, call) => { - if (toolName !== 'bash') return; - const decision = scratchValue<'allow' | 'deny'>('approval'); - let d = await scratch(sc => sc.value(decision).get(), call); - if (d === undefined) { - const answer = await approvals.ask(taskId, args, call); // keyed instance every presentation observes - d = await scratch(async sc => { - const stored = await sc.value(decision).get(); - if (stored !== undefined) return stored; - sc.value(decision).set(answer); - return answer; - }, call); - } - return d === 'allow' ? { args } : { block: { reason: 'denied by user' } }; -}); -``` - -Where: §8.7 and the guide's Hooks section. - -## 1.3 Budget ownership transfer - -The work plan already flags arbitrary tool-work adoption as unresolved. Give it its own package -when the ownership design is settled: adopting a non-delegating tool's in-flight work after the -budget expires needs an explicit transfer of effect and sink ownership before the source invocation -releases its slot. The job-first path ships first. - -Where: `pico-work.md`. - ---- - -# Part 2 — Presentation - -## 2.1 Task provenance is `byTaskId`, not `JobState.origin` - -`origin: { tool, task, callId }` is a kind-specific enum for something the harness knows -generically. Replace it with a field the `Tx` fills from the committing invocation: - -```typescript -interface Task { …; readonly byTaskId?: Id } // task whose commit created it; absent = created outside any task -``` - -A tool's job has `byTaskId` = the tool task, so a renderer reads that task, `toolKind.is(t)`, and -uses the tool's component. A schedule is a job with `every` in its own state; nothing else marks it. -Subagent tasks chain `byTaskId` up to the `run`/`spawn` tool. `Entry.byTaskId` already exists; this -makes tasks symmetric. Remove `origin` from `JobState`, from the bash example in §8.3 and from the -notice text (derive it from the creating task). - -Where: §5.1, §8.3, §8.6. - -## 2.2 `!cmd` is a client-owned task kind - -User-run bash is initiated by the UI, not by the model or a tool, so it is its own kind owned by -the coding agent (or its bash plugin), sharing the exec-into-scratch helper with `jobKind`. Its -settlement records the transcript write in the same commit as `settle`, so a client that dies in -between leaves nothing to repair. Use `tx.write`: it appends immediately when safe, otherwise queues -until the next turn boundary: - -```typescript -type UserBashInput = { cmd: string; cwd: string; includeInContext: boolean; limits?: ShellOutputLimits }; -type UserBashStates = UserBashInput & ( - | { status: 'planned' } - | { status: 'running' } - | { status: 'done'; exitCode: number } - | { status: 'killed' } - | { status: 'lost' } -); -function userBashInput(state: UserBashStates): UserBashInput { - return { cmd: state.cmd, cwd: state.cwd, includeInContext: state.includeInContext, - ...(state.limits === undefined ? {} : { limits: state.limits }) }; -} - -const userBashKind = defineTaskKind()({ - kind: 'pi.user_bash', - initialStatus: 'planned', - roles: { planned: 'start', running: 'inflight', done: 'terminal', killed: 'terminal', lost: 'terminal' }, - preview: { init: async scratch => (await scratch.value(output).get()) ?? emptyOutput() }, - - async execute(task, runtime, call) { - await runtime.commit(tx => tx.patch(task, 'running', userBashInput(task.state)), call); - const result = await execIntoScratch(runtime, task.state, call); // same helper as jobKind - const out = runtime.preview.state; - await runtime.commit(tx => { - tx.write(userBashKind.entry, { - data: { cmd: task.state.cmd, exitCode: result.exitCode, output: out }, - model: task.state.includeInContext ? [userBashMessage(task.state.cmd, out, result.exitCode)] : undefined, - }); - tx.settle(task, 'done', { ...userBashInput(task.state), exitCode: result.exitCode }); - }, call); - }, - async recover(task, runtime, call) { - await runtime.commit(tx => tx.settle(task, 'lost', userBashInput(task.state)), call); - }, - async abort(task, runtime, call) { - await runtime.commit(tx => tx.settle(task, 'killed', userBashInput(task.state)), call); - }, -}); - -// the "!" handler -await c.commit(tx => tx.task(userBashKind, { - background: true, state: { status: 'planned', cmd, cwd, includeInContext, limits }, -}), call); -// Escape → h.abortTask(id, call); the renderer's task_start / task_output / entry cases do the rest -``` - -This is the pattern for any client-owned "thing that runs and should appear in the transcript": a -kind, not a flag on a built-in. Use it as the guide's example of a client-defined task kind; it is -better than the reminder. - -Where: the guide's "Writing Kinds". - -## 2.3 Two rendering styles are first-class - -§9.4 presents `apply(view)` diffing as the way to render. It is one of two, and the other is the -coding agent's interactive mode unchanged: - -- **Event-driven**: `task_start` on a generation makes a streaming component; `task_output` updates - it from the view's preview; `task_start` on a tool makes a tool component; `task_end` retires it; - `entry` finalizes. Bookkeeping is one streaming component and one map, as today. -- **View-driven**: `apply(view)` diffing against what was last drawn, for clients that attach - mid-turn or resnapshot. - -The view is authoritative in both: it is what a client renders on attach, and it has been folded -before the listener runs. Rewrite the "The view is authoritative and the events are wake-ups" -paragraph accordingly, and lead the guide's Rendering section with the event switch. - -Mapping from `packages/coding-agent/src/modes/interactive/interactive-mode.ts` (`handleEvent`, -`renderSessionEntries`): - -| interactive mode | pico | -|---|---| -| `agent_start` / `turn_start` / `message_start(assistant)` | `task_start`, `generationKind.is(t)` | -| `message_update`, including tool components born from streaming tool calls (keyed by call id) | `task_output` on the generation; preview is the partial `AssistantMessage` | -| `message_end`; aborted/error → pending tools get error results, else `setArgsComplete` | `entry`, `assistantKind.is(e)` | -| `tool_execution_start` / `_update` / `_end` | `task_start` / `task_output` / `task_end` on the tool task; result via `entry`, `toolResultKind.is(e)`, keyed by `e.key` | -| `compaction_start` / `_end` | `task_start` / `task_end`, `collapseKind.is(t)`; re-render on the summary `entry` | -| `auto_retry_start` / `_end` | `task_update` to/from `retry_wait`; failure on `task_end` `failed` | -| `summarization_retry_*` | `task_update` on the collapse task | -| `agent_end` / `agent_settled` | after any `task_end`: no live foreground task in the view | -| `queue_update` | `inbox` | -| `thinking_level_changed` | `value`, `addr === generationKind.config.thinking` | -| `session_info_changed` | session watch `value` | -| `entry_appended(custom)` | `entry` of a plugin or unregistered kind | -| `bash_execution_update` | `task_output` on a `pi.user_bash` task (2.2) | - -One map more than today: tool task id → call id, because tool components are created while the -assistant message streams (by call id) and only later associated with their task. - -## 2.4 Typed preview access - -Renderers should not cast. The view offers a typed accessor witnessed by the kind, alongside the -raw map the reducer and the wire use: - -```typescript -interface ConversationView { - readonly previews: ReadonlyMap; // raw, kind-free - preview>( - kind: TaskKind, task: Id): P | undefined; // typed; undefined if missing or another kind -} -const msg = w.view.preview(generationKind, event.task); // AssistantMessage | undefined -const out = w.view.preview(toolKind, event.task); // ToolOutputState | undefined -``` - -`kind.is(task)` already narrows `task.state` in the `task_*` cases. - -Where: §9.4 and every example that currently casts. - -## 2.5 Renderer registry and layouts - -Rendering has two jobs and the client keeps them apart: - -- **Renderers**, one per kind, replaceable: `renderers.entry(kind, previous => …)` and - `renderers.task(kind, previous => ({ start, output?, update?, end }))`. Built-ins register the - same way, so a plugin replaces or wraps one by re-registering under the same kind and receiving - the previous renderer. An unregistered kind gets a generic fallback: model text or collapsed - data for entries, kind + status + preview for tasks. Renderers never parent components. -- **Layouts**, pure functions over the ordered list of rendered blocks: - `Layout = (blocks: Block[], view) => LayoutNode[]`, where a node is a block or a group - `{ key, label, collapsed, children }`. Applied in registration order; the client reconciles the - tree with the chat container by key and keeps open/closed state by key. - -Grouping tool calls per exchange and collapsing a run until its final answer are each about ten -lines of layout, and they compose. Hydration on attach and live rendering share one path: build -blocks from `view.entries` and `view.tasks`, run the layouts, reconcile. The non-rendering -concerns of today's handler (Escape-handler swaps for retry and compaction, the working indicator, -the post-summary re-render, shutdown checks) stay in the mode; a plugin does not get to hijack -Escape. - -This is presentation-side only, the contribution shape `plugins.md` already describes for tool -renderers, keyed by kind string on the wire and by kind object in code. It belongs in the guide's -Rendering section and in `pico-work.md` package 20 as the TUI's parity target. diff --git a/packages/agent/docs/pico/pico-rendering.md b/packages/agent/docs/pico/pico-rendering.md new file mode 100644 index 000000000..a759177bd --- /dev/null +++ b/packages/agent/docs/pico/pico-rendering.md @@ -0,0 +1,305 @@ +# Pico rendering + +This document defines how a presentation renders a replicated Pico conversation. It covers local and +remote clients, initial attachment, live streaming, terminal handoff, renderer registration and stable +component identity. The harness model remains defined by `pico-v3.md`; the examples and client-facing +surface belong in `pico-usage-guide.md`. + +The target is the directness of coding-agent interactive mode and `experimental/mini`, without their +hard-coded event vocabulary or duplicate hydration logic. + +## 1. The presentation owns a replica, not agent state + +A presentation owns one `ConversationView`. It does not read storage, parse commits or hold live task, +provider, tool or model objects. + +Opening a watch atomically captures the view and registers its listener. The harness buffers changes +until delivery starts. Each committed change is folded into the view before listeners observe its +events. An in-process client uses the harness-owned view; a remote client applies the same exported +kind-free reducer before notifying its UI. + +Delivery over one WebSocket or SSE connection is ordered and gap-free. If delivery fails or a bounded +queue overflows, that watch closes. Reconnection opens a new watch and receives a fresh view. There are +no cursors, epochs, acknowledgements, deduplication, replay requests or in-place resnapshot protocol. + +The view is authoritative. Events identify the affected portion so a live renderer can avoid scanning +the whole view; they are not a second source of presentation state. + +## 2. Local view access + +The raw collections remain serializable. A local wrapper adds indexed and typed access: + +```ts +interface ConversationView { + readonly conversation: Conversation; + readonly entries: readonly Entry[]; + readonly tasks: readonly Task[]; + readonly previews: ReadonlyMap; + readonly inbox: readonly Element[]; + readonly values: ReadonlyMap; + readonly context: readonly Id[]; + readonly faulted: boolean; + readonly readAt: Id; + + entry(id: Id): Entry | undefined; + task(id: Id): Task | undefined; + + entriesOf(kind: K): readonly EntryOf[]; + tasksOf(kind: K): readonly TaskOf[]; + + preview>( + kind: TaskKind, taskId: Id, + ): P | undefined; +} +``` + +`preview(kind, taskId)` returns `undefined` when the task is absent, has another kind or has no live +preview. Built-in and plugin renderers use it without casts. `task(id)` is sufficient to classify a +`task_output` event because the reducer has already folded that event before the listener runs. + +A remote wire view may remain arrays and maps encoded as JSON. The wrapper builds its indexes locally; +those indexes are not another replicated representation. + +## 3. Two rendering paths + +Both paths use the same registered renderers and component keys. They differ only in how much of the +view they visit. + +### 3.1 Hydration + +Hydration runs on first attachment and after opening a replacement watch. It walks the current entries +and live tasks and upserts their blocks: + +```ts +function hydrate(view: ConversationView): void { + for (const entry of view.entries) entryRenderers.render(entry, view); + for (const task of view.tasks) taskRenderers.render(task, view); + chrome.sync(view); +} +``` + +This is the view-driven path used by Mini. Existing blocks are reconciled by stable key, so a fresh +view need not tear down unchanged terminal components. + +### 3.2 Live events + +Normal streaming uses targeted event handling, as interactive mode does: + +```ts +function handle(event: ConversationEvent, view: ConversationView): void { + switch (event.type) { + case "entry": + entryRenderers.render(event.entry, view); + break; + case "task_start": + case "task_update": + taskRenderers.render(event.task, view); + break; + case "task_output": { + const task = view.task(event.task); + if (task) taskRenderers.render(task, view); + break; + } + case "task_end": + taskRenderers.end(event.task, view); + break; + case "inbox": + case "value": + case "context": + chrome.sync(view, event); + break; + case "fault": + case "closed": + chrome.connection(event); + break; + } +} +``` + +A streamed token therefore updates one task renderer. It does not rebuild every transcript block or +run every layout. A client that does not need the optimization may ignore the event payload and call +`hydrate(view)` after every notification; it remains correct. + +The renderer callbacks, not a mandatory full-tree diff, are the shared path between hydration and live +updates. + +## 4. Renderer registry + +Entry and task renderers are registered by kind. Registration is typed by the kind object even though +the wire and internal registry use its string token: + +```ts +renderers.entry(noteKind, previous => ({ + render(entry, host, view) { /* entry is NoteEntry */ }, +})); + +renderers.task(downloadKind, previous => ({ + render(task, preview, host, view) { /* typed task and DownloadPreview */ }, + end(task, host, view) { /* terminal typed task */ }, +})); +``` + +The exact component type belongs to the presentation package, not Pico. The common contract is: + +- `render` upserts components through the host using stable keys; +- `end` handles terminal live-state cleanup and does not need a retired preview; +- renderers do not directly parent components in the transcript container; +- registration may replace or wrap `previous`; +- built-ins and plugins use the same API; +- removing renderer code does not affect the replicated view. + +An unregistered entry kind uses a generic renderer: model text when present, otherwise collapsed +kind/data. An unregistered task kind uses kind, status and a collapsed raw preview when present. +Unknown kinds must remain visible and must not break hydration. + +The heterogeneous registry may erase renderer types internally. That cast or validation boundary is +owned once by the registry; renderer authors do not cast task state, entry data or previews. + +## 5. Renderer host + +The host owns component instances and placement. Renderers only describe or update blocks through it. +At minimum it maintains: + +```text +entry id -> settled transcript block +live task id -> live task block or region +tool-call id -> tool component +inbox item id -> queued-input component +``` + +It provides keyed `get`, `upsert`, `move`/`adopt` and `remove` operations. A renderer can update an +existing component without knowing which container currently parents it. The host preserves expansion, +selection and other local UI state while adopting or moving a block. + +No persistent `tool task id -> tool-call id` map is required. Every tool-task state variant carries +its call: + +- on `task_output`, `view.task(event.task).state.call.id` identifies the component; +- on `task_end`, the event's terminal task carries the same call; +- tool-result entries use their indexed call key. + +A renderer may cache expensive presentation-only products such as parsed Markdown. Such caches are +never authoritative and may be discarded on hydration. + +## 6. Stable identity and terminal handoff + +Every rendered object has a stable semantic key: + +| Object | Key | +| --- | --- | +| transcript entry | entry ID | +| live generation | generation task ID | +| assistant content block | generation task ID + content index | +| tool component | tool-call ID | +| live tool preview lookup | tool task ID | +| inbox item | list-element/input ID | +| compaction, job or plugin task | task ID | + +A component may change ownership without changing its semantic identity. + +### Assistant handoff + +A generation preview renders a partial assistant message under the generation task ID. The final +assistant entry has `byTaskId` equal to that generation task. Its renderer adopts the existing live +component, applies the immutable final message and then associates the settled transcript position +with the entry ID. It does not append a duplicate assistant component. + +If no matching live component exists, as on initial attachment, the entry renderer creates the same +settled component directly. + +### Tool handoff + +Tool-call components are born while the assistant preview streams and are keyed immediately by the +call ID. A later tool-task start marks that same component running. Tool output updates it from the +typed preview. The terminal tool-result entry updates the same call-ID component with immutable final +output. No phase creates a second tool component. + +### Other tasks + +A task that has no terminal entry removes or finalizes its live block in `end`. A task kind that +requires a durable transcript representation writes that entry in the same commit in which it +settles. The view is already folded to the whole commit before listeners run, so no client-visible idle +or missing-result state exists between those events. + +## 7. Task provenance required by rendering + +Task creation records generic provenance just as entry creation does: + +```ts +interface Task { + // ... + readonly byTaskId?: Id; +} +``` + +`ConversationTx` fills `byTaskId` from the invocation whose commit created the task; it is absent for +tasks created outside a task invocation. Task kinds do not write it themselves. + +This replaces kind-specific provenance such as `JobState.origin`. A job created by a tool has +`byTaskId` equal to the tool task. A renderer follows that task and narrows it with `toolKind.is` to +select the originating tool renderer. Nested delegated work follows the chain as needed. + +## 8. Structured previews + +A preview is the complete current render model for one live task, not a text delta. + +The generation preview is the full partial `AssistantMessage` assembled from +`AssistantMessageFrame`s. It retains ordered content blocks, text, thinking, signatures, partial tool +arguments, provider metadata, usage and diagnostics. `task_output.ops` only transports changes; the +renderer reads the already-updated message through `view.preview(generationKind, task.id)`. + +A tool preview is the complete current `ToolOutputState`: text or structured content, plugin details, +images, usage, delegation, handoff, added tools, termination, diagnostics and truncation metadata. Tool +renderers own plugin-specific interpretation. The generic host does not flatten it to text. + +Terminal rendering uses immutable entries or terminal task state because settlement retires the live +preview. + +## 9. Layouts are optional presentation policy + +Grouping tool calls, collapsing a run until its final answer and similar structures are useful, but a +general composable layout/reconciliation pipeline is not required by the Pico watch or renderer +contract. + +A presentation may add layouts above the keyed renderer host. Layouts must preserve block keys and +must not make preview delivery depend on rebuilding the complete transcript. They should initially run +only for structural changes unless a particular layout explicitly depends on live preview content. + +The first coding-agent client should reach feature parity with direct keyed placement. Add a general +layout API only after at least two concrete layouts demonstrate that a shared abstraction is smaller +than their local implementations. + +## 10. Client-owned visible work + +A client operation that runs over time and should survive in the transcript is an ordinary task kind, +not a presentation-only event stream. For example, `!cmd` is a `pi.user_bash` task owned by the coding +agent or bash plugin. It shares process-output helpers with jobs, publishes a typed preview, and writes +its terminal entry in the same commit as settlement. + +The presentation starts it with a command and then handles its generic `task_start`, `task_output`, +`task_end` and `entry` events. Escape calls `abortTask(id)`. Plugins use exactly the same pattern for +client-initiated work. + +## 11. Mapping from interactive mode + +| Interactive-mode event | Pico rendering | +| --- | --- | +| `agent_start` / `turn_start` / assistant `message_start` | generation `task_start` | +| `message_update` | generation `task_output`; read typed assistant preview | +| `message_end` | assistant `entry`; adopt by `byTaskId` | +| `tool_execution_start` | tool `task_start`; identify component by call ID | +| `tool_execution_update` | tool `task_output`; read typed tool preview | +| `tool_execution_end` | tool `task_end` plus tool-result `entry` | +| `compaction_start` / `_end` | collapse `task_start` / `task_end`; summary `entry` | +| retry events | generation or collapse `task_update` | +| `agent_end` / `agent_settled` | no live foreground task after the commit | +| `queue_update` | `inbox` | +| thinking/model change | typed configuration `value` | +| custom entry | registered entry renderer or generic fallback | +| `bash_execution_update` | `pi.user_bash` `task_output` | + +The generic vocabulary changes dispatch, not component behavior. Compared with interactive mode, Pico +removes the second independently authored hydration implementation. Compared with Mini, it replaces +hard-coded operation fields with typed task kinds and replaceable renderers while retaining the same +small keyed-component model. diff --git a/packages/agent/docs/pico/pico-simple-blockers.md b/packages/agent/docs/pico/pico-simple-blockers.md new file mode 100644 index 000000000..878de520f --- /dev/null +++ b/packages/agent/docs/pico/pico-simple-blockers.md @@ -0,0 +1,38 @@ +# Pico implementation state + +## Authority + +[`pico-simple-handoff.md`](pico-simple-handoff.md) is the sole normative implementation specification. +Read it completely before implementation. Other Pico documents and prototypes are historical inputs only. + +Astra reviewer `e5771f` reviewed the complete rewritten specification through multiple iterations and +returned explicit `APPROVED`. Keep the reviewer available for implementation follow-up. + +## Git state + +- Branch: `pico` +- Branch base when created: `f3c672245` +- No production implementation has started. +- The documentation preservation commit includes the pre-implementation Pico design cleanups and the + approved simple handoff. +- Implement and commit one small work package at a time for user review. + +## Next action + +Start WP1 from `pico-simple-handoff.md`: core types and kind witnesses under +`packages/agent/src/harness/pico/`, including compile-time tests. Delegate implementation with provider +`openai-codex`, model `gpt-5.6-sol`. Run the focused test and full `npm run check`, then use retained Astra +for review. Commit only WP1 files after the package boundary is approved. + +## Adopted core + +The harness owns `pending -> running -> terminal`. Task input is immutable typed JSON. A checkpoint is an +optional full replacement with `phase: string`. Execute/recover return typed terminal closures applied +atomically with outcome and scratch retirement. Cancellation is mark, revoke writes, signal, join, fresh +abort, then a restricted abort closure. + +`turn: true` is a trusted replaceable task-kind capability. Background is independent because speculative +manual collapse is background plus turn. Non-turn tasks use limited transactions and `accept`/`write`; +turn tasks may append model-affecting entries directly. Every missing live kind becomes orphaned at open. +Input groups belong to built-in generation/post_tools. V1 is job-first and excludes arbitrary unfinished +promise adoption. Tool/job output sharing remains gated. diff --git a/packages/agent/docs/pico/pico-simple-handoff.md b/packages/agent/docs/pico/pico-simple-handoff.md new file mode 100644 index 000000000..e048d7131 --- /dev/null +++ b/packages/agent/docs/pico/pico-simple-handoff.md @@ -0,0 +1,1880 @@ +# Pico v1 implementation specification + +This file is the sole normative implementation specification for the clean-room Pico harness under +`packages/agent/src/harness/pico/`. Other Pico documents and prototypes are historical inputs and test +inspiration only. An implementer must not need them to implement the work packages +marked ready here. + +The specification intentionally gates provider, tool/output/preview, hook-scratch and client integration +where their APIs are not settled. A gated package must not be implemented by guessing. The storage, +entry/context, task, transaction, admission, scheduling, recovery and cancellation foundation is fully +specified here. + +## 1. Required behavior + +Pico stores sessions containing conversations, immutable transcript entries, durable tasks and scoped +state. One process owns a session at a time. Task effects run concurrently; every mutation and scheduler +decision serializes on one commit line. + +A task is one recoverable async operation: + +1. Creation durably stores immutable input and status `pending`. +2. Reservation durably changes status to `running` before `execute` performs effects. +3. The task may atomically replace a full typed checkpoint while it runs. +4. A `running` task encountered by a later process runs `recover`, including when no checkpoint exists. +5. `execute` or `recover` completes effects and cleanup, then returns a terminal closure. +6. The harness invokes the closure once on the line and atomically stores its buffered writes, terminal + outcome and scratch retirement. +7. Durable cancellation marks the task, revokes normal writes, signals and joins the old invocation, + then starts a fresh `abort` invocation from immutable input and the latest checkpoint. +8. `abort` completes cleanup, then returns a restricted closure whose writes, aborted outcome and scratch + retirement commit atomically. + +Recoverability does not preserve a JavaScript stack and does not imply exactly-once external effects. +A crash after an unkeyed external action but before recording its identity/result remains uncertain. + +Required exclusions: no imports from another harness implementation; no worker pool, polling scheduler, +lease, effect gate, author-defined lifecycle graph, `patch`, `settle`, status epoch, workflow replay, +generator DSL, returned step plan or specialized core verbs such as `startShell`. + +## 2. Core data and identifiers + +All durable application payloads are strict JSON. + +```ts +type JsonPrimitive = null | boolean | number | string; +type JsonValue = JsonPrimitive | JsonObject | readonly JsonValue[]; +interface JsonObject { readonly [key: string]: JsonValue } + +type Id = number; +type Seq = number; +``` + +IDs and sequences are positive safe integers. One session sequence orders every mutation in every main +and scratch batch. A mutation that creates an object uses its sequence as that object's ID. There are no +reserved ranges and IDs are never reused. + +A transaction starts from committed `lastSeq`. Its first buffered mutation receives `lastSeq + 1`. +Rejected transactions consume no IDs. IDs may reference earlier mutations in the same batch and may be +returned only after the outer commit is durable. + +Mutable caller values are cloned at the API boundary before buffering. Storage owns another immutable +snapshot or an equivalently immutable representation. Mutating an object after passing it to Pico must +never alter buffered, committed or returned historical data. Reads return immutable values or owned +copies where the API promises mutation by the caller. + +`Call` is Chord context: + +```ts +import type { Context } from "@earendil-works/chord"; +export type Call = Context; +``` + +Every asynchronous public, runtime, storage-adapter, provider, environment, hook and wait operation takes +a required final `Call`. Synchronous transaction builder methods take no `Call`. + +## 3. Entries, conversations and context + +### 3.1 Stored records + +`Message`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `TextContent`, `ImageContent` and +`ToolCall` are provider-neutral pi-ai types. Current pi-ai has no `SystemMessage`; typed `pi.system` +model projection is reserved for the gated messages-only integration package. + +```ts +type ContextEdit = + | { readonly target: Id; readonly action: "omit" } + | { readonly target: Id; readonly action: "replace"; readonly messages: readonly Message[] }; + +interface EntryIdentity { + readonly id: Id; + readonly conversationId: Id; + readonly kind: string; + readonly byTaskId?: Id; +} + +interface EntryBase extends EntryIdentity { readonly key?: string } +interface EntryData { readonly data: D } +interface ModelProjection { readonly model: readonly M[] } +interface ContextHead { readonly head: Id } +interface ContextEdits { readonly edits: readonly ContextEdit[] } + +type Entry = EntryBase & Partial; + +type EntryInput = + Omit & + (E extends ContextHead ? { readonly head: Id | "self" } : { readonly head?: never }); + +interface DataEntryInput { + readonly key?: string; + readonly data?: D; + readonly model?: never; + readonly head?: never; + readonly edits?: never; +} + +interface EntryKind { + readonly kind: string; + is(entry: Entry | undefined): entry is E; +} + +interface Conversation { + readonly id: Id; + readonly parent?: { readonly conversationId: Id; readonly at: Id }; + readonly owner?: Id; +} +``` + +Entries are append-only and never patched, reordered or renumbered. `data`, `model`, `head` and `edits` +are stored composable facets. No kind callback runs while reading or deriving context. Unknown entry kinds +retain all generic behavior because the facets are materialized. + +`Entry.byTaskId` is set by Pico when an invocation appends the entry and is absent for a host append. +Tasks do not have a generic `byTaskId`; a task kind stores required provenance in its typed input. + +Built-in kind strings are `pi.user`, `pi.assistant`, `pi.tool_result`, `pi.system`, `pi.notice`, +`pi.summary`, `pi.handoff` and `pi.reset`. Ready foundation witnesses are: + +```ts +type UserEntry = EntryBase & { readonly model: readonly [UserMessage] }; +type AssistantEntry = EntryBase & { readonly model: readonly [AssistantMessage] }; +type ToolResultEntry = EntryBase & EntryData & { readonly model: readonly [ToolResultMessage] }; +type NoticeEntry = EntryBase & Partial & { readonly model: readonly [UserMessage] }; +type SummaryEntry = EntryBase & EntryData<{ readonly summarizedThrough: Id }> & + { readonly model: readonly [UserMessage] } & ContextHead; +type HandoffEntry = EntryBase & { readonly model: readonly [UserMessage] } & ContextHead; +type ResetEntry = EntryBase & ContextHead; +``` + +Each user, assistant, tool-result, notice, summary and handoff entry contains exactly one corresponding +message. Reset has no model. `pi.system` is reserved but has no ready typed model witness. Stored entry-kind +names are indexed so open can report unknown kinds without scanning payload history. + +### 3.2 Direct append validation + +A direct append validates against committed state plus prior buffered mutations: + +- The conversation exists, including one created earlier in the batch. +- `head: "self"` becomes the minted entry ID. +- A numeric head is a visible transcript entry at the append point. +- Let `P` be the newest visible prior head. A new head boundary cannot precede `P.head` in the logical + transcript. +- A head boundary cannot split a successful assistant/tool-result exchange. +- Every edit target is an earlier visible entry. An edit outside the selected range during later context + derivation is a no-op. +- Ordinary edits cannot target managed `pi.system` entries. The system-preparation operation is the only + authority that may append a complete fresh managed baseline and omission edits for superseded managed + entries in one batch. +- An assistant message is final-successful when `stopReason` is `"stop"`, `"length"` or `"toolUse"`. + `"pending"` and `"deferred"` are not appendable terminal assistant entries. `"error"` and `"aborted"` + may be stored for display but do not enter later model requests or create tool work. +- A successful assistant's tool-call IDs are unique. A tool-result entry key equals one visible call ID, + its single message repeats that call ID/name, has one result at most, and belongs to the same conversation. + Trusted turn authors remain responsible for creating the right tasks and input ownership. + +An exchange consists of one successful assistant entry containing tool calls and its tool-result entries. +A head may retain the assistant (boundary at or before it) or omit the complete exchange (boundary after +its last required result). It may not omit the assistant while retaining any result or retain only a +suffix of its result entries. While future results can still land in that conversation, no boundary after +an incomplete assistant is valid. A self-head may omit an entire incomplete exchange inherited through a +fork when the fork has no local result-producing task for it; source tasks/results are not inherited. + +### 3.3 Transcript versus model order + +The transcript preserves append chronology. Admission prevents ordinary model-visible writes from being +inserted into an active turn and relying on projection to repair arbitrary chronology. + +Projection performs only these deterministic transformations: + +1. Stored head placement. +2. Stored edit folding. +3. Tool-result placement in the assistant call order. +4. Request-local missing-result repair when a historical fork cuts a successful exchange before all + results. For each missing call, synthesize one `ToolResultMessage` with the original call ID + and name, `isError:true`, timestamp equal to the assistant timestamp, text + `"Tool result unavailable: history ends before this call completed."`, and details + `{ reason: "missing_after_fork" }`. The repair is request-local and is never stored. +5. Provider/model normalization in pi-ai. + +Speculative collapse is the deliberate exception. A summary head may append chronologically while another +turn runs. The head targets an old complete exchange boundary and projects before its retained tail, so it +cannot split the current exchange. + +### 3.4 Context derivation + +For a conversation and inclusive target entry `T`: + +```text +H = newest fork-visible entry at or before T with a stored head +from = transcript start when H is absent, otherwise H.head +range = fork-aware visible entries from `from` through T, inclusive +edits = for each target, the newest edit in range wins +entries = when H is absent: range + when H exists: H followed by range with every head entry removed +model = for each selected entry in order, omit/replace its projection using edits, + concatenate messages, then order/repair tool exchanges request-locally +``` + +An entry without `model` contributes no messages. A replacement changes the effective messages at the +target's position without changing its ID. A current-handle cache is disposable derived state. Historical +queries older than its cursor derive separately and never rewind the live cache. A request captures an +immutable selected-reference array and immutable effective replacements through a durable cutoff; later +entries, heads, edits and cache changes do not mutate it. + +### 3.5 Forks + +A fork creates a conversation with `parent = { conversationId, at }`. The target must be a visible entry +of the source; `"start"` means no inherited entries. Source entries keep their original IDs and owners. +A fork's logical transcript recursively includes each source prefix capped at the recorded fork point, +then its local entries. Later source changes are invisible. Tasks are never inherited. + +Any entry is a valid fork point. If it cuts a successful exchange, request projection supplies missing +results locally but creates or inherits no tasks. A fork link is history, not ownership. + +## 4. State and addresses + +```ts +type Scope = + | { readonly type: "session" } + | { readonly type: "conversation"; readonly conversationId: Id } + | { readonly type: "task"; readonly taskId: Id }; + +type Collection = "value" | "list"; +declare const addressType: unique symbol; + +interface Address { + readonly scope: Scope; + readonly namespace: string; + readonly key?: string; + readonly collection: Collection; + readonly rewind: boolean; + readonly [addressType]?: T; +} + +interface Value extends Address { readonly collection: "value" } +interface List extends Address { readonly collection: "list" } +interface Version { readonly seq: Seq; readonly value: T } +interface Element { readonly id: Id; readonly value: T } + +type StickyAddressOptions = { readonly key?: string; readonly rewind?: false }; +type ConversationAddressOptions = { readonly key?: string; readonly rewind: boolean }; +declare function valueAddress( + scope: Extract, namespace: string, + options?: StickyAddressOptions, +): Value; +declare function valueAddress( + scope: Extract, namespace: string, + options: ConversationAddressOptions, +): Value; +declare function listAddress( + scope: Extract, namespace: string, + options?: StickyAddressOptions, +): List; +declare function listAddress( + scope: Extract, namespace: string, + options: ConversationAddressOptions, +): List; +``` + +Addresses are fully bound. Session constructors require no ID; conversation constructors require a +conversation ID; task-scratch constructors require a task ID. Handles validate the bound ID and never +silently replace it. Session addresses are sticky and require `rewind:false`; task addresses are never +rewindable; conversation addresses explicitly select `rewind:true` or `false`. Ready packages compare addresses structurally by the tuple +`[scope type, scope id or null, collection, rewind, namespace, key-present, key or null]`. String wire +encoding is deferred to client integration. + +Values use complete replacement and deletion. Lists use intrinsic append/remove/clear operations. For a +rewindable value, deletion records durable absence. For a rewindable list, remove and clear hide elements +only at and after their own sequence, preserving earlier historical reads. Sticky state exposes current +contents; a backend may physically discard removed sticky elements. + +Historical `at` is an entry ID. Rewindable conversation lookup chooses the newest local mutation with +sequence `<= at`. If none exists and the conversation has a parent, recurse into the parent with +`min(at, parent.at)`. Lists combine inherited and local operations under the same caps. Scratch and sticky +historical reads reject. Forks inherit no scratch and inherit sticky conversation state only through an +explicit copy policy at creation. + +A rewindable conversation `getValue(..., at)` or `readList({at})` requires `at` to be a fork-visible entry +of that address's conversation; an unrelated/missing position rejects `InvalidHistoryPosition`. `undefined` +selects current state. + +Within one main transaction, every rewindable conversation value/list mutation must precede every entry +append. This makes state in the same batch visible at a fork through that entry while excluding later +state. Sticky/session state and task mutations may occur anywhere. A violating builder throws and the +whole transaction rolls back. + +Namespaces beginning `pi.` are protected. Public/plugin constructors reject them unless created with an +internal built-in authority. Managed system records and input/result/receipt state cannot be written via +generic `record`, value or list handles. + +Host transactions may use any nonprotected bound address. Normal and abort task transactions may use +session addresses and conversation addresses in the current task's conversation/owned subtree. A task +address is accepted only by that same task's `scratch` API; it is rejected by main transactions and by +another task. `TaskConversation.value/list` accepts session addresses and addresses bound to that handle's +conversation, and rejects every other conversation/task address. + +## 5. Task records and kinds + +```ts +interface TaskCheckpoint extends JsonObject { readonly phase: string } +type NoCheckpoint = never; + +type TaskOutcome = + | { readonly status: "completed"; readonly result: R } + | { readonly status: "failed"; readonly failure: F } + | { readonly status: "aborted"; readonly result: A } + | { readonly status: "orphaned" }; + +interface TaskBase { + readonly id: Id; + readonly conversationId: Id; + readonly kind: string; + readonly input: I; + readonly checkpoint?: C; + readonly after: readonly Id[]; + readonly background?: true; + readonly turn?: true; + readonly owns: readonly Id[]; + readonly abort?: true; +} + +type Task = TaskBase & ( + | { readonly status: "pending" | "running"; readonly outcome?: never } + | { readonly status: "terminal"; readonly outcome: TaskOutcome } +); + +type RunningTask = + TaskBase & { readonly status: "running"; readonly outcome?: never }; + +type Completion = + | { readonly status: "completed"; readonly result: R } + | { readonly status: "failed"; readonly failure: F }; +``` + +Input is immutable. A checkpoint is optional and every write replaces the complete checkpoint. Its +`phase` is a kind-defined tag that the generic scheduler never interprets. Result, failure and abort +payloads are kind-specific strict JSON. Structurally identical checkpoint types are intentionally +assignable in TypeScript; kind attribution is supplied by the current runtime, not a nominal durable +brand. Differently shaped checkpoints, partial checkpoints and another kind's incompatible shape reject. + +A task kind has five payload types and one literal capability: + +```ts +type Turn = false | true; + +declare const taskTypes: unique symbol; +interface TaskTypes { + readonly [taskTypes]?: { input: I; checkpoint: C; result: R; failure: F; aborted: A }; +} + +declare const taskKindBrand: unique symbol; +interface TaskKindBase { + readonly kind: string; + readonly turn: Turn; + readonly [taskKindBrand]: true; +} + +interface TaskKind + extends TaskKindBase, TaskTypes { + readonly turn: T; + execute(task: RunningTask, runtime: RuntimeFor, call: Call): + Promise>; + recover(task: RunningTask, runtime: RuntimeFor, call: Call): + Promise>; + abort(task: RunningTask, runtime: AbortRuntimeFor, call: Call): + Promise>; +} + +type NoExtra = + Actual & Record, never>; + +type ExactJsonInput = + Expected extends readonly JsonValue[] ? Actual : + Expected extends JsonObject + ? Actual & Record, never> + : Actual; + +type NonTurnKindDefinition = + Omit, "turn" | typeof taskKindBrand> & { readonly turn?: false }; + +type TurnKindDefinition = + Omit, typeof taskKindBrand>; + +interface TaskKindFactory { + >( + definition: NoExtra, D>, + ): D & TaskKind; + >( + definition: NoExtra, D>, + ): D & TaskKind; +} + +declare function defineTaskKind(): TaskKindFactory; +``` + +Only `defineTaskKind` constructs the private brand and installs a module-private erased invocation adapter; +registries store `TaskKindBase` tokens and resolve that adapter rather than invoking methods through an +existential generic type. The public adapter bridge uses `unknown` plus validated records internally, never +exported `any`. The helper preserves all payload witnesses and literal turn capability; omitted `turn` is +returned as `false`. `tx.task`, checkpoint writes and closures infer from that token. `NoExtra` is also applied to the +actual inferred task spec type, so visible extra top-level fields in literals, variables and spreads reject. +Deep structural exactness, casts and erased fields remain normal TypeScript limits. Wire/plugin RPC +boundaries validate strict JSON and optional schemas before calling trusted local APIs. + +`turn:true` is a trusted advanced declaration. It grants direct model-affecting entry authority and puts +live instances in the turn barrier. It does not prove membership in the built-in graph. Third-party kinds +may replace built-ins or construct custom turn graphs and are responsible for assistant/tool/input-group +semantics. The runtime still enforces generic entry, head, edit, ownership and transaction invariants. +`background` is independent: speculative manual collapse is `background + turn`; automatic collapse is +foreground + turn. + +Task-kind replacement while live tasks exist requires the replacement's materialized `turn` to equal every +live task's stored `turn`. Incompatible capability replacement rejects. With no live instance, a future +replacement may change capability for newly created tasks. Terminal history is not rewritten. Payload and +checkpoint compatibility beyond this runtime-checkable property is the replacement author's responsibility. + +## 6. Mutation algebra and storage + +### 6.1 Mutations + +```ts +type StateMutation = + | { readonly type: "value.set"; readonly address: Address; readonly value: JsonValue } + | { readonly type: "value.delete"; readonly address: Address } + | { readonly type: "list.append"; readonly address: Address; readonly element: Element } + | { readonly type: "list.remove"; readonly address: Address; readonly elementId: Id } + | { readonly type: "list.clear"; readonly address: Address }; + +type MainMutation = StateMutation | + { readonly type: "conversation.create"; readonly conversation: Conversation } | + { readonly type: "entry.append"; readonly entry: Entry } | + { readonly type: "task.create"; readonly task: Task } | + { readonly type: "task.running"; readonly taskId: Id } | + { readonly type: "task.checkpoint"; readonly taskId: Id; readonly checkpoint: TaskCheckpoint } | + { readonly type: "task.own"; readonly taskId: Id; readonly conversationId: Id } | + { readonly type: "task.abort"; readonly taskId: Id } | + { readonly type: "task.terminal"; readonly taskId: Id; readonly outcome: TaskOutcome }; + +type CommitBatch = + | { readonly kind: "main"; readonly writes: readonly MainMutation[] } + | { readonly kind: "scratch"; readonly taskId: Id; readonly writes: readonly StateMutation[] }; + +interface CommitReceipt { readonly first: Seq; readonly last: Seq } +``` + +Each mutation consumes one sequence. `conversation.create`, `entry.append`, `task.create` and +`list.append` carry the ID assigned to that mutation. `task.terminal` atomically retires all scratch for +the task; no separate retirement mutation can be forgotten. A scratch batch contains only scratch-scope +state mutations for exactly its declared task. Main batches contain no scratch address. Empty batches are +not passed to storage, do not advance `lastSeq` and publish no event. + +Storage applies a batch atomically or not at all. It checks expected object IDs against assigned sequences, +scope/batch consistency and references, but it does not execute kind code or validate application payload +schemas. An uncertain commit failure poisons the storage handle; the harness fail-stops and never retries +that closure on the same handle. + +### 6.2 Queries + +```ts +interface Cursor { readonly after?: Id; readonly before?: Id; readonly limit: number } +interface Page { readonly items: readonly T[]; readonly next?: Id; readonly readAt: Seq } + +interface ConversationQuery extends Cursor { + readonly parent?: Id; + readonly owner?: Id; +} +interface EntryQuery extends Cursor { + readonly conversationId: Id; + readonly kind?: string; + readonly key?: string; + readonly from?: Id; + readonly through?: Id; +} +interface TaskQuery extends Cursor { + readonly conversationIds?: readonly Id[]; + readonly live?: boolean; + readonly status?: "pending" | "running" | "terminal"; + readonly kind?: string; + readonly abort?: boolean; +} +interface ListQuery extends Cursor { readonly at?: Id } +interface Storage { + readonly lastSeq: Seq; + claim(call: Call): Promise; + commit(batch: CommitBatch, call: Call): Promise; + getConversations(ids: readonly Id[], call: Call): Promise>; + scanConversations(query: ConversationQuery, call: Call): Promise>; + getEntryKindNames(call: Call): Promise>; + getEntries(ids: readonly Id[], call: Call): Promise>; + scanEntries(query: EntryQuery, call: Call): Promise>; + newestHead(conversationId: Id, at: Id, call: Call): Promise; + getTasks(ids: readonly Id[], call: Call): Promise>; + scanTasks(query: TaskQuery, call: Call): Promise>; + getValue(address: Value, at: Id | undefined, call: Call): Promise | undefined>; + readList(address: List, query: ListQuery, call: Call): Promise>>; + close(call: Call): Promise; // also releases the claim +} +``` + +Cursor rules: + +- `after` and `before` are exclusive and mutually exclusive. +- `limit` is a positive bounded integer. +- Filters and fork caps apply before the limit and before decoding payloads where the backend can avoid it. +- Forward (`after` or neither) selects ascending IDs and returns ascending items; `next` is the final item ID + when more exist. +- Backward (`before`) selects the nearest preceding items and still returns them in ascending transcript + order; `next` is the first item ID when more precede it. +- `from` and `through` are inclusive logical-transcript bounds. +- `readAt` is the storage `lastSeq` observed by the coherent read. +- Conversation `parent` and `owner` filters are exact IDs and mutually composable. +- `live:true` means pending/running. `live:false` means terminal. Omitted means all. + +`scanEntries` and `newestHead` are fork-aware. Tasks are never inherited. Named owner-task reads may return +terminal tasks. Latest value/head queries are indexed descending limit-one operations, not load-and-tail. + +Storage validates the structural state columns in this table against committed state plus prior batch +writes. Before storage is called, transaction/driver validation separately proves current invocation, +normal-versus-abort method, open-reconciliation authority, mark and session phase. + +| mutation | structural current state | structural result | +|---|---|---| +| create | absent | pending, no checkpoint/outcome/abort, empty owns | +| running | pending | normal reservation requires terminal dependencies; marked abort reservation ignores dependencies | +| checkpoint | running | complete replacement with phase | +| own | running | newly created child names this owner; append once in same batch | +| abort | pending or running | set mark; repeated helper call is idempotent | +| completed/failed terminal | running and unmarked | terminal, matching outcome required, scratch retired | +| aborted terminal | running and marked | terminal, aborted outcome required, scratch retired | +| orphaned terminal | pending or running | terminal, orphaned outcome required, scratch retired | +| any mutation of terminal | terminal | reject | + +Every owned `conversation.create` has exactly one matching `task.own` in the same batch. Reject an unpaired +side, ownership of an existing conversation, duplicate ownership or mismatched owner IDs. Conversation +ownership and the corresponding task `owns` element are immutable after creation. + +List removal rejects an element ID that never existed or belongs to another list. It is idempotent only for +an existing same-list element already hidden/removed, and still consumes its buffered sequence. Clear is valid on an empty list and +consumes a sequence. Reading any task-scope address after its task is terminal rejects `ScratchRetired` in +all backends; it never returns an accidentally retained sidecar value. + +Transaction reads run while the line is exclusively held and see committed state only. Generic reads do +not see buffered writes. Builder helpers and whole-batch validation maintain a private overlay of prior +buffered writes so they can validate references to newly created objects and deduplicate a request key +created earlier in the same batch. Authors carry values they just wrote rather than read them back. + +## 7. Transaction and capability surfaces + +### 7.1 Common readers and state builders + +```ts +interface TxReaders { + getConversation(id: Id): Promise; + getEntry(id: Id): Promise; + getEntry(kind: EntryKind, id: Id): Promise; + getEntries(ids: readonly Id[]): Promise>; + getTask(id: Id): Promise; + getTask(kind: K, id: Id): Promise | undefined>; + getTasks(ids: readonly Id[]): Promise>; +} + +interface TxValue { + get(at?: Id): Promise; + set(value: T): void; + delete(): void; +} +interface TxList { + read(query: ListQuery): Promise>>; + append(value: T): Id; + remove(id: Id): void; + clear(): void; +} +``` + +The aliases are: + +```ts +type AnyTaskKind = TaskKindBase; +type PayloadsOf = K extends TaskTypes + ? { input: I; checkpoint: C; result: R; failure: F; aborted: A } + : never; +type InputOf = PayloadsOf["input"]; +type CheckpointOf = PayloadsOf["checkpoint"]; +type ResultOf = PayloadsOf["result"]; +type FailureOf = PayloadsOf["failure"]; +type AbortedOf = PayloadsOf["aborted"]; +type TurnOf = K["turn"]; +type TaskOf = Task, CheckpointOf, ResultOf, FailureOf, AbortedOf>; +``` + +The implementation may use distributive helper aliases to satisfy TypeScript variance without weakening +these public results. None may use `any`. Task-bound checkpoint methods exist only for the current kind. + +### 7.2 Admission-capable base transaction + +```ts +interface Acceptance { readonly requestId?: string; readonly conversationId: Id; readonly inputId: Id } + +interface TaskSpec { + readonly conversationId?: Id; + readonly input: I; + readonly after?: readonly Id[]; + readonly background?: true; +} + +interface ConversationCreateSpec { + readonly parent?: { readonly conversationId: Id; readonly at: Id }; +} + +interface BaseTaskTx extends TxReaders { + checkpoint(value: C): void; + task>>( + kind: K, + spec: NoExtra>, S> & { + readonly input: ExactJsonInput, S["input"]>; + }, + ): Id; + createConversation(spec: ConversationCreateSpec): Id; + value(address: Value): TxValue; + list(address: List): TxList; + record(kind: string, conversationId: Id, input: DataEntryInput): Id; + accept(conversationId: Id, options: AcceptOptions): Promise; + queueInput(conversationId: Id, input: QueuedInput): Promise; + write(conversationId: Id, kind: EntryKind, input: EntryInput, requestId?: string): Promise; +} + +interface TurnTaskTx extends BaseTaskTx { + entry(kind: EntryKind, conversationId: Id, input: EntryInput): Id; +} +``` + +A conversation-bound variant omits explicit `conversationId`. In a task transaction, omitted task target +conversation means the current task's conversation. Explicit targets must be that conversation or a +conversation in its ownership subtree, including one created earlier in the batch. `createConversation` +automatically records `owner=current task` plus `task.own`; a host transaction creates an independent +conversation unless an internal owner authority is supplied. Its optional parent must be the current +conversation or one in the current task's owned subtree; a task cannot inherit transcript/state from an +unrelated tree. A parent may be combined with ownership. +`record` accepts an explicit data-only shape and a kind string; it cannot address protected kind strings. +Typed readers may independently use an `EntryKind` with the same string. `accept`, `queueInput` and `write` +are asynchronous because request-key lookup is +a committed storage read; after that read they synchronously buffer all writes. They do not reenter the +line and may operate on a conversation created earlier in the same transaction. This permits one atomic +batch to create an owned child, copy state, accept its first input, create its first generation and +checkpoint the child/input IDs. + +Direct entry authorization and admission helpers are distinct. A non-turn helper may buffer an authorized +model-visible append after validating placement; casting to `TurnTaskTx` and calling direct `entry` fails +runtime authorization. Every builder records private provenance for validation; storage sees only the +resulting mutation algebra. + +A task may create another declared `turn:true` kind. That child kind owns the advanced contract. Generic +task creation does not transfer direct-entry capability to the caller. + +### 7.3 Runtime and restricted conversation handle + +```ts +interface PublicValue { + get(at: Id | undefined, call: Call): Promise; + set(value: T, call: Call): Promise; + delete(call: Call): Promise; +} +interface PublicList { + read(query: ListQuery, call: Call): Promise>>; + append(value: T, call: Call): Promise; + remove(id: Id, call: Call): Promise; + clear(call: Call): Promise; +} +interface ScratchTx { + value(address: Value): TxValue; + list(address: List): TxList; +} +interface ScratchReader { + value(address: Value): Pick, "get">; + list(address: List): Pick, "read">; +} + +interface TaskConversation { + readonly id: Id; + snapshot(call: Call): Promise; + accept(options: AcceptOptions, call: Call): Promise; + queueInput(input: QueuedInput, call: Call): Promise; + write(kind: EntryKind, input: EntryInput, requestId: string | undefined, call: Call): Promise; + result(inputId: Id, call: Call): Promise; + waitForInput(inputId: Id, call: Call): Promise; + abortInput(inputId: Id, call: Call): Promise<"aborted" | "already_placed" | "not_found">; + value(address: Value): PublicValue; + list(address: List): PublicList; +} + +interface AbortTaskConversation { + readonly id: Id; + snapshot(call: Call): Promise; + write(kind: EntryKind, input: EntryInput, requestId: string | undefined, call: Call): Promise; + result(inputId: Id, call: Call): Promise; + waitForInput(inputId: Id, call: Call): Promise; + value(address: Value): PublicValue; + list(address: List): PublicList; +} + +interface BaseTaskRuntime { + readonly taskId: Id; + commit(build: (tx: BaseTaskTx, current: RunningTask) => T | Promise, call: Call): Promise; + scratch(build: (tx: ScratchTx) => T | Promise, call: Call): Promise; + conversation(id: Id, call: Call): Promise; + waitForTask(id: Id, call: Call): Promise; + abortTask(id: Id, call: Call): Promise<"marked" | "terminal">; + now(): number; + sleep(untilMs: number, call: Call): Promise; +} +interface TurnTaskRuntime + extends Omit, "commit"> { + commit(build: (tx: TurnTaskTx, current: RunningTask) => T | Promise, call: Call): Promise; +} +type RuntimeFor = + T extends true ? TurnTaskRuntime : BaseTaskRuntime; +``` + +`runtime.conversation`, `waitForTask` and `abortTask` accept only targets in the current task's ownership +tree: its own conversation, its owned descendants, their tasks, and the task's own dependencies. An +unrelated target rejects `ScopeViolation`. Host operations are unrestricted except for protected data and +normal lifecycle validation. + +`TaskConversation` has no raw commit, direct entry, drive, close, shutdown, delete or host-wide registry +methods. Task operations capture the expected invocation when the runtime/handle is constructed. Every +supplied Call must contain that exact current invocation object. An absent, foreign or stale identity +rejects; task methods never fall back to host authority. + +### 7.4 Terminal and abort transactions + +```ts +interface TerminalTx extends BaseTaskTx {} +interface TurnTerminalTx extends TurnTaskTx {} + +type FinalTx = + T extends true ? TurnTerminalTx : TerminalTx; + +type TerminalClosure = + (tx: FinalTx, current: RunningTask) => + Completion | Promise>; + +interface AbortTx extends TxReaders { + checkpoint(value: C): void; + value(address: Value): TxValue; + list(address: List): TxList; + record(kind: string, conversationId: Id, input: DataEntryInput): Id; + write(conversationId: Id, kind: EntryKind, input: EntryInput, requestId?: string): Promise; + markTask(id: Id): Promise<"marked" | "terminal">; +} +interface TurnAbortTx extends AbortTx { + entry(kind: EntryKind, conversationId: Id, input: EntryInput): Id; +} +type AbortFinalTx = T extends true ? TurnAbortTx : AbortTx; + +type AbortClosure = + (tx: AbortFinalTx, current: RunningTask) => A | Promise; + +interface BaseAbortRuntime { + readonly taskId: Id; + commit(build: (tx: AbortTx, current: RunningTask) => T | Promise, call: Call): Promise; + scratch(read: (scratch: ScratchReader) => T | Promise, call: Call): Promise; + conversation(id: Id, call: Call): Promise; + waitForTask(id: Id, call: Call): Promise; + abortTask(id: Id, call: Call): Promise<"marked" | "terminal">; + now(): number; +} +interface TurnAbortRuntime + extends Omit, "commit"> { + commit(build: (tx: TurnAbortTx, current: RunningTask) => T | Promise, call: Call): Promise; +} +type AbortRuntimeFor = + T extends true ? TurnAbortRuntime : BaseAbortRuntime; +``` + +Abort runtime exposes repeated restricted commits with the same surface as `AbortTx`, plus scratch reads +but no scratch writes. It cannot create tasks/conversations, delete conversations, call `accept`, queue +steer/followUp/nextRun, or drive. `write` in abort is restricted to passive `mode:"write"`; it may append +immediately only when safe and otherwise remains queued. Turn abort keeps direct entry authority so a tool +can atomically publish its aborted tool result. Checkpoint replacement is allowed during lengthy fresh +cleanup; a crash reruns abort from the newest checkpoint because the durable mark remains. + +Terminal closures run on the line after all effects and producer joins. They may await transaction storage +reads and mint same-batch IDs, but may not perform external effects, hooks, sleeps, waits or nested runtime +operations. The harness then adds `task.terminal` with the returned outcome. A throwing closure persists +nothing and faults the session. Abort/close/fault may discard a normal closure without invoking it. +Uncertain persistence fail-stops and does not retry the closure on that handle. + +### 7.5 Host transactions and foundation handles + +Host transactions are not task invocations. They may append model-affecting entries directly only when +`inTurn` is empty; otherwise direct append rejects and `write` is required. + +```ts +interface HostTx extends TxReaders { + task> & { readonly conversationId: Id }>( + kind: K, + spec: NoExtra> & { readonly conversationId: Id }, S> & { + readonly input: ExactJsonInput, S["input"]>; + }, + ): Id; + createConversation(spec: ConversationCreateSpec): Id; + value(address: Value): TxValue; + list(address: List): TxList; + record(kind: string, conversationId: Id, input: DataEntryInput): Id; + entry(kind: EntryKind, conversationId: Id, input: EntryInput): Id; + accept(conversationId: Id, options: AcceptOptions): Promise; + queueInput(conversationId: Id, input: QueuedInput): Promise; + write(conversationId: Id, kind: EntryKind, input: EntryInput, requestId?: string): Promise; +} + +interface ConversationTx extends Omit { + task>, "conversationId">>( + kind: K, + spec: NoExtra>, "conversationId">, S> & { + readonly input: ExactJsonInput, S["input"]>; + }, + ): Id; + record(kind: string, input: DataEntryInput): Id; + entry(kind: EntryKind, input: EntryInput): Id; + accept(options: AcceptOptions): Promise; + queueInput(input: QueuedInput): Promise; + write(kind: EntryKind, input: EntryInput, requestId?: string): Promise; +} + +interface ConversationHandle { + readonly id: Id; + snapshot(call: Call): Promise; + commit(build: (tx: ConversationTx) => T | Promise, call: Call): Promise; + accept(options: AcceptOptions, call: Call): Promise; + queueInput(input: QueuedInput, call: Call): Promise; + write(kind: EntryKind, input: EntryInput, requestId: string | undefined, call: Call): Promise; + result(inputId: Id, call: Call): Promise; + waitForInput(inputId: Id, call: Call): Promise; + abortInput(inputId: Id, call: Call): Promise<"aborted" | "already_placed" | "not_found">; + drive(call: Call): Promise<"idle" | "closed">; + abort(call: Call): Promise; + fork(options: { readonly at: Id | "start"; readonly abort?: boolean }, call: Call): Promise; + collapse(options: { readonly instructions?: string } | undefined, call: Call): Promise; + reset(options: { readonly handoff?: string } | undefined, call: Call): Promise; + value(address: Value): PublicValue; + list(address: List): PublicList; +} + +interface Harness { + root(call: Call): Promise; + conversation(id: Id, call: Call): Promise; + commit(build: (tx: HostTx) => T | Promise, call: Call): Promise; + acceptance(requestId: string, call: Call): Promise; + getEntry(id: Id, call: Call): Promise; + getTask(id: Id, call: Call): Promise; + abortTask(id: Id, call: Call): Promise<"marked" | "terminal">; + drive(call: Call): Promise<"idle" | "closed">; + close(call: Call): Promise; + shutdown(call: Call): Promise; +} +``` + +There is no partial public harness or feature-not-installed facade. Leaf packages are tested directly. +The single `Harness` is exported only when its required built-in dependencies are implemented. + +## 8. Input admission and boundaries + +### 8.1 Records + +```ts +type UserInput = string | readonly (TextContent | ImageContent)[]; + +interface StoredEntryDraft { + readonly kind: string; + readonly key?: string; + readonly data?: JsonValue; + readonly model?: readonly Message[]; + readonly head?: Id | "self"; + readonly edits?: readonly ContextEdit[]; +} + +type QueuedInput = + | { readonly mode: "steer" | "followUp" | "nextRun"; readonly input: UserInput; readonly requestId?: string } + | { readonly mode: "write"; readonly entry: StoredEntryDraft; readonly requestId?: string }; + +type InputResult = + | { readonly status: "queued"; readonly requestId?: string } + | { readonly status: "placed"; readonly requestId?: string; readonly entry: Id } + | { readonly status: "done"; readonly requestId?: string; readonly entry: Id; readonly answer?: Id } + | { readonly status: "unanswered"; readonly requestId?: string; readonly entry?: Id; + readonly reason: "terminated" | "aborted" | "failed" | "stale"; readonly detail?: string }; + +type TerminalInputResult = Extract; + +interface AcceptOptions { + readonly input: UserInput; + readonly requestId?: string; + readonly whenBusy?: "followUp" | "steer" | "reject"; +} +``` + +The protected sticky list `pi.inbox` stores `QueuedInput`; its element ID is `inputId`. Protected sticky +values store `InputResult` by input ID and `Acceptance` by session-wide request key. A request key names the +first acceptance for the session lifetime. Request-key lookup occurs before conversation, busy, payload or +mode comparison. Duplicate `accept`, `queueInput` or `write` returns the original receipt and buffers +nothing, even when the retry supplied different data. + +Only protected admission/boundary helpers write input results, using this transition table: + +```text +absent -> queued +queued -> placed +queued write -> done +queued -> unanswered(aborted | stale) +placed -> done +placed -> unanswered(terminated | aborted | failed) +done | unanswered -> immutable +``` + +An idle accepted input may write `absent -> placed` in its one batch; an idle write may write +`absent -> done`. These are the only collapsed transitions and still create/remove the inbox element so +`inputId` is its list-element ID. + +Enqueue validation checks the stored entry draft as far as current state allows. At placement it is fully +revalidated. If a queued head no longer satisfies visibility, monotonicity or exchange-boundary rules, the +placement atomically removes it and writes `unanswered/stale`; ordinary staleness never faults the session. +Queued edits whose targets have moved outside context remain valid no-ops; protected managed-system edits +remain forbidden. + +### 8.2 Generation admission strategy + +The foundation does not guess a provider generation payload. Input placement receives one strategy: + +```ts +interface InternalBoundaryTx extends TxReaders { + task> & { readonly conversationId: Id }>( + kind: K, + spec: NoExtra> & { readonly conversationId: Id }, S> & { + readonly input: ExactJsonInput, S["input"]>; + }, + ): Id; +} +interface InternalGenerationAdmission { + readonly kind: K; + create( + tx: InternalBoundaryTx, + conversationId: Id, + inputs: readonly Id[], + ): Id | Promise; +} +``` + +This is a private admission-module/test seam, not a public Harness option. The callback runs inside the +current transaction and may perform transaction reads plus one task creation; +it cannot append entries, mutate state, perform effects or enter the line. Validation requires exactly one +new task in the registered core generation slot, in `conversationId`, with materialized `turn:true`, and +requires its returned ID to be that task. The expected token/name is `strategy.kind`; production wiring +supplies the registered generation-slot token. Accounting for exactly the supplied input IDs is the trusted +strategy's typed obligation; the erased kernel cannot inspect an arbitrary generation input to prove it. +WP5 tests supply a minimal typed generation kind; the provider package supplies the production strategy. + +### 8.3 Busy and placement predicates + +`inTurn(conversation)` means at least one live task in that conversation has stored `turn:true`, regardless +of foreground/background. This is transcript admission busy, not drive idleness and not proof of input +ownership. + +- `accept` when `inTurn` is empty performs an idle admission transaction: drain older queued writes; combine + older queued steer/followUp/nextRun in admission order with the new input; append their user entries; + create one built-in generation owning those input IDs; mark each placed. The newly accepted input is last + among older queued items. `whenBusy` is irrelevant. +- `accept` when `inTurn` is nonempty queues `followUp` by default, queues `steer` when requested, or throws + `ConversationBusy` and writes no request receipt when `reject` is requested. +- `write` appends and completes immediately when `inTurn` is empty; otherwise it queues. +- Explicit `nextRun` is never placed merely because turn activity ends; it joins the next explicit idle + `accept`. + +Boundary operations are transaction helpers, not nested commits: + +- **post-tools boundary:** place queued writes, then steering in admission order; steering joins the active + input group. FollowUp and nextRun remain queued. +- **final-answer boundary:** resolve the active group to the answer; place writes; place steer then followUp + in admission order into a new group and create its generation. nextRun remains queued. +- **idle-turn boundary:** when a normal terminal batch changes `inTurn` from nonempty to empty and creates + no turn successor, Pico automatically places writes and places steer/followUp into a new generation. + nextRun remains queued. This covers a solitary speculative collapse and a well-behaved custom turn. +- **abort boundary:** no automatic generation or queue drain. Conversation abort separately withdraws + queued steer/followUp; task abort preserves queues. A later idle accept can consume preserved items. + +Normal terminal finalization uses this exact order: + +```text +invoke returned closure and buffer its writes +obtain its completed/failed outcome +buffer task.terminal +compute prospective inTurn from committed live tasks plus the whole buffered batch +when eligible, append idle-boundary inbox/result/entry/generation mutations +validate and persist the complete batch +``` + +The terminal mutation and automatic boundary are one atomic main batch. Abort, orphan reconciliation, +close and fault never run automatic idle-boundary augmentation. WP5 implements the pure prospective planner; +WP6 integrates it with normal closure finalization. + +If a speculative collapse completes while a generation/tool/post_tools task remains live, no idle boundary +runs; the built-in owner processes the inbox later. If collapse is the sole turn task, its normal terminal +commit triggers the idle boundary, so listener input cannot starve. A third-party turn author may explicitly +use post-tools/final helpers when it owns an input group. If it simply ends as the last turn task, the idle +boundary is the least-footgun fallback. Pico never infers or resolves a custom kind's private input group. + +Input ownership for built-in turns is explicit `inputs: readonly Id[]` in generation/post_tools input or +checkpoint. Exactly one live built-in generation or post_tools owns each placed active group, and ownership +transfers in the same terminal batch. Generic scheduling never scans arbitrary checkpoints for ownership. + +### 8.4 Withdrawing input + +`abortInput(id)` serializes with placement. If the queued item belongs to this conversation, it atomically +removes it and writes terminal `unanswered/aborted`, then returns `aborted`. If placement or any terminal +result already won, it returns `already_placed` and changes nothing. A missing input or one belonging to +another conversation returns `not_found`. If a queued result exists but its inbox element is missing, Pico +faults the session as corrupted state. + +### 8.5 Waiting for input + +`result(id)` is a point read. `waitForInput(id, call)`: + +- validates that the input exists and belongs to the target conversation, otherwise rejects `InputNotFound`; +- returns only `done` or `unanswered`; +- registers/checks/removes its waiter on the line without polling; +- temporarily serves the target conversation's ownership scope until the result, caller cancellation, + close or fault; an existing stable attachment is unaffected; +- removes its signal listener and temporary serving reference exactly once; +- rejects caller cancellation, close and fault; shutdown eventually closes and rejects unresolved queued + waits rather than deleting their durable input; +- does not mark or cancel durable work when its caller cancels. + +Known self-progress cycles use a conservative generic rule evaluated at waiter registration and whenever +the live/dependency graph changes: + +- A live turn invocation waiting for any nonterminal input in its own conversation rejects `SelfWait`, + whether that result is queued or placed and including `nextRun`. +- For a non-turn caller, every current live turn task in the target conversation is a possible placer. If + any such task has an unresolved dependency path to the caller, reject `SelfWait`. +- A foreground non-turn task that accepts into an idle conversation creates an independent generation and + may wait; its own liveness alone is not a cycle. +- A non-turn wait on `nextRun` simply waits for a later explicit idle accept unless the dependency rule + above proves a cycle. + +No blocker set is stored at enqueue. Provider built-ins may later supply a more precise explicit boundary +owner; the generic scheduler never infers one from task payloads. Hidden plugin-promise cycles remain the +author's responsibility. + +## 9. Commit line and runtime authorization + +One FIFO async line serializes commits, reservations, marks, lifecycle transitions, registry mutations, +waiter registration/removal and watch capture. A transaction callback may await storage reads while holding +the line. It must not await effects, hooks, task/input/drive waits, sleeps or nested line operations. Every +intermediate, terminal and abort builder receives a fresh immutable current task read on the line after all +committed checkpoint/mark changes; it never receives only the method's older snapshot. + +Caller cancellation is checked while waiting to enter the line and again before the builder begins. Once +the builder is admitted, caller cancellation cannot abandon builder completion or persistence. Storage +receives an internal non-abandoning Call that preserves telemetry but not the caller's abort signal. Task +invocation identity, lifecycle and durable mark are still revalidated on the line before invoking its +builder. + +Commit sequence: + +```text +enter line +validate session phase and caller/invocation authority +construct capability-bound transaction +run builder against stable committed storage; buffer writes +validate buffered sequence/order/references/capabilities using committed state + overlay +persist complete batch +apply complete batch to all process indexes and views +reserve newly eligible work and resolve affected waiters +leave line +dispatch task methods, signals and listeners outside line +resolve outer commit promise +``` + +Publication never precedes persistence. Every index observes a full batch before scheduling or idleness, +so terminal task + successor has no visible idle gap. + +Private invocation identity contains task ID, method (`execute`, `recover`, `abort`) and a unique object. +The driver installs that exact object in a derived Call and binds every task runtime/handle to it. Runtime +admission compares object identity with the invocation slot. Metadata/RPC transport never confers authority. + +Authorization table: + +| phase/method | normal commit | scratch write | terminal closure | abort restricted commit/closure | host admission | +|---|---:|---:|---:|---:|---:| +| open, execute/recover, unmarked | yes | yes | yes | no | yes | +| open, execute/recover, marked | reject before builder | reject before builder | discard without invoke | no | yes | +| open, abort invocation | no | read only | no | yes | yes | +| stopping | no | no | discard | existing/new abort only | reject | +| closing/faulted/closed | no | no | discard | no; close/fault signal it | reject | + +Safe `accept`/`write` authorization and raw direct-entry authorization are separate. Protected built-in +state and entry operations require internal helpers. Main and scratch writes from absent/foreign/stale task +identity reject; task-provided handles never treat them as host calls. + +## 10. Scheduling, ownership and waits + +Process indexes after open: + +- complete live task map; +- one invocation slot per running task ID; +- tasks by conversation and live turn/foreground subsets; +- reverse dependency edges; +- conversation parent/owner ancestry needed by live tasks, invocations and attachments; +- stable conversation/session attachments; +- temporary drive/task/input waiters; +- session phase. + +Dependencies are immutable, acyclic IDs in the same ownership tree. They may reference tasks created +earlier in the same batch, including the current task from its terminal closure. A task becomes eligible +when every dependency is terminal, regardless of outcome. Missing from the complete live map means terminal +after existence was validated. A foreground task depending on endless background work intentionally keeps +its scope busy. + +Reservation on the line: + +```text +marked live task -> fresh abort, ignoring dependencies +unmarked pending with terminal deps -> commit task.running, reserve execute +unmarked running loaded at open -> reserve recover +``` + +A pending task marked before reservation never becomes running for effects; the harness writes the running +intent only as part of reserving fresh abort so `abort` receives `RunningTask`. A task created and marked in +one batch is likewise cleaned by abort without execute. Open itself starts nothing; recovery reservation +occurs only when a drive/attachment serves the task. + +There is at most one invocation per task ID in a process. Its slot remains held through method return, +producer/progress joins and terminal-closure acceptance or discard. Different task IDs run concurrently. +Callbacks, methods and signals never execute on the line. + +Serving follows ownership, not fork history. A stable conversation attachment serves tasks in that +conversation and every recursively owned descendant, including descendants whose owner task is terminal. +A session attachment serves all conversations. Terminal owner records needed for ancestry are fetched by +ID and only their immutable links are retained. Historical child lists are never traversed. + +Foreground cancellation reach is narrower: start with live foreground tasks directly in the requested +conversation; recursively enter conversations owned by those live foreground tasks and repeat. A background +or terminal owner breaks cancellation reach. Queued input is not a task. + +Conversation drive resolves when its served root has no live foreground task in that foreground ownership +closure. Session drive resolves when no foreground task exists anywhere. Stable attachment remains after a +waiter resolves/cancels, so attached background recurrence continues. Each drive call adds only a temporary +waiter. Cancelling it removes that waiter/listener and never cancels work. + +`waitForTask` rejects `TaskNotFound` for a missing target, returns a terminal task immediately, or installs +one cancellable point waiter plus a temporary serving reference for a live target. It returns the terminal +task and follows the same cancellation cleanup rules. Direct self-wait and a wait on a +task whose unresolved dependency path reaches the caller reject. A task cannot drive its own foreground +scope. Cycle checks occur before an admission that would create known work blocked by its caller. + +## 11. Cancellation, close, shutdown and fault + +### 11.1 Task and conversation abort + +`abortTask(id)` and abort-transaction `markTask(id)` reject `TaskNotFound` for a nonexistent ID, return +`terminal` for an existing terminal task, and return `marked` for either an unmarked or already-marked live +task. Marking does not attach an unrelated scope; cleanup starts when the task is served. Repeated marks are +idempotent and never cancel an already-running fresh abort invocation. + +Conversation abort commits the mark/queue-withdrawal batch and records those task IDs as its tracked set. +It temporarily serves the affected ownership scope. Any mark issued by an abort invocation whose own task +is in that tracked set is added transitively to the same set. The operation resolves when every tracked task +is terminal and no tracked invocation remains. Unrelated background work is excluded. Caller cancellation +cannot abandon cleanup after the mark batch is admitted. + +The mark transaction: + +1. Compute the current foreground cancellation closure from the pre-commit live/ownership snapshot. +2. Mark every live task in it. +3. Remove queued steer/followUp in affected conversations and write terminal `unanswered/aborted` results. +4. Preserve queued write and nextRun. + +When a mark wins: + +```text +commit abort:true +revoke execute/recover main and scratch writes immediately +leave line; signal old invocation +wait for method, effects and owned progress producers to return +on line discard any normal terminal closure and release old invocation +reserve fresh abort with new identity/controller/Call +abort may perform restricted checkpointed cleanup +abort returns closure +apply passive/direct turn writes + aborted outcome + scratch retirement atomically +release abort invocation +``` + +Fresh built-in cleanup marks live foreground tasks in owned child conversations and recorded non-detached +jobs, using atomic mark-if-live; an already-terminal target counts as cleaned. It never invokes public child +conversation abort and therefore preserves child queues. Local deadlines are not durable marks: their +in-band errors remain kind-specific domain outcomes. Cancellation classification uses Pico's identity and +known reason, never error name alone. + +An uncooperative effect can delay abort indefinitely. Forced isolation is outside v1. + +### 11.2 Close + +Close is a nonpersistent line transition that stops admission and reservation. Earlier admitted line work +finishes; later operations reject except invocation completion. Outside the line, signal and join every outstanding invocation, then close storage. A durably terminal task +has no outstanding invocation because terminal publication follows method return and producer joins. It writes no outcomes or marks. +Caller cancellation cannot abandon close. Repeated close calls share completion. + +### 11.3 Shutdown + +Shutdown enters `stopping` and commits marks for every live task in one batch. It preserves every queued +input/result. It serves abort cleanup session-wide until live tasks and invocation slots are empty, then +closes. Fresh abort handlers may commit; all normal work/admission rejects. A failing abort faults shutdown. +Caller cancellation cannot abandon an admitted shutdown. Explicit close may interrupt it; shutdown rejects +and durable marks recover later. Repeated shutdown calls share completion. + +### 11.4 Fault + +Unexpected persistence, invariant, capability, closure or task-contract errors fail-stop the session: +reject waiters immediately, stop admission/reservation, signal and join invocations, close storage, and +preserve durable unfinished tasks. Do not manufacture outcomes. Domain provider/tool failures must be +returned as typed outcomes by their kinds. + +## 12. Scratch + +Scratch uses the state vocabulary under `{type:"task", taskId}`. One scratch transaction writes one +live task only. It has value/list get/set/delete/append/remove/clear; reads are async, writes buffered. +Scratch is never rewindable, inherited or part of context. + +Execute/recover may read and write its own scratch while unmarked. Abort may read but not write scratch; +it persists cleanup progress in the task checkpoint or permitted main state. A crash before terminal +outcome retains scratch. `task.terminal` retires it atomically. Every later scratch write rejects, even if a +sidecar unlink failed. + +A retry under one task ID clears attempt-specific lists before new output. Generation stores compact +assistant frames, not cumulative provider snapshots. Generic output packages store compact append/replace/ +truncate operations, not growing whole-value copies. + +Harness-owned producers own every pending scratch promise, cancel on early exit, suppress only expected +mark/close rejection, report persistence faults and join before the invocation may finalize. Iterator exit +alone is not provider/process completion. Plugin code must await or catch every raw scratch write. + +## 13. Harness open and registries + +The final Harness has one construction API: + +```ts +interface CoreTaskKinds { + readonly generation: TaskKindBase; + readonly tool: TaskKindBase; + readonly postTools: TaskKindBase; + readonly collapse: TaskKindBase; + readonly job: TaskKindBase; +} +interface RootValueWrite { + readonly address: Value; + readonly value: T; +} +interface HarnessOpenOptions { + readonly taskKinds?: readonly TaskKindBase[]; + readonly entryKinds?: readonly EntryKind[]; + readonly replace?: Partial; + readonly rootValues?: readonly RootValueWrite[]; +} +interface OpenInspection { + readonly pending: readonly Task[]; + readonly running: readonly Task[]; + readonly orphaned: readonly Task[]; + readonly unknownEntryKinds: readonly string[]; +} +interface MutableRegistry { + get(kind: string, call: Call): Promise; + register(definition: T, call: Call): Promise; + replace(definition: T, call: Call): Promise; + remove(kind: string, call: Call): Promise; +} +interface Harness { + readonly kinds: CoreTaskKinds; + readonly entryKinds: MutableRegistry; + readonly taskKinds: MutableRegistry; + inspect(call: Call): Promise; +} +interface HarnessFactory { + open(storage: Storage, options: HarnessOpenOptions, call: Call): Promise; +} +``` + +The two `Harness` declarations in this specification merge into one TypeScript interface. +`register`, `replace`, `remove` and `get` serialize on the line. Register rejects an existing name; replace +requires an existing name. Task removal rejects when live instances exist. Core kind removal always rejects. + +Stable core names and capabilities are: + +```text +generation pi.generation turn=true +tool pi.tool turn=true +postTools pi.post_tools turn=true +collapse pi.collapse turn=true +job pi.job turn=false +``` + +Harness installs the production core kinds. `replace` substitutes only named core slots and must preserve +the required turn capability. Additional `taskKinds` cannot use a core name. The internal admission strategy +is bound to the resulting generation slot. + +Empty storage means exactly `lastSeq === 0`. The root conversation is the first mutation, has ID `1`, has no +parent/owner, and can never be deleted. Every `rootValues` address must be a value bound to conversation 1 +(or an authorized built-in address bound there); duplicate addresses reject. Root creation and all root +values commit atomically. Nonempty storage ignores `rootValues`. + +A storage handle is exclusively owned. Its first `claim` succeeds; another claim before `close` rejects. +Every commit/query requires an active claim. `close` makes later operations reject and releases the claim; +repeated close is harmless. `MemoryStorage` retains its data and permits a later claim after close for +recovery tests. Closed JSONL/SQLite objects are not reusable and must be reopened through their factories. +If driver/Harness initialization fails after claim, it closes/releases before rejecting. JSONL/SQLite also +hold a cross-process session lock from backend open through close. Two writers must fail rather than race. + +Open order: + +1. Finish backend replay/recovery and establish `lastSeq`. +2. Install required built-in entry kinds and task slots, then options and explicit replacements. +3. If storage is empty, create root conversation and apply `rootValues` in one commit. Reopen ignores + `rootValues`. +4. Load the complete live-task seed and required ownership ancestry. Load recorded entry-kind names. +5. Validate live replacement capability compatibility. +6. Reconcile every live task whose kind is absent in one main commit. +7. Build all indexes and return inspection/read handles without starting work. + +Core slots `generation`, `tool`, `post_tools`, `collapse` and `job` always have an implementation. They may +be explicitly replaced but not removed. This ensures accept/boundary processing and unavailable-tool +results remain available. Additional task kinds may be removed only when no live instance exists. Entry and +section definitions may be removed without deleting durable records. In-flight invocations/preparations +retain captured definitions; later operations use the new registry. + +Missing-kind reconciliation uses one pre-reconciliation snapshot: + +- Find every pending/running task whose kind is absent, foreground or background. +- For each missing task's owned conversations, recursively mark every registered live foreground descendant + reachable through ownership. Preserve all queued input. +- Missing descendants are themselves orphaned rather than marked for code that does not exist. +- Terminalize each missing task as `orphaned`, retain input/checkpoint/history, remove live/dependency indexes + and retire scratch atomically. +- Dependents observe a terminal dependency and interpret `orphaned`. +- Keep ancestry links so a later root drive serves registered marked descendants even though the owner is + now terminal. +- Report orphaned IDs from `inspect`. + +If validation, root creation, index loading or reconciliation fails, open closes/releases the supplied +storage binding before rejecting and returns no handle. Registration never resurrects orphaned tasks. + +## 14. Built-in contracts retained for later packages + +These contracts are normative behavior. Packages explicitly gated in section 18 wait for their unsettled +author APIs. + +### 14.1 Generation and post_tools + +Generation owns explicit input IDs and captured durable configuration. It checkpoints preparation cutoff, +attempt, retry wait, deferred handle and any request-specific validation data needed for recovery. A +request snapshot is immutable. `before_request` may transform a private messages-only request; actual +offered tools after transformation are the validation basis and must be recoverable when needed. + +Assistant frames live in scratch. Retry and deferred polling loop cooperatively under one stable task ID. +`on_yield` receives the prospective assistant message/draft plus stable task ID, not an entry ID that does +not exist. The returned terminal closure atomically appends assistant/usage, resolves or transfers inputs, +and creates tools/post_tools/continuation. + +For calls, generation creates every tool task and one post_tools depending on them in its closure. Each tool +writes exactly one result. Post_tools reads terminal outcomes in call order, handles completed/failed/ +aborted/orphaned, places the post-tools boundary, and either terminates/handoffs or creates a continuation +while transferring inputs atomically. + +Threshold or overflow creates collapse `C` and replacement generation `G` with `after:[C]`, carrying the +active inputs, in one closure. No invocation waits on collapse. `G` handles every terminal collapse outcome. + +### 14.2 Collapse + +Collapse captures reason, target prefix ending at a complete exchange, first retained boundary, newest head +and settings. It runs speculatively and may be background/manual or foreground/automatic. It checkpoints a +prepared candidate. Its terminal closure reads the current newest head on the line. A newer head returns +ordinary typed stale failure; intervening edits do not stale. Success appends summary head and completes in +one batch. Abort publishes no summary. One live collapse per conversation; simultaneous creation is +serialized and duplicate creation declines or reuses the existing ID according to the calling built-in. + +### 14.3 Tools, jobs and owned conversations + +Tool input includes immutable call and assistant entry ID. Missing implementation, invalid arguments, hook +block and ordinary throw become model-visible error results, not session faults. Recovery retries only with +a durable safe replay policy; otherwise it returns interrupted. Abort publishes an aborted tool result via +turn abort authority. A missing executable tool is handled by the built-in tool kind; it is not a missing +task kind. + +A subagent is an owned conversation. Creation, selected initial values, accepted input, first generation, +ownership links and parent checkpoint IDs commit atomically. Recovery reuses the child/input IDs. `send` +uses a stable request key; `wait` uses an explicit input ID, never transcript-tail inference. Parent cleanup +marks child foreground tasks only and preserves queues. + +Jobs are job-first: the durable job owns execution/output from the first effect. Tool and job sharing or +handoff of scratch/output must preserve one writer, bounded capture, cancellation join and terminal scratch +retirement. Arbitrary unfinished promise adoption is not a v1 feature. A non-adoptable interrupted process +is lost unless durable policy explicitly permits rerun. Recurring schedules use one task ID and phased +checkpoint; no backlog is inferred after downtime. Terminal notices are passive writes and work in either +notification-before-completion order. + +### 14.4 System sections + +Pico sends pi-ai `{messages}` only; top-level `systemPrompt` and `tools` are absent. A typed +`SystemSection` has stable key and synchronous pure renderer. Preparation seeds one ordered draft from +durable payload/rendered state. Handlers run sequentially outer-to-inner and mutate get/set/delete/wrap; +failed skipped handler rolls back only its mutations. Missing contributors do not delete stored sections. + +Baseline records contain complete ordered state; deltas contain set/remove changes. Canonical data folding +is independent of model heads/edits. Payload-only change may have `model:[]`. Tool definitions exist only in +SystemMessage fields; removals precede additions and same-name additions upsert. A fresh post-head baseline +atomically omits retained superseded managed entries. Preparation captures section/registry snapshots, +retries managed-state staleness, and commits system entry plus request cutoff atomically. + +### 14.5 Hooks and external events + +Hooks are the preferred extension point inside built-in operations. They run outside the line, receive and +forward Call, may run again after crash, and return typed decisions that the built-in commits. A hook may +start and await ordinary durable side work while the built-in retains exchange ownership. Approval/question +policy and durable answer reuse belong to workspace/plugins. Exact namespaced hook scratch access remains a +gated API decision. + +A long-lived non-turn background listener injects external events through request-keyed `accept`/`write`. +It may await `waitForInput` when no self-progress cycle exists. It never mutates an already snapshotted +provider request; steering affects the next safe request boundary. + +## 15. Watch and backend contracts + +### 15.1 Watch + +The ready watch foundation deliberately excludes previews and `task_output`; their source/API is gated. + +```ts +interface WatchedValue { readonly address: Address; readonly value?: JsonValue } +interface ConversationView { + readonly conversation: Conversation; + readonly tail: number; + readonly entries: readonly Entry[]; + readonly context: readonly Id[]; + readonly tasks: readonly Task[]; + readonly inbox: readonly Element[]; + readonly values: readonly WatchedValue[]; + readonly readAt: Seq; +} +interface SessionView { + readonly conversations: readonly Conversation[]; + readonly values: readonly WatchedValue[]; + readonly readAt: Seq; +} +type InboxOp = + | { readonly type: "append"; readonly item: Element } + | { readonly type: "remove"; readonly id: Id } + | { readonly type: "clear" }; +type ConversationEvent = + | { readonly type: "entry"; readonly entry: Entry } + | { readonly type: "task_start"; readonly task: Task } + | { readonly type: "task_update"; readonly task: Task; readonly previous: Task } + | { readonly type: "task_end"; readonly task: Task & { readonly status: "terminal" } } + | { readonly type: "value"; readonly value: WatchedValue } + | { readonly type: "inbox"; readonly ops: readonly InboxOp[] } + | { readonly type: "context"; readonly ids: readonly Id[] }; +type SessionEvent = + | { readonly type: "conversation"; readonly conversation: Conversation; readonly change: "created" } + | { readonly type: "value"; readonly value: WatchedValue }; +interface CommitEnvelope { + readonly first: Seq; + readonly last: Seq; + readonly events: readonly E[]; +} +type WatchDelivery = + | { readonly type: "commit"; readonly commit: CommitEnvelope } + | { readonly type: "lag" } + | { readonly type: "closed" }; +interface WatchOptions { + readonly capacity?: number; + readonly onError?: (error: unknown) => void; +} +interface ConversationWatchOptions extends WatchOptions { + readonly tail: number; + readonly values?: readonly Address[]; +} +interface SessionWatchOptions extends WatchOptions { + readonly values?: readonly Address[]; +} +interface WatchHandle { + readonly view: V; + start(listener: (delivery: WatchDelivery) => void): void; + resnapshot(call: Call): Promise; + unsubscribe(): void; +} +declare function applyConversationCommit( + view: ConversationView, + commit: CommitEnvelope, +): ConversationView; +declare function applySessionCommit( + view: SessionView, + commit: CommitEnvelope, +): SessionView; +interface WatchService { + watchConversation( + conversationId: Id, + options: ConversationWatchOptions, + call: Call, + ): Promise>; + watchSession( + options: SessionWatchOptions, + call: Call, + ): Promise>; +} +``` + +`tail` is an integer from 0 through 10,000. A conversation capture contains its last `tail` logical +fork-visible entries, live tasks directly belonging to that conversation, current inbox and exactly the +requested values. Conversation watch values may be session addresses or addresses bound to that +conversation; foreign-conversation/task addresses reject. A session watch accepts session addresses only. +A later source-conversation write emits no event for an existing fork. The pure reducer uses `view.tail` to +retain only the newest logical transcript entries. A session capture contains all current conversations and +exactly its requested session values. + +Capture and subscription registration occur in one line operation. Default capacity is 256 complete commit +envelopes; an explicit capacity must be a positive integer. `start` is synchronous and may be called once. +It delivers commits after the captured `readAt`, then live commits, in sequence order. The view is folded +through a whole envelope before its listener runs. Events from one commit are never split. Same-commit inbox append/remove cancels and emits no inbox event. A commit producing no events after this +subscription's filters is not delivered and consumes no buffer capacity, while the in-process +`handle.view.readAt` still advances. Emit a full `context` event whenever the derived context-ID array +changes, including ordinary entry appends. + +When capacity would be exceeded, discard queued commit deliveries, emit one `lag` delivery, and pause that +subscription. The in-process `handle.view` continues folding every commit while delivery is paused, but the +consumer must not use it as a resumption barrier. `resnapshot` enters the line, captures a fresh view and +sequence barrier, discards deliveries +through that barrier, resumes after it, mutates `handle.view` to the new view before resolving, and returns +that same view. Calling it in response to a listener delivery does not run on the commit line and cannot +deadlock. `unsubscribe` is idempotent, discards buffered deliveries and prevents future callbacks. If a listener throws, unsubscribe only that watch and invoke `options.onError` outside the line when +provided; never fault the session. An error thrown by `onError` is ignored by Pico. Close emits `closed` once unless unsubscribed. + +`task_start` is task creation at pending. Reservation is `task_update` to running. Checkpoint/abort/ownership +changes are task updates. Terminal is `task_end` and always includes an outcome. Reducers are kind-free and +pure. Conversation values are only the explicitly requested addresses; session values are explicitly +configured by the session watch caller in the final Harness API. + +### 15.2 Backend construction and durability + +```ts +interface MemoryStorageFactory { create(): Storage } +interface JsonlStorageFactory { open(path: string, call: Call): Promise } +interface SqliteStorageFactory { + open(path: string, options: { readonly session: string }, call: Call): Promise; +} +``` + +JSONL/SQLite `open` acquires the cross-process session lock and finishes replay before returning. Storage +`claim` separately binds one driver to the returned object. `close` releases both and is idempotent. Memory +has no cross-process lock but enforces one claim. + +Every JSONL main and live-scratch commit is fsynced before in-memory publication or commit resolution. +Creating the main file, a scratch file or a containing directory requires fsync of the created file and +each affected parent directory before publication. Scratch unlink need not be directory-durable because +the fsynced terminal main record is authoritative. No weaker durability mode exists in v1. + +JSONL uses one main file and one file per live task scope, all sharing the sequence. Each newline-terminated +record is: + +```ts +interface JsonlRecord { + readonly first: Seq; + readonly last: Seq; + readonly batch: CommitBatch; +} +``` + +`last - first + 1` equals `batch.writes.length`; each create/element ID matches its derived sequence. +Replay main first, then scratch only for surviving live tasks. Retired scratch is ignored even if malformed. +Retained ranges increase within each file and never overlap across retained files; gaps are valid. Validate +batch kind against file, scratch task/address references, object IDs and lifecycle/scope structure. +`lastSeq` is the maximum complete retained endpoint including clear/remove. + +A torn suffix is bytes after the final newline. Truncate those bytes and fsync before append. A +newline-terminated record that is malformed JSON or structurally invalid fails open. Persist and fsync the +record before applying it to memory. + +SQLite uses indexed conversations, full immutable entry facets, current tasks, values plus rewindable +versions, list elements/clear markers, scratch and commit boundaries. Index task status/abort/conversation/ +kind and entry conversation/head/kind/key. Terminal transaction deletes scratch rows atomically. Reopen +loads live tasks and named owner records, not terminal history into residency. Version mismatch rejects. +Use durable SQLite transactions with `PRAGMA synchronous=FULL` or an explicitly documented equivalent at +least as strong. Journal mode is backend-private if locking, atomic scratch retirement and conformance hold. +There is no migration API until the gated migration package is specified. + +## 16. Complete foundation race matrix + +Every race is tested in both orders with fake clocks/effects and storage barriers: + +- Persistence paused after construction: readers see old complete state. +- Commit callback throws: no writes and no IDs consumed. +- Accept durable but reply lost: request-key retry and lookup return the original receipt. +- Duplicate request key in one transaction: first receipt wins without payload comparison. +- Main plus scratch in one batch: reject all. +- Effect returns versus abort mark: one invocation; fresh abort only after old return. +- Post-mark main/scratch builder: reject before callback. +- Normal closure versus mark/close/fault: commit before mark wins; otherwise closure is not invoked. +- Closure throws: no closure writes/outcome; session faults. +- Terminal persistence uncertain: fail-stop; closure not retried on handle. +- Pending task marked before reservation: execute never runs; fresh abort does. +- Repeated abort mark while abort runs: abort Call remains active. +- Close versus commit: admitted earlier commit finishes; later mutation rejects; completion messages admitted. +- Shutdown versus accept: accept commits before mark batch or rejects; queues preserved. +- Shutdown crash before child cleanup: reopen abort marks child tasks only and preserves queues. +- Broken abort handler: drives/shutdown reject; invocations join; session closes with task durable. +- Two overlapping drives: one invocation per task. +- Cancelled drive/task/input waiter: only that waiter/listener/temporary serving reference is removed. +- Dependency terminal versus dependent reservation: dependent starts once. +- Direct/dependency/input self-waits: reject before durable deadlock. +- Solitary background collapse plus listener accept: input queues, collapse terminal triggers idle boundary. +- Collapse concurrent with generation plus listener accept: collapse terminal does not drain; generation boundary does. +- Collapse abort with queued input: no successor; queue remains for later idle accept. +- Queued head versus newer head: placement writes unanswered/stale, not session fault. +- Parallel tool completion either order: post_tools starts once and projects call order. +- Abort versus post_tools closure: no unmarked successor and every active input resolves once. +- Withdraw versus place input: one terminal result. +- Several inputs in one group: all done results name the same answer. +- Overflow chain: collapse and replacement atomic; no invocation waits on collapse. +- Competing summary head: stale failure; intervening edits do not stale. +- Watch capture versus commit: base includes commit or stream delivers it, never gap/duplicate. +- JSONL main 100/live scratch 150: reopen lastSeq 150. +- Terminal 151/unlink failure: ignore scratch even malformed; lastSeq 151. +- JSONL torn final suffix versus malformed complete line: truncate only former. +- 100,000 terminal children/two live tasks: open reads live seed and required ancestry only. +- Missing owner kind with registered descendants: owner orphaned and scratch retired; descendants marked, + queues retained, root drive later runs their fresh abort. +- Capability replacement with live mismatch: reject before invocation. +- Cast non-turn runtime to turn and direct append: reject, no writes. + +## 17. Ready implementation packages + +Every package is a separate commit. Before each commit run its focused test and `npm run check`; do not run +the full credential-sensitive suite. Each package may use an internal kernel with supplied test kinds and +memory storage; it need not expose incomplete production Harness behavior. + +### WP1 — Core types and kind witnesses + +Prerequisites: none. + +Deliver: +- strict JSON, ID, entry, conversation, task/checkpoint/outcome and address types; +- `EntryKind`, `TaskKind`, payload extractors and literal turn capability; +- capability-selected runtime/closure declaration types, with method bodies deferred; +- no imports from existing harness implementation. + +Accept: +- compile tests for exact task input, full checkpoint with phase, kind-specific outcomes and literal turn + runtime/terminal/abort surfaces; +- non-turn has no direct entry; turn does; turn abort does; +- incompatible checkpoint shape rejects; structurally identical shape is documented assignable; +- Pico declarations introduce and explicitly spell no `any`; imported pi-ai types are exempt; +- compile-only focused test and repository check green. + +### WP2 — Mutation algebra and MemoryStorage + +Prerequisites: WP1. + +Deliver: +- exact main/scratch mutations and storage interface; +- immutable snapshotting; +- sequence allocation/validation, atomic memory commit and empty-batch behavior; +- conversations, entries, task lifecycle/checkpoint/ownership/abort/terminal retirement; +- current and rewindable values/lists including tombstones/clear/remove; +- fork-aware pages, newest head, task scans and all cursor/filter ordering. + +Accept: +- hand-built batch conformance for every query and mutation; +- rejected batch leaves state/lastSeq unchanged; +- terminal mutation makes scratch unreadable atomically; +- fork/history/deep-cap tests; committed reads return no mutable aliases; +- focused test and repository check green. + +### WP3 — Commit line and raw transaction kernel + +Prerequisites: WP2. + +Deliver: +- FIFO async line; +- transaction overlay, synchronous buffered writes, async committed reads; +- ID minting/rollback; rewindable-before-entry rule; +- base/turn/abort transaction capability construction and runtime validation; +- protected namespace/kind checks; +- no public accept/write/runtime handles yet. + +Accept: +- concurrent commits serialize; async read holds line; +- callback failure consumes no ID; +- same-batch references validate from overlay; +- non-turn cast direct entry rejects; data-only record works; +- managed/protected generic writes reject; +- runtime strict-JSON validation rejects invalid entry/task/state payloads before persistence, including + payloads carried by imported pi-ai types; +- no callback/signal dispatch on line. + +### WP4 — Entries, context and forks + +Prerequisites: WP3. + +Deliver: +- typed entry builders and built-in entry witnesses; +- direct append structural validation, head/self/edit/exchange rules; +- exact fork-aware context derivation and disposable cache/snapshot rules; +- missing-result and call-order normalization adapter boundary using faux messages. + +Accept: +- facet combinations, unknown kinds, heads monotonic and exchange-safe; +- summary-under-running-turn trace; repeated heads; edit winner/no-op/protected targets; +- arbitrary fork cutoff and incomplete exchange repair without tasks; +- error/aborted assistant exclusion; immutable request snapshot. + +### WP5 — Input/admission kernel + +Prerequisites: WP4. + +Deliver: +- queued/input-result/acceptance schemas and protected addresses; +- async in-transaction accept/queue/write with request-key overlay dedupe; +- safe enqueue and placement revalidation; +- post-tools/final/idle boundary transaction helpers; +- pure prospective last-turn idle-boundary planner, without provider execution or a public factory API. + +Accept: +- complete mode table; idle same-batch append/remove; duplicate keys including same batch; +- create conversation + accept through the private generation strategy + parent checkpoint atomically; +- solitary and concurrent collapse traces; +- stale queued head terminal result; no fault; +- queued ownership never inferred from arbitrary checkpoints. + +### WP6 — Terminal-closure task kernel and scratch + +Prerequisites: WP5. + +Deliver: +- pending creation and durable running reservation; +- capability-specific execute/recover runtime and normal terminal closures; +- invocation identity/slot; atomic completed/failed outcome plus scratch retirement; +- scratch transactions and normal closure seal/disposition behavior; +- focused driver fixtures using typed test kinds and explicit task serving. + +Accept: +- running intent precedes effect; reopen chooses recover including no checkpoint; +- repeated recovery from newer checkpoint under stable ID; +- one closure outcome, same-batch minted result ID, throwing closure rollback/fault; +- normal closure commits exactly once when still authorized; WP8 adds mark/close/fault discard races; +- scratch crash retention/terminal retirement/post-retirement rejection. + +### WP7 — Dependencies, serving and waits + +Prerequisites: WP6. + +Deliver: +- live/dependency/conversation/ownership indexes; +- stable conversation/session attachment and temporary drive/task/input waiters; +- foreground idle and background continued serving; +- self/dependency/input-cycle checks and temporary target serving. + +Accept: +- dependencies mean terminal and wake once; +- distinct task IDs concurrent, same ID one invocation; +- repeated/cancelled drives retain one attachment and no waiter leak; +- terminal-owner descendants served without history traversal; +- waitForInput result/missing/terminal-fast-path/caller-cancel and safe non-turn self-conversation case; +- close/fault/shutdown waiter behavior is tested in WP8. + +### WP8 — Cancellation and lifecycle + +Prerequisites: WP7. + +Deliver: +- mark/revoke/signal/join/fresh-abort sequence and abort runtime construction; +- restricted repeated abort commits, turn abort closure, and mark/close/fault normal-closure discard; +- conversation abort operation, completion wait and queue policy; +- close, shutdown and fault phase machines with shared repeated completions; +- driver initialization that claims storage, loads live tasks/required ancestry, captures the task registry, + and performs missing-kind orphan reconciliation; any loading/validation/reconciliation failure closes and + releases storage before rejecting. + +Accept: +- every cancellation/lifecycle row in section 16 applicable to memory; +- fresh abort checkpoint/reopen; repeated mark does not cancel abort; +- passive abort writes but no future work; tool-like direct abort result for turn kind; +- close writes no outcomes; shutdown marks all and preserves queues; fault preserves unfinished state; +- initialization starts nothing; every missing live kind orphans, retires scratch and marks descendants; +- failed reconciliation yields no usable driver; no history scan beyond live seed/required ancestry. + +### WP9 — Watch foundation + +Prerequisites: WP8. + +Deliver the kind-free commit-derived view/event reducer, atomic capture/subscription, bounded lag and +resnapshot. Preview and task-output delivery are excluded until their gated API is settled. + +Accept the watch races and task lifecycle event meanings in sections 15–16. + +### WP10 — JSONL backend + +Prerequisites: WP8; WP9 only for cross-backend watch tests. + +Deliver the backend and all recovery rules in section 15. Run the same storage/admission/task conformance +stream as memory, including real process-kill recovery for running/checkpoint/scratch/terminal cases. + +### WP11 — SQLite backend + +Prerequisites: WP8; WP9 only for cross-backend watch tests. + +Deliver indexed persistence/residency and conformance in section 15. Update the session-backend package only +through a reviewed adapter boundary; no import from existing harness runtime/session implementations. + +## 18. Gated packages and explicit decisions + +Do not implement these until their listed decision is settled and appended to this specification: + +- **Harness open/public handles/core registries:** wire the single final `Harness` only when the required + built-in kinds are implemented. It owns root/rootValues, public mutable registries, core replacements and + the production generation-admission strategy. There is no temporary foundation harness. +- **Provider generation/system integration:** verify landed pi-ai messages-only behavior and define complete + generation input/checkpoint/result/failure/abort schemas, retry/usage dedupe and deferred recovery table. +- **Tool/post_tools/output/preview:** define one coherent author API, tool/job scratch-output sharing, + bounded/spill operations and preview reconstruction. Job-first only; no arbitrary promise adoption. +- **Hooks:** define task identity and namespaced scratch capability, then confirm hook points/typed decisions. +- **Collapse provider implementation:** define summarizer request/checkpoint/result schemas and retry budgets; + the context/head/chain foundation above is ready. +- **Jobs/subagents:** define exact job and child task payload schemas plus terminal notification protocol on + the settled output API; ownership/admission/wait foundation above is ready. +- **Typed system sections:** define the concrete draft/persistence API and preparation staleness retry code + after pi-ai verification; section 14.4 fixes required semantics. +- **Full preview/client integration:** settle tracker/sink API; delivery coalescing remains deferred. +- **Conversation deletion, runtime schema bundle, renderer registry/layouts and migration:** later milestones, + not foundation. Root deletion is always forbidden; descendant/fork-reference deletion policy must be + specified before adding a delete API. + +## 19. Repository and process rules + +Implementation location: `packages/agent/src/harness/pico/`. Do not import from +`packages/agent/src/harness/runtime`, `packages/agent/src/harness/session`, `agent-harness.ts`, Pico, Pico2 +or DOM. Read reusable leaf implementations before copying, then own the copy under Pico. Allowed package +boundaries include Chord and pi-ai. + +Use erasable TypeScript syntax. Pico declarations must not introduce or explicitly spell `any`; imported +pi-ai declarations are exempt and their durable values receive strict-JSON boundary validation. Check +external API types in `node_modules`; do not guess. Test +with package-specific Vitest commands and `npm run check`; never run the credential-sensitive full suite. +Commit only files changed by the current work package, using explicit paths. One reviewed commit per work +package; do not begin the next until the user has reviewed the previous commit. diff --git a/packages/agent/docs/pico/pico-usage-guide.md b/packages/agent/docs/pico/pico-usage-guide.md index 4633fc6ac..23fb4d1d9 100644 --- a/packages/agent/docs/pico/pico-usage-guide.md +++ b/packages/agent/docs/pico/pico-usage-guide.md @@ -4,7 +4,9 @@ Durable agent harness for pi: one session file, any number of conversations, eve recorded as a task that survives a crash, and a view any UI can render. **Note**: this guide is about using the harness. `pico-v3.md` is the design and the reference for -why things are the way they are. +why things are the way they are. Tool/sink/preview interfaces and their examples are provisional: +we still need a simpler authoring surface before implementing them. Their required capabilities and +storage/cancellation guarantees are retained; the separate steps proposal has not been adopted. ## Table of Contents @@ -629,7 +631,10 @@ const answer = await c.prompt({ input: 'Inspect the parser' }, call); The pieces are available separately. `inputId` is always the id of the accepted `pi.inbox` list element, even when idle acceptance places and removes it in the same commit. Generation and -`post_tools` carry input ids explicitly, so result lookup never scans the transcript: +`post_tools` carry input ids explicitly, so result lookup never scans the transcript. This is built-in +harness behavior; a replacement for these built-in kinds must retain their input-result responsibilities. +Removing an executable tool does not remove its task kind or the input owner: the built-in tool task +writes an unavailable-tool result and the turn continues through `post_tools`. ```typescript const { inputId } = await c.accept({ input: 'Inspect the parser', requestId: 'req-42' }, call); @@ -766,6 +771,9 @@ drain their own scratch promises and drop late callbacks, never silently recreat ## Watching +Tool-facing preview APIs remain provisional alongside the sink. Preview delivery coalescing is +deferred; the current per-commit watch behavior is unchanged. + ### The View A UI never reads storage or parses commits. It watches a conversation and gets a **view**: a plain @@ -1089,6 +1097,10 @@ anywhere. A violating builder call throws before anything is persisted. ## Writing Tools +**Provisional:** the sink, runtime and preview interfaces are preserved below for discussion, not as +final implementation instructions. We will simplify them while retaining streaming output, bounded +capture, diagnostics, typed details, usage, control outcomes and durable job/child cleanup. + ### The Sink A tool's `execute` returns nothing. Everything it produces goes through a sink, so output streams @@ -1258,6 +1270,11 @@ A handler receives the active Call as its final argument and must forward it to harness awaits its actual return, not an abandoned raced promise. A handler may run again after a crash, so its external side effects need their own idempotence. +Approval/question policy, presentation and durable answer reuse belong to workspace/plugins, using +existing scratch or scoped values as appropriate. Pico does not add a separate memo subsystem. How +hooks receive task identity and authorized scratch access is still an integration question; the +examples do not establish a new hook-payload API. + ## Writing Kinds Kinds are how you extend the harness with new behaviour rather than new state. An entry kind is a diff --git a/packages/agent/docs/pico/pico-v3.md b/packages/agent/docs/pico/pico-v3.md index 693ea2c5c..b1a57f7fd 100644 --- a/packages/agent/docs/pico/pico-v3.md +++ b/packages/agent/docs/pico/pico-v3.md @@ -6,6 +6,11 @@ not a package export. In snippets, `call` is the current `Call` (Chord Context, asynchronous public/runtime operations; `runtime` is the task/tool capability object. Transaction builders do not take another Call. +**Design status:** the tool/sink/preview interfaces and related examples remain provisional and need +simplification before implementation. They preserve the capabilities to cover, not a final authoring +surface. Storage, atomic publication, cancellation ownership and bounded-output requirements remain +constraints on that redesign. The separate steps/returned-plans proposal has not been adopted. + ## 1. Goals **Few concepts, replaceable behaviour.** A session is conversations, immutable entries, durable @@ -1420,7 +1425,10 @@ records `terminated`; an abort of the run, or `abortInput` on a queued item, rec Foreground abort withdraws queued steer and followUp while preserving write and nextRun. Failure and terminate may place safe writes but do not consume queued inputs that require a successor. Exactly one live generation or `post_tools` owns an active group, and ownership transfers in the -same commit that settles the previous owner. +same commit that settles the previous owner. Input groups/results are built-in harness behavior, +not payload fields the generic driver infers from arbitrary plugin tasks. These built-in kinds are +registered at open; a compatible replacement retains their input-result responsibilities. A missing +tool implementation is handled by the surviving tool task kind, not by orphaning the input owner. The list is stored as append/remove/clear operations, not as a rewritten array. Inbox watch events carry the same operations; an idle same-commit append/remove emits none. Memory and SQLite may @@ -1685,7 +1693,9 @@ tool execute: ``` The tool never touches context, siblings or queues. A throwing `before_tool` blocks the tool. An -ordinary tool throw is an error result, not a cancellation. +ordinary tool throw is an error result, not a cancellation. A missing executable tool is also an +ordinary unavailable-tool result: the built-in `tool` kind remains registered and `post_tools` can +continue or finish the input group. This differs from a genuinely missing task kind (§6.4). ```text recover: replay only if the stored policy allows; else an interrupted result from the checkpoint @@ -1929,6 +1939,11 @@ and a crash simply asks again. A plugin kind declares its own points the same wa that replaces a built-in kind keeps its hook names so existing handlers keep working. Commit listeners are not hooks: they observe, never decide, and never await a commit on the same line. +Approval/question workflows and durable answer reuse belong to the workspace/plugin layer. Pico +supplies task identity and existing storage capabilities, not a separate memo or approval subsystem. +The exact hook access to task id and authorized scratch remains an integration question; no extra +hook-payload fields or answer-replay protocol are established here. + ## 9. API ### 9.1 Handles @@ -2189,6 +2204,11 @@ code. ### 9.3 Task and tool integration +**Provisional API:** the sink, tool runtime and output-state shapes below are the current sketch. +Simplify them together with previews before implementing. Preserve streaming text/images, typed +details, bounded capture/spill, diagnostics, usage, control outcomes, jobs and cooperative cleanup; +this section does not settle the final division of responsibilities or arbitrary-work adoption. + ```ts interface Tool { readonly name: string; readonly description: string; readonly parameters: JsonSchema; @@ -2273,6 +2293,9 @@ copying the message into `data`. ### 9.4 Watch +The tool-facing preview API below is provisional (§9.3). Delivery coalescing is deferred, not an +initial implementation requirement; the current per-commit publication rules remain unchanged. + The transport is the commit stream, gap-free and replayable. The interface is typed events derived from it by the harness, and a view the harness keeps current, so a client renders and never parses commits: diff --git a/packages/agent/docs/pico/pico-work.md b/packages/agent/docs/pico/pico-work.md index 72669c49d..29ddb3446 100644 --- a/packages/agent/docs/pico/pico-work.md +++ b/packages/agent/docs/pico/pico-work.md @@ -11,6 +11,12 @@ completed dependency audit. After the remaining conceptual blockers are settled, whole-document review and final work-package decomposition described below. `pico-v3.md` is the reference design; `pico-usage-guide.md` shows the intended surface. +Next design discussions: evaluate status-indexed task authoring and returned plans separately, then +simplify the tool/sink/preview contract. The steps proposal has not been adopted. Groups 10, 14 and 15 +preserve capability/test coverage, but their current API sketches are not ready-to-implement contracts. +Preview delivery coalescing is deferred. Approval/question workflows and durable answer reuse belong +to workspace/plugins; only their access to existing task identity/scratch is a Pico integration question. + ## 1. Types and ids `Id`, `EntryIdentity`, `EntryBase`, the composable `EntryData` / `ModelProjection` / @@ -345,6 +351,9 @@ Tests: ## 10. Tools, post_tools, exchanges +**API redesign pending:** retain the capabilities and behavioral tests below; simplify the tool, +sink and preview authoring interfaces together before implementing them. + The tool kind with the sink (`ToolOutput`, `ToolOutputState`, limits enforced by the sink, `diag`, `delegate`, `handoff`, `addTools`, `terminate`), tool-result entries with structured data plus their materialized model message, `before_tool` (fail-closed) and `after_tool`, @@ -361,7 +370,9 @@ before any handoff/user entry in the settlement commit and appears in the next t a throwing tool → error result, `terminate` still honoured; truncation diag from the sink; a lost accept response is recovered through `acceptance(requestId)`; a duplicate create reports the first receipt without comparing payloads or modes; results remain point-readable after further turns. -post_tools handles orphaned tool tasks by writing unavailable-tool results from their common fields. +A missing tool implementation produces an ordinary error result through the registered tool task kind; +the generation/post_tools input owner remains present. Separately, post_tools handles orphaned tool +tasks by writing unavailable-tool results from their common fields. Turn-task entry appends remain immediate; outside-turn model-visible writes queue while inTurn is nonempty and land at post_tools/final boundaries. Data-only entries are never blocked. @@ -371,7 +382,9 @@ nonempty and land at post_tools/final boundaries. Data-only entries are never bl user content or a write's entry draft and optional request id; append/remove/clear watch operations; queued/placed/done/unanswered result variants; the three placement points; carried generation/post_tools input groups; `queueInput` and `abortInput`; abort draining steer and followUp while preserving write -and nextRun. A write reaches done without an answer in its placement commit. +and nextRun. A write reaches done without an answer in its placement commit. Input-group ownership +and results are built-in harness behavior, implemented by generation/post_tools; compatible replacements +retain that contract. No generic plugin-payload inference or new input-owner protocol is needed. Tests: idle append/remove is one commit and emits no inbox event; busy image payload is one append operation; the modes table; steer joins at post_tools but starts a group after a final answer; @@ -403,6 +416,10 @@ cleanup tolerates already-terminal child/job; `stop`; nested children; child's o ## 14. Jobs and the budget +**Authoring API provisional:** job-first execution remains the initial supported approach. How tool +code starts, observes and delegates work is part of the tool/sink/preview simplification; the current +helper/sink signatures are not final. + `jobKind` on `ExecutionEnv.exec` with output into its scratch, `waitForTask` / `jobOutput`, `bash` delegating first and waiting with the budget, `notify` and the `notice` entry, the `job` tool, schedules. @@ -416,10 +433,15 @@ ignores background recurrence; recover → lost or safe rerun; abort kills non-d Remaining integration design: arbitrary-promise budget adoption must explicitly transfer effect and sink ownership before the tool invocation releases. A raced, abandoned promise is not permitted. -Preserve the capability (crash outcome lost), but implement job-first execution until transfer is settled. +Preserve the capability (crash outcome lost), but do not turn arbitrary-work adoption into an +implementation package until the simpler tool contract and ownership transfer are settled. ## 15. Previews +**API redesign pending:** retain incremental live output and reconstruction from durable scratch. +The tracker/sink-facing API below is a sketch to simplify, not a separate framework to implement first. +Delivery coalescing is deferred; do not add a frame scheduler or delay scratch durability for it. + `runtime.preview` as a Chord tracker per task; the generation applies stream events to a partial message, the tool's preview is its sink state, the job's the same; `preview.init` on attach and reopen; flush after each scratch commit. @@ -549,10 +571,11 @@ global; a kind that changes its shape bumps its own version, which is also the m Needed when a client is not JavaScript. Not part of the gate. -Not packages: permissions and approval policy are plugin territory (`before_tool` can block or -rewrite args and may wait for a person, scratch holds the durable memo, values hold whatever the -plugin remembers, and a keyed service instance shows the question to every attached presentation), -and session migration is a non-issue while this is experimental. +Not Pico packages: approval/question policy, presentation, durable answers and their replay are +workspace/plugin responsibilities. `before_tool` may block, rewrite args or wait; existing scratch +and scoped values provide persistence. The workspace/plugin layer chooses answer keys and lifetimes; +Pico's remaining integration question is authorized hook access to task identity/scratch, not a new +memo protocol. Session migration remains outside this experimental scope. ## What comes from the lane harness, and how @@ -566,7 +589,7 @@ Copy (under `packages/agent/src/harness/` unless noted): |---|---|---|---| | `ExecutionEnv` / `FileSystem` / `Shell` types, the Node env, capture and spill | `types.ts`, `env/`, `tools/tool-context.ts` (03-execenv) | `pico/env/` | 10, 14 | | shell output limits, `applyShellOutputUpdate`, truncation totals | `utils/` | `pico/env/output.ts` | 10 | -| the built-in tools (`read`, `write`, `edit`, `bash`, `image`) | `tools/*.ts` | `pico/tools/`, rewritten to the sink signature | 10, 14 | +| the built-in tools (`read`, `write`, `edit`, `bash`, `image`) | `tools/*.ts` | `pico/tools/`, adapted after the tool/output interface is simplified | 10, 14 | | system prompt helpers, skills, context files, templates | `system-prompt.ts`, `skills.ts`, `prompt-templates.ts` | host handlers refresh typed base payloads; pure section renderers format them, without a source-read framework | 9, 20 | | telemetry span helpers | `telemetry.ts` | `pico/telemetry.ts` | 19 |