pi-mono/packages/agent/docs/pico/pico-v3.md
2026-09-18 13:48:19 +02:00

149 KiB
Raw Blame History

pico v3

A design for the agent harness: how a session is stored, how work is scheduled, and how the built-in agent behaviour is composed from that. Code in this document illustrates the model; it is not a package export. In snippets, call is the current Call (Chord Context, §6.5), required on asynchronous public/runtime operations; runtime is the task/tool capability object. Transaction builders do not take another Call.

Design status: the tool/sink/preview interfaces and related examples remain provisional and need simplification before implementation. They preserve the capabilities to cover, not a final authoring surface. Storage, atomic publication, cancellation ownership and bounded-output requirements remain constraints on that redesign. The separate steps/returned-plans proposal has not been adopted.

1. Goals

Few concepts, replaceable behaviour. A session is conversations, immutable entries, durable tasks, and scoped state. Agent behaviour (generate, run tools, compact, delegate) is written as task kinds. The scheduler understands task lifecycle, dependencies and cancellation, nothing about prompts or summaries. Storage understands objects and atomic batches, nothing about tasks. Replace a task kind and you change the behaviour; the scheduler and storage stay.

One writer per session. Execution is concurrent; writing is not. Every mutation, including working-state writes, goes through one owner that runs commits one at a time. A commit reads committed state, constructs its writes and their ids, commits them as one batch, then publishes. Different sessions can have different owners and share a SQLite file. After a crash a new owner resumes from durable state; it must never run alongside the old one.

Durable work, explicit recovery. Accepted work survives a crash, including work that hasn't started. Opening a session starts nothing. A task records its intent before doing anything external and commits the outcome together with whatever should happen next. A crash in the middle leaves uncertainty, not proof that nothing happened; each task kind decides how to recover: retry, adopt, or report interruption. Nothing promises exactly-once external effects.

Long sessions without a residency layer. Old entries and finished tasks are queryable but never retained because they were once seen. Routine execution reads the current context and the live tasks; it never scans accumulated history. What memory a backend uses is a backend choice: memory and JSONL keep everything loaded; SQLite keeps only what queries return.

Measure. Same workload on each harness and backend: CPU, process memory, bytes on disk including sidecars. Small writes, indexed reads, batched lookups. A faster prototype is a reason to look, not proof that its shortcuts belong here.

2. Conversations

A session holds conversations. A conversation has three things:

  • a transcript: an append-only list of immutable entries (messages, tool results, summaries, plugin-defined entries), which is what happened;
  • tasks: the durable units of work that act on it, such as a generation or a tool call, each with a status that changes as it runs (§5);
  • state: keyed values and lists that plugins and the harness read and write, such as the model in use or a plan-mode flag (§4).

What the model sees, the context, is derived from the transcript by a fixed rule. Tasks and state are never model input by themselves.

2.1 Entries and their kinds

type Id = number;                  // a session sequence number; one alias so the representation can change

type ContextEdit =
  | { readonly target: Id; readonly action: "omit" }
  | { readonly target: Id; readonly action: "replace"; readonly messages: readonly Message[] };

interface EntryIdentity {
  readonly id: Id;                 // its sequence number
  readonly conversationId: Id;
  readonly kind: string;
  readonly byTaskId?: Id;          // which task wrote it
}

interface EntryBase extends EntryIdentity {
  readonly key?: string;           // optional indexed key, e.g. the tool call a result answers
}

interface EntryData<Data extends JsonValue = JsonValue> {
  readonly data: Data;             // kind-specific durable data; never model input by itself
}

interface ModelProjection<Model extends Message = Message> {
  readonly model: readonly Model[]; // provider-neutral messages materialized at append
}

interface ContextHead {
  readonly head: Id;               // first retained transcript entry, inclusive
}

interface ContextEdits {
  readonly edits: readonly ContextEdit[];
}

type Entry = EntryBase & Partial<
  EntryData & ModelProjection & ContextHead & ContextEdits
>;

type UserEntry = EntryBase & ModelProjection<UserMessage>;
type AssistantEntry = EntryBase & ModelProjection<AssistantMessage>;

interface SummaryData { readonly summarizedThrough: Id }
type SummaryEntry = EntryBase & EntryData<SummaryData> & ModelProjection<UserMessage> & ContextHead;

The facets combine. A user entry may be only EntryBase & ModelProjection<UserMessage>; a tool result may also carry non-message EntryData<ToolResultData> for its renderer; a summary has data, a model projection and a head; a reset needs only a head. Entries append and are never patched, reordered or renumbered. Ids increase in transcript order but need not be consecutive.

model is the exact provider-neutral message projection chosen by the writer. It is not the final provider request: context selection, edit folding, tool-exchange normalization and provider conversion still happen per request. data is optional arbitrary kind-specific JSON for typed logic and rendering. Nothing implicitly derives one from the other, so storing both never requires duplicating a message.

An entry kind is only a name and type witness:

interface EntryKind<E extends Entry = Entry> {
  readonly kind: string;
  is(entry: Entry): entry is E;
}

A plugin normally keeps append-time construction in a typed helper. It may compute model, head and edits from its data before calling Tx.entry, but no such computation is registered on the kind or run while reading. Asynchronous inputs are prepared before entering the commit line.

A stored head is the inclusive first retained entry id. Presence of the column marks and indexes the entry as a head. Tx.entry accepts "self" for reset and handoff drafts and stores the minted entry id. Every new head must satisfy:

newHead.head >= previousVisibleHead.head

The commit line validates this directly from stored entries. A head cannot move context backwards. Its boundary must also not split an exchange, whoever appends it: the same rule compaction follows (§2.3), applied to plugin heads so a head can never leave tool results without their call. Older heads are controls, not retained context entries: the newest head replaces them and carries any predecessor summary information it still needs.

Stored edits omit or replace the model projection of earlier visible entries. Arbitrary edits may not target managed system entries: they could make model instructions disagree with canonical prepared section state (§8.2), so the commit rejects. The sole exception is generation preparation appending a complete fresh baseline with omission edits for superseded managed baselines/deltas. Those controls and their replacement instructions commit together (§8.2). Edits are folded in transcript order; the newest applicable edit for a target wins. The target entry is never changed, and an edit whose target is outside the selected range is a no-op. Retained edit entries apply again on later turns. Tool-result pruning and shortening are edits. Dynamic policies such as "always keep only the last N exchanges" are unsupported; code appends a new head or edit when context should change. There is no compose callback or read-time entry-kind behavior.

The built-ins are user, assistant, tool_result, system, notice, summary, handoff and reset. Their writers materialize model messages when they append. reset stores no model; handoff stores its message and starts a context; managed system entries store the exact pi-ai SystemMessage prepared at their transcript position. They record canonical prepared instructions, not proof that a provider received them. Pi-ai owns provider translation and best-effort cache preservation; immutable entries alone do not guarantee an unchanged wire prefix:

type SectionChange =
  | { readonly key: string; readonly action: "set"; readonly value: JsonValue; readonly rendered: string }
  | { readonly key: string; readonly action: "remove" };

interface SystemData {
  readonly baseline?: true;              // complete prepared section state (§8.2)
  readonly sections?: readonly SectionChange[];
}

type SystemEntry = EntryBase & EntryData<SystemData> & ModelProjection<SystemMessage>
  & Partial<ContextEdits>;               // fresh baseline may omit superseded managed entries

A baseline stores the complete ordered section state as set records. A delta stores changed sets and explicit removals. Payloads are JSON; null is a valid payload, not deletion. rendered is the final section text, so a missing renderer never prevents replay or construction of a fresh baseline. Diff by key, payload and rendered text; reordering alone emits nothing. A payload-only change still persists a section-state delta but needs no instruction message (model: []). Rendered changes and removals produce materialized system messages. Never parse prose to reconstruct section state.

Complete tool definitions are stored only in the SystemMessage toolsAdded/toolsRemoved fields, not duplicated in data. Added definitions are upserts by name, including same-name schema changes; removals contain the previous complete definitions. Apply removals before additions. Section and tool names are unique within their respective change lists; structural comparison ignores incidental JSON object-key order. Plugin-defined entries omit model when the model should not see them. The kind types reads and writes; stored objects are trusted after wire validation (§7.3), and a kind that changes its data shape ships a migration.

type ToolResultEntry = EntryBase & EntryData<ToolResultData> & ModelProjection<ToolResultMessage>;
const toolResultKind = defineEntryKind<ToolResultEntry>("tool_result");

tx.entry(toolResultKind, conversationId, { data: resultData, model: [message], key: callId });
const e = await harness.getEntry(summaryKind, id, call);                  // SummaryEntry | undefined
const any = await harness.getEntry(id, call);                             // Entry | undefined
if (summaryKind.is(any)) any.data.summarizedThrough;                // narrowed

Without a kind, a read returns the untyped entry. A typed read of another kind returns undefined, the same as a missing entry; batched reads omit it. The session records the distinct kind strings without scanning entries. Open reports an unregistered entry kind but does not reject: stored model, head and edits preserve context, while only kind-specific typing and rendering are unavailable.

2.2 Context is derived, not stored

The generation derives context from the transcript:

H       = newest entry with stored `head` visible at the request/fork target
from    = no H → transcript start; otherwise H.head
range   = fork-aware transcript entries from `from` through target, inclusive
controls= fold stored `edits` in transcript order
context = no H → eligible range entries
          H exists → H, then eligible range entries excluding every head
          omitted targets disappear; replaced targets keep their id and use replacement messages
messages= concatenate stored `model` arrays; normalize tool exchanges for the selected model

An entry without model contributes no messages. An edit entry may independently have a model projection. Existing tool results are placed with their calls in call order. For a successful assistant cut before all its results, request-local pi-ai transformation supplies missing results; results beyond the fork cutoff and source tasks are never inherited. Error/aborted assistant outputs are excluded from subsequent requests and never create tool tasks/results.

No context list is stored, so nothing can drift from the transcript. A conversation handle keeps an optional process-local cache of the current derived entries, candidate membership and winning edits. contextEntries(through) loads it once, catches it up with entries after its cursor, slices it from the stored boundary when a new head arrives and folds stored edits. Generation tasks receive that handle; ConversationView exposes the resulting context ids. A historical target older than the cache is derived separately and does not rewind it. Each request receives an immutable snapshot.

On a cold handle, cost is the fork-aware candidate range plus edit folding and fork depth, not necessarily the final projected-message count. Memory and JSONL answer from their in-memory indexes; SQLite performs indexed head/range reads. Warm handles process only appended entries. The cache is a convenience owned by the handle and may be garbage-collected with it; the transcript is the truth. Generation preparation persists state.requestThrough and captures its effective context snapshot on the line after the complete batch is durable (§8.2). The snapshot copies the selected reference array, not immutable entry bodies. Later cache updates never mutate that array or its effective replacement projections; request-local normalization/hooks copy what they change. Historical cutoff reconstruction does not rewind the live cache, and request-only references are released after use.

2.3 Compaction and reset

Compaction appends one summary with a stored model projection and head boundary:

transcript  [10 user, 20 asst, 30 user, 40 asst]         context [10, 20, 30, 40]
append      50 summary { model:[summary], head:30 }
scan        [30 user, 40 asst, 50 summary]
context     [50 summary, 30 user, 40 asst]

The summary is newer than 30 and 40 but projects before them. The summarized prefix must end on a complete exchange, lie inside the current context and include the previous head's information. Managed system entries retained across a head are explicitly superseded by the next prepared baseline (§8.2); this does not split the retained tool exchange or add hidden filtering to generic projection. A summary prepared against an old context may still land later: ordinary entries appended while it runs are at or after its prepared retained boundary and remain in the range. Only a competing head invalidates it; intervening context edits do not (§8.4).

Reset appends a handoff head with model or a reset head without it. Both pass head: "self" to Tx.entry, which stores the new entry id as the boundary. The transcript keeps everything. Reset by itself neither cancels tasks nor requests a response.

2.4 Forks share history, not future changes

A fork is a new conversation whose transcript starts as a prefix of its source, shared by reference: entries 10 and 20 below keep their ids and their owning conversation; nothing is copied.

A: 10 ─ 20 ─ 30 ─ 40 ─ 50 ─ 70
          └──────────── 90 ─ 100     B, forked at 20
B transcript: [10, 20, 90, 100]

Because context is derived from the transcript, B's context is what A's context was at the fork point: the newest head among [10, 20, ...] is the one that existed then, and none that A added later can be seen. Later appends, compaction and resets in A cannot affect B. Live tasks are not inherited; continuing B is new work.

2.5 History and ownership are different relationships

interface Conversation {
  readonly id: Id;
  readonly parent?: { readonly conversationId: Id; readonly at: Id };   // forked from, at entry
  readonly owner?: Id;                                                       // task that created it
}
history:    A ── fork at P ──> B         B: parent = A at P, no owner; independent
ownership:  A ── task T ── owns ──> C    C: owner = T; a child, e.g. a subagent's conversation

A fork never enters its source's drive or cancellation scope. An owned conversation does, through the task that owns it. A child can start with fresh or inherited context; that is separate from which state it is initialized with (§4.3).

3. Identity and commits

3.1 Ids are minted while building the commit

One session-wide sequence orders every mutation. A creation's sequence number is the object's id.

// last committed: 99
const entryId = await conv.commit(tx => {
  tx.value(planMode).set(true);                                             // 100
  const id = tx.entry(userKind, { model: [message] });                      // 101
  tx.task(generationKind, { state: { status: "pending", input: id } });     // 102
  return id;
}, call);
// one batch [100–102] persisted and published; entryId === 101 here

A commit can reference ids it just minted. It cannot hand them to an external service or return them to a caller before the batch is durable. A rejected commit consumes nothing: the next one starts at 100 again. Numbers are positive safe integers; no uuids, no reserved ranges.

3.2 Rewindable conversation state before entries

A commit can write rewindable conversation state and an entry together (a tool that turns on plan mode and writes its result), and a fork at that entry must include the state. It must not include state committed later: a /model change made after an answer belongs to a fork made after it, not to a fork at the answer. So the boundary is exactly the entry's commit, and the way to know it without a field, a stamp or a lookup is write order: in one commit, rewindable conversation value and list writes come before entries. Sequence order is call order, so the state is numbered below the entries, and a fork at entry X selects the fork-visible transcript and rewindable conversation history through X:

// tool settlement, one commit
await runtime.commit(tx => {
  tx.value(planMode).set(true);                             // 300
  const result = tx.entry(toolResultKind, { data: resultData, model: [message] }); // 301
  tx.settle(task, "done", { call: task.state.call, assistant: task.state.assistant, output, result });   // 302
}, call);

await conv.value(model).set("claude-opus-5", call);               // 303, a later commit

const b = await conv.fork({ at: 301 }, call);      // has the result and plan mode; does not have the new model

The builder throws if a rewindable conversation value/list write follows an entry; the plan fails before persistence and consumes no ids. Session state and sticky conversation state may appear anywhere because forks do not reconstruct their history, so they may reference ids minted earlier in the same commit. Tasks also go anywhere; they are not inherited by forks and usually come after the entries they name. Historical reads (§4) take an entry as their position; "before the first entry" is the empty position. Any transcript entry is a valid fork point, including an assistant call or one of several tool results. Projection repairs the resulting successful incomplete exchange for the request only; it never inherits or executes the source tasks.

3.3 Failure before and after admission

Validation failure: no writes, no ids consumed, the session keeps working. Uncertain persistence: stop using this handle, reopen, recover the last complete batch and its lastSeq, continue from that. No observer ever sees half a commit. An effect must not treat an uncertain write as permission to redo the same external action with new ids.

