diff --git a/packages/agent/docs/pico/pico-handoff-v2.md b/packages/agent/docs/pico/pico-handoff-v2.md new file mode 100644 index 000000000..ef2234ddd --- /dev/null +++ b/packages/agent/docs/pico/pico-handoff-v2.md @@ -0,0 +1,2926 @@ +# Pico handoff v2 + +Pico is a durable agent harness. This document is the complete implementation specification for +`packages/agent/src/harness/pico/`. Everything is specified except one intentional gap: the exact ordinary +tool API in section 11.1 (how a `ToolDefinition` executes, what facade it receives, how it reaches its output, +memos and children). Its requirements are fixed; its public shape is not, and must not be guessed. Each rule +that is not obvious carries its reason. +Rules that exist only to catch Pico's own bugs are marked as assertions. + +Size is a design constraint: production Pico code (everything under `src/harness/pico/` except the memory, +JSONL and SQLite storage implementations and tests) targets 3k–4k lines. The line and the kernel are small by +intent; if an implementation grows materially past 4k, the response is to simplify the design, not to add +caches, index abstractions or helper layers. Storage backends are excluded because their correctness +(fsync, replay, locking) is not compressible. + +Read order: glossary, sections 1–4 (data), 5 (commit line), 6–8 (tasks, scheduler, cancellation), 9 (input), +10–12 (core kinds, tools and hooks, system prompt), 13–15 (harness, watch, backends), 16 (state adapter), +17 (tests), 18 (work packages), appendix A (type index), appendix B (changes from v1). + +## 0. Glossary + +- **Session**: one open storage plus the process-local kernel over it. One process owns a session + at a time. +- **Harness**: the public object a host uses. One Harness per Session. +- **Conversation**: an ordered transcript. The root conversation has ID 1. A fork has a `parent` pointer + into its source. A subagent has an `owner` task and may independently choose whether to inherit any + transcript (`parent`) or start empty. +- **Entry**: one immutable transcript record. Never edited, reordered or deleted. +- **Context**: the message list sent to the model, derived from entries. +- **Task**: one recoverable async operation with durable status `pending | running | terminal`. +- **Kind**: the code for a task (`execute`, `recover`, `abort`) or the type witness for an entry. +- **Core kind**: one of Pico's four privileged built-in task kinds (`pi.generation`, `pi.tool`, + `pi.post_tools`, `pi.collapse`). Core kinds have direct append and boundary authority; ordinary kinds have + only the boundary-safe passive `write`. +- **Turn task**: a live `pi.generation`, `pi.tool` or `pi.post_tools`. A conversation with a live turn task + is *busy* for input admission. +- **Line**: the single FIFO async mutex through which every commit passes. +- **Commit**: one callback that buffers a `Write[]`, followed by exactly one atomic `Storage.commit`. +- **Kick**: a coalesced "look for eligible tasks" signal to the scheduler. +- **Mark**: durable `abort: true` on a task. Marking starts cancellation. +- **Fresh abort**: the new invocation of `kind.abort` that runs after the old invocation has returned. +- **Scratch**: private per-task state, deleted when the task terminates. +- **Output**: shared live state of a task (streaming assistant text, tool progress), kept as Chord deltas. +- **Sticky**: conversation state with only a current value. **Rewindable**: conversation state whose value at + any historical entry can be read. +- **Inbox**: the per-conversation queue of input that arrived while the conversation was busy. +- **Boundary**: a point in a turn where queued input may be placed into the transcript. +- **Managed entry**: a `pi.system` entry written by system preparation (section 12). +- **Ctx**: Chord's `Context` (cancellation signal, telemetry, invocation identity). Every async operation takes + it last. + +## 1. Data model + +All durable 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; // stable object identity; positive safe integer +type Seq = number; // storage write order; positive safe integer + +import type { JsonRepresentation } from "@earendil-works/chord"; +type Stored = JsonRepresentation; // the durable form of any imported pi-ai type, including unions with primitive arms (UserInput) +``` + +pi-ai's `AssistantMessage`, `ToolCall`, `Usage`, `DeferredHandle` and friends are interfaces: they have no +index signature and `ToolCall.arguments` is `any`, so they are not assignable to `JsonValue` and cannot be +used directly as task payloads, checkpoints, output state or entry `data`. Pico stores them as `Stored`, +Chord's structural strict-JSON representation (`any`/`unknown` become `JsonValue`, methods disappear, +optional keys stay optional). Mapped and literal object types with optional properties are assignable to the +strict `{ readonly [key: string]: JsonValue }` index signature (verified under strict TypeScript, including +optional, nested and `Record` fields), so `JsonObject` stays strict and `{ value: undefined }` +remains a compile error. Strict JSON is validated once at provider/tool ingress; ordinary Pico writes do not +normalize or strip anything. Nothing is duplicated: `Stored` is derived, not redeclared. Public *hydrated* surfaces +(`InputOutcome.answer.message`, hook payloads) hand out the pi-ai type; only durable positions use `Stored`. +Every durable position that carries a pi-ai type uses `Stored`: `Entry.model` and the entry witnesses +(`Stored`, `Stored`, `Stored`, `Stored`), +`ContextEdit.replace.messages`, `QueuedInput.input` and `StoredEntryDraft` (`Stored`, +`Stored`), `ToolInput.call`, `GenerationOutput.message`, the deferred handle, `UsageEntryData.usage`, +`Prep.retry`/`Base.retry`, and the `retry` config token. Public surfaces stay pi-ai typed (`SendInput.content: +UserInput`, `InputOutcome.answer.message: AssistantMessage`, hook payloads) and are converted and validated at +admission or hydration. Compile assertions required in WP1, one per durable position: `Stored`, +`Stored`, `Stored`, `Stored`, `Stored`, +`Stored`, `Stored`, `Stored` each `extends JsonObject`; `Stored +extends JsonValue`; `{ phase: "deferred"; handle: Stored } extends TaskCheckpoint`; +`GenerationOutput extends OutputState`; `ToolInput`, `UsageEntryData`, `QueuedInput`, `StoredEntryDraft`, +`SystemEntryData`, `AssistantEntryData`, `ToolResultData`, `Acceptance`, `SectionRecord`, `ToolControl`, +`ToolDiagnostic` each `extends JsonValue`; `ToolOutputState extends OutputState`, `JobOutput extends OutputState` and `o.stdout = s.stdout` compiles inside a `JobOutput` mutate; every `Task` checkpoint union +in section 10 `extends TaskCheckpoint`; and every entry witness `extends Entry`. In addition, every declaration block in this document is represented in the WP1 compile-only test +(`test/harness/pico/types.compile.ts`, or a dedicated `state-task.compile.ts` for section 16); the compact +payload listings in section 10 are transcribed exactly into named aliases. They must type-check under the +repository `tsconfig` with no `any`: prose that claims exact TypeScript compiles or is wrong. The compile matrix also includes the usage-guide entry example as written (`tx.write(noteKind, { data: +{ text } })` with no `kind`, `tx.write(pinnedKind, { data, model })`, and a `head: "self"` input for a head +witness) and asserts that supplying `kind` or `id` in an `EntryInput` is a compile error. Scope: `defineValue({ +type: "shared", id: 1 }, "x")` and `defineList({ type: "shared", id: 1 }, "x")` are `@ts-expect-error` fixtures. +Task creation: `tx.task(kind, { input: { prompt, extra: 1 } })`, `const spec = { input: {...}, extra: 1 }; +tx.task(kind, spec)`, `tx.task(kind, { ...spec })` and an `input` with a nested extra top-level field are all +`@ts-expect-error`; the exact literal, variable and spread each compile; `tx.task(coreKind, …)` on a +`BaseTaskTx` is an error while the same call on `CoreTaskTx` compiles. Runtime output: +`TaskRuntime` has no `output` key (`rt.output` is a compile error) and +`TaskRuntime["output"]` is `TaskOutput<{ text: string }>`; same for the abort runtime with +`ReadonlyTaskOutput`, and for `FinalTx`/`RunningTask`. Negative fixtures +(`// @ts-expect-error`) for the bundle guards: a fake kind map with two kinds sharing a config key, one with a +config key named `set` or `get`, and two kinds sharing a hook point name, each making the corresponding +`Assert>` or +`Assert>` fail; plus positive fixtures over the real `CoreConfigs` and `CoreHooks`. + +`Id` and `Seq` are separate positive safe-integer domains. IDs are minted by `Storage.nextId()` on the line +before a commit and become valid object identities only when the containing commit succeeds; an ID minted in a +callback that throws must not escape that callback. `Storage.commit` returns exactly one `Seq` per submitted +write, contiguous and ascending, positionally aligned with the `Write[]`, and strictly greater than every +sequence of every earlier commit. Reason: a callback that fails after minting IDs must not leave holes +in the sequence, and a state-only write consumes a sequence without creating an object. Storage keeps a +durable **committed-ID high-water**: after every successful `commit` it raises it to the greatest object ID +(conversation, entry, task, list element) in that batch, and persists it with the batch. On reopen the +allocator starts after the high-water; IDs minted but never committed never reached `commit` and may be +reused, committed IDs never are, including IDs whose only record was in a sidecar that has since been retired +(section 15). + +Values passed into Pico are treated as immutable and are not cloned. External boundaries (RPC, plugins) +validate strict JSON before calling Pico. + +### 1.1 Conversations + +```ts +interface Conversation { + readonly id: Id; + readonly parent?: { readonly conversationId: Id; readonly at: Id }; // fork point + readonly owner?: Id; // task that created it (subagent); absent for host-created +} +// storage record = Conversation & { readonly sectionSeed?: readonly SectionRecord[] }: private immutable creation metadata +// (rendered section seed, section 8.1) carried by the `conversation.create` write; not part of the public projection. +``` + +A conversation's logical transcript is its parent's transcript up to and including `parent.at`, recursively, +followed by its own entries. Later parent entries are invisible. A seed with `parent.at: "start"` stores no +`parent` at all: the child has no inherited entries and no fork link (ownership, if any, is `owner`). Tasks, +inbox and scratch are never inherited. `owner` is a capability and cancellation link (section +8), not an execution link. + +### 1.2 Entries + +```ts +type ContextEdit = + | { readonly target: Id; readonly action: "omit" } + | { readonly target: Id; readonly action: "replace"; readonly messages: readonly Stored[] }; + +interface Entry { + readonly id: Id; + readonly conversationId: Id; + readonly kind: string; + readonly byTaskId?: Id; // set by Pico when a task appended it + readonly data?: JsonValue; // kind-specific payload + readonly model?: readonly Stored[]; // pi-ai messages this entry contributes, in durable form + readonly head?: Id; // context starts here (see 1.3) + readonly edits?: readonly ContextEdit[]; // changes to earlier entries' projection +} + +interface EntryKind { readonly kind: string; is(e: Entry | undefined): e is E } +type EntryIdentity = Pick; // supplied by Pico and the witness, never by the caller +type EntryInput = Omit & + (E extends { head: Id } ? { readonly head: Id | "self" } : { readonly head?: never }); // `kind` comes from the EntryKind argument +``` + +The four facets `data`, `model`, `head`, `edits` are materialized. Reason: context derivation must never call +kind code, so an entry of an unknown kind still projects correctly. + +```ts +declare function defineEntry(kind: string): EntryKind; // typed witness; `is` narrows by kind string +type EntryBase = EntryIdentity; +// facet aliases for composing witnesses (as in the current WP1 code) +interface EntryData { readonly data: D } +interface ModelProjection { readonly model: readonly Stored[] } +interface ContextHead { readonly head: Id } +interface ContextEdits { readonly edits: readonly ContextEdit[] } +type UserEntry = EntryBase & { readonly model: readonly [Stored]; readonly data?: { readonly continuation: true; readonly from: Id } }; // data only on yield continuations +type AssistantEntry = EntryBase & { readonly model: readonly [Stored]; readonly data?: AssistantEntryData }; +type ToolResultEntry = EntryBase & { readonly model: readonly [Stored]; readonly data: ToolResultData }; +type SystemEntry = EntryBase & { readonly model: readonly [] | readonly [Stored]; readonly data: SystemEntryData; readonly edits?: readonly ContextEdit[] }; +type NoticeEntry = EntryBase & { readonly model: readonly [Stored]; readonly data?: JsonValue }; +type SummaryEntry = EntryBase & { readonly model: readonly [Stored]; readonly data: { readonly through: Id }; readonly head: Id }; +type HandoffEntry = EntryBase & { readonly model: readonly [Stored]; readonly head: Id }; +type ResetEntry = EntryBase & { readonly head: Id }; +``` + +Built-in kinds: + +| kind | model | head | data | +|---|---|---|---| +| `pi.user` | `[UserMessage]` | | `{ continuation: true; from: Id }` on yield continuations only | +| `pi.assistant` | `[AssistantMessage]` | | `AssistantEntryData` (attempt count; usage lives on the message) | +| `pi.tool_result` | `[ToolResultMessage]` | | `ToolResultData` | +| `pi.system` | `[SystemMessage]` or `[]` | | `SystemEntryData` (section 12) | +| `pi.notice` | `[UserMessage]` | | optional | +| `pi.usage` | `[]` | | `UsageEntryData = { attempt: number; usage?: Stored; error: string }` (failed generation attempt) | +| `pi.summary` | `[UserMessage]` | yes | `{ through: Id }` | +| `pi.handoff` | `[UserMessage]` | yes | | +| `pi.reset` | `[]` | yes | | + +Assistant entries with `stopReason` `"error"` or `"aborted"` may be stored for display but never enter a +model request and never create tool work. `"pending"`/`"deferred"` are never stored as entries. + +### 1.3 Context derivation + +For conversation `c` and inclusive target `T`: + +```text +H = newest fork-visible entry at or before T with a head +from = H ? H.head : transcript start +range = fork-visible entries from `from` through T +edits = per target, newest edit in range wins +entries = H ? [H, ...range without any head entries] : range +model = concat(entries.map(e => edits[e.id] ? apply : e.model)), then reorder tool results +request = { messages: model } // no top-level systemPrompt or tools; see section 12 +``` + +`head: "self"` on append means "context starts at this entry" (reset, handoff). A summary's head points at +the first retained entry after the summarized prefix, so the summary appears, then the tail. + +Tool-result ordering: results are appended as tools finish, so transcript chronology may not match the +assistant's call order. Projection reorders results into call order. If a fork cut an exchange before all +results landed, projection synthesizes `ToolResultMessage { isError: true, text: "Tool result unavailable: +history ends before this call completed.", details: { reason: "missing_after_fork" } }` per missing call. +Synthesized results are never stored. + +A model request captures the entry ID list and effective messages at a durable cutoff and does not change +afterward. Reason: a generation must know exactly what it sent for recovery and for hooks. A current-context +cache is disposable derived state; historical derivations for older cutoffs read storage and never rewind the +live cache. + +### 1.4 Who appends, and where + +Only core task kinds append entries directly (`CoreTaskTx.entry`). Everyone else, host and ordinary tasks +included, goes through `write` (section 9): the entry is appended immediately if the conversation is not +busy, otherwise it is queued and placed at the next boundary. There is no host `entry()`. + +Reason: every boundary sits between complete exchanges, so an entry placed at a boundary can never split an +assistant/tool-result block. Removing direct host appends removes the only path by which that could happen. + +Transcript invariants, maintained by core kinds and asserted before persistence (section 5.2): + +- Heads only move forward: a new head never points before the previous head's target. Otherwise context + could grow back after a collapse. +- A head never splits an exchange: it keeps an assistant with all its results or omits both. Collapse + guarantees this by choosing `through` at an exchange end. +- Edits target earlier visible entries. Edits never target managed `pi.system` entries, except the omission + edits a fresh baseline carries for superseded managed entries (section 12.4). +- Tool-call IDs within one assistant are unique; at most one result per call, in the same conversation. + +The one runtime check on non-core data is on a queued `write` that carries a `head` (reset, handoff): at +placement, the head target must still be visible and not before the current newest head. A collapse +committed while the write was queued can violate that. The write is then dropped with result +`unanswered/stale`. Edits whose targets have left the context are no-ops and do not stale. + +## 2. Scoped state + +```ts +type Scope = + | { readonly type: "session" } + | { readonly type: "conversation"; readonly conversationId: Id } + | { readonly type: "task"; readonly taskId: Id } // scratch + | { readonly type: "shared"; readonly id: Id }; // task output + +declare const addressType: unique symbol; // compile-only payload witness; never present at runtime, never serialized +interface Address { + readonly scope: Scope; readonly namespace: string; readonly key?: string; + readonly kind: "value" | "list"; readonly rewind: boolean; + readonly [addressType]?: T; // makes Value and Value distinct types +} +interface Value extends Address { readonly kind: "value" } +interface List extends Address { readonly kind: "list" } +interface Element { readonly id: Id; readonly value: T } + +type PublicScope = Exclude; // exported constructors never accept shared scope +type StickyScope = Extract; +type ConversationScope = Extract; +// `defineValue({ type: "shared", id }, ...)` is a compile error; the task-output list is built by an internal constructor +// external constructors reject every `pi.*` namespace at runtime +declare function defineValue(scope: StickyScope, namespace: string, options?: { readonly key?: string; readonly default?: T }): Value; +declare function defineValue(scope: ConversationScope, namespace: string, options: { readonly key?: string; readonly rewind: boolean; readonly default?: T }): Value; +declare function defineList(scope: StickyScope, namespace: string, options?: { readonly key?: string }): List; +declare function defineList(scope: ConversationScope, namespace: string, options: { readonly key?: string; readonly rewind: boolean }): List; +// one public type argument (T); rewind is a literal picked by overload, so `conversationValue("x", { rewind: true })` infers UnboundValue +declare function conversationValue(namespace: string, options: { readonly key?: string; readonly rewind: true; readonly default: T }): UnboundValue & { readonly default: T }; +declare function conversationValue(namespace: string, options: { readonly key?: string; readonly rewind: false; readonly default: T }): UnboundValue & { readonly default: T }; +declare function conversationValue(namespace: string, options: { readonly key?: string; readonly rewind: true }): UnboundValue; +declare function conversationValue(namespace: string, options: { readonly key?: string; readonly rewind: false }): UnboundValue; +declare function conversationList(namespace: string, options: { readonly key?: string; readonly rewind: true }): UnboundList; +declare function conversationList(namespace: string, options: { readonly key?: string; readonly rewind: false }): UnboundList; +declare function sessionValue(namespace: string, options: { readonly key?: string; readonly default: T }): Value & { readonly default: T }; +declare function sessionValue(namespace: string, options?: { readonly key?: string }): Value; +``` + +`default` is resolution metadata, not identity: every reader that resolves this definition (`c.value`, +`c.config.`, `view.config`, core kinds) returns `default` when no value is stored or the value was +deleted. It lives on the token, so there is exactly one place a default is spelled. + +Storage and every `Write` carry fully bound addresses; the unbound form exists only at the public surface. +`ConversationHandle.value`/`list`, `ConversationTx.value`/`list`, `TaskConversation.value`/`list`, +`SeedWrite` in `rootValues`/`ConversationSeed.values`, and `ConversationWatchOptions.values` accept a +bound address or an unbound one, which they bind to their own or target conversation before anything reaches +the kernel. A bound address naming a different conversation than the handle's is a `ScopeViolation`. Reason: +plugins and core kinds declare one token and use it against any conversation; `generationKind.config.model` is +one definition, not one per conversation. + +Identity is `[scope.type, scope id, kind, rewind, namespace, key ?? null]`. Omitted `key` and `key: undefined` +are the same unkeyed singleton; `key: ""` is a distinct keyed member. Only conversation scope may be +`rewind: true`. Public constructors create session, conversation and (for a task's own runtime) task-scope +addresses only; shared scope is internal to `TaskOutput` in v1 and has no public constructor. + +The namespace prefix `pi.` is reserved for built-ins: external constructors reject it, so a plugin cannot +define `pi.anything`. Within it there are exactly two sets: (1) the fixed kinds' config bundle definitions +(`generationKind.config.*` and the other core bundles, section 2.1) are exported tokens usable through every +generic handle and automatically part of `view.config`; they are rejected in requested watch `values` because +they already have their own field and event. (2) The internals `pi.inbox`, `pi.input_result`, `pi.request`, +and `pi.output` are protected: generic read/write/watch/`rootValues` reject them and they surface only through +`view.inbox`, `InputHandle` and `view.taskOutputs`. Plugin and +application definitions (any non-`pi.` namespace) are public and appear in a watch only when requested. There +is no access metadata on definitions and no access registry. + +Values: set / delete. Lists: append / remove / clear. Rewindable state records every write with its `Seq`. +`get(address, at)` with an entry ID resolves `at` to that entry's `Seq` and returns the newest write at or +before it, recursing into the parent capped at `min(at, parent.at)`. Sticky state has only a current value; +historical reads of sticky/session/task/shared state reject `InvalidHistoryPosition`. Forks inherit +rewindable state through the parent walk; sticky *values* are inherited only by an explicit copy at creation +(`copySticky`, section 8.1); sticky lists are never copied in v1. + +**Rewindable writes precede entry appends within one commit.** Reason: a fork at that entry must see the +state written "with" it and not state written after it. The builder throws on violation. + +Who may write which scope: + +| writer | session | own conversation subtree | other conversation | own scratch | shared | +|---|---|---|---|---|---| +| host | yes | yes (all) | yes | no | no | +| task (execute/recover) | yes | yes | no | yes | via `output` only | +| task (abort) | yes | yes | no | read only | read only | + +### 2.1 Typed configuration bundles + +There is no untyped settings object. Each core kind exports a typed bundle of unbound conversation addresses. +The public `conversation.config` facade is assembled from those bundles mechanically, so core tasks and the +public API read and write identical addresses and nothing is declared twice. + +```ts +// the bundles the fixed kinds attach as `.config` (definition objects; the internal constructor is allowed to use pi.* names) +const generationConfig = { + model: conversationValue("pi.model", { rewind: true }), // no default -> no_model + thinkingLevel: conversationValue("pi.thinking", { rewind: true, default: "off" }), + selectedTools: conversationValue("pi.tools.selected", { rewind: true, default: [] }), + profile: conversationValue("pi.prompt.profile", { rewind: true, default: "default" }), + retry: conversationValue>("pi.generation.retry", { rewind: false, + default: { enabled: true, maxRetries: 3, baseDelayMs: 2000, maxAgentDelayMs: 60_000 } }), // pi-ai RetryPolicy; coding-agent defaults +} satisfies ConfigBundle; +const postToolsConfig = { + steeringMode: conversationValue<"all" | "one-at-a-time">("pi.queue.steering", { rewind: false, default: "one-at-a-time" }), + followUpMode: conversationValue<"all" | "one-at-a-time">("pi.queue.follow_up", { rewind: false, default: "one-at-a-time" }), +} satisfies ConfigBundle; +const toolConfig = {} satisfies ConfigBundle; // the tool time budget belongs to the gated ordinary tool API (11.1); nothing in v1 +const collapseConfig = { + threshold: conversationValue("pi.collapse.threshold", { rewind: true, default: 0 }), // context tokens; 0 = never + keepRecent: conversationValue("pi.collapse.keep_recent", { rewind: true, default: 20_000 }), // tokens retained after `through` +} satisfies ConfigBundle; +// generationKind.config === generationConfig, etc. (section 10 kinds are defined with these as `config`) + +// derived from the fixed bundle map, not declared. The map is over the bundle constants, not the kind tokens: +// hook payloads mention `Settings`, so deriving Settings from `typeof generationKind` (which carries hooks) would be circular. +interface CoreConfigs { readonly generation: typeof generationConfig; readonly tool: typeof toolConfig; readonly postTools: typeof postToolsConfig; readonly collapse: typeof collapseConfig; readonly job: {} } +type UnionToIntersection = (U extends unknown ? (x: U) => void : never) extends (x: infer I) => void ? I : never; +type CoreConfigBundle = UnionToIntersection; +type _ConfigsMatchKinds = Assert<{ [K in keyof CoreConfigs]: CoreTaskKinds[K]["config"] extends CoreConfigs[K] ? true : false }[keyof CoreConfigs]>; // the kinds attach exactly these bundles + +type BundleValues = { [K in keyof B]: B[K] extends ConversationValueDefinition ? T : never }; // payload types (for set patches) +type ResolvedValue = D extends { readonly default: infer V } ? V : D extends ConversationValueDefinition ? T | undefined : never; // read type: defaulted members never undefined +type ResolvedValues = { readonly [K in keyof B]: ResolvedValue }; +type Settings = BundleValues; + +// compile-time guards, checked where CoreTaskKinds is assembled: +type ReservedConfigKeys = "set" | "get"; +type Assert = T; // the only way to make a type-level check a compile error +// M is a map of bundles (CoreConfigs or CoreHooks); each member is one kind's bundle +type KeysOf = U extends unknown ? keyof U : never; // distribute over the other bundles; `keyof (A | B)` alone is only their common keys +type Collides = // true iff bundle N shares a key with another bundle (or uses a reserved name) + [Extract]> | Reserved>] extends [never] ? false : true; +type AnyCollision = true extends { [N in keyof M]: Collides }[keyof M] ? true : false; +type DisjointBundles = AnyCollision extends false ? true : false; // boolean, never `never` +type _AssertDisjointConfig = Assert>; // compile error on any duplicate or set/get; also asserted at install time + +type ConfigFacade = { + readonly [K in Exclude]: B[K] extends ConversationValueDefinition + ? (R extends true ? PublicValue> : StickyPublicValue>) // bound to this conversation; get() returns the resolved type + : never; +} & { + set