3.4 Request keys name acceptances

Every accepted input has an inputId, the id of its pi.inbox list element (§8.1). A caller may also supply an opaque request id. Acceptance atomically stores a session value from that key to the new identity:

400  append pi.inbox item                         inputId = 400
401  inputResult[400] = queued or placed
402  request["req-42"] = { conversationId, inputId:400 }

harness.acceptance(requestId, call) returns that receipt, for a caller whose accept response may have been lost. A request key names one acceptance for the life of the session: a second accept or queueInput with the same key writes nothing and returns the stored inputId, whatever its payload or mode. Nothing is compared, so no content or digest is kept for that purpose; a client that reuses a key for different input gets the first acceptance back, and result(inputId, call) says what became of it. Without a request id, no mapping is written. The mapping is session state, so it may follow and reference the newly minted inbox element.

4. State

State is addressed independently of the transcript. Its writes share the sequence, so one position (an entry, §3.2) selects context and rewindable state together.

4.1 Scopes

Scope Rewind Lifetime
Session Sticky: one current value The session
Conversation Rewindable or sticky, chosen per address The conversation and its fork history
Scratch Never rewound, never inherited One live execution, until settlement
const sessionName = sessionValue<string>("pi.session.name");
const plan        = conversationValue<boolean>("plugin.plan", { rewind: true });
const expanded    = conversationValue<boolean>("ui.expanded", { rewind: false });
const moves       = conversationList<Move>("game.moves", { rewind: true });
type Scope   = { session: true } | { conversation: number } | { scratch: number /* task id */ };
interface Address { readonly scope: Scope; readonly namespace: string; readonly key?: string; readonly rewind: boolean }
interface Value<T> extends Address { readonly __value?: T }     // phantom: the payload type
interface List<T>  extends Address { readonly __list?: T }

An address binds namespace, key, scope and rewind policy, and carries its payload type. The constructors above make Value<T>/List<T> for a scope; a conversation handle binds conversation: id itself, so plugin code writes conversationValue("plugin.plan") and never the id. Values are strict JSON; reads return copies. A conversation address cannot be used as a session address. Storage persists scope and policy; nothing depends on the address object surviving a restart.

4.2 Vocabulary

value:  tx.value(addr).get(), .set(v), .delete()
list:   tx.list(addr).append(v) → element id, .remove(id), .clear(), .read(cursor, limit)

A rewindable history reads as of an entry (§3.2): a list appended at 40 and 50, cleared at 60 and appended at 70 reads [A, B] at entry 55, [] at 65, [C] at 75. A remove hides one element the same way a clear hides all of them. Clear hides; it doesn't erase what an earlier fork needs. Deleting a rewindable value records absence. Sticky state exposes only its current contents, so a removed sticky list element may be discarded physically.

4.3 Initialization is explicit; forks inherit

A new child conversation (a subagent) chooses its initial state independently of its context:

spawn({ prompt, context: "inherit",
        values: { inherit: [generationKind.config.model, generationKind.config.thinking],
                  set: [[generationKind.config.selectedTools, ["read", "grep"]]] } })

One commit creates the child, copies the selected current values, applies overrides, appends the user prompt, creates the first generation; that generation writes the child's first system entry from system_instructions (§8.2). Unselected values are absent in the child. Lists are not copied unless a policy says so.

A historical fork inherits differently: all rewindable history as of the fork position, no sticky state unless selected, the same session state, no scratch.

4.4 Capped-source lookup

A fork stores no copies. Reading a rewindable address in fork B, forked from A at an entry:

read(B, addr, at):                             // at = an entry id
  local version ≤ at?  set → value; delete → absent
  no parent? absent
  read(B.parent.conversationId, addr, min(at, B.parent.at))

Lists combine inherited ranges with local appends the same way, honouring clears at every level. Cost is fork depth, never unrelated history. A child initialized by copying (§4.3) has an owner but no parent, so lookup stops there.

4.5 Structured plugin state

Plugins with structured state build it on values and lists: a checkpoint value { through, state } plus a delta list; hydrate by reading the checkpoint visible at a position and the deltas after it. The plugin owns its delta vocabulary and reducer; storage runs nothing.

4.6 Scratch

Scratch is a task's working state while it runs: streamed frames, a checkpoint, a memo. One scope per task, any number of addresses in it, durable across a restart, gone when the task settles.

import { AssistantMessageFrameEncoder, type AssistantMessageFrame } from "@earendil-works/pi-ai";

const frames     = scratchList<AssistantMessageFrame>("frames");
const checkpoint = scratchValue<Checkpoint>("checkpoint");

// a new attempt starts clean; then each frame is one small scratch commit
await runtime.scratch(sc => { sc.list(frames).clear(); }, call);
const { context: streamCall, cancel } = withCancel(call);
const stream = runtime.models.stream(model, request, streamCall);
const encoder = new AssistantMessageFrameEncoder();
try {
  for await (const event of stream) {
    const frame = encoder.encode(event);
    if (frame !== undefined) {
      await runtime.scratch(sc => { sc.list(frames).append(frame); }, streamCall);
    }
  }
} finally {
  cancel();                    // stop this producer on any early exit, not only a task abort
  await stream.result();       // join it; iterator exit alone is not provider completion
}

// after a restart, recover reads what made it to disk
const partial = await runtime.scratch(sc => sc.list(frames).read(), call);

// settlement: the result becomes an entry; settle retires the scope in the same commit
await runtime.commit(tx => {
  const id = tx.entry(assistantKind, { model: [assemble(partial)] });
  tx.settle(task, "done", { inputs: task.state.inputs, assistant: id });
}, call);

Persist compact frames, not raw provider events with a growing partial snapshot on every token. The encoder stores content deltas and necessary start/end metadata; terminal outcome/usage is recorded separately. Tool/job scratch similarly appends explicit output operations or bounded checkpoints. These are application records in ordinary lists, not a Chord storage codec.

Three rules:

  • A scratch commit writes one task's scratch and nothing else; a main commit never writes scratch. On JSONL that makes every commit one file (§7.4).
  • settle deletes the task's scratch in the same commit. Crash before settle: task and scratch are both there for recovery. After: both gone.
  • A task stops writing scratch before it settles. A frame that arrives late can't recreate what settle deleted.

A retry is the same task (§5.3), so an attempt clears the scratch before it starts streaming; what is on disk is always the current attempt's. Scratch is never inherited, never rewound, never in the transcript; a task that wants to keep something writes an entry or a value at settlement.

5. Tasks

A task is durable work belonging to one conversation. Its kind supplies behaviour; its record supplies the inputs, progress and outcome that must survive a process.

5.1 The record

type TaskRole = "start" | "inflight" | "terminal";

type TaskStateBase = JsonObject & { readonly status: string };   // state is strict JSON with a status
type TaskRoles<S extends TaskStateBase> = Readonly<Record<S["status"], TaskRole>>;

interface Task<State extends TaskStateBase = TaskStateBase> {
  readonly id: Id;
  readonly conversationId: Id;
  readonly kind: string;
  readonly role: TaskRole;              // materialized from the kind's roles map on every write
  readonly state: State;                // a tagged union; the status is its discriminant
  readonly after: readonly Id[];        // dependencies, fixed at creation
  readonly background?: true;           // fixed at creation; absent = foreground (§5.6)
  readonly turn?: true;                 // materialized from the kind; this task drives a turn (§5.8)
  readonly owns?: readonly Id[];        // conversations this task created (§5.7)
  readonly abort?: true;                // durable cancellation mark (§6.3)
}

State is a tagged union over status. A kind declares one variant per status, carrying exactly the fields that exist in it, so nothing is an optional that a reader has to guess about and a dependent narrows instead of probing:

type ToolStates =
  | { status: "planned";  call: ToolCall; assistant: Id }
  | { status: "running";  call: ToolCall; assistant: Id; args: JsonObject; replay: "safe" | "never";
                          jobId?: Id; cancelJobOnAbort?: boolean }
  | { status: "done";     call: ToolCall; assistant: Id; output: ToolOutputState; result: Id }
  | { status: "aborted";  call: ToolCall; assistant: Id; output: ToolOutputState; result: Id };

The status lives only in state.status. Storage extracts it into an indexed column when it writes the record, so a scan never decodes state, and role is on the record because a reader of an unregistered kind cannot compute it from the kind's map. Tx derives the role on every write. Fields common to every variant must have the same type in each.

A kind maps its statuses to three roles:

start      the driver may call execute        generation: pending, retry_wait, deferred
inflight   intent was committed; call recover  generation: streaming;  job: running
terminal   done; never patched again           generation: done, failed, aborted

Tx.task, patch and settle derive the role from the registered kind's status map and persist it on every write. Storage selects live rows using the role index without inspecting task state or running kind code. Loading their full records may decode state; terminal history is not decoded. Callers cannot edit the role independently.

orphaned is derived, not declared. A kind never writes it and never lists it. The harness adds one terminal variant to every state union, carrying the fields common to all of the kind's own variants:

type CommonPayload<S extends TaskStateBase> = Pick<S, Exclude<keyof S, "status">>;
type Orphaned<S extends TaskStateBase> = CommonPayload<S> & { readonly status: "orphaned" };
type ToolState = ToolStates | Orphaned<ToolStates>; // orphaned carries only call and assistant

keyof S selects the keys present in every union member; Pick keeps their types and optionality. This is the intersection of field names, not a TypeScript intersection of the variants (which would intersect incompatible status literals into never). Kind definition rejects inconsistent types or optionality for common fields, and rejects a user-declared orphaned status. A field optional in every variant remains optional on orphaned; a variant-specific field is not exposed.

It is written in one situation only: at open, for a live foreground task whose kind is not registered in this process (§6.4). The stored record keeps whatever fields it had; the type promises only the common ones, which is all a dependent may rely on, because the kind never ran its cleanup. Background tasks of unregistered kinds are parked instead, and resume through recover when their kind returns. This is the only case in which the harness settles a task it does not own.

5.2 Kinds

declare const taskType: unique symbol; // compiler-only witness; never populated or serialized
interface TaskType<S extends TaskStateBase, R extends TaskRoles<S>> {
  readonly [taskType]?: { readonly state: (state: S) => S; readonly roles: R };
}
type TypedTask<S extends TaskStateBase, R extends TaskRoles<S>> = Task<S> & TaskType<S, R>;
interface TaskDefinition<S extends TaskStateBase, R extends TaskRoles<S>> extends TaskType<S, R> {
  readonly kind: string;
  readonly roles: R;
}

interface TaskKind<States extends TaskStateBase, Hooks extends HookPoints = {}, Config extends ConfigSpec = {},
                   Preview = never, R extends TaskRoles<States> = TaskRoles<States>> extends TaskDefinition<States, R> {
  readonly initialStatus: StatesWithRole<States, R, "start">["status"]; // helper in §9.2
  is(task: Task | undefined): task is ReadTask<States, R>;
  readonly turn?: true;                             // this kind drives a turn (§5.8)
  readonly hooks?: HookSpecs<Hooks>;                // the points this kind runs (§8.7)
  readonly config?: Config;                         // the values this kind reads (below)
  preview?: {                                       // what UIs see while the task runs (§9.4)
    init(scratch: ScratchReader): Preview;          //   built once, on attach or reopen, from durable scratch
  };                                                //   afterwards the kind mutates runtime.preview.state in place
  execute(task: TypedTask<States, R>, runtime: TaskRuntime, call: Call): Promise<void>;
  recover(task: TypedTask<States, R>, runtime: TaskRuntime, call: Call): Promise<void>;
  abort(task: TypedTask<States, R>, runtime: TaskRuntime, call: Call): Promise<void>;
}

type ReadTask<S extends TaskStateBase, R extends TaskRoles<S>> =
  TypedTask<S | Orphaned<S>, R & { readonly orphaned: "terminal" }>;
type TaskState<K> = K extends TaskDefinition<infer S, infer R> ? S | Orphaned<S> : never;

Task remains the raw stored record. TypedTask adds only a compile-time witness for the complete state union and literal role map, so narrowing task.state does not lose the other transition targets. Typed reads include orphaned; execute/recover/abort receive only the kind's declared variants. The witness is not a runtime capability: invocation identity and current durable state still decide write authority on the line.

Use defineTaskKind<States>()({ ... }): the first call binds the declared union (and optional hook, config and preview types); the second infers the literal roles from the definition. It checks an exact role-map entry for each declared status, a start-role initial status and common-field compatibility. Its return type retains TaskDefinition<States, R>, including the compiler-only state witness; otherwise kind-witnessed reads could infer statuses from roles but lose their payload types. Do not widen the result to a generic TaskKind<States> or its roles to TaskRoles<States> when using typed mutations: that would discard the information needed to distinguish patch from settle.

A kind that reads config declares it, so the values it depends on are on the kind and nowhere else, typed, and usable by callers that initialize a conversation:

type ConfigSpec = Record<string, Value<any>>;

const generationKind = defineTaskKind<GenerationState>()({
  kind: "generation",
  config: {
    model:         conversationValue<ModelId>("pi.model", { rewind: true }),
    thinking:      conversationValue<ThinkingLevel>("pi.thinking", { rewind: true }),
    selectedTools: conversationValue<string[]>("pi.tools.selected", { rewind: true }),
    profile:       conversationValue<string>("pi.prompt.profile", { rewind: true }),
    budgetMs:      conversationValue<number>("pi.tool.budget", { rewind: false }),
  },
  ...
});

await c.config(generationKind).set({ model: "claude-opus-5" }, call);     // or c.settings.set(...) for the generation
const cfg = await runtime.config(generationKind, call);                         // { model, thinking, ... } typed, read on the line
await c.spawn({ prompt, values: { inherit: [generationKind.config.model],
                                  set: [[generationKind.config.selectedTools, ["read", "grep"]]] } }, call);

runtime.config(kind, call) reads every declared value in one batched read. A UI can list what a conversation is configured with by walking the registered kinds' config. Values a plugin defines for itself are declared on the plugin's own kind the same way.

TaskRuntime is what an execution gets from the driver:

interface TaskRuntime {
  readonly taskId: Id;
  commit<T>(plan: (tx: ConversationTx) => T | Promise<T>, call: Call): Promise<T>;
  scratch<T>(plan: (sc: ScratchTx) => T | Promise<T>, call: Call): Promise<T>;
  readonly preview: Tracker<Preview>;              // flushed after successful scratch commits
  now(): number;                                  // injected clock, not Date.now() in task code
  sleep(untilMs: number, call: Call): Promise<void>;
  config<C extends ConfigSpec>(kind: { readonly kind: string; readonly config?: C }, call: Call): Promise<ConfigValues<C>>;
  conversation(id: Id, call: Call): Promise<ConversationHandle | undefined>;
  abortTask(id: Id, call: Call): Promise<void>;
  waitForTask(id: Id, options: { budgetMs?: number } | undefined, call: Call): Promise<boolean>;
  getTask(id: Id, call: Call): Promise<Task | undefined>; // typed/batched reads also available
  getEntry(id: Id, call: Call): Promise<Entry | undefined>;
  value(addr) / list(addr)                         // session state handles; async methods take Call
  readonly models: ModelRegistry;                  // stream/deferred operations take Call
  readonly tools: ToolRegistry;
  hooks<H extends HookPoints>(kind: { readonly kind: string; readonly hooks?: HookSpecs<H> }): HookRunner<H>; // run(point, input, call)
  readonly env: ExecutionEnv;                      // existing Context-final methods; no wrappers
}

The registry maps kind names to kinds; a replacement must understand the persisted statuses and state of its live tasks. Open reads the live tasks anyway (inspect), so an unregistered live kind is handled there rather than rejecting the session (§5.1, §6.4); terminal tasks of a forgotten kind are only a problem when read typed.