>>(patch: NoExtra>, P>, ctx: Context): Promise; // one commit + get(ctx: Context): Promise>>; // one batched read, current, defaults resolved +}; + +// ConversationHandle.config: ConfigFacade. Usage: +// c.config.model.get(ctx) current +// c.config.model.get(entryId, ctx) historical: rewindable members only, typed +// c.config.followUpMode.get(ctx) sticky: no historical form +// c.config.model.set(ref, ctx) one value, one commit +// c.config.set({ model, thinkingLevel, followUpMode }, ctx) exact typed partial, one commit +``` + +Each property of `c.config` is the conversation-bound public value for that address, identical to +`c.value(generationKind.config.model)`; core kind code reads the same definitions through `tx.value( +generationKind.config.model)` in its own conversation. Bundle members are reusable +`ConversationValueDefinition` tokens (namespace, key, rewind, payload type; no conversation ID); the +handle or transaction binds them, and storage only ever sees bound addresses. Flattened property names must be +unique across the fixed kinds and must not be `set` or `get`; both are compile errors (`Assert>` +resolves to `false`, which does not satisfy `extends true`) and are re-asserted when the core kinds are installed +at open. Plugin configuration is not merged into `c.config`; +plugins keep their own bundles and use `c.value(token)`. `ConversationSeed.values` (section 8.1) accepts the +same tokens, so an owned child is seeded with exactly these definitions before its first input. + +Model, thinking level, selected tools, profile and collapse thresholds are rewindable: a fork at yesterday's +answer gets yesterday's model. Queue modes and the retry policy are sticky: they describe how the UI wants +the present handled, not history. Defaults are on the tokens above and are what `get`, `view.config` and the +core kinds observe when nothing is stored; queue modes default to `"one-at-a-time"` (matching the current +`Agent`/coding-agent defaults). `model` has no default: a missing model is a typed `no_model` generation +failure, not a fault. + +Generation tasks never copy configuration into their input; they read it at preparation and record in their +checkpoint only what recovery needs to resend the same request (`Prep`, 10.1: model, thinking level, offered +tool names, retry policy, cutoff, system entry, attempt). + +## 3. Tasks + +### 3.1 Records + +```ts +interface TaskCheckpoint extends JsonObject { readonly phase: string } + +type TaskOutcome = + | { readonly status: "completed"; readonly result: R } | { readonly status: "failed"; readonly failure: F } + | { readonly status: "aborted"; readonly result: A } | { readonly status: "orphaned" }; + +interface StoredTaskOutputRef { readonly id: Id; readonly kind: string } +interface TaskBase { + readonly id: Id; readonly conversationId: Id; readonly kind: string; + readonly input: I; // immutable + readonly checkpoint?: C; // full replacement each time + readonly after: readonly Id[]; // dependencies, immutable, acyclic + readonly background?: true; // excluded from idle and foreground cancellation + readonly owns: readonly Id[]; // conversations this task created + readonly output?: StoredTaskOutputRef; + readonly abort?: true; // the mark +} +type Task = + TaskBase & ( + | { readonly status: "pending" | "running"; readonly outcome?: never } // live: never carries an outcome + | { readonly status: "terminal"; readonly outcome: TaskOutcome } // terminal: always carries one + ); +``` + +Bare `Task` (all defaults) is the erased form used by storage, scans, the watch view and host inspection; +status and outcome are correlated by the union, so a pending task cannot carry an outcome and a terminal task +cannot omit one. + +A checkpoint is the *entire* recovery state; each update replaces it whole. `phase` is a kind-defined tag the +kernel never interprets. There is no event log or replay: only the latest checkpoint is durable. Reason: a +full snapshot is trivially recoverable and inspectable; partial patches need a merge algorithm and break on +shape changes. + +### 3.2 Lifecycle + +```text +1. create task.create {status: pending} durable +2. reserve task.set {status: running} durable, before any effect +3. run kind.execute(task, runtime, ctx) -- or kind.recover if step 2 was found on reopen + may commit checkpoints, scratch, output deltas, child tasks +4. close execute returns a closure; kernel runs it on the line; it buffers writes and returns + completed|failed; kernel appends the terminal task.set; one atomic commit +5. cancel mark -> revoke old writes -> signal ctx -> join old invocation -> reserve fresh abort + kind.abort(task, abortRuntime, ctx) returns a closure; aborted outcome; one atomic commit +``` + +Recoverability means: a `running` task found at reopen gets `recover`, with or without a checkpoint. It does +not mean exactly-once external effects. A crash after an unkeyed external call but before recording its +result is uncertain; kinds that need certainty record a request key in a checkpoint *before* the call +(`phase` naming an in-flight effect) and reconcile in `recover`. + +### 3.3 Kinds + +```ts +type OutputState = JsonObject | JsonValue[]; // mutable JSON container; Chord tracks it, storage persists its deltas +type ConfigBundle = Record>; +declare const taskKindBrand: unique symbol; + +interface TaskKindMethods { + readonly kind: string; + readonly hooks?: H; // typed hook points this kind invokes (section 11.2) + readonly config?: ConfigBundle; // conversation-value definitions this kind reads (section 2.1) + execute(task: RunningTask, rt: TaskRuntime, ctx: Context): Promise>; + recover(task: RunningTask, rt: TaskRuntime, ctx: Context): Promise>; + abort (task: RunningTask, rt: AbortTaskRuntime, ctx: Context): Promise>; +} +interface CoreTaskKindMethods { + readonly kind: string; readonly hooks?: H; readonly config?: ConfigBundle; + execute(task: RunningTask, rt: CoreTaskRuntime, ctx: Context): Promise>; + recover(task: RunningTask, rt: CoreTaskRuntime, ctx: Context): Promise>; + abort (task: RunningTask, rt: CoreAbortTaskRuntime, ctx: Context): Promise>; +} + +type TaskKind = + TaskKindMethods & TaskOutputDefinition & { readonly [taskKindBrand]: "ordinary" }; +type CoreTaskKind = + CoreTaskKindMethods & TaskOutputDefinition & { readonly [taskKindBrand]: "core" }; + +// shared constraint list, used verbatim by every task-typed alias below and in sections 16 and appendix A: +// I extends JsonValue, C extends TaskCheckpoint, R extends JsonValue, F extends JsonValue, A extends JsonValue, +// O extends OutputState, H extends HookPoints +type TaskKindDefinition = Omit, typeof taskKindBrand>; +type CoreTaskKindDefinition = Omit, typeof taskKindBrand>; + +declare function defineTask(): + >(definition: NoExtra, D>) + => D & TaskKind; // H and D["config"] inferred from the literal; brand added +declare function defineCoreTask(): + >(definition: NoExtra, D>) + => D & CoreTaskKind; // internal; not a plugin API +``` + +`defineTask` returns an ordinary kind, `defineCoreTask` a core kind; the returned type is `D & TaskKind<...>`, +so `kind.hooks` and `kind.config` keep the exact literal bundle types (that exactness is what `c.config`, +`c.hook`, `ConfigEvent` and `CoreTaskKinds` derive from). Only the fixed core bundles flatten onto +`c.config`/`view.config`; a plugin kind's bundle is accessed through `c.value(kind.config.x)`. Core kinds +receive `CoreTaskTx` (direct entry append, core child creation, admission and boundary helpers); ordinary kinds +receive `BaseTaskTx` (passive `write` only). The private `taskKindBrand` symbol makes the two non-interchangeable. +Reason for the split: plugins must not be able to append mid-exchange, resolve input groups or create turn +tasks; a type-level brand plus separate runtime constructors makes that unforgeable without runtime checks. The +passive `write` is safe to hand out because it only ever lands at a boundary. + +`TaskOutputDefinition` (appendix A) makes `output` required when `O` is declared and forbidden otherwise. +Task creation goes through `TaskCreator` (5.3): `NoExtra` on the spec and `ExactJsonInput` on `input` reject +visible extra fields whether the argument is a literal, a variable or a spread. Excess-property checking on task specs and inputs (`NoExtra`, `ExactJsonInput`) +is applied so that visible extra fields reject at compile time. Deep exactness, casts and erased fields remain +ordinary TypeScript limits. + +### 3.4 Output + +Output is the live, shared, observable state of a task: the assistant message being streamed, a tool's +progress, a job's stdout tail. It is one Chord tracker per output ID; every nonempty flush is appended as one +delta to the protected shared list built by Pico's internal address constructor (`pi.output`). + +- A kind declares `output: { kind, initial(input) }` with `O extends OutputState` (a plain JSON object or + array; no `Date`, `Map`, class instances or functions, because the durable form is Chord deltas over strict + JSON). On creation with no `output` ref, the task's output ID is its own ID and the first list element is + `track(initial(input)).flush()`, committed with `task.create`. `initial` is pure and synchronous; its return + value is transferred to the tracker and must not be retained. One boundary assertion: the kernel asserts that + `initial`'s result and every `replace` value are strict JSON before handing them to the tracker; a violation + faults the session (kind contract error). +- A task may be created with another live task's `output` ref (same `kind` string). Both write the same + tracker. Use: a tool times out, creates a background job with its own ref, and the job keeps writing where + the tool left off. +- The output lives while any live task references it and is deleted in the same commit that terminalizes the + last reference. The terminal closure of that last task receives the final value as `tx.output` so it can + materialize it (e.g. into the assistant entry) atomically. A retired output ID is never resurrected. +- Runtime facade: `output.read(ctx)`, `output.mutate(state => { ... }, ctx)`, `output.replace(value, ctx)`. + `read` and terminal `tx.output` return immutable snapshots, never the tracker target or a proxy. `mutate` + runs the callback synchronously on the line against the tracker proxy; the callback must not be + async, retain the proxy, or call other runtime methods. If it throws, or if flushing/strict-JSON validation + of the mutated state fails, the session faults: the tracker is now in an unknown state and the only safe + recovery is reopen from durable deltas. These are task-contract faults, not domain failures. Only a **nonempty** tracker + flush creates a `pi.output` list element, consumes a `Seq` and emits a `task_output` event; a `mutate` whose + callback changes nothing tracked yields an empty flush and returns normally with no storage commit and no + event: an empty `Write[]` is never passed to storage and an empty `Op[]` is never persisted. `replace(value)` + is an explicit replacement: after strict-JSON validation it always persists Chord's full base flush (`["r", + value]`), even if the value is structurally equal to the current state; Pico does no deep-equality check. +- After a task is marked, its `output` facade rejects. Fresh abort gets `read` only. +- Replay: fold list elements with `applyImmutable()`, hydrate one tracker, consume its synthetic first flush + locally (do not persist it). +- Each protected append emits one `task_output` watch event with that exact decoded delta. An output may be + referenced by tasks in different conversations (a tool in A hands its output to a job it created in owned + conversation B). A conversation watch tracks outputs by *local* reference: when a `task_start`/`task_update` + introduces an output ID that no live task in this conversation referenced before, the envelope carries a + synthetic full-base `task_output` (`[["r", currentValue]]`) for that ID before the task event, so the + reducer can populate `view.taskOutputs` without a persisted append; when the last local reference ends the + reducer drops the output even if another conversation still holds it. The synthetic base is derived on the + line from the live tracker and is never stored. + +Reason for deltas over snapshots: a long assistant message would otherwise be rewritten in full on every +token. V1 does not compact or rebase an active output log; measure first, and reclaim the whole log only when +its final live reference retires. + +## 4. Storage + +Storage is mechanical. It applies a `Write[]` atomically, assigns sequences, maintains indexes, and answers +queries. It never runs kind code and never validates application payloads. + +```ts +type Write = + | { type: "conversation.create"; conversation: Conversation & { readonly sectionSeed?: readonly SectionRecord[] } } // private metadata, see 1.1 + | { type: "entry.append"; entry: Entry } + | { type: "task.create"; task: NewTask } + | { type: "task.set"; task: Task } // complete replacement + | { type: "value.set"; address: Value; value: JsonValue } + | { type: "value.delete"; address: Value } + | { type: "list.append"; address: List; element: Element } + | { type: "list.remove"; address: List; elementId: Id } + | { type: "list.clear"; address: List }; + +interface Storage { + nextId(): Id; + commit(writes: readonly Write[], ctx: Context): Promise; // one Seq per write, aligned + getConversations(ids: readonly Id[], ctx: Context): Promise>; + scanConversations(query: ConversationScan, ctx: Context): Promise>; + getEntries(ids: readonly Id[], ctx: Context): Promise>; + scanEntries(query: EntryScan, ctx: Context): Promise>; + newestHead(conversationId: Id, at: Id, ctx: Context): Promise; + getTasks(ids: readonly Id[], ctx: Context): Promise>; + scanTasks(query: TaskScan, ctx: Context): Promise>; + getValue(address: Value, at: Id | undefined, ctx: Context): Promise; + readList(address: List, at: Id | undefined, ctx: Context): Promise[]>; + close(ctx: Context): Promise; // releases files/locks; idempotent; called by the Session on close/fault, never by hosts directly +} +``` + +Query rules: + +- Conversation and task scans ascend by ID; `cursor` is an exclusive lower bound. +- Entry scans are fork-aware and newest-first; `through` is an inclusive upper bound, `cursor` an exclusive + upper bound; `kind` filters before `limit`. `newestHead` is the dedicated "newest entry with a head at or + before `at`" query so context derivation never tails a scan. +- `readList` returns the complete logical list ascending by element ID. No paging. +- Pages carry no snapshot sequence. Reason: reads happen on the line or against immutable history; a + multi-page snapshot protocol is unnecessary. +- List removal targets an element of the *logical* list at the write position: an element appended locally or + inherited from a fork parent within that list's lineage (same definition, ancestor conversation, at or before + the fork cap). Removing a visible inherited element is valid and records a local removal (the parent is + untouched); removing an element already removed in this lineage is idempotent and still consumes a sequence. + An ID that never existed, or that belongs to a different definition or an unrelated conversation's list, + rejects. Clear on an empty list is valid. + +Storage side effects on `task.set` to terminal: delete the task's scratch scope; delete each shared output +that no live task references after this batch. Reading retired scratch rejects `ScratchRetired`; reading a +retired output rejects `OutputRetired`, in every backend. + +Empty `Write[]` is never passed to storage. An uncertain commit failure (storage threw after possibly +writing) poisons the session: fail-stop, never retry the callback. + +## 5. The commit line + +One FIFO async mutex serializes every commit, every `nextId()` call, every reservation, mark, registry change, +watch capture and waiter registration. Callbacks, kind methods, hooks and signals never run while the line is +held. + +```text +enter line (caller ctx checked before entering and again before the callback) +build a capability-bound tx with an empty Write[] +run callback + explicit reads (tx.getTask, tx.value().get, ...) await committed storage — allowed only before the first write + writes buffer synchronously; creations call Storage.nextId() synchronously + on throw: discard Write[], minted IDs stay burned, rethrow +validate and assert (section 5.2) +Storage.commit(Write[], internalCtx) -- exactly once, non-cancellable +apply to in-memory indexes; kick scheduler; resolve waiters +leave line +dispatch signals/listeners; resolve caller +``` + +### 5.1 Read before write + +A transaction may read committed state only until its first buffered write; a later explicit read rejects +`ReadAfterWrite`. Reason: there is no read-your-writes overlay. Allowing reads after writes would either +require the kernel to merge the pending `Write[]` into every query (a second storage engine) or return stale +answers that silently disagree with what the callback just wrote. Authors read first, decide, then write, +carrying the values they wrote in local variables. The builder does remember its own same-batch creations for +validation (a task created earlier in the batch is a valid `after` target), but does not expose them as reads. + +Reads while holding the line block every other commit. Keep them small and few. + +### 5.2 What the Session checks + +**Validation** (capability or lifecycle; a failure rejects the commit, the session continues): + +| check | why | +|---|---| +| tx capability: ordinary tx has no `entry`/admission/core creation, only passive `write`; abort tx has no task creation or `write` | plugin isolation (by construction, not a check) | +| address scope: conversation address must be in writer's subtree; task address must be writer's own; protected `pi.*` internals rejected for non-internal writers | capability | +| invocation identity: task tx/runtime ctx carries the current invocation object; stale or foreign rejects | cancellation correctness (section 7) | +| task marked: normal commit/scratch/output from a marked invocation rejects | cancellation correctness | +| rewindable writes before entry appends | fork visibility (section 2) | +| queued `write` head still valid at placement, else `unanswered/stale` | a collapse may have moved the head meanwhile | +| `after` targets exist and form no cycle; task self-wait and dependency-path waits reject | deadlock prevention | +| output ref names a live output of the same kind | writes to nothing otherwise | +| token identity: the kind object passed to `tx.task(kind, …)` / `tx.write(entryKind, …)` / `hooks.on(kind, …)` is the object currently registered for that name (or the fixed built-in witness), compared by identity, else `StaleDefinition` | a redeclared or replaced token must not create durable input/facets under a different current implementation | +| `createConversation` parent is in the writer's subtree | a task cannot inherit an unrelated tree | +| session phase (open/stopping/closing) permits this operation | lifecycle | + +**Pre-persistence assertions** (invariants of kernel- and core-constructed writes; a failure faults the +session because persisting them would be durable corruption): + +- Task transitions: `task.create` only for an absent ID, pending, no checkpoint/outcome/abort, empty `owns`; + running only from pending with every `after` terminal (or marked, ignoring `after`); terminal only from + running and carrying an outcome, with one narrow exception: open-time orphan reconciliation (section 13 step + 5) may terminalize a `pending` *or* `running` task directly as `orphaned`, and only it may; marked snapshots + keep their lifecycle state; terminal is never replaced. +- Every owned `conversation.create` has a same-batch `task.set` whose `owns` gains exactly that ID; `owner` + matches; ownership is never changed later. +- One `pi.output` append per tracker flush, with provenance from a live task referencing that output. +- Transcript invariants of section 1.4 for core appends. + +### 5.3 Transaction surfaces + +```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(v: T): void; delete(): void } +interface TxList { read(at?: Id): Promise[]>; append(v: T): Id; remove(id: Id): void; clear(): void } +interface TxState { // bound addresses only (host scope is unbound) + value(a: Value): TxValue; + list(a: List): TxList; +} +interface BoundTxState { // binds unbound definitions to own conversation + value(a: Value | UnboundValue): TxValue; + list(a: List | UnboundList): TxList; +} + +// exact task creation: visible extra fields in the spec or in `input` reject, for literals, variables and spreads alike +interface TaskCreator { + task & Extra>( + kind: K, + spec: NoExtra & Extra, S> & { readonly input: ExactJsonInput, S["input"]> }, + ): Id; +} + +interface HostTx extends TxReaders, TxState, TaskCreator { // host must name the conversation + createConversation(spec: ConversationCreateSpec): Id; // sync; no first input (section 8.1) + write(conversationId: Id, kind: EntryKind, input: EntryInput): Promise; // passive; returns the input id; no request key +} +interface ConversationTx extends TxReaders, BoundTxState, TaskCreator { // HostTx bound to one conversation + createConversation(spec: ConversationCreateSpec): Id; + write(kind: EntryKind, input: EntryInput): Promise; +} +interface BaseTaskTx extends TxReaders, BoundTxState, TaskCreator { // conversationId defaults to own + checkpoint(c: C): void; // buffers a full task.set with this checkpoint + createConversation(spec: ConversationCreateSpec): Id; // owner = this task; parent must be in subtree; no first input + write(kind: EntryKind, input: EntryInput): Promise; // own conversation; passive, boundary-safe; no request key +} +type CoreTaskTx = Omit, "task" | "write"> & TaskCreator & { // composed, not extended: `write` changes arity; creator accepts ordinary or fixed core children + entry(kind: EntryKind, conversationId: Id, input: EntryInput): Id; + accept(conversationId: Id, options: InternalAcceptOptions): Promise; // section 9 + queueInput(conversationId: Id, input: QueuedInput): Promise; + write(conversationId: Id, kind: EntryKind, input: EntryInput, requestId?: string): Promise; // private admission helper; request-keyed receipts are kind "write" +}; +interface AbortTx extends TxReaders, BoundTxState { // same own-conversation binding as BaseTaskTx; scratch stays task-bound via ScratchReader + checkpoint(c: C): void; + markTask(id: Id): Promise<"marked" | "terminal">; +} +interface CoreAbortTx extends AbortTx { + entry(kind: EntryKind, conversationId: Id, input: EntryInput): Id; // publish aborted result + write(conversationId: Id, kind: EntryKind, input: EntryInput, requestId?: string): Promise; +} +type FinalTx = BaseTaskTx & FinalOutput; +type CoreFinalTx = CoreTaskTx & FinalOutput; +type AbortFinalTx = AbortTx & FinalOutput; +type CoreAbortFinalTx = CoreAbortTx & FinalOutput; +``` + +`accept`/`queueInput`/`write` are async because a request-key lookup and the busy check are committed reads; +called before any write they read then buffer, called after a write they use only their same-batch memory +and reject if a committed read would be needed. `write` is the only way for non-core code to add to the +transcript (section 1.4); it is available to ordinary task kinds in normal commits and terminal closures +(`FinalTx`), so a task can queue its completion notice atomically with its own terminalization. The busy check +is prospective over the whole batch: an ordinary task never makes a conversation busy, and a core task's own +terminal `task.set` in the same batch counts as gone, so a `write` in a terminal closure is appended +immediately unless some *other* turn task is still live after the batch. Public and ordinary `write` take no +request key and return only the input ID: a terminal-closure notice is already exactly-once because closure +settlement is atomic, and a host write that must be idempotent can be keyed by the caller in its own state. +Only the core admission helper `CoreTaskTx.write`/`CoreAbortTx.write` accepts a `requestId`, and receipts it +creates are tagged `kind: "write"`. `Harness.input(requestId)` returns a handle only for receipts tagged +`kind: "send"`, so a passive write can never be retrieved as an `InputHandle`; `InputHandle` is exclusively +admitted user input. The placed entry of a write is observed through the watch (`entry` event); passive writes +have no public handle. Ordinary kinds never get direct `entry`, `accept`/`queueInput`, boundary helpers, +input-group resolution or core creation. `AbortTx` has no `write`. + +### 5.4 Runtime surfaces + +```ts +interface TaskRuntimeBase { + readonly taskId: Id; + scratch(build: (tx: TxState) => T | Promise, ctx: Context): Promise; // own task scope only + readonly hooks: HookRunner; // section 11.2; runs outside the line + conversation(id: Id, ctx: Context): Promise; // own subtree only + waitForTask(id: Id, ctx: Context): Promise; // own subtree or own deps only + abortTask(id: Id, ctx: Context): Promise<"marked" | "terminal">; + now(): number; sleep(untilMs: number, ctx: Context): Promise; +} +interface CommitRuntime { + commit(build: (tx: Tx, current: RunningTask) => T | Promise, ctx: Context): Promise; +} +type RuntimeOutput = [O] extends [never] ? { readonly output?: never } : { readonly output: TaskOutput }; +type AbortRuntimeOutput = [O] extends [never] ? { readonly output?: never } : { readonly output: ReadonlyTaskOutput }; + +type TaskRuntime = + TaskRuntimeBase & CommitRuntime> & RuntimeOutput; +type CoreTaskRuntime = + TaskRuntimeBase & CommitRuntime> & RuntimeOutput; + +interface AbortRuntimeBase { + readonly taskId: Id; + scratch(read: (s: ScratchReader) => T | Promise, ctx: Context): Promise; // read only + readonly hooks: HookRunner; + conversation(id: Id, ctx: Context): Promise; + waitForTask(id: Id, ctx: Context): Promise; + abortTask(id: Id, ctx: Context): Promise<"marked" | "terminal">; + now(): number; sleep(untilMs: number, ctx: Context): Promise; // cancellable wait for graceful cleanup; still no write/create/mutate authority +} +type AbortTaskRuntime = + AbortRuntimeBase & CommitRuntime> & AbortRuntimeOutput; +type CoreAbortTaskRuntime = + AbortRuntimeBase & CommitRuntime> & AbortRuntimeOutput; + +interface BoundPublicState { // shared by every conversation-bound public handle + value | UnboundValue>(a: D): PublicValue, ResolvedValue>; + list(a: List | UnboundList): PublicList; +} +interface TaskConversation extends BoundPublicState { // restricted; no commit, no entry + readonly id: Id; + send(input: SendInput, ctx: Context): Promise; +} +interface AbortTaskConversation extends BoundPublicState { readonly id: Id } +``` + +`current` passed to every `commit`/closure callback is a fresh read of the task on the line, not the stale +method argument. Reason: checkpoints and marks committed since the method started must be visible. + +Invocation identity: the scheduler installs a private `{ taskId, method, token: object }` in a derived ctx +through a Chord context key. Every runtime method and `TaskConversation`/`InputHandle` returned to a task +captures that object and compares it by identity. Metadata or RPC transport never confers authority; a task +that uses an unrelated host ctx is an in-process escape no type can prevent. + +Line reentry (calling `runtime.commit` inside a commit callback) rejects `NestedLineOperation` immediately +rather than deadlocking. + +## 6. Scheduler + +One scheduler per Session covers every conversation. Open constructs the indexes and leaves it inert; +`resume(ctx)` enables it. Indexes: all live tasks; reverse dependency edges; tasks per conversation with +turn/foreground subsets; conversation owner/parent ancestry; one invocation slot per running task; waiters. + +```text +kick: if disabled -> nothing (resume rescans everything); else dirty = true, ensure one drain runner +drain: while dirty { dirty = false; on line: reserve every eligible task; off line: dispatch each } +eligible: live, and (marked -> fresh abort, ignoring dependencies) + or (pending, unmarked, every `after` terminal -> task.set running, dispatch execute) + or (running, unmarked, loaded at open with no invocation -> dispatch recover) +``` + +Every successful main commit kicks after in-memory application. Scratch and output-only commits do not kick +(they cannot change eligibility). A commit racing with drain exit sets dirty; the drain loops. No polling. + +"Dependency terminal" includes failed, aborted and orphaned; the dependent reads the outcome and decides. +Reason: a failed dependency should not strand the dependent as pending forever. + +One invocation per task ID at a time. The slot is held from dispatch through method return, producer joins +(pending scratch/output writes) and closure acceptance or discard. The scheduler never awaits one task +before dispatching another; tasks in the root, forks and subagents run concurrently. + +`background: true` only affects idle observation and foreground cancellation. It does not affect +eligibility or execution. A foreground task depending on endless background work keeps foreground idle false +intentionally. + +Waits (`waitForTask`, `InputHandle.wait`, `waitForIdle`) ensure `resume`, return immediately if already +satisfied, else register one cancellable waiter on the line. A waiter observes; it never owns execution. +Cancelling a wait removes only that waiter. Deadlock guards: direct self-wait rejects; waiting on a task whose +unresolved dependency path reaches the caller rejects; `after` cycles reject at creation. + +## 7. Cancellation and lifecycle + +### 7.1 Task abort + +```text +commit task.set {abort: true} -- the mark; idempotent +revoke: old invocation's commit/scratch/output now reject before the callback +signal old invocation's ctx -- off line +await old method return + producer joins +on line: discard any normal closure it returned; release slot +reserve fresh abort: new invocation object, new ctx; kind.abort(task, abortRuntime, ctx) +abort may checkpoint (cleanup progress) and read scratch/output; may not create work +abort returns closure -> aborted outcome + scratch/output retirement, one commit +``` + +Why mark-then-fresh-abort rather than "let the old invocation handle its own cancellation"? The old +invocation may be mid-await on an external effect and may never return cleanly; its partial writes must not +be trusted. A durable mark survives a crash, so reopen runs `abort` (not `recover`) for a marked task even if +the old process died. Revoking before signaling guarantees no write from the old invocation lands after the +mark. + +A pending task that is marked before reservation never executes; the kernel writes `running` only as part of +reserving fresh abort so `abort` receives a `RunningTask`. A repeated mark on a task whose abort is already +running is a no-op and does not cancel the abort. An uncooperative effect (ignores ctx) delays abort +indefinitely; no forced isolation in v1. + +Cancellation is classified by Pico's own identity and known reason, never by error name alone. Local tool +deadlines are not marks; their errors are domain outcomes. + +### 7.2 Conversation abort + +`conversation.abort(ctx)`: + +1. Compute the foreground cancellation set: every live task directly in this conversation that is not + `background: true`, whatever its kind (turn tasks, threshold collapse, ordinary plugin tasks); for each, the + conversations it owns; recurse into their live non-background tasks. A background or terminal owner stops + the walk, so manual collapse, jobs and other background work survive. Fork parentage is not ownership and + grants no reach. +2. One commit: mark every task in the set; remove queued `steer`/`followUp` items in affected conversations + and write `unanswered/aborted` results; keep queued `write` items. +3. Track the set (plus any task marked transitively by those aborts). Resolve when all are terminal. + +Task abort (`abortTask`) preserves queues; conversation abort withdraws steer/followUp. Reason: aborting one +tool should not throw away the user's typed follow-up; aborting the whole turn should. + +### 7.3 Close, shutdown, fault + +- **close**: stop admission and reservation; let admitted line work finish; signal and join every + invocation; close the storage. Writes no marks or outcomes. Running tasks stay `running` durably and get + `recover` next open. +- **shutdown**: enter `stopping`; one commit marks every live task; only abort commits are accepted; wait + until no live tasks and no invocation slots; close. Queues are preserved. A failing abort faults. Explicit + `close` may interrupt shutdown, which then rejects; durable marks recover later. +- **fault**: on invariant/persistence/closure/contract error: reject all waiters, stop admission, signal and + join invocations, close storage. Never manufacture outcomes. Domain failures (provider errors, tool throws) + are typed outcomes, not faults. + +Repeated close/shutdown calls share one completion. Caller cancellation cannot abandon an admitted close or +shutdown. + +Phase machine and exact sequence: + +```text +open --close--> closing --> closed +open --shutdown--> stopping --(all live tasks terminal, no invocation slots)--> closing --> closed +any --fault--> faulted (terminal; storage closed after joins) + +closing: 1 close() queues on the line like any operation; the callback already admitted owns the line and + finishes and persists normally; close enters after it + 2 on line: phase = closing; every waiter (task/input/idle) rejects Closed; every watch is detached and + gets {type:"closed", reason:"session"} once; operations queued behind close reject Closed + 3 off line: signal every invocation ctx; await method returns and producer joins; + returned closures are discarded (nothing written); invocation commits reject Closed + 4 storage.close(ctx); phase = closed +faulted: the faulting operation holds the line; on line: phase = faulted; waiters reject Faulted; watches are + detached and get {type:"closed", reason:"session"}; then off line: signal and join invocations, + storage.close(ctx). Nothing is written after the fault. +closed/faulted: permanent for this Harness object; every method rejects Closed/Faulted except `close()` and + `shutdown()`, which are idempotent and return the shared completion; a new Harness.open on the + same storage path is the only way forward +``` + +| phase / method | normal commit | scratch write | terminal closure | abort commit/closure | host admission | +|---|---|---|---|---|---| +| open, execute/recover, unmarked | yes | yes | yes | no | yes | +| open, execute/recover, marked | reject | reject | discard | no | yes | +| open, abort invocation | no | read only | no | yes | yes | +| stopping | no | no | discard | yes | reject | +| closing / faulted / closed | no | no | discard | no | reject | + +## 8. Ownership + +A task that calls `tx.createConversation` becomes the conversation's `owner` and the conversation ID is added +to its `owns` in the same commit. Ownership grants: + +- capability: `runtime.conversation(id)`, `waitForTask`, `abortTask` accept targets in the owner's subtree; +- cancellation reach: conversation abort and `pi.tool` abort follow `owns`; +- foreground idle: a conversation is idle when its foreground cancellation set is empty. + +Ownership does not affect scheduling. A terminal owner's owned conversations keep running. A subagent is an +owned conversation; there is no "subagent task". One task cannot resolve a conversation owned by a sibling +task; the only exception is the `pi.tool` descendant resolver of section 11.1 for later sibling tool calls. + +### 8.1 Seeding owned conversations + +All model, system and tool configuration is conversation-scoped. An owned conversation does **not** copy its +parent's current configuration implicitly; it gets exactly what its creator seeds. Reason: a subagent with a +different model, a smaller tool set and its own identity is the normal case, and an implicit copy of a +parent's mutable settings would be a hidden dependency that changes under the child. + +```ts +type SeedWrite = + | { readonly address: Value | UnboundValue; readonly value: JsonValue } // set + | { readonly address: Value | UnboundValue; readonly delete: true }; // durable absence (cuts an inherited rewindable value) + +interface ConversationSeed { + readonly parent?: { readonly conversationId: Id; readonly at: Id | "start" }; // transcript inheritance; default none + readonly values?: readonly SeedWrite[]; // conversation-bound values: generationKind.config.*, postToolsKind.config.*, plugin state + readonly copySticky?: readonly UnboundValue[]; // copy parent sticky values at creation (async surfaces only: needs a parent read) + readonly sections?: readonly SectionSeed[]; // sectionSeed(section, value) to set; removeSection(key) to cut an inherited section (host forks) + readonly input?: UserInput; // first input; creates the first generation. Omit to create inert (path 1 below) + readonly requestId?: string; +} +type ConversationCreateSpec = Omit; // the synchronous tx form +type ForkOptions = { readonly at: Id | "start"; readonly abort?: boolean } & Omit; // parent is the handle's conversation +``` + +Creation is one transaction: `createConversation` (for a task creator: owner = current task, `owns` updated), +every `values` set/delete and sticky copy, the section seed (below), for owned children the configuration +isolation writes (below), and, when `input` is present, `accept` of the first input through the fixed +generation strategy plus the creator's own checkpoint recording the child conversation ID, input ID and +request key. Either all of it commits or none does. + +Which surface can do which part: `HostTx.createConversation(spec)` and `BaseTaskTx.createConversation(spec)` +are synchronous and take `ConversationCreateSpec`: no `input` (accepting one needs a committed request-key +read, which a builder cannot do after its first write) and no `copySticky` (copying needs a parent read; a +caller that wants it reads first and passes explicit `values`). The asynchronous conveniences +`Harness.createConversation(seed, ctx)` and `ConversationHandle.fork(options: ForkOptions, ctx)` (host; one +commit each) and the core-owned mediated path of section 11.1 (tools; internal `CoreTaskTx` performing the +request-key/busy/parent reads before its first write) accept the full seed. There is no other way to create a +conversation with a first input. + +Request key on creation: a seed with `input` and `requestId` cannot use first-key-wins, because the original +acceptance (if any) belongs to some other conversation and returning that handle would break fork/creation +semantics, while creating an empty conversation anyway would leave garbage. Rule: the async creation planner +checks `pi.request` on the line before minting anything; if a receipt for that key exists (of either kind), +creation rejects `RequestKindMismatch` and writes nothing, and the caller recovers the original through +`h.input(requestId)`. A fresh key, or no key, creates conversation and input atomically. + +Two planners, one seed shape: + +| | host fork / host `createConversation` with `parent` | task-owned child (subagent) | +|---|---|---| +| built-in rewindable config (`model`, `thinkingLevel`, `selectedTools`, `profile`, `threshold`, `keepRecent`) | inherited at the fork point, then seed overrides | **isolated**: never inherited (below) | +| built-in sticky config (`retry`, `steeringMode`, `followUpMode`) | absent (reads default) unless `copySticky`/`values` | absent (reads default) unless `copySticky`/`values` | +| system sections | desired state starts from inherited canonical state | desired state starts **empty** (below) | +| plugin rewindable values | inherited unless seed sets/deletes | inherited unless seed sets/deletes | +| `owner` | none | the creating task | + +A fork is the same agent continuing from an earlier point; an owned conversation is a different agent. + +**The seed must be sufficient on its own.** If `input` is present, the child's first generation may be +reserved by the scheduler in the same drain that follows the creation commit, so nothing registered +afterwards can be assumed to exist. Every child-specific configuration and system-section value must +therefore be durable in the creation transaction, either literally (`values`, `sections`) or through a durable +profile value (`generationKind.config.profile`) that already-registered handlers expand deterministically. + +**Hooks are code, not data.** A hook handler is a process-local callback; it cannot be serialized, persisted +or made part of an atomic commit, and there is no durable identity for a callback. `ConversationSeed` +therefore has no `hooks` field. Child-specific behaviour that lives in hooks reaches the child by exactly one +of two paths: + +1. **Inert creation, then activate.** The host creates the child without `input` (`tx.createConversation`, + `h.createConversation`, `c.fork`), obtains its `ConversationHandle`, registers child-scoped hooks with + `c.hook.*` / `c.hooks.on` and adjusts `c.config`, and only then calls `send`. Nothing runs before `send`, so + there is no race. This is the host path. +2. **Atomic creation with a durable profile.** A tool-mediated (or host) creation that includes `input` seeds + `generationKind.config.profile` (and any other selection values) in the same transaction. Hook + implementations for every profile were registered before `resume`, harness-wide or on the parent with + `subtree: true`, and dispatch on the child's profile value read from `info.conversationId`. No + post-creation registration is needed, so no race exists. This is the subagent path. + +On reopen, the host reinstalls hook code before `resume`; durable profile/config values select behaviour. A +child whose profile has no registered implementation gets the generic handlers, which must treat an unknown +profile as a plain default, never as an error. + +**Owned conversations never inherit built-in configuration.** For a task-owned child, even with `parent` +set (transcript inheritance), the creation transaction writes each seeded rewindable member of the full +`CoreConfigBundle` locally and a durable `value.delete` for each unseeded rewindable member (`model`, +`thinkingLevel`, `selectedTools`, `profile`, `threshold`, `keepRecent`; the set is derived from the bundle, so +a new rewindable built-in is covered automatically). Rewindable lookup therefore finds a local write for every +member and never walks into the parent for configuration. Sticky members cannot inherit by construction and +follow `copySticky` or `values` only. Absent `model` yields a typed `no_model` failure on the child's first +generation; every other absent member reads its default. Plugin rewindable values are *not* isolated: Pico +cannot know which unknown definitions should be cut, so they follow ordinary parent history at the fork point +unless the seed sets or deletes them (`SeedWrite` with `delete: true`). Host forks skip this block entirely. + +Section seed: set entries are rendered with the registered definitions at creation time (an unregistered +section rejects); removals (`removeSection(key)`) need no definition. The resulting `SectionRecord[]` (`set` +with value+rendered, or `remove`) is persisted as private immutable metadata on the `conversation.create` +storage record itself (section 1.1), not as an address: it is atomic with creation, survives inert setup and +reopen, is invisible to the public `Conversation` projection and to every generic handle, and needs no new +protected namespace. Preparation applies it while this conversation has no *local* managed `pi.system` entry +(inherited managed history from a fork parent does not count): for a host fork it is folded over the inherited +canonical state, for an owned child over the empty desired state (below). After the first local managed entry +is appended it is inert. The root uses the same metadata for `root.sections`. + +**First preparation, two planners.** The *comparison base* is always the inherited canonical section state +(fork-visible managed history if `parent` is set, empty otherwise); the delta or baseline is computed against +it (section 12.5). What differs is where the *desired* state starts: + +- Host fork / host `parent`: desired = inherited canonical state, then seed `set`/`remove`, then + `systemInstructions` handlers. Tools = the fork's inherited-then-overridden `selectedTools`. +- Task-owned child: desired = **empty**, then seed `set`, then `systemInstructions` handlers (subtree + handlers on ancestors provide defaults; child-local and profile-dispatched handlers run last). Tools = the + child's isolated `selectedTools`. Diffing desired against the inherited base therefore emits a `remove` for + every inherited section the child did not seed or re-set, and `toolsRemoved` for every inherited tool not in + the child's loadout. + +Inheriting the parent's `pi.system` entries and then sending without that delta would present the main agent's +identity and tool loadout to the child's model; that is a defect, not a configuration choice. The requirement +holds regardless of how the mediated `conversation()` API of section 11.1 is eventually shaped. Whichever path +created it, a child's system sections, configuration and profile are in place before its first generation +prepares; that is the invariant both paths exist to guarantee. + +## 9. Input admission + +### 9.1 Records + +```ts +type UserInput = string | readonly (TextContent | ImageContent)[]; + +type QueuedInput = + | { mode: "steer" | "followUp"; input: Stored; requestId?: string } // durable form; SendInput.content is converted at admission + | { mode: "write"; entry: StoredEntryDraft; requestId?: string }; // passive entry, e.g. a notice + +// durable, protected, ID-only +type StoredInputResult = + | { status: "queued"; requestId?: string } + | { status: "placed"; requestId?: string; entry: Id } + | { status: "done"; requestId?: string; entry: Id; answer?: Id } + | { status: "unanswered"; requestId?: string; entry?: Id; reason: "terminated"|"aborted"|"failed"|"stale"; detail?: string }; + +// public, hydrated by the handle before it resolves +type InputOutcome = + | { readonly status: "queued"; readonly requestId?: string } + | { readonly status: "placed"; readonly requestId?: string; readonly input: UserEntry } + | { readonly status: "done"; readonly requestId?: string; readonly input: UserEntry; + readonly answer?: { readonly entry: AssistantEntry; readonly message: AssistantMessage } } + | { readonly status: "unanswered"; readonly requestId?: string; readonly input?: UserEntry; + readonly reason: "terminated" | "aborted" | "failed" | "stale"; readonly detail?: string }; +type TerminalInputOutcome = Extract; + +interface SendInput { requestId?: string; content: UserInput; whenBusy?: "followUp" | "steer" | "reject" } +interface InputHandle { + readonly id: Id; readonly conversationId: Id; + result(ctx: Context): Promise; // point read plus entry hydration, no activation + wait(ctx: Context): Promise; + abort(ctx: Context): Promise<"aborted" | "already_placed" | "not_found">; +} +``` + +`StoredInputResult` is what admission writes, for user input and passive writes alike; `InputOutcome` is +what callers see, and only for admitted user input (`send`). The handle reads the stored record, then fetches +the referenced entries (`getEntries`, one round trip) and resolves with them attached. Reason: every caller of +`wait` wants the answer text, and entries are immutable, so hydration is a pure convenience with no staleness. +`answer.message` is `answer.entry.model[0]` handed out as the pi-ai `AssistantMessage` type (the stored +form is structurally identical after ingress validation), not the current edited context projection. Passive writes have stored results (for placement bookkeeping and `unanswered/stale`) but no public +handle; `Harness.input(requestId)` returns `undefined` for a `kind: "write"` receipt. + +Protected state (written only by admission helpers): + +- `pi.inbox`: sticky conversation list of `QueuedInput`. The element ID is the input ID; ascending IDs are + admission order. Not inherited by forks. Reads return one complete current collection; no backend replays + history per read. +- `pi.input_result`: sticky session value keyed by input ID → `StoredInputResult`. +- `pi.request`: sticky session value keyed by `requestId` → `Acceptance` (tagged `kind`; `Harness.input` ignores `"write"`). + +Result and receipt lookups are point reads, never list scans. + +### 9.2 Request keys + +A `requestId` names the first acceptance for the session's lifetime. Lookup happens before anything else. A +duplicate `send` returns a handle to the original acceptance and writes nothing, even through another +conversation with different content. Reason: a client that sent, lost the reply and retried must get the same +input, not a second one. `accept`/`queueInput`/`write` obey the same rule within their own `Acceptance.kind`. +Across kinds a key collision is internal misuse: a `send` whose key names a `"write"` receipt (or a core `write` +whose key names a `"send"` receipt) rejects `RequestKindMismatch` and writes nothing; there is no cross-kind +return. + +### 9.3 Busy + +`busy(c)` = at least one live `pi.generation`, `pi.tool` or `pi.post_tools` directly in conversation `c`, +foreground or background. Evaluated against committed live tasks plus the current batch's `Write[]`. +Ordinary tasks and `pi.collapse` never make a conversation busy. Reason: collapse runs speculatively next +to a turn; an idle `send` must not be blocked by it. + +### 9.4 Send + +```text +send when not busy: + one commit: place older queued writes; place older queued steer/followUp per queue mode plus the new + input as pi.user entries; create one pi.generation { inputs: [...placed ids] }; results -> placed; + receipt if requestId. `whenBusy` ignored. +send when busy: + followUp (default) or steer: one commit: inbox append; result queued; receipt. No task. + reject: throw ConversationBusy; write nothing. +write when not busy: append entry, result done (no task). When busy: queue. +``` + +After the commit, `send` ensures `resume` and returns the handle. It never waits for generation. + +Result transitions (only admission/boundary helpers write them): + +```text +absent -> queued -> placed -> done | unanswered(terminated|aborted|failed) + queued -> unanswered(aborted|stale) + queued write -> done +idle send collapses absent -> placed; idle write collapses absent -> done (still creates+removes inbox element) +``` + +### 9.5 Boundaries and queue modes + +Boundaries are helper calls inside a core task's terminal closure, not separate commits. Which queued items a +boundary selects is governed by `postToolsKind.config.steeringMode`/`followUpMode` (section 2.1): `"all"` takes +every queued item of that mode, `"one-at-a-time"` takes the oldest. All queued writes are always selected. +Selected items, whatever their mode, are placed in one pass strictly by ascending input ID (inbox element ID, +i.e. admission order); no mode has priority over another. Unselected items keep their relative order. + +- **post-tools** (in `pi.post_tools` closure): selected = all queued writes + steer per `steeringMode`; place + them by ID. Steer joins the active input group (the continuation generation owns them). FollowUp stays + queued. Reason: steer means "interrupt the current line of work"; followUp means "after you're done". +- **final answer** (in `pi.generation` closure when no tool calls): the closure has already appended the + assistant entry; then resolve the active group → `done(answer: assistant)`; selected = all queued writes + + steer per `steeringMode` + followUp per `followUpMode`; place them by ID after the assistant; if any + steer/followUp was placed, they form a new group and its generation is created. +- **yield continuation** (section 10.1): when an `onYield` hook asks to continue and no steer/followUp is + queued, the closure, in this order: appends the assistant entry (the exchange is now complete); places + selected queued writes in global inbox order (they land at the post-answer boundary, never inside the + exchange); appends a `pi.user` entry `{ model: [{ role: "user", content: , timestamp }], data: + { continuation: true, from: } }` that has **no** input result (it is not admitted input); and + creates the successor generation carrying the **same** `inputs`; the active group's results stay `placed`. + If one of the placed writes is a self-head (reset/handoff), the continuation is invalidated: the caller just + cleared the state the hook wanted to continue. The closure then behaves as a plain final answer: assistant + appended, writes placed, active inputs `done(answer)`, no continuation entry and no successor. +- **idle turn** (kernel, after any normal core closure): if this commit takes the conversation from busy to + not busy and creates no turn task, place queued items as at a final-answer boundary. Covers post_tools + ending the turn without a continuation. +- **abort**: no automatic drain. Conversation abort withdrew steer/followUp; task abort left them. A later + idle `send` consumes them. + +A queued `write` carrying a `head` is revalidated at placement (section 1.4). If the head is no longer valid, +the item is removed and its result set to `unanswered/stale`. Never a fault. + +Heads placed at a boundary interact with the group the boundary forms. Placement is still strictly by ID, but +after placing the selected items the boundary applies two rules so that no generation ever owns an input that +its own request cannot see: + +- Every steer/followUp admitted *before* a self-head write placed in this batch (a queued reset/handoff with + `head: "self"`) is now outside the context the caller asked for, whether or not the mode selected it: a + selected one whose entry now lies before the head is excluded from the successor group, an unselected one is + removed from the inbox, and both resolve `unanswered/stale`. Otherwise an unselected `one-at-a-time` leftover + would resurrect the cleared state on a later send. Items admitted after the final self-head keep their order + and are selected normally. Example: queued followUp B1, B2, then queued reset R with `one-at-a-time`: B1 is + placed then made invisible by R, B2 was never selected; both resolve `unanswered/stale` and no generation is + created for either. The same rule runs when a self-head `write` is admitted while the conversation is idle + (for example `abortTask` left followUps queued and the host then calls `c.reset`): the immediate append also + removes every older queued steer/followUp as `unanswered/stale`, in the same commit. Otherwise they would + resurrect on the next send into a context the reset just cleared. +- At a post-tools boundary, a placed self-head also ends the *old* active group: its inputs resolve + `unanswered/terminated` (the model can no longer see them), no ordinary continuation is created, and only + visible triggers placed after the final head (steer or followUp with entries after `R`) may form a fresh group + and start a generation. Example: queued reset R, then queued followUp B; placement gives `reset R; user B`; the + old group is terminated, B starts a fresh generation whose request begins at `R`. + +Both use existing result reasons; nothing new is stored. + +Trace: + +```text +idle send A TX[user A; generation G1{inputs:[A]}; A=placed; receipt] kick -> G1 +G1 calls X,Y TX[assistant; tool X; tool Y; post_tools P1{after:[X,Y], inputs:[A]}; G1 terminal] + kick -> X, Y +busy send B TX[inbox B; B=queued] +busy send S (steer) TX[inbox S; S=queued] +busy write W TX[inbox W; W=queued] +X done TX[tool_result X; X terminal] +Y done TX[tool_result Y; Y terminal] kick -> P1 +P1 closure TX[user S; S=placed; entry W; W=done; generation G2{inputs:[A,S]}; P1 terminal] (S admitted before W) +G2 final answer E2 TX[assistant E2; A,S=done(answer:E2); user B; B=placed; generation G3{inputs:[B]}; G2 terminal] +``` + +There is no fourth inbox mode (no `nextRun`): the modes are `steer`, `followUp` and `write`, and Pico does not +buffer non-triggering context for a later turn. An application that accumulates such context (notes, events, +partial instructions) keeps it in its own value or list and folds it into one later `UserInput` when it +actually wants a turn; `write` is only for content that should become a passive transcript entry now. + +Input ownership is explicit: `inputs: Id[]` in generation/post_tools input. Exactly one live turn task owns +each placed group; ownership transfers in the same commit that creates the successor. The kernel never +inspects a checkpoint to find owners. + +### 9.6 Withdraw and wait + +`InputHandle.abort`: on the line, if still queued: remove inbox element, result `unanswered/aborted`, return +`aborted`. If placed or terminal: return `already_placed`, change nothing. If unknown: `not_found`. A queued +result whose inbox element is missing is a corrupted-state fault. + +`InputHandle.wait`: reject `InputNotFound` if no result exists; ensure `resume`; return an existing terminal +result; else register one waiter, cleaned up exactly once on result, caller cancel, close or fault. Caller +cancellation never marks or removes the durable input. In both paths the handle hydrates the referenced +entries after observing the terminal stored record and before resolving; hydration is off the line and reads +immutable entries, so it cannot observe a different state than the record names. + +## 10. Core task kinds + +All five are installed at open and cannot be replaced or removed. Generation, tool, post_tools and collapse +use `defineCoreTask`; job uses ordinary `defineTask`. Payloads below are normative compact syntax; the code +uses named `type` aliases with exactly these shapes. Hook points are declared per kind (section 11.2). + +### 10.1 `pi.generation` + +```ts +input: { inputs: Id[] } +checkpoint: // `Prep` is captured once at `prepared`; every later phase carries it unchanged except `attempt` (see steps 2-3) and `tools` (step 2) + Prep = { cutoff: Id; system: Id | null; model: ModelRef; thinkingLevel: ThinkingLevel; tools: string[]; retry: Stored; attempt: number } + | { phase: "prepared" } & Prep + | { phase: "requesting"; requestKey: string } & Prep // effect in flight + | { phase: "retrying"; untilMs: number; lastError: string } & Prep + | { phase: "deferred"; handle: Stored } & Prep // provider-side async +result: { assistant: Id; tools: Id[]; postTools?: Id; successor?: Id } +failure: { reason: "provider" | "overflow" | "retries_exhausted" | "no_model"; detail: string; assistant?: Id } +aborted: { assistant?: Id } +output: GenerationOutput = { message?: Stored } // absent until the provider's `start` frame; reset to {} on retry +``` + +The task is created by admission before model, provider, API, timestamp or usage are known, so `initial(input)` +is `{}`. pi-ai's `event.partial` is one shared object the provider keeps mutating after `start`, so it must +never be inserted into or retained by the tracker. Generation runs every provider event through pi-ai's +`AssistantMessageFrameEncoder`, whose `start` frame carries a *clone* of the partial and whose later frames are +compact per-block deltas. Generation keeps only the small process-local per-block reducer state (which block +index is text/thinking/tool call, whether it has ended, the tool-call JSON so far) and applies each encoded frame +directly inside one `output.mutate` on the tracker's own `message`, using the same switch and invariants as +pi-ai's frame reducer. It never buffers prior frames, never re-reduces the sequence, never keeps a second +`AssistantMessage`, and never `replace`s the whole message per token: one frame, one incremental `mutate`, one +Chord delta. pi-ai's `reduceAssistantMessageFrames` is a test oracle only (the final tracker value must equal +its reduction of the same frames), not a runtime dependency. `output.replace({})` when a retry begins. The +terminal `AssistantMessage` from `done`/`error` or a deferred fetch is cloned and strict-JSON validated before +the closure stores it as the assistant entry; a terminal path that requires an entry but has no message is a +kind-contract fault. Watchers treat an absent `message` as "no response yet". +One task output, no second frame log. + +Execute: + +1. Read `generationKind.config` at the current position (`model` absent → `failed/no_model`). Run system + preparation (section 12), which may append a managed `pi.system` entry and returns its ID. Commit + `prepared` with `Prep`: the cutoff (newest visible entry ID after preparation), the system entry ID, the + resolved model, thinking level, the loadout's tool names and the retry policy as read now. Reason: the + cutoff fixes what is sent; the copied config makes recovery send the same request under the same cutoff and + attempt even if `c.config` changed meanwhile; later phases read `Prep`, never `c.config`. +2. Derive the immutable request snapshot from the cutoff. Run `beforeRequest` hooks on a private copy + (messages only). The transformed request is **not** persisted; a recovery or retry that re-enters this step + reruns the hooks (never step 1: `prepared` and system preparation run once per generation), so handlers must + be deterministic for a given (cutoff, config) or accept that a retried request may differ. Then, on the + *final* transformed request: (a) overflow check, `estimate(final) > model.contextWindow - model.maxTokens` → + the overflow path below, without calling the provider; (b) derive `tools` = the tool names actually declared + by the final request's `SystemMessage`s (added minus removed), whatever the hooks did; no subset rule is + enforced, and a model call naming a tool with no registered executor is already a missing-tool result in + `pi.tool`. Commit `requesting` with a fresh `requestKey`, that `tools`, and `attempt` (1 on the first pass; + `retrying.attempt + 1` when entered from step 3 or from recovery of `retrying`). Call the provider outside + the line with a derived ctx. Encode each event with `AssistantMessageFrameEncoder`; on the + `start` frame `output.mutate(o => { o.message = frame.partial })` (the encoder's clone, never the provider's + `partial`); apply every later frame inside `output.mutate` to `o.message`. +3. Every terminal provider `AssistantMessage` (stop, length, toolUse, error, aborted, or the result of a + deferred fetch) first goes to `afterResponse` outside the line with `{ message, usage, attempt }`, before + any classification; it observes, it does not decide. Then, on a provider error (`stopReason: "error"`): let + `policy = checkpoint.retry`. Classify with pi-ai's + `isRetryableAssistantError(message)`; if retryable, `policy.enabled` and `attempt <= policy.maxRetries`: + one commit buffers a data-only `pi.usage` entry `{ attempt, usage, error }` (no `model`; records the failed + attempt's usage) together with the `retrying` checkpoint (`attempt` = the attempt that just failed, + `untilMs = now + retryDelayMs(policy, attempt)`); then `sleep`, `output.replace({})`, go to 2, which commits + `requesting` with `attempt + 1`. Otherwise + `failed/provider` (not retryable or disabled) or `failed/retries_exhausted`. Provider returned `deferred`: + commit `deferred` with `Stored`; wait `handle.pollAfterMs` (fallback 5 s when absent), then + `models.fetchDeferred(model, handle)`; a message → step 4; another deferred handle → commit `deferred` with + the new handle and repeat; an error → step 3 as a provider error. Deferred polling is not the retry backoff. +3b. Terminal non-retryable outcome: a provider `stopReason: "aborted"` without a Pico mark (the provider ended + the stream on its own) and a final `"error"` (not retryable, retry disabled, or attempts exhausted) take the + same path: the closure appends the error/aborted `AssistantMessage` once as a display-only `pi.assistant` + entry (never in context, never creating tools), resolves the active inputs `unanswered/failed`, and + terminalizes `failed/provider` or `failed/retries_exhausted`. A Pico mark on the ctx does not reach here: it + unwinds the invocation and fresh abort runs (below). +4. Successful response (`stop`, `length`, `toolUse`): `afterResponse` has already run (step 3; the assistant + *entry* does not exist yet, hooks never run inside or after the closure). For a final answer (no tool + calls): run `onYield` outside the line **only if** no steer/followUp is queued at that moment. Closure then rechecks the inbox and cancellation on the line. If a trigger arrived meanwhile, + the yield decision is stale and discarded; normal final-answer boundary. Otherwise `{ continue }` → yield + continuation boundary (section 9.5) with a `pi.user` entry carrying the returned text; the successor + generation keeps the same `inputs`. No `continue` → final-answer boundary. +5. Tool calls: closure buffers the assistant entry, one `pi.tool` per call and one + `pi.post_tools{after: tools, inputs, assistant, tools}`. Every call gets a `pi.tool`, including one naming a + tool that was not offered; `pi.tool` decides (10.2) using the generation's own terminal checkpoint as the + evidence, so `ToolInput` carries no duplicate offered list. + +Recover: `prepared` → step 2 (hooks rerun; request built from `Prep`, not from current config; step 1 never +reruns). `requesting` +→ the effect may have happened and there is no result lookup: treat the interruption as a retryable failed +attempt with usage absent, evaluated against the persisted `checkpoint.retry` and `checkpoint.attempt` exactly +like step 3: if `retry.enabled` and `attempt <= retry.maxRetries`, one commit appends `pi.usage{ attempt, error: +"interrupted" }` and the `retrying` checkpoint, then sleep and step 2 with `attempt + 1`; otherwise terminalize +`failed/provider` (disabled) or `failed/retries_exhausted` with **no** assistant entry (there is no message to +display) and resolve inputs `unanswered/failed`. Never a silent increment, never an unconditional `retrying`. +`retrying` → sleep out `untilMs`, then step 2. `deferred` → +resume polling with the stored handle. Usage accounting: each failed attempt's usage is one `pi.usage` entry committed with its +`retrying` checkpoint; the successful attempt's usage lives once, on the final `pi.assistant` entry's +`AssistantMessage.usage`. Nothing is counted twice. Missing usage is unknown, not zero. + +Size estimation: `estimate(messages) = estimateContextTokens(messages).tokens` from pi-ai, applied to the +projected request messages (including the messages-only system/tool declarations once the pi-ai PR lands). +The same estimator is used for overflow, threshold and `through` selection. + +Overflow (request too large): detected in step 2(a) on the final transformed request, never on the pre-hook +snapshot, and always before `requesting` is committed or the provider is called (pi-ai `Model` fields; no +separate cap in v1): compute `through` (below). If a collapsible prefix exists, the +closure creates a **foreground** `pi.collapse C{reason: "overflow", through}` and a replacement +`pi.generation G{after: [C], inputs}`, transfers input ownership to `G`, and terminalizes `failed/overflow` +without resolving inputs. `G` reads `C`'s outcome first: completed → normal execution against the new head; +failed/declined/aborted/orphaned → `G`'s closure `write`s a `pi.notice` ("context too large; compaction +failed: "), resolves the inputs `unanswered/failed` and terminalizes `failed/overflow`. If no +collapsible prefix exists (the retained suffix is the whole context), there is no `C` and no `G`: the closure +`write`s the same notice, resolves inputs `unanswered/failed` and terminalizes `failed/overflow` directly. + +Input ownership, all generation outcomes: success → inputs `done` (final answer) or transferred to +`pi.post_tools` (tool calls); `no_model`, terminal provider error/abort, and interrupted `requesting` with no +retry left → `unanswered/failed`; overflow with +collapse → transferred to the replacement; replacement after failed collapse → `unanswered/failed`; Pico abort +→ `unanswered/aborted`. + +Threshold (context large but the request succeeded): if `collapseKind.config.threshold > 0` and +`max(usage.input + usage.output, estimate(request))` exceeds it, the normal closure additionally creates a +**foreground** `pi.collapse C{reason: "threshold", through}`; the generation completes normally. Automatic +collapse is foreground because it is part of the turn's work: conversation abort reaches it and `waitForIdle` +waits for it. Manual `c.collapse` is background and speculative. Neither makes the conversation busy (9.3). + +Choosing `through` (all cases, including manual): let the context be the sequence of complete exchanges +(user/notice entries count as their own one-message exchanges). Walk from the newest exchange backwards, +accumulating `estimate` of the retained suffix; keep extending the suffix while its estimate is ≤ +`collapseKind.config.keepRecent`. `through` is the last entry of the exchange immediately before the retained +suffix. If the whole context fits in `keepRecent`, there is nothing to collapse and no task is created (manual +`c.collapse` then rejects `NothingToCollapse`). If even the newest exchange exceeds `keepRecent`, the suffix is +that single exchange. The summary's `head` is the first entry after `through`. + +Abort: if the checkpoint is `deferred`, fresh abort first calls `models.cancelDeferred(model, handle)` best +effort (errors ignored); the closure stores the partial message as an `aborted` assistant entry if any content +exists (display only) and resolves inputs `unanswered/aborted`. + +### 10.2 `pi.tool` + +```ts +input: ToolInput = { assistant: Id; call: Stored } +checkpoint: + | { phase: "started"; replay: "safe" | "unsafe"; call: Stored } // final identity-preserving, schema-validated call + | { phase: "children"; replay; call: Stored; ... } // keyed child/owned-conversation records; exact remaining schema gated (11.1) +result: { entry: Id; control?: ToolControl } +failure: never // tool errors are model-visible results, not task failures +aborted: { entry: Id } +output: ToolOutputState +``` + +Execute: first the offered-set check. Read the assistant entry (`input.assistant`), follow its `byTaskId` to the +generation task, and read `tools` from that task's retained checkpoint (terminal records keep their last +checkpoint). The task must be a completed `pi.generation` whose result names this assistant entry, and its last +checkpoint phase must be `requesting` or `deferred` (both carry `Prep.tools` as actually offered; a deferred +response can end with tool calls). Anything else (no `byTaskId`, task absent, not completed, different assistant, +or a `prepared`/`retrying` checkpoint) is corruption and faults: Pico itself wrote those records. `call.name` absent from that list → synthetic +`pi.tool_result` `isError: true, "tool not offered"` without consulting the registry. Then look up the tool by +`call.name` in the registry and validate the original arguments. A missing tool or invalid arguments produces +the corresponding synthetic error result. Otherwise run `beforeTool` outside the line, before any `started` +checkpoint; a block (including a hook throw under this point's policy) produces a synthetic error result. +Validate the final rewritten arguments again. Only the invocation branch commits `started` with the tool's +`replay` policy and the complete final call, immediately before invoking outside the line through the gated +execution API (section 11.1); in WP10 that is an internal test executor. A tool throw becomes an error result. +Run `afterTool`. Closure: re-read any children on the line (never trust a +stale `Promise.race`); buffer exactly one `pi.tool_result` entry whose `model` message is the bounded output +followed by diagnostics, and whose `data` carries details, usage, control and truncation metadata; return +completed with `control`. + +Recover: no checkpoint → the crash happened between reservation and the `started` commit, and since `started` +is always committed before the tool is invoked, no external effect can have occurred: repeat lookup, argument +validation, `beforeTool`, final validation and then start normally. A `started` checkpoint never reruns +`beforeTool`: its stored final call is the effect evidence. Resolve the current executor by name and replay +only when both the persisted and current declarations say `replay: "safe"` and the persisted arguments validate +against the current schema. Missing/current-unsafe/schema-incompatible → a model-visible interrupted or +unavailable error result, never a fault; safe/current-safe/valid → invoke the persisted call. This avoids +running old evidence under an incompatible deployment without adding definition fingerprints. In-process +invocations retain the definition they started with. `children` resumes the recorded children and retains the +same final call. + +Abort: mark each child with `abortWithTool: true` and every live foreground task in each subagent with +`abortWithTool: true`, atomically mark-if-live; an already terminal target counts as cleaned. Do not withdraw +subagent queues. Closure appends an aborted `pi.tool_result` through `CoreAbortTx.entry`. + +### 10.3 `pi.post_tools` + +```ts +input: { inputs: Id[]; assistant: Id; tools: Id[] } +checkpoint: never +result: { successor?: Id; ended?: "terminate" | "handoff" } +failure: never +aborted: null +``` + +Runs after every tool is terminal. Execute: through one read-only `runtime.commit` (reads only, no writes) +take an immutable snapshot of the terminal tool tasks and their result entries; terminal records never change, +so the snapshot cannot go stale. Run `afterTools` outside the line on that snapshot. Closure, one commit, in +this exact order (rewindable writes must precede every entry append, section 2): + +1. Reads: `generationKind.config.selectedTools` fresh (so a config change made while `afterTools` ran is not + lost); the snapshot is already in hand. +2. Fold `control` from the snapshot **in the assistant's call order**: `addTools` is additive (current list + followed by every requested name not already present, first occurrence wins, nothing dropped); `handoff` wins + over `terminate` (a handoff also ends the turn) and among several `handoff` messages the last in call order + wins. +3. Buffer the single `selectedTools` set, if `addTools` produced a change. This is the only rewindable write and + it comes before any entry. +4. For every tool outcome that is `orphaned` or `aborted` without a result entry, append a synthesized error + result, in call order, so the exchange is complete. +5. If `handoff`: append `pi.handoff{ head: "self", model: [user handoff message] }` **directly** through + `CoreTaskTx.entry` (not `write`), so the head exists before anything else placed in this batch. +6. If `handoff` or `terminate`: resolve the active group `done` (answer: assistant) and run the final-answer + boundary, whose follow-up placement and successor generation now come after the head and therefore start the + fresh context. Otherwise: post-tools boundary and `pi.generation{ inputs }` as the continuation with the group + plus placed steer. +7. Terminal snapshot. + +Reason for 5 before 6: a `write` would evaluate prospective busy after the successor generation was buffered and +queue the handoff behind it, so the next request would still see the old context. This direct control handoff +is not an inbox-ordered self-head write: queued steer/followUp items, even if admitted earlier, are placed after +the new head by step 6 and may start in its fresh context rather than being staled. + +Abort: `pi.post_tools` owns the active input group while it is live, so its fresh abort closure resolves every +ID in `input.inputs` as `unanswered/aborted` (writing the internal input-result state directly through core +authority; no public helper exists) and terminalizes `aborted: null`. Otherwise a marked post_tools would +strand placed inputs forever. Queued items are untouched here: task abort leaves them for a later idle send; +conversation abort has already withdrawn steer/followUp in its own mark batch. + +### 10.4 `pi.collapse` + +```ts +input: { reason: "threshold" | "manual" | "overflow"; through: Id; instructions?: string } +checkpoint: // `Base` is captured once, before `beforeCollapse`, and carried unchanged except `attempt` (retrying records the failed attempt; the next summarizing uses attempt + 1) + Base = { expectedHead: Id | null; instructions?: string; model: ModelRef; thinkingLevel: ThinkingLevel; retry: Stored; attempt: number } + | { phase: "summarizing" } & Base + | { phase: "retrying"; untilMs: number; lastError: string } & Base + | { phase: "prepared"; summary: string } & Base // candidate text is durable; only finalization remains +result: { summary: Id } +failure: { reason: "stale" | "declined" | "provider" | "no_model"; detail: string } +aborted: null +``` + +`through` is the last entry of a complete exchange (chosen by the creator, section 10.1 for automatic +collapse; `c.collapse` uses the same `keepRecent` rule). Execute: through one read-only `runtime.commit` +capture `Base` (`expectedHead` = current newest head, `generationKind.config` model/thinking/retry at this +position, `attempt: 1`) together with the entries to summarize; if `model` is unset, terminalize +`failed/no_model` immediately (before `beforeCollapse` and before any checkpoint), for manual, threshold and +overflow alike; run `beforeCollapse` (fold `first`) outside +the line on that snapshot. Then one commit: re-read `newestHead`; if it differs from `Base.expectedHead`, the +hook's decision was made against a superseded context → terminalize `failed/stale` (never pair old entries +with a new head). Otherwise: no handler answer → proceed with `input.instructions`; `{ decline: true }` → +`failed/declined` for every reason (manual, threshold, overflow), no summary, no fault; `{ instructions }` → +replaces `Base.instructions`; `{ summary }` → commit `prepared` with that text and skip the provider; else +commit `summarizing`. Call the summarizer outside the line. Provider error: classify with +`isRetryableAssistantError`; retryable, `retry.enabled` and `attempt <= retry.maxRetries` → commit +`retrying{ untilMs = now + retryDelayMs(retry, attempt), lastError }` (recording the attempt that failed), +sleep, commit `summarizing` with `attempt + 1`, call again; otherwise `failed/provider`. The +summarizer request is request-local and **tool-free**: every tool definition effective in the projected +historical request (the fold of `toolsAdded`/`toolsRemoved` across its `SystemMessage`s, which is authoritative +regardless of the current registry) is removed by a request-local `toolsRemoved` on the private copy, and no +new tools are offered; a summarizer response containing tool calls is treated as a provider +failure (`failed/provider`, "summarizer returned tool calls") and retried under the policy. On a successful response commit `prepared` +with the summary text. Closure: `newestHead` read first; if it differs from `expectedHead`, `failed/stale` +(someone else collapsed). Else append `pi.summary { data: {through}, model: [user summary], head: first entry +after through }` and return completed. Edits appended in between do not stale. Recover: `summarizing` → the +call may have happened; count it as that attempt's failure exactly like generation's `requesting` recovery: +if `retry.enabled` and `attempt <= retry.maxRetries`, commit `retrying{ untilMs: now + retryDelayMs(retry, +attempt), lastError: "interrupted" }`, sleep, then `summarizing` with `attempt + 1`; otherwise `failed/provider`. Never replay the same attempt number. +`retrying` → sleep out `untilMs`, then `summarizing` with `attempt + 1`; `prepared` → finalize only, no +provider call. One live collapse per conversation; a second creation returns the existing ID. Collapse +never blocks generation or input. Manual `c.collapse` returns the task ID; the caller reads its outcome. + +### 10.5 `pi.job` + +A job is a long-running external process owned by a task from its first effect. It is an ordinary kind: its +notices are passive `write`s, which `BaseTaskTx` provides, so it needs no core authority. Jobs are created by +hosts through `HostTx.task`, by other ordinary tasks through `BaseTaskTx.task`, and by tools through the gated +mediated task operation of section 11.1. + +```ts +input: JobInput (declared with ProcessHost below) +checkpoint: + | { phase: "waiting"; untilMs: number; occurrence: number } + | { phase: "spawning"; key: string; occurrence: number } // inflight: the host may have started it + | { phase: "running"; key: string; occurrence: number } +result: { exitCode: number; occurrences: number } +failure: { reason: "spawn" | "interrupted"; detail: string } +aborted: { killed: boolean } +output: JobOutput (declared with ProcessHost below; mutable, unlike the readonly host snapshots) +``` + +Jobs run through an injected **process host**, the only code that touches the OS. The host, not Pico, owns +whatever durability it can honestly provide: + +```ts +type JobInput = { readonly command: string; readonly args: readonly string[]; readonly cwd: string; + readonly env?: { readonly [name: string]: string }; + readonly notify: boolean; readonly rerun: boolean; readonly every?: number; readonly notBefore?: number }; +type ProcessSpec = Pick; +type ProcessOutput = { readonly stdout: string; readonly stderr: string; readonly droppedStdout: number; readonly droppedStderr: number }; // bounded tails; readonly host snapshot +type JobOutput = { -readonly [K in keyof ProcessOutput]: ProcessOutput[K] } & { exitCode?: number; occurrence: number }; // the task's mutable OutputState, mirrored from status() inside output.mutate +type ProcessStatus = + | ({ readonly status: "running" } & ProcessOutput) + | ({ readonly status: "exited"; readonly exitCode: number } & ProcessOutput) + | { readonly status: "unknown" }; // host has no durable record of this key +interface ProcessHost { // implemented by hosts; copy this verbatim + start(key: string, spec: ProcessSpec, ctx: Context): Promise; // idempotent per key: a second start for a key the host knows is a no-op. + // Rejects only when it knows no process started (ENOENT, EACCES, bad cwd); if the outcome is + // uncertain it must resolve and report through status(). A rejection is a domain outcome, not a fault. + status(key: string, ctx: Context): Promise; + kill(key: string, signal: "SIGTERM" | "SIGKILL", ctx: Context): Promise; // idempotent if key already exited or is unknown +} +``` + +Pico polls `status` (same backoff schedule as retries, capped at 1 s) and copies the bounded output into the +task output; it never holds a stream and never reads a PID. The bundled `nodeProcessHost()` keeps runs in process memory +only: after a restart every key is `unknown`. A host that redirects output to files and supervises the child +(or a remote executor) may return `running`/`exited` across restarts; that is the host's contract to keep, and +Pico does not specify how. The host is injected through `HarnessOpenOptions.processHost`; when absent, a job's +execute terminalizes `failed/spawn` ("no process host") before any checkpoint. Memory-storage tests use a fake +host. + +Execute, per occurrence `n`: if `notBefore`/`every` says wait, commit `waiting` and `sleep`. Commit +`spawning{ key: `${taskId}:${n}`, occurrence: n }` **before** any OS effect (the inflight checkpoint). Call +`host.start(key, ...)`, commit `running{ key, occurrence }`, then poll `status` until `exited`, mirroring +output into `output.mutate`. On exit: if `notify`, `tx.write` a `pi.notice` for this occurrence in the same +commit as the next checkpoint (`waiting` for a schedule, or the terminal snapshot for a one-shot); if `every`, +`output.replace` a fresh state for `n + 1` and loop under the same task ID (no backlog is inferred after +downtime); else return completed. + +`start` rejecting → the closure terminalizes `failed/spawn` with the host's message (and `write`s the notice +if `notify`); nothing escapes the task method. `status` rejecting (the host itself is broken or unreachable) +→ the closure terminalizes `failed/interrupted` with the host's message: the process outcome is unknown *and* +unknowable, so `rerun` does not apply (rerun is for `unknown`, where the host answered). A `kill` rejection +during fresh abort is the documented failing-abort case (7.3) and faults. `status` returning `unknown` at any point, during the ordinary +poll or on recover, means the process may or may not have started or finished and Pico cannot know: if +`rerun`, `start` again with the same key (a possible duplicate external effect, exactly the uncertainty the +recoverability disclaimer in 3.2 allows), else `failed/interrupted`. + +Recover: `waiting` → sleep until the stored `untilMs`, then continue that occurrence (commit `spawning`, ...). +`spawning` or `running` → `host.status(key)`; `running`/`exited` → continue as above; `unknown` → the rule +just stated. Abort, by checkpoint: none or `waiting` → no process exists, no host call, closure returns +`{ killed: false }`; `spawning`/`running` → `kill(key, "SIGTERM")`, `rt.sleep(now + 5000)`, then +`kill(key, "SIGKILL")`, and return `{ killed: true }` if both host calls resolve. Here `killed` means the host +accepted the cleanup sequence for a possibly started key, not proof that the process was still alive; `kill` +must be idempotent for an exited or unknown key. A rejection is the failing-abort fault described above. A +schedule ends wherever it is. Notices are passive writes: a notice about occurrence `n` is admitted with `n`'s +checkpoint and placed at the next boundary if the conversation is busy; `result` carries no entry ID because +placement may happen after the job terminalized. + +## 11. Tools and hooks + +### 11.1 Tools: normative behaviour, gated API + +The behaviour of the built-in `pi.tool` kind (10.2) and of every tool *result* is normative. The exact +programming interface an ordinary tool author writes against is **gated**: this is the one intentional gap in +this document. Nothing below the "Gated" heading may be implemented until its final shape is appended here. + +Normative (settled), independent of the gated shape: + +```ts +// what a tool declares, minus its execution signature +interface ToolDeclaration