Typed access uses the kind as witness. Public/runtime reads are asynchronous and require Call; transaction reads use the transaction view without another Call:

// public/runtime
getTask<S extends TaskStateBase, R extends TaskRoles<S>>(
  kind: TaskDefinition<S, R>, id: Id, call: Call): Promise<ReadTask<S, R> | undefined>;
getTasks<S extends TaskStateBase, R extends TaskRoles<S>>(
  kind: TaskDefinition<S, R>, ids: readonly Id[], call: Call): Promise<ReadonlyMap<Id, ReadTask<S, R>>>;
// inside Tx: same asynchronous typed reads, but no additional Call
getTask<S extends TaskStateBase, R extends TaskRoles<S>>(
  kind: TaskDefinition<S, R>, id: Id): Promise<ReadTask<S, R> | undefined>;
getTasks<S extends TaskStateBase, R extends TaskRoles<S>>(
  kind: TaskDefinition<S, R>, ids: readonly Id[]): Promise<ReadonlyMap<Id, ReadTask<S, R>>>;

The getter returns ReadTask<S, R> without casts, or undefined when the task is missing or of another kind; without the kind it returns the untyped task. kind.is(task) narrows to the same read type. A bare numeric id supplies no state/role witness for typed mutation; read it with its kind first.

5.3 What an execution may do

Four rules, and they are the whole contract:

  1. Commit an inflight status before any external effect. A crash before that point re-runs execute from the top; a crash after it goes through recover.
  2. You may block on the world: a provider stream, a process, a human inside a hook, a conversation you created. The driver runs executions concurrently; a blocked one holds up nothing.
  3. Make a durable status transition or settle before returning, except when cancellation/close unwinds the invocation. The driver checks a status epoch (§6.1), not final-status equality.
  4. Prefer fixed after dependencies for prerequisite work. Child conversations use drive(call); separately running jobs may use the bounded waitForTask API (§8.3). Known self/dependency waits reject (§6.2); never abandon an unfinished effect to release an invocation slot.

Timing is the task's business: a retry stores notBefore in its state and sleeps in its own execute (runtime.sleep, which observes Call cancellation). Not everything on the signal throws: pi-ai may return stopReason: "aborted". The kind handles its domain outcome normally; if a durable abort mark won, its attempted commit rejects and the driver runs the fresh abort handler (§6.3). A recurring job sets its next notBefore and returns in a start status. The scheduler has no timers.

A task is one logical operation, not one attempt. Its declared status graph may contain cycles: a generation revisits streaming across retries and deferred polls, and a schedule loops from planned through running back to planned under one stable id. Recovery uses only the current status, state, role and scratch; it does not reconstruct the path taken. Returning to the starting status after intermediate committed transitions is valid. Returning without any transition is a contract violation; transitions are not a generic proof of progress or a diagnosis of bad cycles.

5.4 Dependencies mean terminal, not successful

after lists tasks that must be terminal before this one starts. Terminal, not done: a task whose dependency failed or was aborted still starts and decides for itself what that means. Dependencies are set at creation, reference existing tasks in the same ownership tree, form no cycles, and are never edited. A task may create a successor whose after includes itself in its settlement commit; it cannot add new dependencies to itself. A dependency outside an attached subtree may require another explicit drive; attaching one scope never implicitly executes a sibling scope.