{ + readonly name: string; readonly description: string; readonly parameters: P; + readonly replay?: "safe" | "unsafe"; // default unsafe + readonly output?: { maxBytes?: number; maxLines?: number; retain?: "head" | "tail" }; // default 64k / 200 / head +} +// what a tool produces (public, pi-ai typed content) +interface ToolResult { + content: readonly (TextContent | ImageContent)[]; + details?: D; // typed, for UIs; stored in entry data + isError?: boolean; + usage?: ToolUsage; + diagnostics?: readonly ToolDiagnostic[]; // about the call, rendered after the output + control?: ToolControl; +} +// durable payload pieces are exact type aliases (interfaces have no implicit index signature and would not satisfy JsonObject) +type ToolControl = { + readonly terminate?: true; // end the turn after this exchange + readonly handoff?: string; // end the turn and reset context with this message + readonly addTools?: readonly string[]; // additive: appended to selectedTools by post_tools before its continuation, so the next model request offers them; never removes +}; +type ToolDiagnostic = { readonly severity: "info" | "warn" | "error"; readonly message: string; readonly code?: string }; +``` + +Ordinary tool code never receives `commit`, `entry`, `write`, task lifecycle methods or any admission helper. +Reason: a tool is plugin code; the transcript and the task are Pico's. The `pi.tool` kind owns the tool's +task: it enforces `output` bounds on `content`, emits the truncation diagnostics it owns, stores the exact model +message in entry `model` and the rest (`ToolResultData`) in entry `data`, and streams the tool's live progress +as its own `TaskOutput` (10.2). `h.tools` registers `ToolDeclaration`s plus the gated execution +part. + +**Gated: the ordinary tool execution API.** Not settled, not to be inferred from the generic task runtime: +the `execute` signature, the facade a tool receives (previously sketched as a `ToolApi`; that sketch is +withdrawn), how the tool reaches the `pi.tool` task's `TaskOutput` for streaming progress, durable memos, the +keyed child-task and subagent operations, and the owned-descendant resolver. The settled requirements the +final shape must satisfy are: + +- It is a small `pi.tool`-mediated surface over the current invocation: the tool's identity is the stable + `pi.tool` task ID, its output is that task's `TaskOutput`, and durable strict-JSON memos are protected + state scoped to that task, retired with it. +- A keyed child-task operation, if included, uses one internal `pi.tool` transaction to create the child and + record its ID, kind, `abortWithTool: boolean` and optional shared-output ref in the complete parent + checkpoint before returning. Jobs remain tasks created this way; there is no `startShell` verb and no + adoption of an unfinished promise. +- A keyed subagent operation, if included, applies a `ConversationSeed` (section 8.1) in one internal + `pi.tool` transaction: `createConversation` (owner = the tool task), the seeded config/sticky/section + values, the internal `accept` of the first input through the fixed generation strategy, and the complete + parent checkpoint recording conversation ID, input ID, request key and `abortWithTool`. It then resolves the + existing restricted `TaskConversation` and waits outside the line. A subagent is an owned conversation, + never a subagent task or a spawn operation with its own lifecycle. +- A repeated creation key is either explicitly first-key-wins or compares a persisted canonical spec + (normalized parent position, input, request key, initial values, seed profile/version, `abortWithTool`); + never mutable current values. The choice is part of the gated decision. +- Returned task handles expose wait/abort, never commit. A local wait timeout is observation only: it returns + "timed out" without marking the child. The `pi.tool` terminal closure rereads the child on the line: a + terminal child yields the normal final result; a live child yields the continues-in-background result while + the child retains any shared output. No design may trust stale `Promise.race` state. +- Returned conversation handles are capped at the existing `TaskConversation`: scoped `send` plus the returned + input's result/wait/abort; no commit, direct entry, admission helpers, close, shutdown or unrelated state. + Any model-facing status/tail projection must be specified before it is added. +- `abortWithTool: false` means only that enclosing `pi.tool` abort does not mark that child; it is not a + generic detachment abstraction. Parent normal completion cancels neither. +- Later sibling tool calls need one narrow built-in resolver for strict owned descendants of their current + parent conversation, including ownership through terminal sibling tasks. Fork-only ancestry and unrelated + roots reject. It must not broaden `runtime.conversation` or `TaskConversation`. + +Until this decision is appended here, no ordinary tool can be executed: `pi.tool` (10.2) is implemented and +tested against an internal test executor that returns `ToolResult`s, and every built-in tool (including `bash` +and `subagent`) waits for the API. WP10 delivers `pi.tool` to that boundary and no further. + +### 11.2 Hooks + +Hooks are declared by the task kind that invokes them. There is no global hook interface. A kind declares +typed points; its implementation calls `runtime.hooks.run(kind.hooks.point, input, ctx)` at the moments it chooses; the +scheduler and kernel never interpret point names. Core kinds ship predefined points; ordinary kinds declare +their own the same way. + +```ts +type Fold = "collect" | "first" | "chain"; +interface HookPoint { + readonly fold: F; // how several handlers' outputs combine + readonly onThrow: "skip" | "abort"; // skip: report, drop this handler's output, continue + // abort: stop, kind receives { threw: error } + readonly [hookIn]?: In; readonly [hookOut]?: Out; // compile-only +} +declare function defineHookPoint(options: { readonly fold: F; readonly onThrow: "skip" | "abort" }): HookPoint; +declare function defineHookPoint>(options: { readonly fold: "chain"; readonly onThrow: "skip" | "abort" }): HookPoint; // chain: object input, output is a patch of In +type HookPoints = Record>; + +type HookHandler

= (input: HookIn

, info: HookInfo, ctx: Context) => HookOut

| void | Promise | void>; +interface HookInfo { readonly kind: string; readonly taskId: Id; readonly conversationId: Id } + +interface HookRunner { + run

(point: P, input: HookIn

, ctx: Context): Promise>; // point token from this kind's bundle +} +type HookResult

= + P extends { fold: "collect" } ? { outputs: readonly HookOut

[]; threw?: unknown } : + P extends { fold: "first" } ? { output?: HookOut

; threw?: unknown } : + { output: HookIn

/* chained */; threw?: unknown }; + +interface HarnessHookRegistry { + on[keyof NonNullable]>( + kind: K, point: P, handler: HookHandler

, + ): () => void; // unregister; harness-wide has no scope option +} +interface ConversationHookRegistry { + on[keyof NonNullable]>( + kind: K, point: P, handler: HookHandler

, options?: { readonly subtree?: boolean }, + ): () => void; // this conversation; subtree: also conversations it owns, recursively +} +// h.hooks: HarnessHookRegistry h.hooks.on(myKind, myKind.hooks.somePoint, handler) +// c.hooks: ConversationHookRegistry c.hooks.on(myKind, myKind.hooks.somePoint, handler, { subtree }) +``` + +Points are passed as tokens (`kind.hooks.somePoint`), not strings. Reason: plugin kinds may reuse point names, +and a token carries its owning kind, input/output types, fold and failure policy, so nothing is looked up by +name and nothing can collide. + +Fold semantics: `collect` runs every handler and returns all non-void outputs in order; `first` stops at the +first non-void output; `chain` is legal only for object `In` and requires `Out extends Partial`: handlers run +sequentially, each output is shallow-merged over the current input, a void return passes it through, and the +final current input is the result. The generic rule has no terminal predicate. One built-in point adds an +explicit short-circuit, stated on that point only: `beforeTool` stops the chain as soon as an output sets +`block`. Handler order: harness-wide in registration +order, then outermost conversation to innermost. Handlers run outside the line, receive and must forward +`ctx`, may take as long as they like (a human approval is a hook that waits), and may run again after a crash +because the kind re-enters the phase that ran them; external side effects need their own keys. The runner +awaits each handler's actual return, never a raced promise. Cancellation errors always propagate and unwind +the invocation; `onThrow` governs other errors only. Handlers hold no task, transaction or transcript +authority; they receive `HookInfo` for identity and use ordinary public handles for state. + +A decision a hook returns is revalidated in the commit that applies it (section 10); the hook itself commits +nothing. Hooks never run on the line, inside a closure, or after it; a kind that needs committed data for a hook +takes an immutable snapshot through a read-only `runtime.commit` first. Timing of every core point: +`systemInstructions` during preparation, before `prepared`; `beforeRequest` after `prepared`, before +`requesting`; `afterResponse` after every terminal provider message, before retry/failure classification and +before the generation closure; `onYield` after `afterResponse`, only on success and when no trigger is queued; +`beforeTool` after lookup and initial validation but before the `started` checkpoint; `started` is committed +immediately before invocation. `afterTool` runs after the tool returns, before the tool closure; `afterTools` after the read-only +snapshot, before the post_tools closure; `beforeCollapse` before the `summarizing` checkpoint. + +Core points (declared on the respective kind; `fold`/`onThrow` shown): + +| kind | point | input | output | fold / onThrow | +|---|---|---|---|---| +| generation | `systemInstructions` | `{ sections: SystemSectionDraft; config: Settings; tools: readonly ToolDeclaration[] }` | `{ tools?: ToolDeclaration[] }` (override only) | collect / skip; a skipped handler's draft edits are rolled back | +| generation | `beforeRequest` | `{ request: { messages: Message[] }; cutoff: Id }` | `{ request?: { messages: Message[] } }` | chain / skip | +| generation | `onYield` | `{ answer: AssistantMessage }` | `{ continue: string }` | first / skip | +| generation | `afterResponse` | `{ message: AssistantMessage; usage?: Usage; attempt: number }` (every terminal provider message: success, error, aborted, deferred fetch) | void | collect / skip | +| tool | `beforeTool` | `{ call: ToolCall; block?: { reason: string } }` | `{ call?: ToolCall; block?: { reason: string } }` | chain, stops on `block` / abort → block | +| tool | `afterTool` | `{ call: ToolCall; result: ToolResult }` | `{ result?: ToolResult }` | chain / skip | +| post_tools | `afterTools` | `{ assistant: Id; results: Id[] }` | void | collect / skip | +| collapse | `beforeCollapse` | `{ reason; through: Id; entries: Entry[] }` | `{ decline: true } \| { instructions?: string; summary?: string }` | first / skip | + +`beforeTool` is the one point with a short-circuit: an output that sets `block` ends the chain; the tool is not +invoked and the result is an error result with `block.reason`. Otherwise the final `call` is what runs, and only +its `arguments` may differ from the original: `id`, `name` and every other identity field (type, signature, +namespace, provider metadata) must equal the model's original call; a fold whose `call` changed any of them is +treated as `block` with reason "call identity changed" (a hook may narrow or normalize a call, never redirect it +to a different executor). The final `arguments` are then schema-validated exactly like the original call's: a +hook that rewrites them into something invalid produces the same synthetic `isError` result, never an invocation +with bad arguments. `onThrow: "abort"` there means a throwing handler sets `block` with the +error message. `beforeRequest`'s input is `{ request, cutoff }` and only `request` is patchable; `afterTool`'s is +`{ call, result }` and only `result` is patchable; `cutoff` and `call` are read-only context. + +This is the same derivation principle as configuration (section 2.1): a kind exposes two typed bundles, +`kind.config` and `kind.hooks`; `c.config` is assembled from the first, `c.hook` (section 13.1) from the +second. `c.hooks.on(kind, kind.hooks.point, handler, scope)` and `h.hooks.on(...)` are the generic forms for +any kind. Each point's metadata is owned by the kind that declares it; the flattened `c.hook.` methods +are wrappers and introduce no second schema. + +## 12. System prompt and tool loadout + +Pico sends pi-ai `{ messages }` only. System instructions and the tool loadout are `SystemMessage`s inside +`messages` at their historical positions. pi-ai owns native translation and best-effort cache preservation. +This depends on the pi-ai system-message API (`SystemMessage` with `toolsAdded`/`toolsRemoved`, messages-only +adapters, PR #9116), which is a hard dependency of WP9 and WP10: those packages wait for it and import its +types and adapters; Pico never ships a stand-in `SystemMessage` or its own provider-side translation. + +```ts +// pi-ai (PR #9116); reproduced here for reading only, imported in code. After the PR, pi-ai's `Message` union includes it, +// which `Entry.model: readonly Stored[]` and `SystemEntry extends Entry` rely on: the WP1 compile matrix passes only +// against a pi-ai that has landed it (a hard dependency, section 18). +interface SystemMessage { + role: "system"; content: string; toolsAdded?: Tool[]; toolsRemoved?: Tool[]; timestamp: number; +} +``` + +### 12.1 Sections + +```ts +interface SystemSection { readonly key: string; render(value: T): string } // default generic is the erased registry form +declare function defineSystemSection(definition: { readonly key: string; render(value: T): string }): SystemSection; +declare const systemSections: { readonly identity: SystemSection; readonly environment: SystemSection; readonly skills: SystemSection }; +// h.sections: MutableRegistry erased default generic; register rejects duplicate key; replace requires same key; remove keeps stored data +``` + +Tokens carry a stable key and a pure synchronous renderer. Only keys, payloads and rendered text are stored; +never renderers. Payloads are JSON; changing a registered payload type needs a compatible replacement. + +### 12.2 Draft + +```ts +interface SystemSectionDraft { + get(s: SystemSection): T | undefined; // owned copy + set(s: SystemSection, value: T): void; // existing key keeps position; new key appends + delete(s: { readonly key: string }): void; // explicit; every section token satisfies this; null is a valid payload + wrap(s: SystemSection, transform: (rendered: string) => string): void; // preparation-local +} +``` + +Preparation seeds one draft from the canonical section state (12.4). `systemInstructions` handlers run in +order (section 11.2) editing that draft. A handler that throws has its own mutations and wrappers discarded; +earlier handlers' changes stay. After all handlers: touched sections are rendered, wrappers apply in +registration order after rendering, and the result is frozen. The tool loadout defaults to +`generationKind.config.selectedTools` resolved against `h.tools` (unknown names are dropped and reported through +`onReport`, section 13); +handlers do not need to return tools. A handler that returns `tools` overrides the loadout for this +preparation with that complete list; the last such handler wins. + +Handlers must be replay-safe as a set: a handler that appends to a persisted seed accumulates unless an +earlier handler resets that section's base each time. Wrappers are not stored; untouched sections keep their +previously rendered text including old wrapper output. + +### 12.3 Managed entries + +```ts +type SectionRecord = + | { key: string; action: "set"; value: JsonValue; rendered: string } + | { key: string; action: "remove" }; +type SystemEntryData = { readonly baseline?: true; readonly sections: readonly SectionRecord[] }; +``` + +- **Baseline**: `baseline: true`, complete ordered state of every section. Its `SystemMessage.content` is every + section formatted as `## ${key}\n${rendered}`, in canonical order, joined by one blank line; `toolsAdded` is the + complete loadout; `timestamp` is the commit time. +- **Delta**: only changed sections (`set`) and explicit deletions (`remove`). Its `SystemMessage.content` states + each change: `The ${key} section now reads:\n${rendered}` for a set, `The ${key} section no longer applies.` + for a removal, joined by one blank line. `toolsRemoved` + lists previous complete definitions no longer wanted; `toolsAdded` lists new or changed complete + definitions. Removals apply before additions; same-name additions upsert. Tool definitions live only in + `SystemMessage` fields, never duplicated in `data`. +- A delta whose data changed but whose rendering and tools did not has `model: []` (metadata-only). +- Tool-only changes may have empty `content`. + +The frozen draft is diffed against the canonical state: unchanged data and rendering → no entry; else a +delta, or a baseline when 12.4 requires one. + +### 12.4 Canonical state, heads, forks, restarts + +Canonical section state = fold the fork-visible managed entries from the newest `baseline` forward through +its deltas. This uses the indexed `pi.system` kind scan and never depends on model heads or edits; a +collapse or reset does not erase section payloads. An optional prepared-state cache may shortcut the fold. + +A usable model baseline must follow the newest head. If the newest head is after the newest baseline, the +next preparation appends a fresh full baseline at the tail carrying omission edits for every retained +superseded managed entry (baselines and deltas between the head and the tail). This atomic supersession is +the only permitted edit of managed entries; any other omit/replace targeting them rejects. Already superseded +entries omitted by the current baseline do not cause another baseline. + +```text +10 user; 11 baseline; 20 assistant; 30 user; 31 delta; 35 notice; 40 assistant; 50 user +60 summary, head=30 +context at 60: [60, 30, 31, 35, 40, 50] +70 assistant; 80 user +81 baseline, edits:[{target:31, action:omit}] +context at 81: [60, 30, 35, 40, 50, 70, 80, 81] +``` + +Forks and owned conversations created with `parent` see the managed entries visible at their fork point. +Their first preparation compares desired state against that inherited canonical state and appends the delta +(or baseline, if a head intervened): for a host fork the desired state starts from the inherited sections; for +a task-owned child it starts empty, so every unseeded inherited section is removed and every inherited tool +not in the child's isolated loadout is in `toolsRemoved` (section 8.1). Inherited messages are never +rewritten. A conversation created without `parent` has no managed history; its first preparation appends a +full baseline from the seed plus handlers. Restart without a plugin keeps its payload and rendered text; re-registering a compatible +definition restores typed editing; only explicit `delete` removes a section. + +### 12.5 Preparation and staleness + +```text +on line: take the preparation snapshot S = { newestManaged: Id | null, + newestHead: Id | null, // decides baseline vs delta (12.4) + config: current generationKind.config values, + sectionsRev, toolsRev: registry revisions (h.sections, h.tools) } + and copy the canonical section fold and definitions +off line: run systemInstructions handlers on the draft; render; collect tools +on line, in the `prepared` commit: + re-take S'; if S' != S (any field) -> discard, repeat preparation (until one snapshot commits or the task is cancelled) + compute delta/baseline; if any -> buffer entry.append (managed; omission edits if baseline) + buffer checkpoint prepared{cutoff = newest visible entry after this append, system = entry id or null, ...} +``` + +Every mutable registry carries a monotonically increasing revision, bumped by `register`/`replace`/`remove`. +Reason for snapshotting config and registry revisions, not just the newest managed entry: a `c.config.set`, a +`replace` of a section renderer or a tool registration that commits while handlers run would otherwise pair the +managed entry at its historical position with instructions or a loadout computed from stale inputs. `newestHead` +is in the snapshot because a speculative collapse can append a summary head while handlers run; without it the +commit could append a delta against a baseline that now lies before the head, violating the fresh-baseline rule +of 12.4. A miss is ordinary churn, not a fault: preparation simply repeats and the next pass chooses baseline +versus delta against the new head; cancellation is the only exit. Whether an +offered tool's *executable* is still compatible when the model later calls it is a property of the gated +ordinary tool API (section 11.1); tool names in the checkpoint identify what was offered, nothing more. + +The request snapshot is captured after that commit and is immutable; a summary landing afterwards changes +the live context, not the request. A crash after the managed append but before the request preserves the +prepared instructions; the next preparation finds no change and appends nothing. + +`beforeRequest` runs after this commit on a private messages-only copy (section 10.1 step 2); the tool names +offered after transformation are recorded in the `requesting` checkpoint as the validation basis for the +model's calls. Transformed requests themselves are not stored. + +## 13. Harness + +```ts +interface HarnessOpenOptions = {}> { + readonly models: Models; // pi-ai; generationKind.config.model resolves against it + readonly tools?: readonly ToolDeclaration[]; // execution part gated (11.1) + readonly taskKinds?: readonly OrdinaryKind[]; + readonly entryKinds?: readonly EntryKind[]; + readonly sections?: readonly SystemSection[]; + readonly root?: RootSeed; // fresh root only; ignored on reopen + readonly rootValues?: readonly SeedWrite[]; // advanced alias for root.values + readonly onReport?: (report: Report, ctx: Context) => void; // non-fatal diagnostics (hook skipped, unknown tool name, unknown entry kind); off line; throws ignored + readonly processHost?: ProcessHost; // section 10.5; absent -> every pi.job fails { reason: "spawn", detail: "no process host" } at execute +} +type Report = + | { readonly type: "hook_skipped"; readonly kind: string; readonly point: string; readonly conversationId: Id; readonly error: unknown } + | { readonly type: "unknown_tool"; readonly conversationId: Id; readonly name: string } + | { readonly type: "unknown_entry_kind"; readonly kind: string }; +interface RootSeed> { + readonly config?: NoExtra, R>; // same shape and exactness as c.config.set + readonly values?: readonly SeedWrite[]; // any other conversation or session values (set or delete) + readonly sections?: readonly SectionSeed[]; // build with sectionSeed(section, value) +} +// Harness.open>(storage: Storage, options: HarnessOpenOptions, ctx); R is inferred from root.config +interface Harness { + root(ctx: Context): Promise; + conversation(id: Id, ctx: Context): Promise; + createConversation(seed: ConversationSeed, ctx: Context): Promise; // independent (no owner); may include first input; one commit + conversations(query: ConversationScan, ctx: Context): Promise>; // explicit query; `limit` is required by PageQuery + entries(query: EntryScan, ctx: Context): Promise>; // fork-aware, newest-first; pre-head history lives here + tasks(query: TaskScan, ctx: Context): Promise>; // hosts never touch Storage directly + commit(build: (tx: HostTx) => T | Promise, ctx: Context): Promise; + input(requestId: string, ctx: Context): Promise; + getEntry(id: Id, ctx: Context): Promise; + getEntry(kind: EntryKind, id: Id, ctx: Context): Promise; + getTask(id: Id, ctx: Context): Promise; + getTask(kind: K, id: Id, ctx: Context): Promise | undefined>; + value>(a: D): PublicValue, ResolvedValue>; // session scope; bound only + list(a: List): PublicList; + waitForIdle(ctx: Context): Promise; // all foreground tasks in the session; rejects Closed/Faulted + abortTask(id: Id, ctx: Context): Promise<"marked" | "terminal">; + resume(ctx: Context): Promise; close(ctx: Context): Promise; shutdown(ctx: Context): Promise; + inspect(ctx: Context): Promise; + readonly kinds: CoreTaskKinds; + readonly taskKinds: MutableRegistry; + readonly entryKinds: MutableRegistry; + readonly tools: MutableRegistry; + readonly sections: MutableRegistry; + readonly hooks: HarnessHookRegistry; + watchConversation(id: Id, options: ConversationWatchOptions, ctx: Context): Promise>; + watchSession(ctx: Context): Promise>; + watchSession(options: SessionWatchOptions, ctx: Context): Promise>; +} +interface ConversationHandle extends BoundPublicState { // value/list bind unbound definitions; a defaulted definition reads as T, others as T | undefined + readonly id: Id; + commit(build: (tx: ConversationTx) => T | Promise, ctx: Context): Promise; + send(input: SendInput, ctx: Context): Promise; + waitForIdle(ctx: Context): Promise; // this conversation's foreground set; rejects Closed/Faulted + abort(ctx: Context): Promise; + fork(options: ForkOptions, ctx: Context): Promise; // section 8.1; parent is this conversation + collapse(ctx: Context): Promise; collapse(options: { readonly instructions?: string }, ctx: Context): Promise; + reset(ctx: Context): Promise; reset(options: { readonly handoff?: string }, ctx: Context): Promise; // see below + readonly config: ConfigFacade; // section 2.1: model, thinkingLevel, selectedTools, profile, retry, steeringMode, followUpMode, threshold, keepRecent + readonly hook: HookFacade; // section 13.1: systemInstructions, beforeRequest, afterResponse, onYield, beforeTool, afterTool, afterTools, beforeCollapse + readonly hooks: ConversationHookRegistry; // generic: on(kind, kind.hooks.point, handler, { subtree }) + watch(ctx: Context): Promise>; + watch(options: ConversationWatchOptions, ctx: Context): Promise>; // === h.watchConversation(id, ...) +} +``` + +`reset` is a `write` of `pi.reset` or `pi.handoff{head: "self"}`: appended immediately when idle, queued when +busy. It resolves once the write is durably admitted (appended or queued), not when placed; placement is +observed through the watch (`entry` event with `head`). A `head: "self"` write can never go stale (its boundary +is itself). Cancelling `ctx` before admission cancels nothing durable; after admission the write stays. There is +no public handle for passive writes, and `InputHandle` is reserved for admitted user input. + +`fork({ abort: true })` aborts the source conversation's foreground set first ("go back"). `fork` inherits +rewindable state and managed system history through the parent walk; the seed fields override locally +(section 8.1). `h.createConversation(seed, ctx)` creates an independent conversation (no owner) and, unlike the +synchronous `tx.createConversation`, may carry the first input. + +Registries supply implementations, not selections: registering a tool does not offer it to the model; +`generationKind.config.selectedTools` does. `h.kinds` exposes the fixed core kinds as typed witnesses for +`config`/`hooks` derivation and `TaskOf` narrowing; possession of a witness confers no creation authority. + +Registries (`h.taskKinds`, `h.entryKinds`, `h.tools`, `h.sections`) are asynchronous and serialize on the line. +`register` rejects an existing name and any `pi.*` name; `replace` requires an existing ordinary name; +task-kind `replace`/`remove` reject while live instances exist; entry/section/tool removal keeps durable +records. In-flight invocations and preparations keep the definitions they started with; but any *new* creation +they attempt with the old token (`tx.task(oldKind, …)`, `tx.write(oldEntryKind, …)`) rejects `StaleDefinition` +(section 5.2), because the current registration is a different object. Hook registration +(`h.hooks.on`, `c.hooks.on`, `c.hook.*`) is different in kind: synchronous, process-local, returns an +unsubscribe function, touches no durable state and does not enter the line. + +Open order: + +1. Backend replay; initialize `nextId` after the highest committed ID; construct the disabled scheduler. +2. Install core kinds and built-in sections, then options. +3. Empty storage: create root conversation (ID 1) and apply `root` (`config` → the corresponding + `generationKind.config`/... definitions, `values` set/delete, `sections` → the root record's section-seed metadata) plus + `rootValues`, all bound to conversation 1, no duplicate addresses, in one commit. Nonempty: ignore both. +4. Load live tasks and the ownership ancestry they need. Do not load terminal history. Discover stored + entry-kind names through the kind index for `unknownEntryKinds`. +4a. Validate every live fixed built-in task (`pi.generation`, `pi.tool`, `pi.post_tools`, `pi.collapse` and + the ordinary-authority but fixed `pi.job`) against its fixed input/checkpoint schema. Any mismatch fails + open: built-in state that Pico itself cannot read is corruption, never an orphan. The five are always + installed, cannot be replaced, and never reach step 5. +5. Orphan reconciliation, one commit: every live task whose kind is not registered → `terminal/orphaned`, + scratch retired; every registered live foreground task in conversations it owns → marked. Queues kept. + Dependents see `orphaned` and decide. Registration never resurrects an orphaned task. +6. Return. Nothing runs until `resume`. + +Any failure in 1–5 (including 4a) closes the storage and rejects; there is no partially open Harness. + +### 13.1 Conversation config and hooks facades + +Both facades are assembled from the core kinds' `config` and `hooks` bundles. They add no schema and no +authority; every member is the generic operation with the kind or address bound. + +```ts +// config: one property per core config address, plus batched set/get (section 2.1). Usage: +// c.config.model PublicValue: get(ctx) | get(at, ctx), set, delete +// c.config.thinkingLevel PublicValue (defaulted: never undefined) +// c.config.selectedTools PublicValue +// c.config.steeringMode StickyPublicValue<"all" | "one-at-a-time", "all" | "one-at-a-time">: get(ctx), set, delete +// c.config.followUpMode same sticky queue-mode facade +// c.config.retry StickyPublicValue, Stored> +// c.config.threshold / keepRecent PublicValue +// c.config.set({ model, thinkingLevel, selectedTools, steeringMode, followUpMode, retry }, ctx) exact partial, one commit +// c.config.get(ctx) all current values, one read + +// hook: one method per built-in point, flattened across the fixed kinds exactly like config +interface CoreHooks { readonly generation: typeof generationHooks; readonly tool: typeof toolHooks; readonly postTools: typeof postToolsHooks; readonly collapse: typeof collapseHooks; readonly job: {} } +type CoreHookBundle = UnionToIntersection; +type HookFacade = { + readonly [N in keyof H]: (handler: HookHandler, options?: { readonly subtree?: boolean }) => () => void; +}; +type _AssertDisjointHooks = Assert>; // compile error on a duplicate flattened point name; re-asserted at install +type _HooksMatchKinds = Assert<{ [K in keyof CoreHooks]: NonNullable extends CoreHooks[K] ? true : false }[keyof CoreHooks]>; + +// the bundles the fixed kinds attach as `.hooks` (the points' In/Out types are the table in 11.2) +const generationHooks = { + systemInstructions: defineHookPoint<{ readonly sections: SystemSectionDraft; readonly config: Settings; readonly tools: readonly ToolDeclaration[] }, { readonly tools?: readonly ToolDeclaration[] }>({ fold: "collect", onThrow: "skip" }), + beforeRequest: defineHookPoint<{ readonly request: { readonly messages: readonly Message[] }; readonly cutoff: Id }, { readonly request?: { readonly messages: readonly Message[] } }>({ fold: "chain", onThrow: "skip" }), + onYield: defineHookPoint<{ readonly answer: AssistantMessage }, { readonly continue: string }, "first">({ fold: "first", onThrow: "skip" }), + afterResponse: defineHookPoint<{ readonly message: AssistantMessage; readonly usage?: Usage; readonly attempt: number }>({ fold: "collect", onThrow: "skip" }), +} satisfies HookPoints; +const toolHooks = { + beforeTool: defineHookPoint<{ readonly call: ToolCall; readonly block?: { readonly reason: string } }, { readonly call?: ToolCall; readonly block?: { readonly reason: string } }>({ fold: "chain", onThrow: "abort" }), + afterTool: defineHookPoint<{ readonly call: ToolCall; readonly result: ToolResult }, { readonly result?: ToolResult }>({ fold: "chain", onThrow: "skip" }), +} satisfies HookPoints; +const postToolsHooks = { + afterTools: defineHookPoint<{ readonly assistant: Id; readonly results: readonly Id[] }>({ fold: "collect", onThrow: "skip" }), +} satisfies HookPoints; +const collapseHooks = { + beforeCollapse: defineHookPoint<{ readonly reason: "threshold" | "manual" | "overflow"; readonly through: Id; readonly entries: readonly Entry[] }, { readonly decline: true } | { readonly instructions?: string; readonly summary?: string }, "first">({ fold: "first", onThrow: "skip" }), +} satisfies HookPoints; + +// Usage: +// c.hook.systemInstructions(h, { subtree: true }) === c.hooks.on(generationKind, generationKind.hooks.systemInstructions, h, { subtree: true }) +// c.hook.beforeTool(h) === c.hooks.on(toolKind, toolKind.hooks.beforeTool, h) +// c.hooks.on(myPluginKind, myPluginKind.hooks.myPoint, h) plugin kinds: generic registry, token-addressed +``` + +Singular `c.hook` is the flattened built-in convenience; plural `c.hooks` is the generic registry. Built-in +point names are unique across the fixed kinds by construction (`Assert>`) and re-checked when core kinds are installed. Plugin points are never flattened because plugin names can collide; they are +addressed by token. + +`c.watch(ctx)` / `c.watch(options, ctx)` is `h.watchConversation(c.id, options ?? {}, ctx)`. + +The Harness has neither facade. It keeps process-wide definition registries (`h.taskKinds`, `h.entryKinds`, +`h.tools`, `h.sections`), the typed witnesses `h.kinds`, and generic harness-wide `h.hooks.on(kind, +kind.hooks.point, ...)`. Configuration is conversation state, so it lives on the conversation. + +Advanced forms remain: `generationKind.config.model` tokens with `c.value(...)`, `c.hooks.on(kind, +kind.hooks.point, ...)`, `h.watchConversation(id, ...)`, `rootValues`. The primary surface is `root.config`, +`c.config.`, `c.hook.(...)`, `c.watch(...)`. + +### 13.2 Cloudflare Durable Objects + +One Durable Object hosts one Session, one Harness and one scheduler covering the root and every fork and +subagent. A request wake opens Pico, `send` durably admits and ensures `resume`. An alarm or one logical +Cloudflare Task wake opens Pico and calls `resume`, which recovers all eligible work in all conversations. +Platform wake-up is not effect recovery: do not create one lane or scheduler per conversation, and do not use +platform replay as a second authority for provider/tool effects. If admission and the platform wake cannot +share a transaction, an adapter adds an ordered handoff or idempotent retry so acknowledged work always +causes another wake. Checkpoints and `recover` remain authoritative across invocation limits. + +## 14. Watch + +```ts +interface WatchOptions { readonly capacity?: number; readonly onError?: (error: unknown) => void } // default capacity 256 +interface ConversationWatchOptions extends WatchOptions { + readonly values?: readonly (Value | UnboundValue)[]; // plugin/application values; `pi.*` rejects (built-in config has view.config; internals are protected) + readonly lists?: readonly (List | UnboundList)[]; // plugin/application lists; same rule +} +interface SessionWatchOptions extends WatchOptions { readonly values?: readonly Value[]; readonly lists?: readonly List[] } // session scope only + +interface WatchedValue { readonly address: Value; readonly value?: JsonValue } +interface WatchedList { readonly address: List; readonly elements: readonly Element[] } // complete current list at capture; bound address +interface WatchedTaskOutput { readonly id: Id; readonly kind: string; readonly value: OutputState } +interface ConversationView { + readonly conversation: Conversation; + readonly entries: readonly Entry[]; // the active transcript in transcript order: every entry with id >= the newest head's boundary (the head entry sits at its own position) + readonly context: readonly Id[]; // model-visible IDs derived from `entries` (edits folded, omissions applied) + readonly tasks: readonly Task[]; readonly taskOutputs: readonly WatchedTaskOutput[]; + readonly inbox: readonly Element[]; + readonly config: ConfigSnapshot; // every built-in config value, always captured + readonly values: readonly WatchedValue[]; // only the explicitly requested plugin/application values + readonly lists: readonly WatchedList[]; // only the explicitly requested plugin/application lists + readonly readAt: Seq; +} +type ConfigSnapshot = ResolvedValues>; // same definitions as c.config; only `model` (no default) is `| undefined` +interface SessionView { readonly conversations: readonly Conversation[]; readonly values: readonly WatchedValue[]; readonly lists: readonly WatchedList[]; readonly readAt: Seq } +// Session-level usage totals and fault/report feeds are intentionally not part of v1 (not gated, not planned +// here). `HarnessOpenOptions.onReport` is the complete v1 non-fatal report channel; faults surface as `Faulted` +// rejections and `closed/session` watch deliveries. + +type ListOp = { type: "append"; element: Element } | { type: "remove"; id: Id } | { type: "clear" }; +type ConversationEvent = + | { type: "entry"; entry: Entry } | { type: "task_start"; task: Task } | { type: "task_update"; task: Task; previous: Task } + | { type: "task_end"; task: Task & { status: "terminal" } } | { type: "task_output"; id: Id; kind: string; delta: readonly Op[] } + | ConfigEvent // built-in config, typed by key + | { type: "value"; value: WatchedValue } // requested plugin/application values only + | { type: "list"; address: List; ops: readonly ListOp[] } // requested plugin/application lists; ops for one address in one commit, in write order + | { type: "inbox"; ops: readonly ListOp[] } | { type: "context"; ids: readonly Id[] }; +type ConfigEvent = { + [K in Exclude]: { + readonly type: "config"; + readonly key: K; + readonly value: ResolvedValue; // after a delete, the default, never undefined for defaulted members + readonly previous: ResolvedValue; + }; +}[Exclude]; // flat and discriminated on `key`: e.key === "model" narrows e.value to ModelRef | undefined +type SessionEvent = + | { type: "conversation"; conversation: Conversation; change: "created" } | { type: "value"; value: WatchedValue } + | { type: "list"; address: List; ops: readonly ListOp[] }; + +interface CommitEnvelope { readonly first: Seq; readonly last: Seq; readonly events: readonly E[] } +type WatchDelivery = ({ readonly type: "commit" } & CommitEnvelope) | { readonly type: "closed"; readonly reason: "overflow" | "session" }; // d.events, d.first, d.last +interface WatchHandle { readonly view: V; start(listener: (d: WatchDelivery) => void): void; unsubscribe(): void } +declare function applyConversationCommit(view: ConversationView, commit: CommitEnvelope): ConversationView; +declare function applySessionCommit(view: SessionView, commit: CommitEnvelope): SessionView; +``` + +Capture and subscribe happen in one line operation, so the view plus the stream after `readAt` is gapless +and duplicate-free. A conversation view holds the **active transcript** in transcript order: every fork-visible +entry whose ID is ≥ the newest head entry's boundary, through the current tail; the head entry itself sits at +its own chronological position (after the boundary), not first; with no head, the whole transcript. Placing +the summary before the retained tail is context projection (1.3), not the view. It also holds live tasks +directly in that conversation, every distinct output they reference, the current inbox, every built-in +configuration value as `config`, exactly the requested custom values as `values` and requested lists as +`lists` (session addresses or addresses bound to this conversation; others reject `ScopeViolation`; built-in +config and protected `pi.*` reject). A later source-conversation write emits nothing for an existing fork. + +`view.config` is derived from the same `CoreConfigBundle` as `c.config` (section 2.1): one property per +built-in definition (`model`, `thinkingLevel`, `selectedTools`, `profile`, `retry`, `steeringMode`, `followUpMode`, +`threshold`, `keepRecent`), current value or `undefined`, with the same default resolution the core tasks use. +A watch always captures these; `values` is for plugin and application addresses only, and a built-in config +definition listed there rejects (it has its own field and event; it is never silently folded into `config`), +as does any protected internal. Duplicate requested `values`/`lists` entries are deduplicated by address +identity (the tuple in section 2), keeping the position of the first request: each address appears once in +`view.values`/`view.lists` and produces one event per commit. Reason: every renderer shows the model and tool loadout; making +each client know the address list would duplicate the schema. A built-in config write emits a dedicated +flat `config` event: `{ type: "config", key, value, previous }`, typed by key. Reason: a generic `value` event with an address is mechanically sufficient but forces every UI to match +addresses back to names; the discriminated `key` gives `e.key === "model"` → `e.value: ModelRef | undefined` for free, +derived from the same bundle as `c.config`. A batched `c.config.set({...})` produces one +`config` event per changed key in one envelope; the reducer folds all of them into `view.config` before the +listener runs. Built-in definitions never appear as `value` events; requested plugin/application addresses +never appear as `config` events. Initial state is read from `view.config`. Protected state (`pi.inbox`, +`pi.input_result`, `pi.request`, `pi.output`, scratch, conversation-record seed metadata) is never exposed through `config` or +`values`; inbox and task outputs have their own fields. + +There is no `tail` count or other window option in v1. Reason: the head already defines what the model and +the user are working with; a numeric window would cut it arbitrarily. + +Entry reducer, exactly: + +```text +capture: entries = active transcript in transcript order (ids >= newest head's boundary; or all if no head) +entry event, no head: entries.push(entry) +entry event with head: boundary = entry.head (head:"self" was materialized to entry.id at append) + entries = entries.filter(e => e.id >= boundary) // drop everything before the retained boundary + entries.push(entry) // the new head entry itself comes last +``` + +So a summary with `head = firstRetained` keeps `firstRetained..current` and appends the summary; a reset or +handoff with `head = its own id` drops every prior active entry and appends itself. The truncation and the +append happen inside the fold of the same commit envelope, so no intermediate view shows both prefixes and no +separate truncate event exists: the `entry` event already carries `head`. The capture is the only time the +full active list is materialized; afterwards the view is maintained incrementally. `context` remains its own +full-ID event because edits and heads change the derived projection non-locally. Entries before the active +head are history, read through `h.entries({ conversationId, cursor, limit })`/`h.getEntry`, not through the live watch. + +Events of one commit arrive in one envelope, folded into `view` before the listener runs. A boundary commit +therefore shows inbox removal, entry append, result and successor task together, with no transient duplicate +or idle gap. Same-commit inbox append+remove emits no inbox event. A commit with no events after filtering is +not delivered and uses no capacity, while `view.readAt` still advances. A `context` event is emitted whenever +the derived context ID list changes. + +Reducers are pure and free of plugin kinds: `applyConversationCommit` needs no registry because every event +carries what it applies (`config` events carry `key`/`value` over the statically fixed `CoreConfigBundle`; `list` +events carry the bound address and ops). `task_start` on create; `task_update` on any non-terminal replacement; +`task_end` on terminal (always with outcome). `task_output` carries a decoded delta applied with +`applyImmutable()`; when the last referencing task ends in the same envelope, output events precede +`task_end` and the reducer then drops the output. `list` ops for one address within one commit are grouped +into one event in committed write order and folded onto `view.lists[address].elements` before the listener: +`append` pushes the element (stable ID), `remove` deletes by ID, `clear` empties. The inbox uses the same op +shape on its own field. No replay, cursor or acknowledgement protocol exists for lists; overflow closes the +watch like everything else. + +Overflow closes the watch with `overflow` after discarding queued deliveries; the consumer opens a new watch +and gets a fresh authoritative capture. No resume protocol. A listener throw closes only that watch and calls +`onError` off the line; an `onError` throw is ignored; the session never faults. Session close delivers +`closed/session` once. `unsubscribe` is idempotent and stops all callbacks. + +### 14.1 Plugins and remote clients + +Pico does not publish plugin state to UIs and has no plugin-config publication API. The integration pattern is +the existing Chord facet split, already used by `packages/coding-agent/examples/plugins/pi-example-plugin` +(`contract.ts`, `session.ts`, `tui.ts`) and the experimental services in +`packages/coding-agent/src/experimental/services/` (`models-provider.ts`, `transcript-provider.ts`) and +`src/experimental/mini/worker/`: + +- **contract**: `defineService(id)` with typed methods and `ReplicatedState` fields. The DTO is what the + plugin chooses to expose; it is not the plugin's Pico addresses. +- **session facet**: runs in the process that owns the Harness. It stores its durable state in its own Pico + definitions (`conversationValue`/`sessionValue`/lists) through `c.value`/`h.commit`, reconstructs a + presentation-safe `MutableReplicatedState` from those values on activation and reopen, and updates it from + Pico commits (its own `commit` results, or a watch on the values it cares about). It `env.provide`s the + service. +- **presentation facet** (TUI, web): `env.use(Service)`, subscribes to the replicated state, renders, calls + the typed methods. It never receives a Harness, an address, or a `ConversationView` of another plugin's + values. + +Pico is the durable authority; Chord `ReplicatedState` is a live snapshot/delta projection over RPC, never +storage. Built-in config stays special: `view.config` and the `config` event are automatic because every UI +needs them. Everything a plugin wants a UI to see goes through its own service DTO. This is guidance about how +to compose the existing pieces, not a Pico API; nothing in sections 1–14 changes for it. + +## 15. Backends + +```ts +MemoryStorage.create(): Storage +JsonlStorage.open(path: string, ctx): Promise +SqliteStorage.open(path: string, options: { readonly session: string }, ctx): Promise +``` + +A `Storage` is opened by its backend and handed to `Harness.open`. One Session owns a `Storage` while open; +that exclusivity and the `close` lifecycle are internal Session/backend concerns, not a separate public +concept. JSONL/SQLite hold a cross-process lock from open to close; two writers fail rather than race. Memory +has no lock but still rejects a second concurrent Session; the same `MemoryStorage` may be reopened by a later +Session after close for recovery tests. + +Persisted output deltas are decoded Chord `Op[]`; `Op` is strict JSON by construction (tuples of tag, path +segments and Chord `JsonValue` payloads), so it needs no codec. Replay validates every `Op` tuple shape before +applying. The JSONL file and the SQLite schema each carry their one existing format version; nothing is added +for `Op`. + +**JSONL**: one main file; one sidecar per live task scratch scope; one sidecar per live output with +post-creation deltas (task creation and the initial output base are in main). Record: `{ first: Seq, last: +Seq, maxId: Id, writes: Write[] }`, newline-terminated, `last - first + 1 === writes.length`. `maxId` is the +committed-ID high-water *after* this batch, written into every record of every file, main and sidecar alike. +Reason: sidecar appends (scratch list elements, output deltas) mint IDs; once that task or output retires its +sidecar is ignored, so the IDs would be lost if reopen only scanned retained writes. Because the retiring main +record carries the high-water forward, reopen takes `max(maxId)` over retained records only and is still +correct. fsync every record before +publication; fsync new files and their directories. Replay main first, derive live scratch/output IDs from +live tasks, replay only those sidecars. Retired sidecars are ignored even if malformed (a stale file cannot +resurrect state). Sequence ranges increase within a file and never overlap across retained files; gaps are +valid. Bytes after the last newline are a torn write: truncate, fsync, continue. A complete but malformed +line fails open. Sidecar unlink failure is harmless because the main record proves retirement. Reopen +initializes `nextId` to `max(maxId over retained records) + 1` and the applied `Seq` from the greatest retained +endpoint. + +```text +main seq 1..10 maxId 12 (task 12 created; its scratch sidecar opens) +scratch seq 11..15 maxId 40 (list elements 13..40 appended in the sidecar) +main seq 16 maxId 40 (task 12 terminal; sidecar retired, may be unlinked or left malformed) +reopen: retained = main only -> nextId = 41; no ID in 13..40 is ever reused +failed callback minted 41..43 without commit -> nothing persisted -> next open still starts at 41 +``` + +**SQLite**: tables for conversations, entries (facets as columns), tasks (current snapshot), values + +rewindable versions, list elements + clear markers, scratch, output deltas, commit boundaries, and one +`meta(max_id)` row updated in every commit transaction (the same high-water rule as JSONL). Index task +status/abort/conversation/kind/output and entry conversation/head/kind. `PRAGMA synchronous=FULL` or a +documented equivalent. Terminal `task.set` deletes scratch rows in the same transaction. Reopen loads live +tasks and named owner records, not terminal history. Schema version mismatch rejects; no migration in v1. + +Both run the same conformance suite as memory. + +## 16. State-indexed task authoring (`defineStateTask`) + +An optional public adapter for ordinary kinds with several durable phases. It compiles to an ordinary +`TaskKind`; the scheduler and kernel are unchanged. It makes one kind's checkpoint dispatch exhaustive at +compile time. + +```ts +// constraints abbreviated: always mean I,R,F,A extends JsonValue; C extends TaskCheckpoint; +// O extends OutputState; H extends HookPoints (section 3.3). Written out in full here because this block must compile literally. +type PhaseOf = C["phase"]; +type CheckpointAt> = Extract; +type PhasePayload> = Omit, "phase">; +type PhaseTask> = + Omit, "checkpoint"> & { readonly checkpoint: CheckpointAt }; +type InitialTask = + Omit, "checkpoint"> & { readonly checkpoint?: never }; + +type StateTransitionTx = Omit, "checkpoint">; +type StatePhaseTx> = StateTransitionTx & { checkpoint(value: CheckpointAt): void }; +type InitialStateRuntime = + Omit, "commit">; // no commit before the first checkpoint +type StateRuntimeFor, H extends HookPoints> = + Omit, "commit"> & { + commit(build: (tx: StatePhaseTx, current: PhaseTask) => V | Promise, ctx: Context): Promise; + }; + +interface PhaseTransition> { + readonly type: "transition"; readonly phase: P; + readonly commit: (tx: StateTransitionTx, current: RunningTask) => PhasePayload | Promise>; +} +interface StateTerminal { + readonly type: "terminal"; readonly closure: TerminalClosure; +} +type StateTransitions = + { [P in PhaseOf]: PhaseTransition }[PhaseOf]; // distributed: each member pairs one literal phase with exactly its payload +type StateResult = + StateTransitions | StateTerminal; + +interface StateActions { + transition

, Actual extends PhasePayload>( + phase: P, + commit: (tx: StateTransitionTx, current: RunningTask) => NoExtra, Actual> | Promise, Actual>>, + ): PhaseTransition; + terminal(closure: TerminalClosure): StateTerminal; +} + +type PhaseHandler, H extends HookPoints> = + | { readonly role: "start"; readonly recover?: never; + run(task: PhaseTask, rt: StateRuntimeFor, actions: StateActions, ctx: Context): Promise> } + | { readonly role: "inflight"; + run(task: PhaseTask, rt: StateRuntimeFor, actions: StateActions, ctx: Context): Promise>; + recover(task: PhaseTask, rt: StateRuntimeFor, actions: StateActions, ctx: Context): Promise> }; +type PhaseMap = + { readonly [P in PhaseOf]: PhaseHandler }; + +type StateTaskDefinition = { + readonly kind: string; + readonly hooks?: H; // kind-owned hook points, as on defineTask + readonly config?: ConfigBundle; + readonly initial: { readonly role: "start"; + run(task: InitialTask, rt: InitialStateRuntime, actions: StateActions, ctx: Context): Promise> }; + readonly phases: PhaseMap; // handlers receive StateRuntimeFor<..., H> + abort(task: RunningTask, rt: AbortTaskRuntime, ctx: Context): Promise>; +} & TaskOutputDefinition; + +declare function defineStateTask(): + >( + definition: string extends PhaseOf ? never : ExactStateTaskDefinition, D>, + ) => D & TaskKind; // H and D["config"] inferred from the literal exactly as in defineTask; lifecycle unchanged +``` + +Compile contract: `C` is a finite union with literal `phase`s (broad `string` rejects); every phase has +exactly one handler and no extra key exists; each handler receives its complete narrowed checkpoint; `start` +has `run` only, `inflight` has `run` and `recover`; `initial` has no checkpoint and no `commit`; transition +payloads reject missing or visible extra fields for literals, variables and spreads; the returned +`StateResult` itself preserves the phase/payload correlation (`StateTransitions` distributes over the phases, +so a hand-built `{ type: "transition", phase: "a", commit: () => payloadForB }` is a compile error, not only +through `actions.transition`); `terminal` preserves the typed `TerminalClosure`. `ExactStateTaskDefinition`/`ExactPhaseMap`/`ExactPhaseHandler` are the +`NoExtra` helpers that enforce this; they are spelled in appendix A. + +Generated `execute`/`recover`: + +```text +execute: no checkpoint -> fence; initial.run + transition(P, commit) -> module-private commitStateTransition: on the line, check invocation and + expected source phase, run commit(tx, fresh current), buffer full task.set with {phase:P, ...payload}, + persist, return the applied PhaseTask

(checkpoint and owns from the live projection) + fence; phases[P].run ... repeat until terminal(closure) -> return closure to the normal terminal path +recover: no checkpoint -> fence; initial.run + start checkpoint P -> fence; phases[P].run + inflight checkpoint P -> fence; phases[P].recover + after any committed transition the loop continues with run +``` + +The scheduler calls the generated method once per invocation. `commitStateTransition` is a module-private +symbol on the runtime; it is not exported and grants no author capability. Transition callbacks run on the +line: reads before the first write, then compose same-commit entries/tasks/state; no effects, waits, hooks or +nested commits. + +Why `initial` cannot commit: with no durable phase, a crash would rerun `initial` and duplicate any child it +created. Children are created in the first transition callback, atomically with the first checkpoint. +Checkpoint-backed handlers keep `commit` for same-phase bookkeeping (e.g. create a child and record its ID in +the current phase before waiting); phase changes only through returned transitions. + +`start` means the checkpoint represents no uncertain external effect, so reopen reruns `run`. `inflight` +means an effect may have started, so reopen requires `recover`. + +Fence: immediately before every handler call the adapter synchronously checks the exact invocation object, +ctx, session phase, durable mark and abort signal. If a mark landed first, the handler is never invoked; the +ordinary cancellation unwind returns from the outer method and fresh abort follows. If the transition +persisted first, the handler is admitted and a later mark signals it normally. + +## 17. Test matrix + +Each item is a test, run in both orders where a race is named. + +**Storage / line** +- IDs vs sequences: failed callback burns IDs; reopen reuses uncommitted IDs, never committed ones; + state-only writes advance `Seq` without `nextId`; IDs minted only in a since-retired sidecar are not reused + after reopen (high-water carried by the retiring main record). +- Read before write ok; read after write → `ReadAfterWrite`, nothing persisted. +- Rewindable write after entry in one batch → reject. +- Protected internals: external `conversationValue("pi.inbox")` rejects at construction; a hand-built `pi.inbox` + address rejects `ScopeViolation` on generic read/write/watch/`rootValues`; imported `generationKind.config.model` + works through `c.value`/`c.config`/`rootValues`. +- Concurrent commits serialize; an awaited read holds the line. +- Uncertain storage failure → fail-stop; callback not retried. +- Terminal `task.set` retires scratch and unreferenced output atomically; retired reads reject in all backends. +- Rewind through fork caps; tombstones; list remove/clear at historical positions; nested forks; unrelated + position → `InvalidHistoryPosition`. +- Scan cursors, `through`, `newestHead`, filters; list remove of a foreign element (other definition or + unrelated conversation) rejects; a fork removes an inherited parent element through its own bound address + (local removal, parent unchanged, historical reads before the removal still show it); removing it twice is + idempotent. +- Pre-persistence assertion failures fault: invalid transition, unpaired ownership, output ref to retired output. + +**Admission** +- Idle send: one batch places, creates one generation, records result + receipt. +- Busy send: followUp/steer queue, no task; reject throws and writes nothing. +- Duplicate requestId: same batch, later, other conversation → original handle, nothing written. +- `h.input(requestId)` for a core request-keyed write receipt → `undefined`; a `send` reusing that key → + `RequestKindMismatch`; public `tx.write` has no request key and returns the input id only. +- `h.createConversation({ input, requestId })` / `c.fork({ at, input, requestId })` with an existing receipt for + that key → `RequestKindMismatch`, no conversation and no input created; `h.input(requestId)` still finds the + original; a fresh key creates both atomically. +- No `processHost` → `pi.job` fails `spawn` before any checkpoint. +- Withdraw vs place: exactly one terminal result. +- Hydration: `wait`/`result` return `input: UserEntry` and `answer.{entry, message}` where `message === + entry.model[0]`; a later edit targeting the assistant entry does not change a previously or subsequently + hydrated `message`. Passive writes have no public input outcome. +- Steer vs post_tools; followUp vs final answer: consumed exactly once. `all` vs `one-at-a-time` per mode. + Placement order is by input ID across modes: a steer admitted before a write precedes it at the boundary. +- Queued write with head vs collapse committed meanwhile → `unanswered/stale`, no fault. +- Queued write placed at post-tools boundary lands after the complete exchange. +- Reset before followUp in the inbox (`R` then `B`) at post-tools: old group `unanswered/terminated`, no + continuation, `B` placed after `R` starts a fresh generation whose cutoff context starts at `R`. FollowUp before + reset (`B` then `R`): `B` placed then made invisible by `R` → `unanswered/stale`, no generation for `B`; with + `one-at-a-time` and `B1, B2, R` queued, the unselected `B2` is also removed and `unanswered/stale`, so a later + idle send does not resurrect it; same cases at a final-answer boundary and an idle send; idle `c.reset` with + followUps left queued by a task abort → they resolve `unanswered/stale` in the reset's commit and a later send + starts from the reset alone. +- Several inputs in one group all resolve to the same answer. +- Task abort keeps queue; conversation abort withdraws steer/followUp, keeps write. +- Idle-turn boundary after post_tools with no continuation. +- Owned conversation seed: create + config values + isolation deletes + section seed + first accept + owner + checkpoint in one commit; crash before commit leaves nothing; with `parent`, child reads no rewindable + `CoreConfigBundle` member from the parent; first preparation removes every unseeded inherited section and + every inherited tool outside the isolated loadout; a host fork with `parent` keeps inherited sections and + config and only applies overrides; sync `tx.createConversation` rejects `input`/`copySticky` at the type + level; `SeedWrite` delete cuts an inherited plugin value; first generation reserved in the same drain still + sees the seed; missing seeded model → `no_model` failure, no fault. +- Child hooks: inert creation then `c.hooks.on` then `send` runs child handlers on the first request; atomic + creation with `profile` dispatches pre-registered subtree handlers by profile on the first request; reopen + with code reinstalled before `resume` restores both; unknown profile falls to defaults. +- Collapse does not make busy: idle send during collapse starts generation. +- Yield continuation: same `inputs` carried, results stay `placed`; transcript order is assistant, then placed + writes, then the `pi.user` continuation; queued trigger arriving between hook and closure wins and the yield + decision is discarded; a queued reset/handoff placed at the boundary also discards it: inputs `done(answer)`, + no continuation, no successor. + +**Tasks / scheduler** +- Reserve before effect; reopen runs `recover` with and without checkpoint. +- Closure commits once; throwing closure → nothing persisted, session faults. +- Dependency terminal (any outcome) wakes dependent exactly once. +- Resume vs commit both orders: nothing missed, nothing double-dispatched; drain-exit race. +- Tasks across conversations run concurrently; background continues after foreground idle. +- Self-wait and dependency-path wait reject; `after` cycle rejects. +- Cancelled waiter removes only itself. +- Output: a `mutate` with no tracked change persists nothing and emits nothing (no `Seq` consumed); a `replace` + with a structurally equal value still persists one base flush and emits one event; two tasks write one output + in line order; shared ref keeps output live past first task's terminal; + `initial` not called for a shared ref; final closure gets `tx.output`; `mutate` throw faults; sidecar after + retirement ignored on reopen. +- Output across conversations: task in B adopts A's output → B's watch envelope carries a synthetic full base + before `task_start` and later deltas apply; last local ref ends in B → B drops it while A keeps it; the + adjacent serialized commits in either order stay gapless: delta-before-adoption (the synthetic base already + includes it) and adoption-before-delta (the next `task_output` applies it). + +**Cancellation / lifecycle** +- Mark vs effect return: one invocation; fresh abort after old return. +- Post-mark commit/scratch/output → reject before callback. +- Closure vs mark: committed-first wins; otherwise closure discarded. +- Pending marked before reservation: no execute, abort runs. +- Repeated mark during abort: no effect. +- Close writes nothing; shutdown marks all, preserves queues; crash during shutdown → reopen + resume finishes. +- Close sequence: `close` waits behind the admitted commit, which persists; then phase `closing` on the line, + waiters reject, watches get `closed/session`, queued operations reject `Closed`; invocations joined before + `storage.close`; the Harness object is permanently unusable. Fault: waiters reject `Faulted`, watches get + `closed/session`, nothing written afterwards. +- Conversation abort reaches foreground ordinary tasks and threshold collapse in the subtree; background jobs + and manual collapse survive. +- Broken abort handler: shutdown rejects, task stays durable. +- Orphan reconciliation: missing kind → orphaned, scratch retired, registered descendants marked, queues kept. +- `pi.tool` abort marks children with `abortWithTool: true` only; subagent queues preserved. +- Registry: replace/remove of a kind with live instances rejects. +- Token identity: `tx.task(unregisteredKind)` and `tx.write(unregisteredEntryKind)` reject `StaleDefinition`; after + `h.entryKinds.replace(newNote)`, `tx.write(oldNote, …)` rejects and `tx.write(newNote, …)` works; a task + method that captured the old definition keeps running to completion with it; a structurally identical + redeclared token with the same `kind` string rejects. +- Hooks: `c.hook.` methods exist for exactly the fixed kinds' points and dispatch to the same registry + as `c.hooks.on(kind, kind.hooks.point, ...)`; a duplicate flattened built-in name fails install; two plugin + kinds with same-named points register independently by token. +- Hooks: ordinary kind declares a custom point; `collect`/`first`/`chain` folds; `skip` vs `abort` on throw; + cancellation propagates regardless; harness-wide then outer→inner order; `subtree` reaches owned + conversations only; handler rerun after crash re-enters the phase. + +**Core kinds** (faux provider, faux tools) +- Generation: tool calls → N tools + one post_tools atomically; final answer → boundary + successor. +- Retry sequence: `retrying{attempt: n}` → `requesting{attempt: n+1}` with identical cutoff/system/model/thinking; + step 1 does not rerun; overflow measured on the post-hook request; a hook that adds a tool declaration makes + that name appear in `requesting.tools`. +- Streaming: provider `partial` is never inserted into the tracker (mutating it after `start` changes nothing + in the output); each encoded frame yields exactly one incremental Chord delta (no whole-message replace per + token); after the stream the tracker value equals `reduceAssistantMessageFrames(frames)`; the stored assistant + entry is a validated clone. +- Recover from each generation phase; failed attempts produce `pi.usage` entries, the final attempt's usage is + on `pi.assistant` only; recovery from `requesting` with retries left records `pi.usage{ error: "interrupted" }` + and retries; with retries exhausted or disabled it terminalizes `failed/*` with no assistant entry and inputs + `unanswered/failed`; + `c.config.model` changed between crash and recover → recovered request still uses `Prep.model`; `beforeRequest` + reruns on recovery from `prepared`; `requesting` checkpoint carries post-hook tool names; `retry` policy + honoured (`maxRetries`, `retryDelayMs`, `enabled: false` → no retry); `afterResponse` runs once per attempt + including error/aborted responses and deferred fetch results; deferred: `pollAfterMs` respected, + re-deferred handle refreshes the checkpoint, abort calls `cancelDeferred`. +- Generation terminal error: non-retryable/exhausted error and provider-side `aborted` → one display-only + assistant entry, inputs `unanswered/failed`; overflow with no collapsible prefix → notice + `failed/overflow`, + no collapse task; replacement generation after failed collapse → notice + inputs `unanswered/failed`. +- Threshold: estimate over `threshold` → foreground collapse created, generation completes, conversation abort + reaches it; `through` = boundary before the largest suffix within `keepRecent`; whole context fits → no task + and manual collapse rejects `NothingToCollapse`; overflow via `estimateContextTokens` → `failed/overflow` + + replacement generation. +- Overflow → collapse + replacement generation atomically; replacement handles collapse failure/decline. +- Tool: `beforeTool` output changing `call.name`/`id` → blocked "call identity changed", not invoked; call naming a + tool absent from the originating generation's offered `tools` → "tool not offered" error result, registry not + consulted; a deferred generation that completed with tool calls (last checkpoint `deferred`) passes the evidence + check and proceeds to normal registry lookup; generation record missing → fault; missing tool / bad args (original or after a + `beforeTool` rewrite) / `block` / throw → error result, not fault; `beforeTool` chain merges `call` + rewrites and stops at the first `block` (later handlers not run); crash after reservation but before + `started` → recover reruns validation/hooks and starts (no interruption result), for `safe` and `unsafe` + alike; crash after `started` persists the final rewritten call and never reruns `beforeTool`: current + missing/unsafe/schema-incompatible definition yields an interrupted/unavailable result, while current safe + and valid replays the persisted arguments; parallel completion order vs projection order; output bounds and + truncation diagnostics. +- Tool child (once the mediated capability is settled): keyed creation is idempotent after safe replay; + wait timeout → background result without marking; closure reread finds terminal child → normal result; + `abortWithTool: false` child survives tool abort; sibling resolver rejects fork-only ancestry. +- Tool control: `terminate` ends the turn; `handoff` ends it and appends `pi.handoff` *before* the final + boundary so a queued follow-up's generation starts in the fresh context; `handoff` beats `terminate`; several + `addTools` append to the existing `selectedTools` in call order with stable dedupe (nothing previously + selected is dropped) in one write, and the next baseline/delta shows the change; `addTools` reads + `selectedTools` fresh in the closure, so a config change made during `afterTools` is not lost. +- Post_tools synthesizes results for orphaned/aborted tools; mixed batch (one orphaned tool, another with + `addTools`): the `selectedTools` write precedes the synthesized result entry in the `Write[]`, the fork + invariant holds, and both effects land in one commit. +- Post_tools aborted (task abort and conversation abort): every input in `inputs` resolves `unanswered/aborted`; + `InputHandle.wait` on them settles; no successor generation; queued followUp survives task abort. +- Collapse: stale head → `failed/stale`; edits in between do not stale; summary head placement; `decline` → + `failed/declined`; hook `summary` goes to `prepared` without a provider call; crash in `summarizing` reruns + the summarizer as a failed attempt (`retrying` then `attempt + 1`, or `failed/provider` when exhausted/disabled, + never the same attempt twice); crash in `prepared` finalizes without a call; unset model → `failed/no_model` + before any hook or checkpoint; summarizer request offers + no tools; a summarizer response with tool calls is a provider failure; head moves while `beforeCollapse` + waits → `failed/stale`, hook decision discarded; retry passes through `retrying` with `untilMs`. +- Job: `spawning` before OS effect; fake host `status` → `running`/`exited` continues; `unknown` (on recover or + mid-poll) → `interrupted` unless `rerun`, which calls `start` again with the same key; `start` for a known + key is a no-op; `start` rejecting (ENOENT) → `failed/spawn`, no fault; `status` rejecting → `failed/interrupted` + without rerun, no fault; `kill` rejecting in abort → fault; recover from `waiting` sleeps the stored deadline + then spawns; abort with no key → `{ killed: false }` and no host call; abort with a spawning/running key → + TERM, 5 s, KILL (idempotent if already exited); each schedule + occurrence writes its notice with the next `waiting` checkpoint; dropped byte counts copied from `status`; + abort ends the schedule. +- System: preparation reruns when config, `h.sections`, `h.tools` or the newest head change while handlers run (a collapse landing mid-preparation yields a baseline, not a stale delta); baseline content is `## key\nrendered` blocks joined by blank lines; delta content is `The key section + now reads:` / `no longer applies.` blocks; unchanged draft appends nothing; delta on change with `toolsRemoved` before `toolsAdded`; data-only + change → `model: []`; baseline after a head with omission edits for superseded managed entries; no repeated + baseline; fork sees inherited managed entries; restart without plugin keeps payload and rendered text; + staleness retry when a managed entry lands during preparation; throwing handler's edits rolled back only. + +**Watch** +- Capture vs commit: no gap, no duplicate. +- One boundary commit → one envelope. +- Overflow closes; new watch has fresh base. Listener throw closes only that watch. +- Output delta ordering vs `task_end`; output removed when last reference ends. +- Session watch: conversation created, session values and lists; `closed/session` on close. +- Lists: requested list captured with complete elements; append/remove/clear in one commit → one `list` event + with ops in write order, folded before the listener; element IDs stable; `pi.inbox` and built-in config + rejected in `values`/`lists`; session watch rejects conversation lists. +- Reset: idle → entry appended before `reset` resolves; busy → queued and `reset` resolves on admission, entry + placed at the boundary and visible via watch; never stale. +- Defaults: unset `followUpMode` reads `"one-at-a-time"` via `c.config`, `view.config` and the post_tools + boundary; `delete` restores the default and the `config` event carries the default as `value`, never + `undefined`; initial watch capture has every defaulted key present; unset `model` reads `undefined` and + generation fails `no_model`; historical `c.config.thinkingLevel.get(entryId)` at a position with no write + resolves the default. Compile: `ConfigSnapshot["followUpMode"]` is exactly + `"all" | "one-at-a-time"` and `["model"]` is `ModelRef | undefined`; `c.value(plainToken).get` is `T | + undefined`, `c.value(defaultedToken).get` is `T`; `conversationValue("plan.mode", { rewind: true })` is + `UnboundValue` and with `default: false` it is `& { default: boolean }`; `sessionValue("n", + { default: "x" })` reads as `string`. +- Watch options: the same plugin address requested twice appears once in the view and yields one `value` + event; a built-in config token or `pi.inbox` in `values` rejects rather than deduplicating into `config`. +- `view.config` captured without requesting; `c.config.model.set` → one `config` event `{ type: "config", key: + "model", value, previous }` folded into `view.config.model`; `c.config.set({ model, followUpMode })` → two `config` events in + one envelope, both folded before the listener; a requested plugin value lands in `view.values` via `value`; + built-in definitions never emit `value`; protected addresses cannot be requested. +- Active transcript: capture with no head returns all entries; with a head returns, in transcript order, every + entry from the boundary through current with the head entry at its actual position. + Reducer: plain entry appends; summary entry with `head = k` drops entries with id < k then appends the + summary; reset/handoff (`head = self`) drops all prior active entries then appends itself; both in the same + fold as the rest of that commit; `context` event updates in the same envelope; pre-head entries remain + readable through entry scans; capture after a head equals the incrementally reduced view. + +**State adapter** +- Live transitions through several phases: one scheduler `execute`; each handler gets the applied projection. +- Initial child creation vs crash: no transition → initial reruns with no child; transition → next phase, no + duplicate. +- Reopen dispatch: absent → initial; start → run; inflight → recover. +- Same-phase commit vs abort: both or neither; post-mark commit rejects. +- Transition vs mark both orders: mark-first prevents the next handler. +- Compile: broad string phase rejects; missing/extra phase key rejects; extra transition field rejects; + inflight without recover rejects; a hand-built transition object mixing phase `"a"` with phase `"b"`'s payload + is rejected as a `StateResult` (negative fixture), while `actions.transition("a", ...)` with the right payload + is accepted. + +**Backends** +- JSONL: main through Seq 100, scratch through 150 → reopen at 150; terminal at 151 with unlink failure → + sidecar ignored; torn suffix truncated; malformed complete line fails open; kill -9 during each phase. +- SQLite: same conformance; lock contention fails fast. +- 100k terminal tasks, 2 live: open touches only live seed + ancestry. + +## 18. Work packages + +One commit per package, reviewed before the next starts. Run the package's tests and `npm run check` before +each commit. Any package test exercising providers or tool execution uses faux implementations; no real API. + +| # | package | delivers | +|---|---|---| +| 1 | types | sections 1–3 and 11.2 declarations (`HookPoint`/`defineHookPoint` are referenced by `TaskKind`), `Stored` and its compile assertions (section 1), `defineTask`/`defineCoreTask`/`defineEntry`/address helpers; compile tests | +| 2 | storage | `Write`, `Storage`, `MemoryStorage`, conformance tests | +| 3 | line | Session line, transactions, read-before-write, validation and assertions (5.2), capability-bound builders | +| 4 | context | context derivation, forks, tool-result repair, transcript invariant assertions | +| 5 | admission | inbox/results/receipts, send, boundaries and queue modes, withdraw, wait; test generation kind | +| 6 | task kernel | reservation, runtimes, closures, scratch, output tracker, invocation identity | +| 7 | scheduler | resume/kick/drain, dependencies, waiters, idle | +| 8 | cancellation | mark/revoke/join/fresh abort, conversation abort, close/shutdown/fault, open + orphan reconciliation, Harness object over the test generation kind, registries, hooks registry | +| 9 | system | sections, draft, managed entries, canonical fold, baseline/delta, staleness retry (section 12); depends on pi-ai `SystemMessage` having landed | +| 10 | generation + tool + post_tools | 10.1–10.3, `ToolResult`/`ToolDeclaration`, hooks of 11.2; `pi.tool` runs an internal test executor, no ordinary tool API; faux provider only. Uses pi-ai's landed `SystemMessage`/messages-only adapters; no parallel representation | +| 10a | ordinary tool API | section 11.1 gated block (execution signature, facade, output access, memos, children, resolver), after its shape is decided and appended; then the built-in tools | +| 11 | collapse + job | 10.4, 10.5 | +| 12 | watch | section 14 | +| 13 | jsonl | section 15 | +| 14 | sqlite | section 15 | +| 15 | state adapter | section 16 | + +Package 10 is the design validation point: if generation/tool/post_tools do not fit the kernel from 6–9, +change the kernel before building 12–15. Its acceptance also includes the size check: count production lines +under `src/harness/pico/` excluding `memory-storage`, `jsonl-storage`, `sqlite-storage` and tests; materially +over 4k means stop and simplify before continuing, never add caching or indexing layers to compensate. + +## 19. Repository rules + +- Location: `packages/agent/src/harness/pico/`. No imports from `harness/runtime`, `harness/session`, + `agent-harness.ts` or earlier Pico prototypes. Chord and pi-ai are allowed. Read reusable leaf code before + copying, then own the copy. +- Erasable TypeScript only. No `any` in Pico declarations (imported pi-ai types exempt). +- Check external types in `node_modules`; do not guess. +- Test with `node .../vitest/dist/cli.js --run test/harness/pico/.test.ts` and `npm run check`. Never + the full suite. +- Commit only files of the current package, by explicit path. +- Production size target 3k–4k lines excluding storage backends and tests (intro, WP10); report the count in + each package's commit message from WP6 onward. +- Public exports: `pico/index.ts` exports only the public surface. `defineCoreTask`, `CoreTaskTx`, + `CoreAbortTx`, `CoreTaskRuntime`, `CoreAbortTaskRuntime`, the internal admission helpers (`accept`, + `queueInput`, request-keyed `write`), the core runtime/builder constructors and `commitStateTransition` are + module-internal and never re-exported; no `export *` from a module that declares them. The fixed kind tokens + (`generationKind`, `toolKind`, `postToolsKind`, `collapseKind`, `jobKind`) with their `config` and `hooks` + witnesses, `defineTask`, `defineStateTask`, `defineEntry`, `defineHookPoint`, `defineSystemSection`, address + helpers, `Harness`, storages and all public types are exported. A compile test asserts that + `import { defineCoreTask } from ".../pico"` fails. + +## Appendix A. Type index + +Every identifier used above that is not declared inline. Imported: `Context`, `Op`, `applyImmutable`, `track` +from Chord; `Message`, `UserMessage`, `AssistantMessage`, `ToolResultMessage`, `TextContent`, `ImageContent`, +`ToolCall`, `Tool`, `Models`, `Usage`, `DeferredHandle`, `RetryPolicy`, `isRetryableAssistantError`, `retryDelayMs`, `estimateContextTokens`, `AssistantMessageFrameEncoder` from pi-ai (`reduceAssistantMessageFrames` only in tests, as the oracle); `JsonRepresentation` from Chord (as `Stored`, section 1); `ThinkingLevel` +re-exported from `packages/agent/src/types.ts` (`"off" | "minimal" | "low" | "medium" | "high" | "xhigh" | +"max"`; never redeclared in Pico); `TSchema`, `Static` from TypeBox. + +```ts +declare const taskOutputType: unique symbol; // taskKindBrand is declared in 3.3 + +// identity and payloads +type ModelRef = { readonly provider: string; readonly modelId: string }; // field names match the existing models-provider service +// durable payloads below are exact `type` aliases so they satisfy JsonObject; public non-durable shapes stay interfaces +type AssistantEntryData = { readonly attempt: number }; // usage is already on AssistantMessage.usage; not duplicated +type UsageEntryData = { readonly attempt: number; readonly usage?: Stored; readonly error: string }; +type UsageEntry = EntryBase & { readonly data: UsageEntryData }; // pi.usage, data-only +interface UnboundValue { readonly namespace: string; readonly key?: string; readonly rewind: R; readonly default?: T; readonly [addressType]?: T; bind(conversationId: Id): Value } // the defaulted overload returns UnboundValue & { readonly default: T } +type PayloadOf = D extends Address | UnboundValue ? T : never; +interface UnboundList { readonly namespace: string; readonly key?: string; readonly rewind: R; readonly [addressType]?: T; bind(conversationId: Id): List } +// `addressType` is declared in section 2; bound and unbound definitions both carry the witness so inference survives binding +type ConversationValueDefinition = UnboundValue; // the name used for config bundle members +type ConversationListDefinition = UnboundList; +// SeedWrite (set | delete): section 8.1. Unbound addresses are bound to the target conversation (root = 1, or the created child). `RootValueWrite` is the set form: { address, value }. +type RootValueWrite = Extract; +// conversationValue(ns, { rewind: true }) -> UnboundValue; { rewind: false } -> UnboundValue +interface StickyPublicValue { get(ctx: Context): Promise; set(value: T, ctx: Context): Promise; delete(ctx: Context): Promise } // Read is the resolved type (default applied) for defaulted definitions +// CoreConfigs, CoreConfigBundle, BundleValues, Settings, ConfigFacade, DisjointBundles: section 2.1. CoreHooks, CoreHookBundle, HookFacade: section 13.1. +type Acceptance = { readonly kind: "send" | "write"; readonly requestId?: string; readonly conversationId: Id; readonly inputId: Id }; // durable receipt; `kind` keeps write receipts out of Harness.input +interface InternalAcceptOptions { readonly input: UserInput; readonly requestId?: string; readonly whenBusy?: "followUp" | "steer" | "reject" } // public-typed; the helper validates and stores Stored +type StoredEntryDraft = { readonly kind: string; readonly data?: JsonValue; readonly model?: readonly Stored[]; readonly head?: Id | "self"; readonly edits?: readonly ContextEdit[] }; +// StoredInputResult, InputOutcome, TerminalInputOutcome: section 9.1 +// ConversationSeed, ConversationCreateSpec: section 8.1. Report: section 13. +type ToolResultData = { readonly details?: JsonValue; readonly usage?: ToolUsage; readonly diagnostics?: readonly ToolDiagnostic[]; readonly control?: ToolControl; readonly truncated?: { readonly bytes: number; readonly lines: number } }; +type ToolUsage = { readonly [key: string]: number }; +type ToolOutputState = { progress?: string; log: string[]; details?: JsonValue }; // mutable OutputState +type EnvironmentInfo = { readonly cwd: string }; // exact alias; the established minimal built-in; richer environment data is an application section +type SkillInfo = { readonly name: string; readonly description: string }; // exact alias; systemSections.skills renders the list +// Registries and open arrays take the erased default `SystemSection` (= SystemSection): `render(value: T)` is method +// syntax, so every SystemSection is assignable under method bivariance. Seeds pair a section with a value, which the erased +// form cannot check, so seeds are built only through the checked helper: +type SectionSeed = + | { readonly section: SystemSection; readonly value: JsonValue } // erased set pair + | { readonly key: string; readonly remove: true }; // remove an inherited section by stable key (host forks) +declare function sectionSeed(section: SystemSection, value: T): SectionSeed; // checked set +declare function removeSection(key: string): SectionSeed; // removal + +// tasks +type NewTask = Omit & { readonly status: "pending"; readonly owns: readonly []; readonly abort?: never; readonly checkpoint?: never; readonly outcome?: never }; +type RunningTask = Omit, "output"> & TaskOutputField & { readonly status: "running"; readonly outcome?: never }; +type Completion = { readonly status: "completed"; readonly result: R } | { readonly status: "failed"; readonly failure: F }; +type TerminalClosure = (tx: FinalTx, current: RunningTask) => Completion | Promise>; +type CoreTerminalClosure = (tx: CoreFinalTx, current: RunningTask) => Completion | Promise>; +type AbortClosure = (tx: AbortFinalTx, current: RunningTask) => A | Promise; +type CoreAbortClosure = (tx: CoreAbortFinalTx, current: RunningTask) => A | Promise; +interface TaskOutputKind { readonly kind: string; readonly [taskOutputType]?: O } +declare function defineTaskOutput(kind: string): TaskOutputKind; +interface TaskOutputRef { readonly id: Id; readonly kind: string; readonly [taskOutputType]?: O } +type TaskOutputField = [O] extends [never] ? { readonly output?: never } : { readonly output: TaskOutputRef }; // no-output arm forbids the key rather than leaving it unconstrained +type TaskOutputDefinition = [O] extends [never] ? { readonly output?: never } : { readonly output: { readonly kind: TaskOutputKind; initial(input: I): O } }; +type FinalOutput = [O] extends [never] ? { readonly output?: never } : { readonly output: O }; +// RuntimeOutput, AbortRuntimeOutput, TaskRuntimeBase, CommitRuntime, AbortRuntimeBase: section 5.4 +interface TaskOutput { readonly ref: TaskOutputRef; read(ctx: Context): Promise; mutate(m: (state: O) => undefined, ctx: Context): Promise; replace(value: O, ctx: Context): Promise } +interface ReadonlyTaskOutput { readonly ref: TaskOutputRef; read(ctx: Context): Promise } +type TaskSpec = { readonly conversationId?: Id; readonly input: InputOf; readonly after?: readonly Id[]; readonly background?: true } & ([OutputOf] extends [never] ? { readonly output?: never } : { readonly output?: TaskOutputRef> }); +type OrdinaryKind = { readonly kind: string; readonly [taskKindBrand]: "ordinary"; readonly hooks?: HookPoints; readonly config?: ConfigBundle }; +type CoreKind = { readonly kind: string; readonly [taskKindBrand]: "core"; readonly hooks?: HookPoints; readonly config?: ConfigBundle }; +type AnyDefinedKind = OrdinaryKind | CoreKind; +type PayloadsOf = + K extends TaskKind ? { input: I; checkpoint: C; result: R; failure: F; aborted: A; output: O; hooks: H } : + K extends CoreTaskKind ? { input: I; checkpoint: C; result: R; failure: F; aborted: A; output: O; hooks: H } : never; +type InputOf = PayloadsOf["input"]; type CheckpointOf = PayloadsOf["checkpoint"]; type ResultOf = PayloadsOf["result"]; +type FailureOf = PayloadsOf["failure"]; type AbortedOf = PayloadsOf["aborted"]; type OutputOf = PayloadsOf["output"]; +type TaskOf = DistributiveOmit, CheckpointOf, ResultOf, FailureOf, AbortedOf>, "output"> & TaskOutputField>; +type DistributiveOmit = T extends unknown ? Omit : never; +type NoExtra = Actual & Record, never>; +type ExactJsonInput = Expected extends readonly JsonValue[] ? Actual : Expected extends JsonObject ? NoExtra : Actual; +// CoreTaskKinds carries the exact fixed-kind token types, not the erased CoreKind, so CoreConfigBundle/CoreHookBundle/ConfigEvent/HookFacade derive exact keys: +interface CoreTaskKinds { + readonly generation: typeof generationKind; // CoreTaskKind & { config: typeof generationKind.config } + readonly tool: typeof toolKind; + readonly postTools: typeof postToolsKind; + readonly collapse: typeof collapseKind; + readonly job: typeof jobKind; // ordinary; config {} and hooks {} +} +interface OpenInspection { readonly pending: readonly Task[]; readonly running: readonly Task[]; readonly orphaned: readonly Task[]; readonly unknownEntryKinds: readonly string[] } + +// state adapter exactness helpers +type ExactPhaseHandler = Actual extends { readonly role: infer Role } ? Actual extends Extract ? NoExtra, Actual> : never : never; +type ExactPhaseMap = Actual & Record, never> & { readonly [P in keyof Expected]: ExactPhaseHandler }; +type ExactStateTaskDefinition = NoExtra & { readonly phases: ExactPhaseMap }; + +// state facades +interface PublicValue { get(ctx: Context): Promise; get(at: Id, ctx: Context): Promise; set(value: T, ctx: Context): Promise; delete(ctx: Context): Promise } // Read = resolved type; both current and historical get apply the definition's default when nothing is stored at that position +interface PublicList { read(ctx: Context): Promise[]>; read(at: Id, ctx: Context): Promise[]>; append(value: T, ctx: Context): Promise; remove(id: Id, ctx: Context): Promise; clear(ctx: Context): Promise } +interface ScratchReader { value(a: Value): Pick, "get">; list(a: List): Pick, "read"> } + +// registries and hooks +interface MutableRegistry { // async, on the line + get(name: string, ctx: Context): Promise; register(d: D, ctx: Context): Promise; replace(d: D, ctx: Context): Promise; remove(name: string, ctx: Context): Promise; + readonly revision: number; // monotonically increasing; bumped by register/replace/remove; read by preparation snapshots (12.5) +} +// hooks: HookPoint, HookPoints, HookHandler, HookInfo, HookRunner, HookResult, HarnessHookRegistry, ConversationHookRegistry are declared in 11.2 +type HookIn

= P extends HookPoint ? In : never; +type HookOut

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