A foreground task may depend on a background one, and it is live while it waits, so its conversation stays busy and drive does not resolve until the dependency settles. That is what the overflow chain wants (G' after: [C]). It is a trap for work that may never settle: a foreground task depending on a recurring schedule keeps its conversation busy forever. Depend on work that ends, or wait inside the task's own execute instead.

5.5 post_tools joins an exchange

The generation's settlement publishes the exchange atomically:

await runtime.commit(tx => {
  const assistant = tx.entry(assistantKind, { model: [message] });    // calls [A, B]
  const tools = message.calls.map(call =>
    tx.task(toolKind, { state: { status: "planned", call, assistant } }));
  tx.task(postToolsKind, {
    after: tools,
    state: { status: "waiting", assistant, tools, inputs: task.state.inputs },
  });
  tx.settle(task, "done", { inputs: task.state.inputs, assistant });
}, call);

Each tool executes and settles itself: hooks, execution, result entry, terminal variant. It does not look at siblings, queues or context. post_tools starts when both are terminal, reads them with the typed getter, narrows on their status, and decides what happens next:

async execute(task, runtime, call) {
  await runtime.commit(async tx => {
    const tools = await tx.getTasks(toolKind, task.state.tools);            // Map<id, ReadTask<ToolStates, typeof toolKind.roles>>
    const outcomes: ToolOutputState[] = [];
    for (const t of tools.values()) {
      switch (t.state.status) {
        case "done": case "aborted": outcomes.push(t.state.output); break;   // both wrote their result entry
        case "orphaned":                                                     // kind unregistered at open (§5.1)
          tx.entry(toolResultKind, unavailableResult(t.state.call)); break;
        default: throw new Error(`tool ${t.id} still live in post_tools`);
      }
    }
    if (outcomes.some(o => o.terminate)) {
      for (const inputId of task.state.inputs) {
        const r = await tx.value(inputResult(inputId)).get();
        if (r?.status !== "placed") throw new Error(`Invalid active input ${inputId}`);
        tx.value(inputResult(inputId)).set({ status: "unanswered", requestId: r.requestId, entry: r.entry,
                                             reason: "terminated" });
      }
      return tx.settle(task, "stopped", { assistant: task.state.assistant, tools: task.state.tools });   // no successor
    }
    const added = outcomes.flatMap(o => o.addedTools ?? []);
    const selectedTools = added.length ? [...current, ...added] : current;
    if (added.length) tx.value(generationKind.config.selectedTools).set(selectedTools);
    const handoff = outcomes.find(o => o.handoff);
    if (handoff) tx.entry(handoffKind, {
      data: { text: handoff.handoff }, model: [handoffMessage(handoff.handoff)], head: "self",
    });
    const inputs = await landPostToolsInbox(tx, task.conversationId, task.state.inputs); // writes + steer (§8.1)
    tx.task(generationKind, { state: { status: "pending", ...nextGeneration(task, { selectedTools, inputs }) } });
    tx.settle(task, "done", { assistant: task.state.assistant, tools: task.state.tools, inputs });
  }, call);
}

inputResult(id) above is the sticky conversation value address for that input (§8.1), not a driver operation. The abort handler resolves the same owned inputs as unanswered with reason aborted and settles in one commit. The runtime rejects a marked execute commit before its closure runs; no branch is needed in the normal settlement. Inbox-placement helpers above implement exactly the mode table in §8.1.

Sequential tools are the same mechanism with B after: [A].

crash before generation settles → generation live; recover
crash after it settles          → assistant, tools and post_tools exist; nothing to repair
crash after the last tool       → post_tools exists and is startable; nothing to infer

5.6 Foreground and background

A task is foreground unless created with background: true; the flag is fixed at creation. Generations, tools, post_tools and automatic collapses are foreground; jobs, schedules, spawned subagents and manual collapses are not. The foreground set of a conversation is its live foreground tasks, plus, through any live foreground task that owns a conversation, that conversation's foreground set. Abort and "is this conversation busy" are defined on that set. Detached work is cancelled by its own handle.

5.7 Ownership

A task that creates a conversation adds it to owns; the conversation records owner. Both are written in the creation commit. A task may own several (a fan-out tool driving three children); a conversation has one owner. Ownership defines drive scope and cancellation reach. Fork provenance is a different link (§2.5) and creates no ownership.

5.8 Turn tasks and appending entries

A kind that drives a turn declares turn: true; Tx materializes it onto the task like role, and storage indexes it. The built-in generation, tool, post_tools and collapse kinds declare it; a plugin kind that drives its own turn declares it too. That gives one predicate, computed from the index without decoding state or knowing any kind name:

inTurn(conversation) = live tasks in it with turn: true

It exists because of one hazard. An entry with a model appended between an assistant's tool calls and their results changes the prefix the next request replays: providers that validate message order reject it, and Anthropic thinking signatures are invalidated. The lane harness holds the same invariant by deferring custom messages while streaming; pico holds it with inTurn.

tx.entry appends immediately and returns the id, which is what a turn task needs (the generation's assistant entry, a tool's result, a head with "self"). It rejects in exactly one case:

reject if  the entry has a `model`
       and the committing task is not turn: true, or there is no committing task
       and inTurn(entry's conversation) is not empty

So an entry without a model is never blocked, a turn task writing its own exchange records is never blocked, and nothing is blocked in a conversation with no live turn task. The rejection names tx.write.

tx.write(kind, draft) is the door for model-visible entries from outside a turn: it appends immediately when inTurn is empty, and otherwise queues as the write mode (§8.1), landing at the next post_tools or final-answer boundary. It returns an inputId, not an entry id, because the entry may not exist yet. ConversationHandle.write is the same wrapped in a commit.

inTurn is also the honest definition of "busy" for a UI: a foreground subagent tool is in it because it is a tool, a background job is not.

6. Scheduling and cancellation

6.1 The commit line is the scheduler

Every driver decision runs on the same serialized line as commits. Only task methods, signal listeners, watcher callbacks and telemetry callbacks run outside it. There is no polling loop, Wake, effect-admission gate, worker pool or second scheduler.

Process state consists of:

State Purpose
live: Map<Id, { task, statusEpoch }> current non-terminal tasks only
running: Map<Id, Invocation> one execute/recover/abort call per task id
byConversation, dependents live-task membership and reverse live dependency edges
ownership links immutable ancestry needed by live tasks, outstanding calls and attached roots
attached roots stable conversation ids or one session-wide flag
waiters temporary drive/task waiters, indexed by their scope or target
phase open, stopping, closing, faulted, closed

Open seeds the complete live map before building readiness indexes, but starts no task. After a successful batch, the line applies every task change to these indexes before scheduling or testing idle. A settlement removes the task and its dependency edges; creating a successor in the same batch never exposes an idle gap. statusEpoch advances on each actual committed status change, including intermediate changes in a batch whose final status equals its initial status. State-only patches, abort marks, same-status patches and failed commits do not advance it.

Normal execution does not scan storage after every task returns:

task commit on line:
  persist whole batch
  update live/dependency/conversation indexes
  reconsider changed tasks, affected dependents and waiters
  reserve eligible calls in running
  publish complete state; leave line
  dispatch calls/signals/listeners outside line

invocation completion on line:
  validate the invocation object matches running[taskId]
  inspect that task in live (absence means it already settled)
  check outcome and statusEpoch; remove its running slot
  reconsider that task and affected waiters
  reserve eligible calls; leave line
  dispatch outside line

Only the current invocation may change its owned task's state/status or settle it. Host cancellation may set the abort mark, but host or sibling status writes cannot mask a broken invocation's progress guard. Task code receives a snapshot; later authority is checked against the line's live record and invocation identity, not that snapshot. No extra storage read is needed for this check.

A task can be reserved only when it is served, live and not already in running:

marked                    → abort, irrespective of after
unmarked, role=start      → execute when every dependency is terminal
unmarked, role=inflight   → recover

Existing dependency ids are validated on creation. Once the complete live map is seeded, a valid dependency absent from it is terminal. Dependency settlement wakes only its live dependents. New attachments may inspect the current live map once; ordinary completions do not scan all tasks. An outstanding invocation retains its slot even after it commits terminal status, until its method actually returns. Different task ids execute concurrently. Retaining the slot is the abort join: there is no separate invocation that waits for, or overwrites, its current owner.

A normally returning execute/recover must have settled or advanced its captured statusEpoch. planned → running → planned is valid; returning without any committed status transition is not. Cancellation/close unwind is the exception. An abort handler must settle before returning. The driver does not diagnose arbitrary changing-but-buggy cycles.

Unexpected task-contract errors fail-stop the session, rather than poisoning individual tasks and leaving their dependents waiting forever. Reject all pending drives and shutdown with the fault immediately; stop admission and claims, signal owned calls outside the line, join them and close. Do not manufacture terminal outcomes. The report includes task id, kind, method and error. Reopen starts nothing: the host can inspect and mark a task before driving, or replace a broken kind with one that understands and settles its stored state. Ordinary provider/tool failures are domain outcomes handled by their kinds, not driver faults.

6.2 Attachments and waiters

conversation.drive(call) attaches one stable conversation id; harness.drive(call) attaches the session. Each call registers an independent temporary waiter. Repeating a drive never retains another scope object. Resolving or cancelling a waiter removes it and its signal listener, but keeps the attachment, so background and recurring work continue. Session attachment subsumes serving filters; separate conversation waiters still retain their own idle predicate.

Serving follows ownership, never fork provenance. A conversation attachment serves all its owned descendants, even when their owner task has settled. Discover membership starting from conversations with live tasks, walking conversation.owner → owner task's conversationId upward. Batch named ancestor reads through getConversations and getTasks and cache only the extracted immutable links; never enumerate historical children or retain terminal owner payloads. Owner liveness always comes from live, not the ancestry cache. Release unused ancestry when no live task, outstanding invocation or explicit attachment needs it.

100,000 completed children; 2 live tasks
→ open reads 2 live tasks and only their required ancestry
→ later task completions use current indexes
→ no traversal of the 100,000 historical children

Idleness is decided on the line against current indexes:

  • Conversation: no direct live foreground task in that conversation. Reaching foreground descendants requires a live foreground owner in the root, so this is equivalent to an empty foreground set.
  • Session: no live foreground task anywhere, including foreground tasks in detached children.

Both resolve "idle", or "closed" when close wins. Session faults reject, not resolve idle. A full-quiescence wait, if exposed separately, may never complete with a recurring task.

Drive uses call.abortSignal to cancel only the actual registered waiter, not durable work. Register and check cancellation on the line; a signal listener enqueues removal. Every outcome removes the waiter and listener exactly once. Do not race an uncancellable drive promise with an outer promise. Cancelling prompt after acceptance also leaves its accepted input durable.

Task-bearing calls reject known self-waits: a foreground task cannot drive its own foreground scope, nor wait for a task whose unresolved dependency path leads back to it. The latter also catches a background task T driving a foreground D with after:[T]. Apply the checks before prompt acceptance and reject new work that would introduce such an indexed wait cycle. A foreground ancestor reached through a settled/background owner is not automatically the caller's foreground scope. Arbitrary cycles hidden in plugin promises remain the task author's responsibility. Dependencies elsewhere in an ownership tree may require another explicit attachment; never silently widen a drive scope.

6.3 Cancellation is a request, then an abort invocation

abortTask(id, call)  mark one live task; reject terminal
conversation.abort(call)
  one commit: mark its current foreground ownership closure;
  withdraw queued steer/followUp in affected conversations; keep write/nextRun

abort: true is a durable request, not terminal status. When that mark commits, the current execute/recover invocation loses mutation authority immediately. Its later main and scratch writes reject TaskCancelled on the line, before running their builders. This is normal control flow, not a kind bug. The signal is dispatched after leaving the line; standard effects and waits observe it cooperatively. No new admission gate is required. An effect may start in the interval before signal delivery and then be cancelled; cancellation neither rolls it back nor promises exactly-once I/O.

100 task streaming; execute invocation A owns it
110 abort=true commits; A's mutation authority revoked
    line releases; A's signal fires
    stream returns/throws; execute finishes local finally cleanup and returns
    A's completion enters line; remove A, reserve fresh abort invocation B
    line releases; kind.abort(task, runtime, callB) runs
120 partial/error outcome and unanswered(aborted) input results, if the kind needs them
121 task terminal aborted; scratch retired in the same commit
    abort method returns; remove B

Normal execute/recover settlement needs no mark check and no tx.origin branch. If its settlement wins first, the task is already terminal and a later abort rejects. If the mark wins first, the whole normal settlement rejects and the driver runs abort. A provider's in-band stopReason: "aborted" is not itself a durable mark: without a mark, the kind must commit its failure/retry outcome; with a mark, its attempted domain commit rejects TaskCancelled and cleanup runs in the fresh invocation. An invocation cancelled only by a task-local deadline may still commit its domain result.

The driver recognizes its own TaskCancelled and the invocation's known cancellation reason, including supported cancellation errors correlated with the mark/close. An arbitrary exception does not become harmless merely because a signal fired. Hooks propagate cancellation rather than treating it as an ordinary fail-open/fail-closed decision. Expected close/stale callback rejections are handled at their boundary; uncertain persistence still faults the session.

abort() receives a new identity, telemetry span and initially active controller. Repeated marks never cancel an already-running abort handler. Only close/fault cancels it. Abort ignores after, may perform cleanup effects and write outcomes, but cannot create successor tasks/conversations or queue future work via accept/nextRun. A target with no owned call and no attached scope remains marked until driven; marking it does not implicitly attach unrelated work.

Fresh cleanup can use only durable records. A tool that creates a cancellable background job records the job id and its cancellation policy on its own task in that same commit. Its abort handler uses those references, never locals from execute or a post-mark catch that tries another mutation. Child conversation ownership is already durable. Built-in cleanup marks only the child's live foreground tasks; it does not call the public conversation abort operation that also withdraws queued inputs. This task-only cleanup policy applies on every abort invocation, including after crash/reopen, so shutdown cannot accidentally discard preserved queues during recovery. Cleanup's cancellation request treats an already-terminal target as finished, using an atomic mark-if-live operation internally or handling that specific public terminal result; a read-then-mark does not close the race.

Scratch before the mark is authoritative. Harness-owned sinks/progress bridges own and handle their pending scratch promises, drop late cancelled output and drain callbacks before invocation completion. Task code must await/catch its own scratch writes; no void runtime.scratch(...) without error handling. Do not turn rejected scratch into a successful no-op or race away an unfinished tool/hook. If code ignores the signal and never returns or reaches a rejecting harness operation, abort/close can wait forever. An isolate kill boundary is future work; RPC cancellation alone is not forced termination.

6.4 Open, close, shutdown, delete

Open reports unregistered entry kinds, seeds live/ownership indexes, exposes inspection/queries, and starts nothing. A live task whose kind is not registered cannot run: a background one is parked (not started, not recovered, not counted for idleness) and resumes through recover when its kind returns; a foreground one would keep its conversation busy forever, so open settles it in the derived orphaned variant (§5.1) and reports it. inspect returns those separately. Unknown entries preserve context through stored facets.

Close runs one nonpersistent line operation that enters closing and stops admission and claims. All earlier line operations have already finished. Later storage operations reject; internal invocation completion messages remain admitted. Outside the line, signal and join every owned call, including terminal tasks whose methods are still unwinding, then close storage. No new abort marks or outcomes are written. Never hold the line while awaiting a task that may itself be awaiting a line operation.

Shutdown runs one line operation: enter stopping and commit one batch marking all live tasks. Queued inputs and their queued result records remain untouched, including in idle conversations. There is no inbox sweep or special storage query. After this batch, only abort cleanup mutations are admitted; an abort handler already running remains authorized. Cancelled execute/recover mutations reject. Abort handlers resolve their already-running input groups, but built-in child cleanup only marks tasks and never drains queues (§6.3), including after restart. Serve cancellation session-wide; once both live tasks and running invocation slots are empty, close. A failed abort handler faults and rejects shutdown rather than leaving an eternal drain. Preserved queues create no work by inference on reopen/drive; a later acceptance places eligible queued input at the defined boundary.

Repeated lifecycle calls share completion. Task-token-bearing close/shutdown/session-wide drive reject rather than waiting on their own invocation. Close joins regardless of its caller's signal; once shutdown is admitted, caller cancellation cannot abandon marking, joining or closing. Explicit close may interrupt shutdown; shutdown then rejects and remaining marked tasks recover later. Lifecycle entry points are exceptions to ordinary admission so repeated close works while already closing.

Delete rejects while either live tasks or outstanding invocation slots remain in the subtree, then rejects new work. Entries inherited by independent forks are never erased.

6.5 Call, telemetry and storage version

Call is an alias of Chord Context, not a second context implementation. The driver installs a private typed identity alongside its signal; span/budget derivation preserves it without casts:

import type { Context } from "@earendil-works/chord";
import { createContextKey, withAbortSignal, withContextValue } from "@earendil-works/chord/context";

export type Call = Context;

interface Invocation {
  readonly taskId: Id;
  readonly method: "execute" | "recover" | "abort";
  readonly controller: AbortController;
  readonly initialStatusEpoch: number;
}

const invocationKey = createContextKey<Invocation>("pico.invocation"); // private

// parentWithoutCallerCancellation retains the host telemetry parent, not the drive waiter's signal.
const call: Call = withContextValue(invocationKey, invocation, withAbortSignal(
  invocation.controller.signal, parentWithoutCallerCancellation,
));
const identity = call.value(invocationKey); // Invocation | undefined, no assertion

The line compares object identity with running.get(identity.taskId). TaskRuntime is bound to its expected invocation and rejects absent/foreign/stale identities; public host calls may have no token while the session is open. Returned ordinary conversation handles also receive Call on every async operation, so they preserve attribution without a task-specific facade. Capturing a public handle and deliberately supplying BACKGROUND_CONTEXT remains an in-process cooperative escape, not isolation.

Every async Harness/Conversation/TaskRuntime method takes a required final call: Call, including reads, waits and mutations. Options before it are passed as undefined when unused. Pure accessors, synchronous registration/disposal and operations inside Tx do not require a Call. TaskRuntime and ToolRuntime name capabilities; call names cancellation/telemetry/identity; model context keeps its existing meaning. There is no TaskContext, CallContext, admission helper or signal-injecting facade.

The driver starts one telemetry span per execute/recover/abort invocation, independent of the drive caller's cancellation. Commits use the supplied Call's current telemetry parent; nested provider/tool/ hook spans derive and forward a Call. Existing Context-taking env, provider and hook boundaries interpret the signal rather than merely carrying it. Node operations without interruptible APIs may only check before/after; hooks and plugin code must cooperate. Commit admission uses lifecycle, mark, method and identity, not a blanket signal check: cancellation never abandons admitted persistence.

RPC transports cancellation and selected telemetry metadata, never the invocation object. A trusted host-side binding reattaches the local identity for remote task calls. A cancelled RPC waiter does not prove remote code stopped; no slot is released until completion or a real kill boundary confirms it.

Telemetry schema names/attributes are a separate implementation package; the host supplies the tracer and no spans are stored in session data. Storage metadata carries a version; mismatches reject and a backend exposes migration before open. None of these mechanisms require a renewable session lease.

7. Storage

One interface: point reads, batched reads, bounded indexed scans, one atomic commit. No journal reader, no second transaction layer, no residency API.

7.1 What is stored

Conversations (parent for forks, owner for owned children), entries, tasks (with indexed role and scheduling fields), values and their rewindable versions, list elements and clear markers, scratch scopes. Context is not stored (§2.2).

7.2 Queries, from the callers' side

Caller Reads Never
Driver open/attach complete live-task seed and named owner records; then committed-batch index updates repeated full scans after calls, historical child traversal
Shutdown current live-task index inbox sweep, historical conversation traversal
Context newest head at a target; fork-aware range from its returned boundary unrelated transcript history
post_tools tool tasks by id sibling scans
UI a transcript page before/after an id, with a limit the whole conversation
Validation named task/conversation records and full entries by id unrelated records
Fork transcript and rewindable conversation history ≤ the entry id today's state filtered
State consumer latest version by index; a bounded list range a replay of unrelated records
Reopen (SQLite) live tasks and the conversations they need every historical transition

"Latest" is an indexed descending query with limit 1, never load-and-take-last. Filters apply before limits and decoding. Entries are immutable and read whole; there is no separate header or projection API. Named owner-task lookups use existing batched getTasks, even for terminal owners.

interface Page<T> { readonly items: readonly T[]; readonly next?: Id; readonly readAt: Id }

interface Cursor { readonly after?: Id; readonly before?: Id; readonly limit: number }
interface ConversationQuery extends Cursor { readonly parent?: Id; readonly ownedFrom?: Id }
interface EntryQuery        extends Cursor { readonly conversationId: Id; readonly kind?: string; readonly key?: string;
                                             readonly from?: Id; readonly through?: Id } // inclusive logical range
interface TaskQuery         extends Cursor { readonly conversationIds?: readonly Id[]; readonly live?: boolean;
                                             readonly role?: TaskRole; readonly kind?: string; readonly abort?: boolean }
interface ListQuery         extends Cursor { readonly at?: Id }                   // at = an entry id (§3.2)
interface ValueQuery        { readonly scope: Scope; readonly namespace: string; readonly after?: string; readonly limit: number }  // keys, ordered

interface Version<T>  { readonly seq: Id; readonly value: T }
interface Element<T>  { readonly id: Id; readonly value: T }

interface Storage {
  readonly lastSeq: Id;                                   // last committed sequence
  commit(batch: CommitBatch): Promise<{ first: number; last: number }>;

  getConversations(ids: readonly Id[]): Promise<ReadonlyMap<Id, Conversation>>;
  scanConversations(q: ConversationQuery): Promise<Page<Conversation>>;

  getEntries(ids: readonly Id[]): Promise<ReadonlyMap<Id, Entry>>;
  scanEntries(q: EntryQuery): Promise<Page<Entry>>;                            // full entries, fork-aware
  newestHead(conversationId: Id, at: Id): Promise<Entry | undefined>;             // full entry, target-capped, fork-aware

  getTasks(ids: readonly Id[]): Promise<ReadonlyMap<Id, Task>>;
  scanTasks(q: TaskQuery): Promise<Page<Task>>; // omitted conversationIds: entire session

  getValue<T>(addr: Value<T>, at?: Id): Promise<Version<T> | undefined>;         // at = an entry id
  scanValues(q: ValueQuery): Promise<Page<{ key: string; version: Version<JsonValue> }>>;
  readList<T>(addr: List<T>, q: ListQuery): Promise<Page<Element<T>>>;

  close(): Promise<void>;
}

Value<T>, List<T>, Address and Scope (session / conversation / scratch) are the address types of §4.1; Conversation, Entry, Task and TaskRole are the records of §§2 and 5; CommitBatch is §7.3.

Pages carry a cursor and the sequence they were read at. Transcript reads are logical conversation reads: they combine each fork's capped source prefix with local entries, carrying every ancestor cutoff recursively. from and through are inclusive entry positions in that logical transcript; source entries after a fork point never appear. newestHead(conversationId, at) applies the same cutoffs and returns the newest visible entry with head at or before at. Rewindable conversation state follows the same ancestor cutoffs. Session state stays current; sticky conversation state is copied from current state only when the fork explicitly selects it. Tasks are never inherited. Historical reads on sticky or scratch addresses reject. Coherent multi-read operations run on the line; a cursor alone doesn't freeze mutable state.

7.3 Batches

type StateWrite =
  | { type: "value.set"; addr: Address; value: JsonValue }
  | { type: "value.delete"; addr: Address }
  | { type: "list.append"; addr: Address; value: JsonValue }
  | { type: "list.remove"; addr: Address; element: number }
  | { type: "list.clear";  addr: Address };

type MainWrite =
  | StateWrite
  | { type: "createConversation"; conversation: Conversation }
  | { type: "deleteConversation"; id: number }
  | { type: "entry";   entry: Entry }
  | { type: "task";    task: Task }
  | { type: "patch";   id: Id; role: TaskRole; state: TaskStateBase; abort?: true }
  | { type: "settle";  id: Id; role: "terminal"; state: TaskStateBase };  // also retires scratch

type CommitBatch =
  | { readonly kind: "main";    readonly writes: readonly MainWrite[] }
  | { readonly kind: "scratch"; readonly task: number; readonly writes: readonly StateWrite[] };

A scratch batch can only carry state writes, by type; ScratchTx (§4.6) only exposes those. Write i gets sequence lastSeq + 1 + i.

The Tx read lastSeq when it started and numbered its writes from there; the line guarantees nothing landed in between, so storage numbering from its own lastSeq + 1 yields the same ids. Records that carry an id (entry, task, a list element) let storage check that for free. The harness validates against committed state plus earlier writes in the batch (kinds validate status transitions; the harness enforces ownership, dependencies, exchange rules and admission; nobody validates payload shapes: stored objects are trusted, and schema validation belongs at wire boundaries). For an entry with head, validation requires the stored id to be visible and at or after the previous fork-visible head's stored boundary. Stored edit targets must be earlier visible entries; managed-system targets are restricted to baseline supersession (§2.1). Input-result transitions are validated, terminal results cannot change, and consuming/cancelling an inbox element writes its result in the same batch. No entry-kind code runs. Storage checks scope, sequences and structure, prepares only touched data, persists atomically, publishes. A commit with no writes writes nothing.

7.4 Memory and JSONL

The memory backend holds current maps (conversations, tasks, values), ordered entries per conversation, version histories, and lookup indexes (ids, addresses, live-task membership, parent and owner links). Indexes reference the same immutable payloads. JSONL is the memory backend plus one line per batch:

{"first":100,"writes":[
  {"type":"value.set","conversationId":1,"namespace":"plugin.plan","value":true},
  {"type":"entry","conversationId":1,"kind":"user","model":[{"role":"user","content":"Inspect","timestamp":0}]},
  {"type":"task","conversationId":1,"kind":"generation","state":{"status":"pending"}}]}

Write all bytes, then publish in-memory changes, never before. Durability against process crash is required; fsync policy is a backend option.

Whole values, ordinary list operations. Every value.set stores the complete supplied value, just like the lane harness. There is no automatic diff, Chord encoding or hidden replay chain for a value. Lists store append/remove/clear operations. Repeatedly setting a growing whole value can write quadratic bytes; use compact appended frames/output operations or bounded checkpoints for streaming. Chord deltas are used only for preview/watch delivery (§9.4), not storage serialization.

Scratch goes to session.scratch/<task id>, one file per task, with the same plain batch format. Retirement in the main file is authoritative; unlink happens afterwards. Task ids are never reused. A retry keeps its task and clears its scratch through a persisted list operation, not by deleting the only record of its latest sequence.

Recovery across files. Main and scratch commits share one session sequence, so gaps inside any one file are normal:

main:       100 create generation
scratch:    101–150 frame appends
crash before settlement
reopen:     main says generation live; replay its scratch; lastSeq=150; next write=151

alternatively:
main:       151 settle generation, retire scratch
crash before unlink
reopen:     generation terminal; ignore old scratch; lastSeq=151

On open:

  1. Replay complete main batches, reconstructing task liveness and scratch retirement.
  2. Replay scratch files only for surviving live tasks. Ignore retired scratch entirely, even if malformed; it cannot affect state or the sequence high-water mark.
  3. Validate increasing, nonoverlapping retained batch ranges and task/scope references. Per-file gaps are valid; do not require the main log to contain discarded scratch positions.
  4. Set lastSeq to the maximum complete batch endpoint across main and surviving scratch files, including scratch clear/remove writes. A retired scratch file's sequences are already below its later main settlement, so deleting it loses no high-water information.
  5. Discard only unterminated final lines in replayed files and physically remove those torn suffixes before appending again. Any malformed complete main or surviving scratch batch makes open fail.

No new index, journal or cross-file transaction is required: each commit still writes exactly one file.

7.5 SQLite

Tables: conversations, entries, tasks (current records including terminal), values and value_versions, list_elements and clear_markers, scratch, commit_boundaries, kinds (one row per distinct entry kind), session_metadata (storage version, §6.5). An entry row has nullable JSON columns for data, model and edits, plus a nullable integer head; storage understands those core fields but never a plugin's data shape. Keys start with the session id. Indexes follow the query table: entries by (conversation, id), (conversation, kind, key, id) and a partial index (conversation, id) where head is not null; tasks by (role, id) and (conversation, role, id) with a partial index on non-terminal roles; value_versions by (conversation, namespace, key, seq); conversations by parent and by owner. One commit is one transaction: number the writes from lastSeq + 1, insert rows, record the boundary, advance lastSeq. No full rewrite of anything per append; no shutdown-specific inbox index.

Values are stored whole, current and historical: a point read must not become a replay chain, and disk is cheaper than that. Scratch is rows in the scratch table keyed by (task, address), written in the task's own small transactions, and retired by DELETE ... WHERE task = ? inside the settlement transaction: atomic with the result and the terminal status, nothing to unlink. Reopen reads live tasks and named records they reference (including terminal owners), not accumulated history. Once ownership links are extracted, terminal owner payloads need not remain resident.

8. Built-in flows

Task kinds, not scheduler special cases. Braces group one commit. Every attempt records its usage; every settlement retires its scratch. Hooks and external calls run outside the line, and their decisions are re-validated inside it.

8.1 Accepting input

Queued input is a conversation-scoped sticky list, pi.inbox. Its list element id is the stable inputId; its value carries the complete entry draft, so a UI can render queued text or images without another read:

type UserInput = string | readonly (TextContent | ImageContent)[];

type QueuedInput =
  | { readonly mode: "steer" | "followUp" | "nextRun"; readonly input: UserInput; readonly requestId?: string }
  | { readonly mode: "write"; readonly kind: EntryKind<E>; readonly entry: EntryInput<E>; readonly requestId?: string };

type InputResult =
  | { readonly status: "queued";     readonly requestId?: string }
  | { readonly status: "placed";     readonly requestId?: string; readonly entry: Id }
  | { readonly status: "done";       readonly requestId?: string; readonly entry: Id; readonly answer?: Id }
  | { readonly status: "unanswered"; readonly requestId?: string; readonly entry?: Id;
      readonly reason: "terminated" | "aborted" | "failed"; readonly detail?: string };

The three modes that ask for a turn carry user content; a write carries its own entry kind and the same EntryInput<E> (§9.2) Tx.entry takes, and asks for nothing. inputResult(id) denotes the sticky conversation Value address with namespace pi.inputResult and key String(id), typed as InputResult. It is ordinary stored state, not a driver callback.

accept({ input, requestId?, whenBusy? }, call) is "the user hit enter": when the conversation is idle it places the entry and creates a generation in one commit; when it is busy it queues, followUp by default, steer or reject if the caller says so. Either way it returns an inputId, and result(inputId, call) says which happened. queueInput(input, call) is the explicit form for a specific mode, and abortInput(inputId, call) withdraws a queued one.

Mode Placement Effect on the answer group
write next post_tools or final boundary none; its result is done with no answer (§5.8)
steer next post_tools or final boundary joins the active group at post_tools; starts the next group after a final answer
followUp final-answer boundary starts the next group
nextRun next explicit idle accept joins the group started by that acceptance

A generation and its post_tools carry inputs: Id[]. Generation-with-calls creates the tools and exactly one post_tools with the same group in its settlement commit. post_tools places writes, places selected steering in admission order, extends the group with those steer ids, creates the continuation generation and settles. At a final answer, the generation resolves every current input to that answer, places writes, then places selected steer/followUp items into a new group and creates its generation. An on_yield continuation retains the current group. No tail scan attributes results.

Placement appends the entry, removes the list element and writes placed in one commit; a write is placed and done in that same commit, so placed is never observable for it. Only the generation and post_tools transition a group: a final answer records done with the answer's entry id; the harness giving up records unanswered with reason failed; a tool's terminate records terminated; an abort of the run, or abortInput on a queued item, records aborted. Foreground abort withdraws queued steer and followUp while preserving write and nextRun. Failure and terminate may place safe writes but do not consume queued inputs that require a successor. Exactly one live generation or post_tools owns an active group, and ownership transfers in the same commit that settles the previous owner. Input groups/results are built-in harness behavior, not payload fields the generic driver infers from arbitrary plugin tasks. These built-in kinds are registered at open; a compatible replacement retains their input-result responsibilities. A missing tool implementation is handled by the surviving tool task kind, not by orphaning the input owner.

The list is stored as append/remove/clear operations, not as a rewritten array. Inbox watch events carry the same operations; an idle same-commit append/remove emits none. Memory and SQLite may discard a removed sticky element; JSONL retains its append record, so any payload later copied into a transcript entry appears twice on disk, including an idle acceptance. Once an unplaced item is withdrawn, its draft is no longer queryable; inputResult retains only its terminal status and optional request id.

8.2 Generation

create:      capture inputs, the kind's declared config (model, thinking, selected tools, profile;
             §5.2, rewindable values), provider options and retry policy, so a retry uses what the
             attempt used
execute:     if a collapse is needed by threshold: { create collapse C; create generation G' after:[C]; settle }
             desired = seed typed section draft from durable prepared values/rendered text;
                       run system_instructions handlers, then touched renderers/wrappers outside line;
                       capture complete desired tool definitions; no discovery/cache callbacks in storage
             { inspect current head and effective managed system entries;
               if no valid current-epoch baseline: append full baseline plus stored omission edits;
               else if prepared state differs from desired: append materialized section/tool delta;
               capture requestThrough = exact resulting transcript tip; status streaming }
             project at requestThrough, not the later tail; before_request with Call on a request copy
             validate messages-only mode and capture actually offered tools; stream; frames to scratch
outcome:
  calls        { assistant; tools; post_tools carrying inputs; settle done }
  final        { assistant; resolve every current input; place next-group inbox items and successor if any;
                 settle done }
  deferred     { status deferred, handle }         → execute again: poll with sleeps until final
  retryable    { status retry_wait, attempt+1, notBefore } → execute again: sleep, then stream
  overflow     { settle failed(overflow); create collapse C; C's settlement creates G' }
  failure      { retain partial/error outcome for display if useful; resolve inputs unanswered(failed);
                 settle failed; no tools/results }
  aborted      provider returns in-band: attempt domain failure/retry commit;
                 durable mark present → TaskCancelled; execute unwinds; driver calls abort below

Durable configuration, prepared instructions. Config changes persist immediately as ordinary scoped value writes: model/thinking/selected tools/profile rewind with forks, while each other address keeps its declared policy. Host files and catalogue implementations are not stored as configuration. The system_instructions hook uses the generation's captured config to produce desired sections and complete selected tool definitions. Its answer is prepared outside the line. A system entry records canonical instructions prepared for a request, not proof of delivery or an external acknowledgement.

Typed section definitions and one mutable draft. A token identifies a JSON payload type and an append-time renderer. It is not an entry kind, discovery callback or read-time projection hook:

interface SystemSection<T> {
  readonly key: string;
  render(value: T): string;
}

interface SystemSectionDraft {
  get<T>(section: SystemSection<T>): T | undefined; // owned copy; call set to change the draft
  set<T>(section: SystemSection<T>, value: T): void;
  delete(section: string | { readonly key: string }): void;
  wrap<T>(section: SystemSection<T>, transform: (text: string) => string): void;
}

interface SkillInfo { name: string; description: string }
const skillsSection = defineSystemSection<SkillInfo[]>({
  key: "pi.skills",
  render: skills => skills.map(skill => `- ${skill.name}: ${skill.description}`).join("\n"),
});

Built-in definitions are exported through systemSections: identity carries a string, environment carries structured data such as { cwd: string }, and skills carries typed skill descriptions. Plugins may define others. Payloads must be JSON-representable. Typed getters use the registered definition's payload contract; plugins need no casts. Registration rejects duplicate keys; replacing a definition must preserve the persisted payload shape or migrate it. Payload shape validation belongs at wire boundaries, as for other typed stored values, not to arbitrary renderer casts.

Every preparation seeds a private ordered draft from durable section values and rendered text. Hooks edit that same draft in registration order, harness-wide first and innermost scope last. Under the skip-failed-handler policy, discard that handler's mutations/wrappers while retaining earlier changes; a failed refresh must not publish a partial deletion. set replaces an existing payload in place; a new key appends. delete explicitly removes a key (even by string when its renderer is unavailable). get/set/wrap require a registered compatible definition. Thus a later plugin can filter or append typed skills by get-and-set, rather than parsing rendered prose. Hooks return only optional complete desired tools; the last supplied list wins, omission preserves an earlier handler's list, and no supplied list means no desired tools.

After hooks, render touched sections outside the line. wrap composes text transforms in registration order after the section renderer, without mutating the shared token. Wrappers live only in this draft; only their final text is stored. Untouched seeded sections retain their stored rendered text, including when their contributor or renderer is absent. An explicit set/wrap or compatible renderer replacement recomputes that text. Refreshing a base intentionally rebuilds its wrappers; preserving a missing wrapper across such a refresh is not promised. Freeze/discard the draft after preparation; later leaked callbacks cannot alter the committed snapshot.

No accidental accumulation. Host hooks should set complete authoritative base payloads each time before transformation plugins run. Otherwise the composed transformation chain must reach a fixed point when applied to its own previous output; each handler being individually idempotent is not enough. Blindly appending to a seeded array/text each preparation would accumulate duplicates. Registration order provides composition, not automatic deduplication of arbitrary payloads.

Diff final JSON payloads and rendered text by key, ignoring incidental object-key order. Persist changed payloads even if rendering is identical; those changes can be data-only system entries (model: []). Changed rendering with identical payload also emits an instruction update, including renderer/wrapper changes. Baselines and simultaneous changes use draft order; pure reordering is a no-op. There is no separate order option, order-change message or generic source-renderer framework.

Durable seed, independent of model retention. Reconstruct canonical section state through the fork-visible target by reading managed system entries back to the latest baseline, then folding their section records forward. Use existing indexed kind scans and an optional per-handle prepared-state cache. A fresh baseline checkpoints the complete state. This fold is independent of model heads and projection edits: an omitted historical model message does not erase its persisted section payload. That is how an unavailable plugin's instructions survive a compaction that dropped its old baseline. No extra full-state value, blob store or storage API is required.

Separately inspect the effective model projection to decide whether it has a usable current-epoch baseline. Arbitrary edits targeting managed system entries reject (§2.1); changing instructions uses the section draft instead. Supersession omissions written by the current baseline do not trigger another rebaseline. A fresh baseline uses the canonical payload/rendered seed plus this preparation's changes, even when the previous model baseline fell outside the retained range.

Hook-private discovery/cache state stays in closures or host services, not in section records. Refresh skills through filesystem events or bounded polling, not remote scans per hook call. A failed refresh is not a deletion: retain the last successful snapshot; seed first use before claiming a base. Missing hook registration also is not deletion; use the draft's explicit delete operation.

Fold effective pi-ai system-message tool fields from all projected messages, including declarations outside managed entries. Diff complete definitions structurally by name. Additions and same-name replacements emit toolsAdded; removals emit the previous stored definition in toolsRemoved. Tools-only changes may use empty content. The adapter, not pico, renders compatibility notices.

100 user
110 system baseline: identity + plan_mode; toolsAdded=[read, write]
120 assistant
125 config commit: plan_mode=true; selectedTools=[read, grep]       (not a transcript entry)
130 user
140 system delta: replace plan_mode; toolsRemoved=[write]; toolsAdded=[grep]
150 assistant

A crash between 125 and 140 preserves the config; the next generation prepares the missing change. A crash after 140 but before the provider call also preserves that entry. An unchanged hook answer produces no duplicate delta. Neither event proves the provider saw the instructions. A fork uses its visible prefix and inherited rewindable config; changed host sources on restart may cause a new delta, but cannot rewrite old messages.

Head changes and fresh baselines. A current-epoch baseline is a managed baseline after the newest visible head entry in transcript order (not after its retained boundary). With none, append a complete baseline carrying ordinary omission edits for superseded retained managed system entries. Include old baselines as well as deltas, but never unrelated system notices. Store the omissions on that same baseline entry, not on the head, so arbitrary head writers remain generic and a fork cannot land between replacement instructions and their omission controls.

10 user; 11 baseline; 20 assistant; 30 user; 31 system delta; 35 job notice; 40 assistant; 50 user
60 summary, head=30
context at 60: [60 summary, 30 user, 31 delta, 35 notice, 40 assistant, 50 user]
70 assistant
80 user
81 system { baseline:true, full sections, full desired toolsAdded,
            edits:[{ target:31, action:omit }] }
context at 81: [60 summary, 30 user, 35 notice, 40 assistant, 50 user, 70 assistant, 80 user, 81 baseline]

The baseline stays at its appended position after the retained tail. Before preparation, old retained system entries remain visible; generic context derivation never secretly suppresses them. Every request goes through preparation first. Repeated heads use the same rule. Reset/handoff often leave no old managed entries inside the retained range, so the new baseline needs no omission targets. Compute the remaining tool fold after planned omissions: a baseline adds the complete desired tool set and explicitly removes unwanted declarations that remain from other effective system messages. baseline:true is pico metadata, not a magic reset understood by pi-ai.

Frozen request cutoff and cache. The generation stores the following request-specific field:

// Request fields on the streaming variant; other generation variants/fields are omitted.
interface GenerationStreamingState {
  readonly status: "streaming";
  readonly inputs: readonly Id[];
  readonly requestThrough: Id; // inclusive transcript entry cutoff, required after preparation
}

Capture the durable section-state cursor and registered definitions before running hooks/renderers outside the line. Preparation retains that definition snapshot; later registrations affect subsequent preparations. On the line, verify that no concurrent managed section-state write changed the seed; if one did, rerun preparation outside the line. A head-only change does not stale the section seed. Then inspect the current head and effective model state, choose baseline versus delta, and persist the new system entry if needed and the generation's inflight intent with requestThrough. The cutoff is the resulting transcript tip, even when no system entry was needed. Deferred variants retain the cutoff for their prepared request; an unprepared pending variant has none. After persistence, while still on the line, catch up the handle's context cache and capture a separate immutable array of the selected entry references and effective replacement projections. This is the request snapshot; entry bodies are immutable and can be shared. Never hand out mutable cache bookkeeping or mutate a published replacement. Request-local normalization/hooks copy the objects they modify.

prepare through 51 → requestThrough=51, snapshot captured
head 60 lands     → live cache advances; request snapshot stays unchanged
request streams   → uses snapshot through 51
next preparation  → sees head 60; writes a fresh baseline if required

If a head lands while the hook is running, preparation sees the new head and chooses a baseline. If it lands after preparation, the captured request is unaffected. Historical/recovery reads through requestThrough derive independently from storage when the live cache has advanced; they do not rewind it. Release request-only references after use; no registry of historical cache versions is retained. The cutoff identifies canonical transcript input, not arbitrary request-local hook changes.

Pi-ai owns provider translation. Pico passes only { messages }, with neither top-level systemPrompt nor tools, even when there are no selected tools. Both fields absent selects the agreed pi-ai system-message mode; explicit empty strings/arrays are not absence. Adapters translate the stored message history for their provider/model, preserving cache prefixes where possible. Unsupported mid-conversation instruction/tool changes fall back to <system>-bracketed user messages at their historical positions and adapter-derived bulk wire tool declarations. Pico never hoists a baseline, flattens historical changes into a rewritten prompt, or sends a parallel top-level tool list. Fallback user messages do not have native system priority; cache reuse is best-effort, not universal.

The pi-ai SystemMessage shape comes from PR #9116; PR #9117 covers coding-agent prompt/tool deltas. These were open when this contract was reviewed. Messages-only mode and its adapter fixtures are an external integration requirement, not a claim that the current main branch already implements them.

Request-local overrides. before_request receives a private request copy and may transform it. Validate afterward that it remains messages-only. Such transformations neither mutate stored entries nor alter the canonical section fold for the next generation. Exact transformed-request audit requires separate optional capture; the transcript alone records only canonical preparation. Validate returned tool calls against the definitions actually offered after transformation, plus implementation and permission checks—not today's catalogue or the untransformed tool set.

One task lives through retries and deferral; retries share a budget carried in state. Overflow does not keep the generation alive: it settles and hands the retry to the collapse's settlement, carrying the attempt count, so nothing live ever waits on the collapse.

recover streaming:  frames in scratch → retain/publish the partial for display; settle without tools/results
                    no frames → retry within budget, else fail
recover deferred:   execute again; the handle is in state
abort:              fresh invocation after execute/recover returned:
                    read committed scratch; optionally retain partial for display, excluded from requests;
                    in one commit resolve owned inputs unanswered(aborted), record known usage, settle aborted;
                    no tool tasks/results; settlement retires scratch

Usage is recorded for failed, deferred and discarded attempts too; a missing report is unknown cost, not zero. A report received only after the cancellation cutoff may not have reached scratch and is therefore unknown to fresh abort cleanup. The record is a session list, pi.usage, one element per attempt (conversation, task, model, tokens, cost), appended in the settlement commit together with an update of the session value pi.usage.totals, so stats are a point read and never a fold. Tools and jobs append the same way through the sink's usage. Persistence and invariant failures are not provider errors: they fault the session.

8.3 Tools and post_tools

tool execute:
  resolve the tool in the registry (the loadout may have changed since the turn started);
  validate the model's arguments against its schema
  before_tool → allow(args) | block(reason, terminate?)     (a human approval waits inside the hook)
  block / invalid / unknown tool → own error result, no invocation
  { status running; effective args; replay policy }
  invoke tool(callId, params, out, runtime, call); the sink owns its scratch promises (§9.3)
  after_tool outside the line
  { result entry from the folded sink; usage; settle done with result id and ToolOutputState }

The tool never touches context, siblings or queues. A throwing before_tool blocks the tool. An ordinary tool throw is an error result, not a cancellation. A missing executable tool is also an ordinary unavailable-tool result: the built-in tool kind remains registered and post_tools can continue or finish the input group. This differs from a genuinely missing task kind (§6.4).

recover:  replay only if the stored policy allows; else an interrupted result from the checkpoint
abort:    previous invocation already returned; read durable scratch and cleanup references;
          mark owned child foreground tasks/non-detached jobs, without touching child queues;
          write aborted error result; settle

post_tools is the code in §5.5: read the tool tasks, stop on terminate, write the handoff if one was requested, place writes and steering, carry the extended input group into the next generation, and settle. Its abort resolves its input group as unanswered with reason aborted and settles with no successor.

Blocking budget. A tool call may not block a turn indefinitely. Tools that run processes or child conversations create a job (or a conversation) first and wait on it with the budget; if the budget runs out, the call settles now with what it has, out.delegate(job) and a diag, and the work continues as the job:

const job = await runtime.commit(async tx => {
  const task = await tx.getTask(toolKind, runtime.taskId);
  if (task?.state.status !== "running") throw new Error("Expected a running tool");
  const { status, ...payload } = task.state;
  const id = tx.task(jobKind, { background: true,
    state: { status: "planned", cmd, cwd, origin: { tool: "bash", task: runtime.taskId, callId: toolCallId } } });
  tx.patch(task, status, { ...payload, jobId: id, cancelJobOnAbort: true }); // complete running payload
  return id;
}, call);
const done = await runtime.waitForTask(job, { budgetMs: runtime.budgetMs }, call);
const output = await runtime.jobOutput(job, call);
out.replace(output.text); out.capture(output.truncation);
if (!done) {
  out.delegate(job);
  out.diag("info", `still running as job ${job}; use job wait / status / stop`, "budget");
}

The budget is the generation's config (budgetMs, default a few minutes); the tool kind passes it as runtime.budgetMs. waitForTask observes Call cancellation and unregisters its waiter. A marked tool cannot mutate from its execute catch; its fresh abort handler reads the stored job reference and cancels non-detached work. Explicit backgrounding records a different cancellation policy. Successful delegation settlement atomically records that the job is detached and requests its completion notice; if the job has already finished, that transaction places the notice itself. Use a separate sticky notification record rather than patching the state of a running job from its former owner.

Remaining budget integration question: adopting arbitrary non-delegating tool work after timeout requires an explicit transfer of effect and sink ownership before the source invocation releases its slot. Racing and abandoning the tool promise is not a correct implementation. Preserve this capability for a separate ownership design; crash recovery remains lost. The job-first path above needs no promise adoption and is the initial implementation path.

new_context is a tool that calls out.handoff(message); the reset happens here, after the exchange is complete, never inside the tool, because a head landing mid-exchange would cut it in half. Conflicting handoffs reject rather than pick an order.

A final answer's continuation is decided in the generation's settlement. on_yield may request an explicit continuation that keeps the current input group. Otherwise the answer resolves that group, then eligible steer/followUp items are placed into a new group and its generation. An owned conversation's final answer still resolves its own inputs; its owner tool observes that result while driving it (§8.5). An idle conversation whose tail is a user entry creates no work by inference.

8.4 Collapse

create:   capture the prefix to replace (ending on a complete exchange), its first retained entry,
          the newest head id, context and settings; one live collapse per conversation
execute:  { status summarizing }
          before_collapse (may decline or supply the summary); call the summarizer; candidate to scratch
          { if no head newer than the captured one:
              append summary { model:[summary], head:firstRetained }; settle done
            else: settle failed(stale) }

Entries that land while it runs remain at or after the prepared retained boundary, so they stay in context without anyone doing anything. Context edits that land meanwhile affect active projection but do not invalidate or recompute the summary. A summary may land while a generation streams, since the stream already projected its context and the replaced prefix is old. Automatic collapses (threshold, overflow) are foreground; manual ones are background. A collapse never creates a generation except through the settlement chain in §8.2. Abort records usage and settles without publishing. Prefix pruning/windowing appends another entry with a stored head; pruning or replacing individual retained entries uses edit entries (§2.1). Dynamic compose policies are unsupported.

8.5 Subagents

A subagent is a conversation. There is no subagent task, and there is one tool, subagent, whose argument is a command:

type SubagentCommand =
  | { command: "run";    prompt: string; context?: "fresh" | "inherit"; tools?: string[] }   // foreground
  | { command: "spawn";  prompt: string; context?: "fresh" | "inherit"; tools?: string[] }   // background
  | { command: "send";   id: Id; text: string }
  | { command: "status"; id: Id }
  | { command: "wait";   id: Id }
  | { command: "stop";   id: Id };
run:     { create child conversation (this task owns it); initial values; accept prompt → inputId; first generation }
         await child.drive(call)                   // cancels this waiter, not the child
         child.result(inputId, call); { result entry; settle }
         recover: the child exists; drive it again with the fresh Call
         abort: mark child's foreground tasks only, preserve its queues; write own error result and settle

spawn:   the same creation commit; settle at once with the child's id
send:    child.accept({ input: text }, call) → queued if the child is busy
status:  the child's tail and live tasks
wait:    await child.drive(call); child.result(lastInputId, call); settle    (recover: drive again)
stop:    child.abort(call)

id is whatever spawn returned, passed back by the model; the tool resolves it with runtime.conversation(id, call). There is no separate subagent registry to rebuild after a restart. Between spawn and wait, whatever drives the parent's tree drives the child. A spawned child is owned by a task that is already terminal, so it is not in the parent's foreground set and aborting the parent leaves it alone; stop is the way to cancel it. Inherited context for a child is the parent's context at the last complete exchange before the launching one.

8.6 Jobs and schedules

A job is a background task that runs a process. Its state:

type JobInput = {
  cmd: string; cwd: string; limits?: ShellOutputLimits;
  origin?: { tool: string; task: Id; callId: string }; // originating call, for UIs
  every?: number; notBefore?: number; rerun?: "safe"; // recurrence and recovery policy
};
type JobState = JobInput & (
  | { status: "planned" }
  | { status: "running"; startedAt: number }
  | { status: "exited"; output: ToolOutputState; exitCode: number }
  | { status: "killed"; output: ToolOutputState }
  | { status: "lost" }
);

Starting one is creating the task; a tool does it in its execute (§8.3), a UI in a commit:

const jobId = await conv.commit(tx =>
  tx.task(jobKind, { background: true, state: { status: "planned", cmd: "npm test", cwd, every: 6 * 3600_000 } }), call);
states:  planned { cmd, cwd, every?, notBefore? } | running { …, startedAt } |
         exited { …, output, exitCode } | killed { …, output } | lost { … }
roles:   planned=start; running=inflight; exited/killed/lost=terminal
execute: sleep until notBefore with Call; { status running; startedAt = runtime.now() }
         env.exec(command, options, call), with bounded capture and spill
         harness progress bridge owns scratch writes, handles cancellation and drains callbacks
         after process and progress bridge return:
           { if notification requested: append notice and consume notification request;
             if recurring: status planned, notBefore = runtime.now() + every;
             otherwise: settle exited with bounded output and exitCode }
         a post-mark write rejects TaskCancelled; no in-band killed settlement
recover: safe rerun policy → execute again with the fresh Call
         otherwise { consume pending notice request; append lost notice if requested; settle lost }
abort:   previous invocation and its process/progress callbacks have returned
         read committed output from scratch;
         { consume pending notice request; append killed notice if requested; settle killed }

The progress bridge is harness-owned sink plumbing (§6.3), not a plugin-created detached promise. It observes every scratch result, suppresses only expected cancellation/close rejections, reports persistence faults, and is joined with process execution. Notification requests are current sticky state keyed by job id; consuming them and publishing the notice is atomic on every terminal path, including exited, lost and killed. If delegation follows settlement, delegation publishes that same outcome instead. The two orderings must not change notification behavior.

runtime.env is the harness's ExecutionEnv (FileSystem & Shell); exec runs the command, captures output through onUpdate (here into scratch, so a watcher can show it live) and returns when it exits or the signal fires. running is inflight, so a restart goes through recover; since the shell doesn't hand out an adoptable process, recover reruns if the job declared that safe and otherwise settles lost. A recurring job is the same task looping planned → running → planned; abortTask(id) ends it wherever it is. Output stays in scratch while it runs and in the terminal state after, where jobOutput, the job tool and UIs read it; there is no result entry, since a call that waited already carries the output. A job whose call returned early appends a notice entry with a stored SystemMessage when it finishes, so the next generation learns of it without polling. Interval and catch-up policy are in state, so a restart never launches a backlog.

8.7 Hooks

Hooks belong to kinds. A kind declares its points and their types; plugins register handlers per kind and point; the harness only routes:

type HookPoints = Record<string, { input: unknown; output: unknown }>;

interface GenerationHooks extends HookPoints {
  system_instructions: { input: { conversationId: number; config: GenerationConfig; sections: SystemSectionDraft };
                         output: void | { tools?: readonly Tool[] } }; // sections mutate one shared preparation draft
                         // handlers run sequentially; new keys append; set replaces; delete is explicit
  before_request: { input: { request: ProviderRequest }; output: { request?: ProviderRequest } };
  after_response: { input: { response: AssistantMessage; usage: Usage }; output: void };
  on_yield:       { input: { answer: AssistantEntry }; output: { continue?: string } };
}
interface ToolHooks extends HookPoints {
  before_tool: { input: { toolCallId: string; toolName: string; args: JsonObject };
                 output: { args?: JsonObject; block?: { reason: string; terminate?: boolean } } };
  after_tool:  { input: { toolCallId: string; output: ToolOutputState }; output: void };
}
interface CollapseHooks extends HookPoints {
  before_collapse: { input: { reason: "manual" | "threshold" | "overflow"; entries: readonly Entry[] };
                     output: { decline?: boolean; instructions?: string; summary?: string } };
}

const generationKind = defineTaskKind<GenerationState, GenerationHooks>()({ ..., hooks: { before_request: {}, after_response: {}, on_yield: { failClosed: false } } });
const toolKind = defineTaskKind<ToolStates, ToolHooks>()({ ..., hooks: { before_tool: { failClosed: true }, after_tool: {} } });

// a plugin registers a handler
harness.hooks.on(toolKind, "before_tool", async ({ toolName, args }, call) => {
  if (toolName === "bash" && !(await ui.approve(args, { signal: call.abortSignal }))) return { block: { reason: "denied" } };   // may wait for a human
});

// the kind runs it
const decision = await runtime.hooks(toolKind).run("before_tool", { toolCallId, toolName, args }, call);

A handler can be scoped to a conversation instead of the whole harness; for system_instructions that is the normal case, since a subagent is a different agent:

root.hooks.on(generationKind, "system_instructions", hostInstructions, { subtree: true });  // root and what it owns
child.hooks.on(generationKind, "system_instructions", investigatorInstructions);           // this child only; overrides for it

Scoped handlers run after harness-wide ones for the same point, innermost conversation last, so a child's sections win by key. Registration is process state, not durable; a presentation registers when it attaches.

Handlers run in registration order, each seeing the merged output so far; the kind decides what to do with the result. A handler runs outside the line and its decision is applied inside a commit whose runtime validates current invocation authority. Handlers receive a required final Call, forward it to waits/effects, and must return before their parent task invocation releases ownership. Cancellation control errors bypass ordinary hook failure policies. Handlers may run again after a crash, so their external side effects need their own idempotence. failClosed says what a throw means: for before_tool a throw blocks the tool; for the others a throw is reported and skipped. before_tool may wait as long as it likes (a human approval is a hook that waits); the task is still planned meanwhile and a crash simply asks again. A plugin kind declares its own points the same way, and a plugin that replaces a built-in kind keeps its hook names so existing handlers keep working. Commit listeners are not hooks: they observe, never decide, and never await a commit on the same line.

Approval/question workflows and durable answer reuse belong to the workspace/plugin layer. Pico supplies task identity and existing storage capabilities, not a separate memo or approval subsystem. The exact hook access to task id and authorized scratch remains an integration question; no extra hook-payload fields or answer-replay protocol are established here.

9. API

9.1 Handles

interface ConversationHandle {
  readonly id: Id;
  snapshot(call: Call): Promise<Conversation>;
  accept(options: { input: UserInput; requestId?: string;
                    whenBusy?: "followUp" | "steer" | "reject" }, call: Call): Promise<{ inputId: Id }>;
  prompt(options: { input: UserInput; requestId?: string;
                    whenBusy?: "followUp" | "steer" | "reject" }, call: Call): Promise<AssistantEntry | undefined>;
  result(inputId: Id, call: Call): Promise<InputResult | undefined>;
  drive(call: Call): Promise<"idle" | "closed">;
  queueInput(input: QueuedInput, call: Call): Promise<{ inputId: Id }>;      // §8.1
  write<E extends Entry>(kind: EntryKind<E>, input: EntryInput<E>, call: Call): Promise<{ inputId: Id }>;   // §5.8
  abortInput(inputId: Id, call: Call): Promise<"aborted" | "not_found">;
  abort(call: Call): Promise<void>;
  collapse(options: { instructions?: string } | undefined, call: Call): Promise<Id>;
  reset(options: { handoff?: string } | undefined, call: Call): Promise<void>;
  fork(options: { at: Id | "start"; abort?: boolean; values? }, call: Call): Promise<ConversationHandle>;
  spawn(options, call: Call): Promise<Id>;
  value(addr) / list(addr) // async reads/writes take a final Call
  config<C extends ConfigSpec>(kind: { readonly kind: string; readonly config?: C }): { get(call: Call): Promise<ConfigValues<C>>; set(partial: Partial<ConfigValues<C>>, call: Call): Promise<void> };
  readonly hooks: { on(kind, point, handler, o?: { subtree?: boolean }): () => void };   // scoped to this conversation (§8.7)
  readonly settings: ConfigHandle<GenerationConfig>;   // sugar: config(generationKind)
  commit<T>(plan: (tx: ConversationTx) => T | Promise<T>, call: Call): Promise<T>;
}

interface Harness {
  readonly entryKinds: EntryKindRegistry; // mutable registration (§9.6)
  readonly taskKinds: TaskKindRegistry;
  readonly sections: SystemSectionRegistry;
  root(call: Call): Promise<ConversationHandle>;
  conversation(id: Id, call: Call): Promise<ConversationHandle | undefined>;
  conversations(query, call: Call): Promise<Page<Conversation>>;
  acceptance(requestId: string, call: Call): Promise<{ requestId: string; conversationId: Id; inputId: Id } | undefined>;
  inspect(call: Call): Promise<{ start: Task[]; inflight: Task[]; orphaned: Task[]; parked: Task[] }>;
  drive(call: Call): Promise<"idle" | "closed">;
  getEntry(id, call); getEntry(kind, id, call); getEntries(ids, call); getEntries(kind, ids, call);
  entries(conversationId, page, call);
  getTask(id, call); getTask(kind, id, call); getTasks(ids, call); getTasks(kind, ids, call);
  abortTask(id: Id, call: Call): Promise<void>;
  value(addr) / list(addr) // session state handles; async methods take Call
  commit<T>(plan: (tx: Tx) => T | Promise<T>, call: Call): Promise<T>;
  watch(conversationId: Id, options: { tail: number; values?: Address[]; raw?: boolean }, call: Call): Promise<WatchHandle<ConversationView, ConversationEvent>>;
  watch(call: Call): Promise<WatchHandle<SessionView, SessionEvent>>;
  deleteConversation(id: Id, call: Call): Promise<void>;
  shutdown(call: Call): Promise<void>;
  close(call: Call): Promise<void>;
}

Call is defined in §6.5. call.abortSignal cancels the drive caller's wait, not the work. fork({ at, abort: true }, call) marks the source's foreground set in the same commit that creates the fork, for "go back to that point"; which conversation a UI treats as current is the UI's business. prompt returns the answer entry only when its input's result is done with an answer; unanswered, or input still placed when the drive returned, gives undefined. result(inputId, call) is one sticky-value point read, never a transcript scan. After an uncertain remote response, acceptance(requestId, call) recovers the conversation and input identity, or the caller simply retries with the same request key and gets the same inputId back (§3.4).

9.2 Commits

const entryId = await harness.commit(tx => {
  tx.value(planMode).set(true);                              // state first (§3.2)
  const id = tx.entry(noteKind, conversationId, {
    data: { text: "plan accepted" },
    model: [{ role: "user", content: "<note>plan accepted</note>", timestamp: 0 }],
  });                                                       // final id, inside the closure
  tx.task(reminderKind, { background: true, state: { status: "scheduled", about: id } });
  return id;                                                // any value; resolved after commit
}, call);
type EntryInput<E extends Entry> =
  Omit<E, keyof EntryIdentity | "head"> &
  (E extends ContextHead ? { readonly head: Id | "self" } : { readonly head?: never });

interface EntryDraft {
  readonly key?: string;
  readonly data?: JsonValue;
  readonly model?: readonly Message[];
  readonly head?: Id | "self";
  readonly edits?: readonly ContextEdit[];
}

// Distribute over the declared variants; orphaned is never a public write target.
type StatesWithRole<S extends TaskStateBase, R extends TaskRoles<S>, Role extends TaskRole> =
  S extends TaskStateBase
    ? S["status"] extends "orphaned" ? never : R[S["status"]] extends Role ? S : never
    : never;

// Keep status and payload correlated, even when callers pass unions.
type WriteArgs<S extends TaskStateBase> = S extends TaskStateBase
  ? [status: S["status"], payload: Omit<S, "status">]
  : never;
type ExactWrite<S extends TaskStateBase, A extends WriteArgs<S>> = A & [
  status: A[0],
  payload: Record<Exclude<keyof A[1], keyof Omit<Extract<S, { status: A[0] }>, "status">>, never>,
];

interface Tx {
  // reads (committed state)
  getEntry(id) / getEntry(kind, id) / getEntries(...) / getTask(id) / getTask(kind, id) / getTasks(...)
  value<T>(addr: Value<T>): { get(at?): Promise<T | undefined>; set(v: T): void; delete(): void };
  list<T>(addr: List<T>): { append(v: T): Id; remove(id: Id): void; clear(): void; read(q): Promise<Page<Element<T>>> };
  // writes; rewindable conversation state before entries (§3.2)
  entry<E extends Entry>(kind: EntryKind<E>, conversationId: Id, input: EntryInput<E>): Id;   // §5.8
  write<E extends Entry>(kind: EntryKind<E>, input: EntryInput<E>): Id;   // inputId; appends now or queues (§5.8)
  queueInput(input: QueuedInput): Id;                                     // inputId (§8.1)
  task<S extends TaskStateBase, R extends TaskRoles<S>>(kind: TaskDefinition<S, R>,
    spec: { state: S; after?: Id[]; background?: true; owns?: Id[] }): Id;
  patch<S extends TaskStateBase, R extends TaskRoles<S>,
        A extends NoInfer<WriteArgs<StatesWithRole<S, R, "start" | "inflight">>>>(
    task: TypedTask<S, R>, ...args: A & NoInfer<ExactWrite<StatesWithRole<S, R, "start" | "inflight">, A>>): void;
  settle<S extends TaskStateBase, R extends TaskRoles<S>,
         A extends NoInfer<WriteArgs<StatesWithRole<S, R, "terminal">>>>(
    task: TypedTask<S, R>, ...args: A & NoInfer<ExactWrite<StatesWithRole<S, R, "terminal">, A>>): void;
  createConversation(spec): Id;  deleteConversation(id: Id): void;
}

Both operations take an explicit target status and its complete payload without status:

tx.patch(task, "running", { call, assistant, args, replay });
tx.settle(task, "done", { call, assistant, output, result });

// Same-status update: narrow, remove the tag, then supply the full replacement.
if (task.state.status === "running") {
  const { status, ...payload } = task.state;
  tx.patch(task, status, { ...payload, jobId });
}

There is no status-free or partial-merge overload. Both writes replace the stored state with { ...payload, status }; fields from the previous variant are not retained implicitly. patch accepts start/inflight targets, settle terminal targets and retires scratch. Neither permits orphaned; only open's internal orphan handling writes it. Same-status replacement remains valid but does not advance statusEpoch. Creation still receives the complete initial tagged state. An invocation's task snapshot does not change after patch; read again when a later replacement needs state committed since that snapshot, or carry the explicitly constructed next payload.

WriteArgs is a union of status/payload tuples: a union-valued status and an unrelated payload do not prove a valid pair. NoInfer prevents arguments from widening the task's known union/role map. ExactWrite rejects extra top-level keys visible in a variable or spread, not just object literals, including a duplicated status. These are static checks, not runtime schema validation: casts, erased extra fields and nested structural typing still follow TypeScript's limits. Wire boundaries validate untrusted payloads; line validation checks current liveness, invocation ownership and allowed roles.

The implementation below shows erased runtime signatures; the public signatures are above. It numbers as it goes, keeps the one ordering rule, and hands storage a batch; storage numbers from its own lastSeq and gets the same ids (§7.3).

class Tx {
  private writes: MainWrite[] = [];
  private seq: number;
  private sawEntry = false;
  constructor(private storage: Storage, private kinds: Kinds) { this.seq = storage.lastSeq; }

  private push(w: MainWrite): number { this.writes.push(w); return ++this.seq; }
  private state(w: StateWrite): void {
    if (this.sawEntry && "conversation" in w.addr.scope && w.addr.rewind)
      throw new Error("rewindable conversation state must precede entries (§3.2)");
    this.push(w);
  }

  value<T>(addr: Value<T>) {
    return {
      get: (at?: number) => this.storage.getValue(addr, at).then(v => v?.value),   // committed state
      set: (value: T) => this.state({ type: "value.set", addr, value }),
      delete: () => this.state({ type: "value.delete", addr }),
    };
  }
  list<T>(addr: List<T>) {
    return {
      append: (value: T) => { this.state({ type: "list.append", addr, value }); return this.seq; },
      remove: (element: number) => this.state({ type: "list.remove", addr, element }),
      clear: () => this.state({ type: "list.clear", addr }),
      read: (q: ListQuery) => this.storage.readList(addr, q),
    };
  }

  entry(kind: EntryKind, conversationId: number, draft: EntryDraft): number {
    this.sawEntry = true;
    const id = this.seq + 1;
    const { head, ...fields } = draft;
    const entry: Entry = {
      id, conversationId, kind: kind.kind, ...fields,
      ...(head === "self" ? { head: id } : head === undefined ? {} : { head }),
    };
    this.push({ type: "entry", entry });
    return id;
  }
  task<S extends TaskStateBase, R extends TaskRoles<S>>(
    kind: TaskDefinition<S, R> & { readonly initialStatus: S["status"]; readonly turn?: true }, spec: {
    conversationId: Id; state: S; after?: Id[]; background?: true; owns?: Id[];
  }): Id {
    const id = this.seq + 1;
    if (spec.state.status !== kind.initialStatus) throw new Error(`initial status must be ${kind.initialStatus}`);
    const role = getOrThrow(kind.roles[spec.state.status]);
    this.push({ type: "task", task: { id, kind: kind.kind, role, turn: kind.turn, ...spec } });
    return id;
  }
  patch(task: Task, status: string, payload: JsonObject) {
    if (Object.hasOwn(payload, "status")) throw new Error("payload must not include status");
    const role = this.roleFor(task.id, status);
    if (role === "terminal") throw new Error(`${status} requires settle`);
    this.push({ type: "patch", id: task.id, role, state: { ...payload, status } });
  }
  settle(task: Task, status: string, payload: JsonObject) {
    if (Object.hasOwn(payload, "status")) throw new Error("payload must not include status");
    if (this.roleFor(task.id, status) !== "terminal") throw new Error(`${status} is not terminal`);
    this.push({ type: "settle", id: task.id, role: "terminal", state: { ...payload, status } });
  }
  private roleFor(id: Id, status: string): TaskRole {
    /* kind from transaction view; reject unknown status and reserved orphaned */
  }

  getTask = this.storage.getTask; getTasks = this.storage.getTasks;   // reads: committed state
  getEntry = this.storage.getEntry; getEntries = this.storage.getEntries;

  batch(): CommitBatch { return { kind: "main", writes: this.writes }; }
}

// The line owns admission, persistence, indexes and decisions; callbacks run afterward.
async commit<T>(plan: (tx: Tx) => T | Promise<T>, call: Call): Promise<T> {
  const completed = await this.line.run(async () => {
    const invocation = call.value(invocationKey);
    this.checkAdmission(invocation);               // phase, exact running identity, method and mark (§6.3)
    const tx = new Tx(this.storage, this.kinds);
    const result = await plan(tx);
    const batch = tx.batch();
    this.validate(batch, invocation);              // whole-batch authority and structural invariants
    if (batch.writes.length === 0) return { result, actions: [] };
    await this.storage.commit(batch);
    const actions = this.driver.applyBatch(batch); // apply ALL writes; then reserve affected work/waiters
    this.publishState(batch, actions);             // fold views, enqueue delivery; no listener execution
    return { result, actions };
  });
  this.dispatch(completed.actions);                // start effects, signal controllers, deliver callbacks
  return completed.result;
}

ConversationTx is the same with conversationId bound. ScratchTx is the value/list half with the batch tagged scratch. Builders may be async to await storage reads on the line; no other line operation can interleave. Reads (including Tx/ScratchTx value/list/entry/task reads) return Promises; writes are synchronous, buffered and validated in order. Never await an external effect, a driver waiter or another line operation inside the builder. Ids are final when returned, and a throw discards everything. The outer promise resolves with the closure's value after persistence and publication, which is when ids may escape. ConversationHandle.commit binds the conversation; TaskRuntime.commit is the same for task code.

9.3 Task and tool integration

Provisional API: the sink, tool runtime and output-state shapes below are the current sketch. Simplify them together with previews before implementing. Preserve streaming text/images, typed details, bounded capture/spill, diagnostics, usage, control outcomes, jobs and cooperative cleanup; this section does not settle the final division of responsibilities or arbitrary-work adoption.

interface Tool<TParams, TDetails extends JsonValue> {
  readonly name: string; readonly description: string; readonly parameters: JsonSchema<TParams>;
  readonly output?: ShellOutputLimits;               // retained window for text output
  readonly replay?: "safe" | "never";
  execute(toolCallId: string, params: TParams,
          out: ToolOutput<TDetails>, runtime: ToolRuntime, call: Call): Promise<void>;
}

// the sink; everything a tool produces goes through it (mobile-handoff/01-harness/04-tool-output)
interface ToolOutput<TDetails extends JsonValue> {
  write(text: string): void;                         // append to the text block
  image(image: ImageContent): void;                  // images are never windowed
  replace(text: string): void;                       // replace the retained text wholesale
  capture(metadata: ShellOutputMetadata): void;      // truncation totals / spill path, no text resent
  readonly details: TDetails;                        // the tool's own details object; mutate it
  usage(usage: Usage): void;                         // accumulates
  addTools(names: string[]): void;                   // replaces; post_tools updates selected tools (§5.5)
  terminate(value: boolean): void;                   // replaces; orthogonal to how execution ended
  handoff(message: string): void;                    // replaces; new_context asks for a reset (§8.3)
  delegate(job: Id): void;                           // this call's work continues as that job (§8.6); generic, so UIs can follow it
  diag(severity: "info" | "warn" | "error", message: string, code?: string): void;   // commentary, see below
}

interface ToolRuntime {
  readonly taskId: Id;                               // the tool task; identity for origin/for links
  readonly budgetMs: number;                         // blocking budget (§8.3)
  readonly env: ExecutionEnv;                        // FileSystem & Shell
  commit<T>(plan: (tx: ConversationTx) => T | Promise<T>, call: Call): Promise<T>;
  conversation(id: Id, call: Call): Promise<ConversationHandle | undefined>;
  abortTask(id: Id, call: Call): Promise<void>;
  waitForTask(id: Id, options: { budgetMs?: number } | undefined, call: Call): Promise<boolean>;
  jobOutput(id: Id, call: Call): Promise<ShellOutputView & { exitCode?: number }>;
}

// what the live sink folds to; recorded on the tool task and used to build the result entry
interface ToolOutputState {
  content: (TextContent | ImageContent)[];
  details: JsonValue;
  usage?: Usage;
  addedTools?: string[];
  terminate: boolean;
  handoff?: string;
  delegated?: Id;
  diags: readonly { severity: "info" | "warn" | "error"; message: string; code?: string }[];
  truncation: ShellOutputTruncation;                 // totals over everything ever written
}

type ToolResultData = Omit<ToolOutputState, "content">;

execute returns nothing and reports failure by throwing; the harness sets isError when it rejects, and a tool may throw anything. terminate is orthogonal to how execution ended, so a failing tool can still stop the turn (out.terminate(true); throw error). Usage and addTools go through the sink rather than a return value so that a replayed tool can seed from durable state. The sink owns its scratch promises: it handles expected post-mark/close rejections, reports real persistence errors and drains pending callbacks before the invocation returns. Raw plugin scratch writes must be awaited/caught. The sink's text ops (write, replace, and the slide the env emits for a moving tail) are what scratch and the watch stream carry, so a UI shows output live without the tool doing anything.

Diagnostics are a channel, not text. Commentary about a call (it was truncated, the output spilled to a file, the path was resolved differently, the file changed on disk since it was read, the search stopped at 500 matches) goes through out.diag, never into the text the model reads as the tool's data. The harness emits the ones it owns: the sink calls diag itself when it truncates or spills, the path resolver when it corrects a path, the blocking budget when a call continues as a job. A tool adds only what it alone knows. Tool settlement renders the data first and the diagnostics after it, delimited, and stores that message on the result entry:

…last line of the file
<harness>
[warn] output truncated: 2,000 of 51,204 lines shown; full output at /tmp/pi/out-4421.log
</harness>

so the model can parse tool output as data, a plugin can post-process it without stripping notices it doesn't know, and a UI renders diagnostics as callouts by severity. isError says whether the call failed; a warn diag does not change it. Tool settlement stores the exact rendered result as entry model; the non-message fields of ToolOutputState become ToolResultData for typed logic and rendering. The transcript therefore records exactly the commentary the model saw without copying the message into data.

9.4 Watch

The tool-facing preview API below is provisional (§9.3). Delivery coalescing is deferred, not an initial implementation requirement; the current per-commit publication rules remain unchanged.

The transport is the commit stream, gap-free and replayable. The interface is typed events derived from it by the harness, and a view the harness keeps current, so a client renders and never parses commits:

interface WatchHandle<View, Event> {
  readonly view: View;                     // captured on the line at watch(); folded before each event is delivered
  start(listener: (event: Event) => void): void;   // delivers everything since the capture, then live
  resnapshot(call: Call): Promise<View>;   // fresh capture, same subscription (when lagging)
  unsubscribe(): void;
}

interface ConversationView {
  readonly conversation: Conversation;
  readonly entries: readonly Entry[];      // the last `tail` entries; older ones via entries(id, { before })
  readonly context: readonly Id[];         // the derived context, as ids
  readonly tasks: readonly Task[];         // live tasks, typed by kind (retry attempt, deferred handle, ToolOutputState ... in state)
  readonly inbox: readonly Element<QueuedInput>[];
  readonly values: ReadonlyMap<Address, JsonValue>;   // every value the registered kinds declare in config, plus any asked for
  readonly previews: ReadonlyMap<Id, JsonValue>;      // per live task: the kind's tracked preview (§5.2)
  readonly faulted: boolean;
  readonly readAt: Id;
}

type InboxOp =
  | { readonly type: "append"; readonly item: Element<QueuedInput> }
  | { readonly type: "remove"; readonly id: Id }
  | { readonly 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 }
  | { type: "task_output";  task: Id; ops: readonly DeltaOp[] }           // ops on the task's preview; applied to view.previews
  | { type: "value";        addr: Address; value: JsonValue | undefined }
  | { type: "inbox";        ops: readonly InboxOp[] }
  | { type: "context";      ids: readonly Id[] }                  // a head or edit changed derived context
  | { type: "fault";        error: unknown } | { type: "closed" };

const w = await h.watch(c.id, { tail: 100, values: [myPlugin.config.mode] }, call);
render(w.view);
w.start(event => render(w.view, event));    // w.view is already folded when the listener runs

The view is authoritative and the events are wake-ups: a renderer is apply(view), diffing against what it last drew (entries keyed by id, tool components keyed by task id), and it may ignore an event's payload and still be correct. The payload is for skipping work on a hot path (task_output: redraw one component from its preview) and for logs and tests.

Because every piece of work is a task of a known kind, the four task events carry what the lane harness needed a name per case for, and cover plugin kinds without anyone adding one:

lane event v3
run_start / run_end task_start / task_end where generationKind.is(task)
retry_scheduled, run_suspend task_update with status retry_wait (attempt, notBefore) / deferred (handle) in state
message_update task_output on the generation; view.previews holds the partial assistant message
tool_start / tool_update / tool_end task_start / task_output / task_end on the tool task; ToolOutputState in state
compaction_start / compaction_end task_start / task_end where collapseKind.is(task); reason and summary id in state
navigation_start / end a conversation created (session watch); with abort, task_end aborted on the source
config_update, value_update value
queue_update inbox
entry_added, message_end entry

Events are delivered per commit, in commit order, all events of one commit before any of the next, so a terminal task and its successor never show as an idle gap. watch captures the view and registers the subscription in one serialized step on the line, so nothing falls between them. Task-output watch events travel as Chord delta ops only; durable scratch uses ordinary records (§7.4). For watch delivery, the folded preview lives in the view, never in the event. resnapshot marks a barrier on the delivery tail while the line holds the new capture, so calling it from inside the listener neither deadlocks nor refolds stale state. Buffering is bounded; a lagging client gets a fault and calls resnapshot. A listener never awaits a commit on the same line.

Events are proportional to their change, and the view fold is a generic reducer, applyEvent(view, event), exported by the harness package and needing no kinds: append the entry, upsert the task, apply task-output ops to previews[task], apply inbox ops, set the value. Inbox operations are folded across one commit before delivery, so an idle acceptance's same-commit append and remove emits no inbox event. The harness maintains w.view with it, and a UI in another process runs the same reducer on the same events; nothing is re-diffed for the wire:

// worker                                                    // ui process
const w = await h.watch(c.id, { tail: 100 }, call);                on("view",  m => { view = m.view; ui.apply(view); });
send({ type: "view", view: w.view });
w.start(e => send({ type: "event", event: e }));             on("event", m => { applyEvent(view, m.event); ui.apply(view, m.event); });

The replication contract, so a reducer in another language can't drift: events of one commit travel as one message; an Address serializes to one canonical string key; and preview ops are Chord delta ops (packages/chord/src/delta, mobile-handoff 01-delta): r replace, s set, d delete, a append, t truncate, p splice, with the wire codec's interning optional for thin clients. That applier is already the mobile port; the reducer adds its ten cases on top and the harness package's implementation is the reference, tested by porting delta.test.ts first.

task_output.ops are ops on the task's preview, which is a Chord delta tracker the kind mutates in place (runtime.preview.state): the generation applies each stream event to a tracked partial message, the tool's preview is the sink's tracked state, a job's is the same. The harness flushes the tracker after each scratch commit; a streamed token is one ["a", path, delta], computed from dirty paths and a memcmp, never a recomputed object (a reassigned preview would flush as a full replace). On attach or reopen the harness builds the preview once with preview.init(scratch) and the first flush is the base. Fan-out to several in-process watchers uses applyImmutable or copies the batch, since apply adopts r payloads.

The lane-shaped snapshot (operation, runningTools, retry, deferred, streamingMessage) is a pure function of view.tasks and view.previews; a renderer that wants it computes it. h.watch(c.id, { raw: true }) delivers the commits themselves, for a receiver that has a harness and runs the fold.

There is a session-level watch for what isn't one conversation's:

interface SessionView {
  readonly conversations: readonly Conversation[];
  readonly values: ReadonlyMap<Address, JsonValue>;     // session-scoped: pi.usage.totals, pi.session.name, ...
  readonly faulted: boolean;
  readonly readAt: Id;
}
type SessionEvent =
  | { type: "conversation"; conversation: Conversation; change: "created" | "deleted" }
  | { type: "value";  addr: Address; value: JsonValue | undefined }
  | { type: "usage";  row: UsageRow; totals: Usage }
  | { type: "report"; task?: Id; kind?: string; method?: string; error: unknown } // hook/task errors
  | { type: "fault";  error: unknown } | { type: "closed" };
harness.watch(call: Call): Promise<WatchHandle<SessionView, SessionEvent>>;

9.5 End to end

const h = await Harness.open(storage, options, call);
const c = await h.root(call);
const w = await h.watch(c.id, { tail: 100 }, call);  render(w.view);  w.start(e => render(w.view, e));

const answer = await c.prompt({ input: "Inspect the parser" }, call);

const driving = c.drive(call);  await c.abort(call);  await driving;      // durable intent, then cleanup

const child = await c.spawn({ prompt: "Inspect only tests", context: "fresh",
                              values: { inherit: [model] } }, call);
await h.drive(call);                                                  // drives the child too
console.log(await h.conversation(child, call));

const alt = await c.fork({ at: answer.id }, call);
await alt.prompt({ input: "Try a different implementation" }, call);        // source remains untouched

w.unsubscribe(); await h.close(call);
const h = await Harness.open(storage, {
  models,
  tools: [readTool, writeTool, bashTool, ...pluginTools],     // subagent and job tools are built in
  kinds: { entry: pluginEntryKinds, task: pluginTaskKinds },  // added to the built-ins
  replace: { generation: myGenerationKind },                  // swap a built-in by name; same statuses, hook names
  rootValues,
}, call);
h.kinds.generation;  h.kinds.tool;  h.kinds.postTools;  h.kinds.collapse;  h.kinds.job;   // whatever is registered under the name

The built-in entry kinds (user, assistant, tool_result, system, notice, summary, handoff, reset) and task kinds (generation, tool, post_tools, collapse, job) are registered by open itself, because accept, prompt, queueInput and collapse need them to write and type new records. Reading context does not need entry kinds. A task-kind replacement is registered under the built-in's name and must understand its persisted statuses and keep its hook names, so existing handlers keep working; a wrapper that delegates to the original is the usual shape. h.kinds.<name> is how handles and plugins refer to whatever is currently registered, so c.settings is the config of the generation kind in use, not of a specific import.

Harness.open takes storage, models, tools, additional kinds, replacements, section definitions and initial root values. It creates the root only for empty storage and initializes rootValues in that same commit; on reopen it ignores those initial values and preserves durable configuration. Tool registration never implicitly selects tools. The host explicitly supplies model/thinking/selectedTools for a fresh root or configures it before generation; missing required configuration reports an error. Children inherit only the values selected by their spawn policy; historical forks inherit rewindable values at their entry cutoff. Reopen never creates work by inference.

9.6 Mutable kind and section registration

Construction accepts initial kinds: { entry: [...], task: [...] } and sections: [...] collections. Built-in section tokens are registered initially; their content is supplied by host hooks. These are initial values, not immutable registries:

await h.entryKinds.register(noteKind, call);
await h.entryKinds.replace(noteKindV2, call);
await h.entryKinds.remove(noteKind.kind, call);

await h.taskKinds.register(reminderKind, call);
await h.taskKinds.replace(reminderKindV2, call);
await h.taskKinds.remove(reminderKind.kind, call);

await h.sections.register(policySection, call);
await h.sections.replace(policySectionV2, call);
await h.sections.remove(policySection.key, call);

The registry methods retain the supplied kind/section generics. register rejects an existing name; replace requires an existing compatible definition (otherwise migrate); remove removes code, not stored entries/tasks/section values. All changes serialize on the line. Existing task calls and section preparations retain their captured implementation; future ones use current registrations. Explicit section-renderer replacement marks that section for recomputation in a later preparation; never rewrite old entries or clear a newer replacement's pending work from an older preparation.

Removing a section definition does not remove its instructions. Historical reads and untouched seeded sections use stored rendered text without the renderer. Registering it again restores typed editing. Only sections.delete(tokenOrKey) on the preparation draft requests durable section removal. Stable string keys cross restarts; function objects and token identity do not get serialized. The mutable draft is a local hook API, not a JSON wire DTO; an eventual remote-plugin adapter must provide an explicit contribution/edit protocol rather than serializing token renderers or draft callbacks.

Task-kind removal rejects while live tasks of that kind exist. At open, absent task kinds follow §6.4: foreground tasks become terminal orphaned; background tasks remain parked until registration restores their recovery implementation. Registration does not resurrect orphaned or other terminal tasks and does not implicitly attach a drive scope.

10. Validation

10.1 Backend conformance

One validated mutation stream replayed into memory, JSONL (close, reopen) and SQLite (close, reopen), compared on: current objects and live-task metadata, transcript pages and inherited prefixes, derived contexts at several boundaries, value tombstones and list clears, request keys, scratch lifetimes, next allocated sequence. Randomized small histories plus explicit deep-fork cases: fork before and after a delete, before and after a clear, before a later summary, nested source cutoffs, sticky state untouched by historical reads.

10.2 Failure and concurrency

Scenario Required
Persistence paused after construction readers see the old complete state
Accept commit durable, reply lost acceptance(requestId) returns the original conversation/input id
Main and scratch writes in one batch reject, nothing written
Torn final JSONL line only that line discarded
Malformed complete line open fails; no silent truncation
Retirement committed, unlink fails old scratch invisible
Effect returns as abort starts one invocation per task; fresh abort only after return
Post-mark main/scratch write reject before builder; sinks handle cancellation and drain callbacks
Status cycle returns to initial status epoch counts intermediate committed changes; no false fault
Host patches an owned task reject lifecycle/state mutation; abort marks remain allowed
100,000 historical children, two live tasks query only live seed/needed ancestry, no history traversal
Repeated drive and cancelled waiter one attachment; remove waiter/listener exactly once
Known dependency self-drive reject before acceptance or cycle-creating admission
Close vs task commit earlier commit completes; later mutation rejects; completion still admitted
Shutdown vs accept / idle nextRun inbox accept before mark batch or reject; queued items/results preserved
Shutdown crash before child cleanup reopened abort marks tasks only; child queues remain unchanged
Main 100, live scratch through 150, crash reopen recovers lastSeq=150; next write=151
Retired scratch still on disk ignore it even if malformed; later main settlement covers its sequences
Parent cleanup vs already-terminal job already finished is successful cleanup
Broken abort handler reject drives/shutdown, signal/join and close; replace kind before recovery
Parallel tools settle in either order post_tools starts once
Abort vs post_tools settlement no unmarked successor
Child finishes while its owning tool is marked tool settles aborted, not done
Two overlapping drives one execution per task
A drive caller cancels its wait other callers and tasks unaffected
Summary lands after a competing head/reset rejected as stale; intervening edits do not stale it
Withdraw vs land of the same inbox item one wins on the line; one terminal input result
Abort vs generation/post_tools group transfer every active input resolves once; no unmarked owner escapes
Several inputs share one generation every result points to the same final answer entry
Overflow retry while collapse runs no live task waits on the collapse
Watch registration races a commit base includes it or the stream delivers it

Faux providers, fake processes and clocks, storage barriers; both orders of every race; failed commits consume no ids; old task ids remain queryable after compaction.

10.3 Performance

Workloads: many short turns with and without tools; frequent compaction plus cold reopen; parallel and sequential tools; large streaming output; deep forks with repeated historical reads; many finished child conversations with few live tasks; many live background tasks; queued input and cancellation. Measure CPU, wall latency, RSS and heap, bytes on disk including scratch and WAL, query counts and rows decoded. Report backend-owned memory separately from live execution data; JSONL growth with history is expected, SQLite must not keep a full-history copy.

The pico2 prototype's 9.7 s against 35.9 s on 2,000 faux turns is a reason to measure this design the same way, not a result for it. Its lessons are the ones above: cheap live queries, batched reads, small writes, no history scans on the hot path.