pi-mono/packages/agent/docs/pico/pico-usage-guide.md

77 KiB

pico

Durable agent harness for pi: one session file, any number of conversations, every piece of work recorded as a task that survives a crash, and a view any UI can render.

Note: this guide is about using the harness. pico-simple-handoff.md is the sole normative implementation specification and the reference for why things are the way they are. Provider/system, hook, ordinary-tool, job and subagent facades remain gated where marked; their examples show intended behavior, not permission to guess unsettled APIs. Required task output, storage and cancellation guarantees are retained; the separate steps proposal has not been adopted.

Table of Contents

The Mental Model

A session is one storage file (or one row set in SQLite). It holds conversations. A conversation has three things:

  • a transcript: an append-only list of immutable entries. User messages, assistant messages, tool results, summaries, system instructions, and anything a plugin wants to record. Entries are never edited or reordered.
  • tasks: the units of work. Generating a response is a task, running a tool is a task, so is compacting, running a background process, or anything a plugin wants done. A task has immutable input, pending/running/terminal lifecycle, optional complete checkpoints and optional task output. Those durable boundaries make a crash recoverable.
  • state: keyed values and lists, for the model in use, plan mode, a game board, whatever a plugin needs to remember.

What the model sees, the context, is not stored as a list. Each entry may store model messages, a head boundary, and edits that omit or replace earlier model messages. The harness prepends the newest head, reads forward from its stored boundary, folds retained edits, then performs request-local tool and provider normalization. Compaction appends a head; tool-result pruning appends an edit. Forks, compaction and reset never mutate old entries.

Object identity and write order are separate:

type Id = number;   // Storage-minted stable identity for committed conversations, entries, tasks and list elements
type Seq = number;  // storage-assigned committed write order

Creation and list-append builders synchronously call Storage.nextId() while holding the Session line. A failed callback discards its writes but burns those IDs in the current open binding. They become valid object identities only after commit succeeds. Reopen initializes after the greatest committed creation/list-append ID and may therefore reuse IDs that were minted but never committed by an earlier process; committed IDs are never reused. commit receives no allocator argument. Each write receives a Seq. Historical entry-ID lookup resolves that entry's sequence internally. Watch barriers and commit envelopes use Seq, never object IDs.

All main writes happen through commits: a closure that runs on the Session's single mutation line, where everything inside it lands together or not at all. Tasks run concurrently; mutations never do.

Opening is inert. resume() activates one scheduler for every eligible task in every conversation. It stays active while the Harness is open, and every successful main commit coalesces a scheduler kick. Foreground and background tasks both execute; foreground only controls idle and ordinary abort reach. A UI watches a conversation and gets one authoritative view it can render directly.

Calls and cancellation

Every asynchronous harness, conversation and task-runtime operation takes a required final Call. Call is a type alias of Chord Context: it carries an abort signal, telemetry parent and, for task code, a private typed invocation identity. It is not model context. No casts or admission helpers are needed. Pure accessors, synchronous registrations and methods inside a transaction take no Call.

import type { Call } from '@earendil-works/pi-agent';
import { BACKGROUND_CONTEXT, withCancel } from '@earendil-works/chord/context';

const call: Call = BACKGROUND_CONTEXT; // host call, without cancellation
const { context: waitingCall, cancel } = withCancel(call);
const input = await conversation.send({ content: 'Inspect the parser' }, call);
const waiting = input.wait(waitingCall);
cancel();                            // removes this waiter; does not abort durable work
await waiting;                       // rejects with cancellation

Tasks receive (task, runtime: TaskRuntime, call: Call) and forward call. Tools receive (toolCallId, params, out, runtime: ToolRuntime, call: Call). Environment/provider/hook operations interpret the signal; custom handlers must cooperate. Derive a Call for nested telemetry or a tighter deadline and pass it onward. The scheduler does not inherit a waiter caller's signal into task execution.

The line reads task identity through a private createContextKey<Invocation>; Chord returns the correct type from call.value(key). Derived calls preserve that exact object. Stale task writes reject. No invocation identity is serialized over RPC; trusted host bindings supply it locally. Deliberately using an unrelated host Call or ignoring cancellation is an in-process escape, not something a facade or type can prevent.

Installation

npm install @earendil-works/pi-agent

Quick Start

import { Harness, JsonlStorage, systemSections, type Call } from '@earendil-works/pi-agent';
import { BACKGROUND_CONTEXT } from '@earendil-works/chord/context';
import { readTool, writeTool, bashTool } from '@earendil-works/pi-agent/tools';
import { generationKind } from '@earendil-works/pi-agent/kinds';
import { builtinModels } from '@earendil-works/pi-ai/providers/all';

const call: Call = BACKGROUND_CONTEXT;

// One file per Session. Reopening restores it inert; send/wait/resume activates recovery.
const storage = await JsonlStorage.open('./session.jsonl');

// The built-in kinds (generation, tool, post_tools, collapse, job; the entry kinds) and the
// subagent and job tools are registered by open. You add models and the tools you want. Nothing runs yet.
const h = await Harness.open(storage, {
  models: builtinModels(),
  tools: [readTool, writeTool, bashTool],
  // Address/value pairs, applied only when creating the root; reopening preserves stored settings.
  rootValues: [
    [generationKind.config.model, { provider: 'anthropic', modelId: 'claude-opus-5' }],
    [generationKind.config.thinking, 'high'],
    [generationKind.config.selectedTools, ['read', 'write', 'bash']],
  ],
}, call);

const c = await h.root(call);

// Configuration is durable state; this hook edits the prepared section payloads.
// The harness stores changed payloads and rendered system messages before each request.
c.hooks.on(generationKind, 'system_instructions', ({ sections, config }, call) => {
  sections.set(systemSections.identity, 'You are a careful engineer working in this repository.');
  sections.set(systemSections.environment, { cwd: process.cwd() });
  return { tools: h.tools.select(config.selectedTools) }; // complete tool definitions
}, { subtree: true });

// Watch the conversation. `view` is a plain object a UI renders from; subscribe whenever you like.
// The view is complete as of capture and commit envelopes follow from its Seq barrier.
const w = await h.watch(c.id, { tail: 100 }, call);
w.start(delivery => {
  if (delivery.type !== 'commit') return;
  for (const event of delivery.commit.events) {
    if (event.type === 'task_output') {
      const output = w.view.taskOutputs.find(value => value.id === event.id);
      process.stdout.write(renderTaskOutput(output));
    }
    if (event.type === 'entry') console.log(`\n[entry ${event.entry.id} ${event.entry.kind}]`);
  }
});

// Admission is durable and resumes the whole Session; waiting observes this input's explicit result.
const input = await c.send({
  requestId: 'cli-1',
  content: 'Inspect the parser and list the public API',
}, call);
const result = await input.wait(call);
const answer = result.status === 'done' && result.answer
  ? await h.getEntry(result.answer, call)
  : undefined;
console.log(answer?.model?.[0]?.content);

w.unsubscribe();
await h.close(call);   // cancels nothing durable; reopen restores here, then send/wait/resume continues

Run it, kill it in the middle, run it again: the second run recovers whatever was in flight (publishing a partial answer, rerunning or reporting an interrupted tool) and continues. That is the whole point.

Snippets below assume h, c and call set up like this. Optional options before Call are passed as undefined when unused. A task or hook always forwards its supplied Call, not this host root.

Sessions and Conversations

Opening

Harness.open(storage, options, call) takes the storage backend and everything that defines behaviour:

option what it is
models a pi-ai Models collection
tools what the model may call, besides the built-in subagent and job tools; a ToolRegistry or an array
kinds ordinary plugin entry and task kinds, added beside the fixed built-ins; protected pi.* names reject
rootValues explicit initial root configuration, applied only in the fresh root's creation commit
sections initial custom typed section definitions, in addition to built-ins

Registries supply implementations, not selections. Registering read/write/edit/bash does not select them automatically. Supply initial model/thinking/selectedTools through rootValues, or configure the fresh conversation before generation. On reopen, rootValues is ignored: durable configuration wins. Children inherit the values selected by their spawn policy; forks inherit rewindable configuration at the fork point. Missing required generation configuration is an error.

The built-in kinds are fixed and installed by open itself because send, safe-boundary admission and collapse cannot work without them. They cannot be registered, replaced or removed. Their readonly witnesses remain available through h.kinds for hooks and inspection:

h.kinds.generation   // config: model, thinking, selectedTools, ...; hooks: system_instructions, before_request, on_yield
h.kinds.tool         // hooks: before_tool, after_tool
h.kinds.collapse     // hooks: before_collapse
h.kinds.job

Plugins extend these operations through their typed hooks and may register ordinary durable task kinds for side work. They cannot supply an alternative generation/tool/post_tools graph or obtain the core transcript transaction surface.

Open checks the recorded kind strings without scanning the transcript and reports live work, but starts nothing. An unregistered historical entry kind is reported, not rejected: its stored model, head and edits still build context, while its typed data and custom renderer are unavailable. Open reconciles missing live task kinds before returning:

const { pending, running, orphaned, unknownEntryKinds } = await h.inspect(call);
// pending:  tasks whose effects never began
// running:  tasks the prior process left in flight; recover() handles them after resume
// orphaned: live tasks whose kind was missing and was terminalized during open
// unknownEntryKinds: stored entries that retain generic facets but lack typed plugin narrowing

What Happens on Reopen

Nothing runs during open. Resume once to start or recover every eligible foreground and background task in every conversation. Activation is idempotent and returns after initial dispatch scheduling, not after idle:

const h = await Harness.open(storage, opts, call);
await h.resume(call);

send, InputHandle.wait, collapse, task/input abort and foreground-idle waits also ensure this session-wide resume. Use explicit resume() when restored work must progress and no new progress-seeking command will be issued. A read, inspection, fork without input, value lookup or watch capture does not activate recovery.

Closing

await h.close(call);         // cancel in-process work, write nothing; everything resumes on the next open
await h.shutdown(call);      // mark live tasks, wait for abort cleanup, close; queued input survives

close is the normal exit: it stops admission on the commit line, then signals and joins owned invocations outside it, without writing task outcomes. Earlier line operations have already finished; later mutations reject. Close waits for actual invocation completion, regardless of caller cancellation. An uncooperative task can delay it indefinitely.

shutdown closes normal admission and atomically marks live tasks only. Queued input and its queued result records remain stored, including in idle conversations. Fresh abort handlers resolve their already-running input groups and finish cleanup. Built-in child cleanup marks tasks only, never drains child queues, including after a crash and reopen. Shutdown waits for both live tasks and running calls to disappear before closing. Preserved queues create no work merely from reopen or resume; a later explicit idle send consumes queued writes/follow-ups/steering according to the normal boundary policy.

Once admitted, caller cancellation does not abandon shutdown. Repeated lifecycle calls share completion; explicit close interrupting shutdown makes shutdown reject. Task calls cannot invoke host lifecycle methods.

Cloudflare Durable Objects

Use one Durable Object per Pico Session: one open Harness, one session scheduler, and any number of root, forked and owned conversations. A request that calls send activates the scheduler. An alarm or one logical Cloudflare Task wake should open Pico and call resume, which recovers all eligible Session work.

Platform wake-up is not Pico effect recovery. Do not create one Cloudflare lane/task per conversation, and do not use Cloudflare replay as a second authority for provider/tool effects. If durable Pico admission and the platform wake cannot share a transaction, add an ordered handoff or idempotent retry so acknowledged work can always cause another wake. Pico checkpoints and recover() remain authoritative across process and platform invocation limits.

Configuration

Settings Exported Alongside Kinds

There is no generic settings object and generic TaskKind does not capture configuration. Built-in/product packages export typed address bundles alongside their kinds, so each address is still spelled once:

generationKind.config
// {
//   model:         conversationValue<ModelRef>('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 }),
// }

A UI can list built-in configuration from these exported bundles. A plugin defines and exports its own addresses the same way; those values remain conversation state and are never copied wholesale into a task.

Reading and Writing

// all of a kind's values, one batched read
const { model, thinking } = await c.config(generationKind).get(call);

// some of them, one commit
await c.config(generationKind).set({ thinking: 'low' }, call);

// `settings` is config(generationKind), because that is what every UI touches
await c.settings.set({ model: { provider: 'openai', modelId: 'gpt-5.6' } }, call);

// one value, one point read, by its declared address
const tools = await c.value(generationKind.config.selectedTools).get(call);

A change is a normal commit: it appears in the watch stream as a value event, and the next generation picks it up. Nothing is cached in memory that could disagree with storage.

What Is Rewindable

A value is either rewindable (its history is kept, a fork at an entry sees the value in force there) or sticky (current only; the UI's present, not the conversation's history). Model, thinking and selected tools are rewindable: forking at yesterday's answer gets yesterday's model. Something like ui.expanded is sticky.

Children created by spawn don't inherit history; they are initialized explicitly (see Subagents). Generation tasks do not copy this generic configuration into their durable input. They carry only minimal built-in effect-recovery evidence such as input IDs, context cutoff, selected model identity, attempt/deferred IDs and offered tool-name/version evidence when required. A plugin needing exact recovery persists its own compact key/version. Fork configuration remains built-in fork-aware conversation state, so a fork sees the values in force at its selected entry and local overrides do not affect the parent.

System Prompt and Tool Loadout

How It Works

Pico sends pi-ai only { messages }: no parallel top-level systemPrompt or tools. Managed system entries contain the exact rendered pi-ai messages, including complete tool additions/removals. Pi-ai owns native/fallback translation and best-effort cache preservation.

Configuration changes remain ordinary durable value writes. System entries separately record the instructions prepared for requests—not proof of delivery. Host files, discovery caches, callbacks and renderer functions are not stored. Section JSON payloads and final rendered text are stored, so a missing plugin cannot make historical requests depend on its renderer.

The target pi-ai system-message API and messages-only adapter behavior are integration prerequisites (#9116, with coding-agent integration in #9117). They were open when reviewed; this guide describes the intended contract, not a claim that those PRs already implement the agreed adapter behavior.

Answering the Hook

A typed section token names the payload and its append-time renderer:

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

const rulesSection = defineSystemSection<string[]>({
  key: 'myplugin.rules',
  render: rules => rules.map(rule => `- ${rule}`).join('\n'),
});
await h.sections.register(rulesSection, call);

Tokens use stable string keys in storage. Payloads must be JSON-representable; changing a registered payload type requires a compatible replacement or migration. The typed token supplies normal get/set inference without casts in plugin code. Built-ins export tokens through systemSections, such as identity, environment and skills; their values come from the host, not from the registry.

Each generation seeds one private ordered section draft from the last durable prepared state. Handlers run sequentially, harness-wide first and innermost conversation last, editing that same draft:

c.hooks.on(generationKind, 'system_instructions', ({ sections, config }, call) => {
  sections.set(systemSections.identity, 'You are a coding assistant.');
  sections.set(systemSections.skills, skillsCache.current); // complete typed skill data
  sections.set(rulesSection, ['Run relevant tests.']);       // authoritative base for later transforms
  return { tools: h.tools.select(config.selectedTools) };  // complete selected JSON definitions
}, { subtree: true });

The draft provides:

interface SystemSectionDraft {
  get<T>(section: SystemSection<T>): T | undefined;
  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;
}

get returns an owned copy; use set to change the draft. Existing keys retain their position; new keys append. delete is explicit—null remains a valid payload. Registered compatible definitions are required for typed get/set/wrap; deletion can use a stable key even if its definition is unavailable. No ordering configuration exists, and reordering alone emits no update.

After handlers finish, touched sections render outside the line. Wrappers apply in registration order after rendering. The frozen result is diffed against stored payloads and rendered text. Changed data with unchanged rendering produces a metadata-only system entry (model: []); changed rendering with unchanged data still produces a system-message update. No historical read runs these functions.

Sections from Plugins

A later handler can modify a built-in section's structured payload, not parse its prose:

c.hooks.on(generationKind, 'system_instructions', ({ sections }, call) => {
  const skills = sections.get(systemSections.skills); // typed skill array | undefined
  sections.set(systemSections.skills,
    (skills ?? []).filter(skill => skill.name !== 'deploy'));
});

Appending to the same key is ordinary typed get-and-set:

c.hooks.on(generationKind, 'system_instructions', ({ sections }, call) => {
  sections.set(rulesSection, [
    ...(sections.get(rulesSection) ?? []),
    'Check migration safety.',
  ]);
  sections.wrap(rulesSection, text => `Repository policy:\n${text}`);
});

This example relies on the earlier host handler resetting rulesSection to its authoritative base on every preparation. Without that reset, repeatedly appending to a persisted seed accumulates text. The whole transformation chain must produce the same result when applied again, or start from a refreshed base; individually idempotent handlers are not sufficient when they interact.

Wrappers are preparation-local. Untouched sections keep their stored rendered text, including old wrapper output when the contributing plugin disappears. Explicit set/wrap or a renderer replacement recomputes it. Refreshing the base deliberately rebuilds wrappers from currently installed handlers; we do not promise to preserve missing wrappers across that refresh.

Skills discovery can remain in the hook owner's closure or a host service. Watch local changes or poll a remote source at a bounded interval; hook calls read the cached snapshot. Failed refresh is not removal: retain the last successful snapshot. The base hook explicitly deletes its section when the source really disappears. If a handler fails and is skipped, discard its draft mutations/wrappers, not earlier handlers' changes; a half-finished refresh must not remove instructions. No discovery callback or private cache state is stored with sections.

Changing the Loadout

Configuration is durably persisted when changed:

await c.settings.set({ selectedTools: ['read', 'grep'] }, call);

The next preparation renders the final draft and compares it with previous prepared state. A transcript might look like this (readDefinition etc. mean complete JSON definitions, not executable functions):

100 user
110 system baseline: identity + rules; add read/write
120 assistant
125 config write: selectedTools=read/grep             (durable state, not a transcript entry)
130 user
140 system delta: changed rules; remove write; add grep
150 assistant

Stored baseline:

const baseline: SystemEntry = {
  id: 110, conversationId: 1, kind: 'system',
  data: { baseline: true, sections: [
    { key: 'pi.identity', action: 'set',
      value: 'You are a coding assistant.', rendered: 'You are a coding assistant.' },
    { key: 'myplugin.rules', action: 'set',
      value: ['Run relevant tests.'], rendered: '- Run relevant tests.' },
  ] },
  model: [{
    role: 'system',
    content: '## pi.identity\nYou are a coding assistant.\n\n' +
      '## myplugin.rules\n- Run relevant tests.',
    toolsAdded: [readDefinition, writeDefinition], timestamp: 1000,
  }],
};

Stored change after a plugin modifies rules:

const change: SystemEntry = {
  id: 140, conversationId: 1, kind: 'system',
  data: { sections: [{ key: 'myplugin.rules', action: 'set',
    value: ['Run relevant tests.', 'Check migration safety.'],
    rendered: '- Run relevant tests.\n- Check migration safety.',
  }] },
  model: [{
    role: 'system',
    content: 'The myplugin.rules section now reads:\n' +
      '- Run relevant tests.\n- Check migration safety.',
    toolsRemoved: [writeDefinition], toolsAdded: [grepDefinition], timestamp: 2000,
  }],
};

Tool definitions live only in SystemMessage fields, not duplicated in section data. Compare definitions structurally by name; a changed schema/description is a complete toolsAdded upsert. Removal includes the previous complete stored definition. Apply removals before additions. Tool-only changes may have empty instruction content. Hooks supplying tools replace the complete desired loadout; the last supplied list wins, and no list means no desired tools.

Explicit section deletion stores { key, action: 'remove' } and a system message saying that section no longer applies. Omitting a hook or unregistering its definition is not deletion.

Pico sends:

const request = {
  messages: [user100, ...baseline.model, assistant120, user130, ...change.model],
}; // no top-level systemPrompt or tools

For unsupported provider/model combinations, pi-ai translates system messages to <system>-bracketed user messages at their historical positions and derives any bulk wire tool declarations it needs. Pico never hoists the baseline or flattens changes into a rewritten top-level prompt. Cache preservation is best-effort; a fallback user message does not have native system priority.

Subagents Have Their Own

The exact spawn helper is gated; this example fixes the required configuration semantics. Children explicitly choose their durable configuration. Subtree handlers supply defaults, and inner hooks can replace built-in payloads or add sections:

const childId = await c.spawn({ prompt: 'Audit the tests',
  values: { inherit: [generationKind.config.model],
    set: [[generationKind.config.selectedTools, ['read', 'grep']]] },
}, call);
const child = await h.conversation(childId, call);
child.hooks.on(generationKind, 'system_instructions', ({ sections }, call) => {
  sections.set(systemSections.identity, AUDITOR_IDENTITY);
});

Definitions can be supplied initially through Harness.open(..., { sections: [...] }, call) or changed later through h.sections.register/replace/remove. Registration is mutable process state, serialized on the line; an in-flight preparation retains its definition snapshot. register rejects duplicate keys; replace is explicit and compatible; remove unregisters code without erasing stored sections. Entry/task registries follow the parallel h.entryKinds/h.taskKinds API. Replacing or removing an ordinary task kind rejects while live tasks of that kind exist; after they are terminal, replacement affects future tasks only. At open, every live task with a missing kind is terminalized as orphaned, regardless of foreground/background; registered descendants selected by cleanup policy are marked. Later registration never resurrects terminal tasks.

Compaction, Forks and Restarts

Canonical section state is reconstructed from fork-visible managed system entries back to the most recent baseline, then folded forward. Model heads and projection omissions do not erase these section payloads. This uses existing indexed kind scans, with an optional prepared-state cache. A fresh baseline checkpoints the whole state; no extra full-state value or token/renderer serialization is needed.

Consequently, restarting without a plugin retains its JSON payload and rendered text—even if compaction removed its original baseline from model context. Untouched unknown sections also appear in the next fresh baseline. Re-registering a compatible definition restores typed editing; explicit deletion is how the host removes an abandoned section.

Every generation checkpoints requestThrough, an inclusive transcript cutoff. It captures canonical section state and definitions before running hooks/renderers outside the line. Preparation then checks on the line that no managed section write changed its seed; if one did, repeat preparation. A head-only change does not stale the section data, but may require a baseline rather than a delta.

One line operation commits the system entry and inflight intent/cutoff, catches the live context cache up, and captures an immutable array of effective entry references after the full batch is durable. Later cache updates do not mutate that array or its replacement projections. Request-local transforms copy what they modify. Reconstructing an older cutoff reads storage without rewinding the live cache.

prepare through 51 → requestThrough=51; capture request snapshot
60 summary lands  → live cache changes, request snapshot does not
70 answer lands   → answer to the already prepared request

A usable model baseline must follow the newest head entry. Otherwise the next preparation appends a full baseline carrying ordinary omission edits for superseded retained managed system entries:

10 user; 11 baseline; 20 assistant; 30 user; 31 managed 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, 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 tail position. Its edits omit managed baselines/deltas, not unrelated notifications with role=system. This atomic baseline supersession is the only permitted edit of managed system projections; arbitrary omit/replace edits targeting them reject. Change their instructions through the section draft instead. Before preparation, old retained deltas remain visible. Generic head writers and context projection need no system-specific callbacks or hidden filtering.

Repeated heads use the same mechanism. Fold effective tool declarations after planned omissions; add all desired tools and explicitly remove unwanted declarations that remain in other system messages. baseline:true is pico metadata, not a reset command understood by pi-ai.

Already superseded entries omitted by the current baseline must not cause repeated baselines.

A crash after configuration changes preserves them. A crash after system append but before request preserves prepared instructions; unchanged data/rendering yields no duplicate delta. Forks inherit only their visible section history and rewindable config. Current host-source or renderer changes can produce a new prepared update, but never re-render old model messages.

// Fork the earlier loadout example at 120: baseline 110 selected read/write, before the grep change.
const b = await c.fork({ at: 120 }, call);
await b.settings.set({ selectedTools: ['read'] }, call);
const forkInput = await b.send({ content: '...' }, call); // toolsRemoved=[write]; 110 stays visible
await forkInput.wait(call);

before_request may transform a private request copy, which must remain messages-only. These changes do not mutate stored section state. The transcript is not an exact audit of arbitrary transformed requests without optional separate capture. Tool-call validation uses the actual offered definitions after transformation, plus normal implementation and permission checks.

Sending Input

send and InputHandle

send is the application-facing admission operation. It first durably accepts or deduplicates content, then non-abandoningly ensures the one Session scheduler is resumed, and returns an InputHandle; it does not wait for a model answer. Caller cancellation after admission cannot strand the accepted input.

const input = await c.send({
  requestId: 'req-42',
  content: 'Inspect the parser',
}, call);

const result = await input.wait(call); // done | unanswered
const answer = result.status === 'done' && result.answer
  ? await h.getEntry(assistantKind, result.answer, call)
  : undefined;

input.id is the stable ID of the protected inbox element allocated for acceptance, even when idle admission appends and removes that element in one commit. pi.inbox is a conversation-scoped sticky, non-rewindable list and is not inherited by forks. Ascending element IDs preserve Session admission order. Generation and post_tools carry input IDs explicitly. Results are direct protected values keyed by input ID, and request receipts are direct protected values keyed by the Session-wide request key; neither lookup scans the transcript or inbox. input.result(call) is a point read and does not activate work. input.wait(call) ensures resume, returns an existing terminal result immediately or installs one cancellable observation waiter. Cancelling that Call removes only that waiter; it neither aborts the input nor changes scheduler scope. Several waiters may join the same result.

Inbox reads return one complete current materialized ordered collection. Memory retains that collection in process, JSONL replays operations once at open and then maintains it, and SQLite reads all current indexed rows in element-ID order and may physically remove them on remove/clear. No inbox read replays history from the last clear; rewindable list history is unrelated. An admission or boundary decision commits its inbox removal together with result/receipt changes, entry placement and successor task creation in one main transaction.

A result moves queued -> placed -> done | unanswered. Idle send may collapse absent directly to placed. done names the answering entry when one was owed; an internal passive write is done with no answer. Terminal results never change.

A request key is Session-wide and first-key-wins for the Session lifetime. Lookup happens before conversation, busy mode or payload comparison. A retry through any conversation returns a handle bound to the original conversation/input and writes nothing:

const first = await c.send({ requestId: 'req-42', content }, call);
const same = await anotherConversation.send({ requestId: 'req-42', content: 'different' }, call);
console.assert(first.id === same.id && first.conversationId === same.conversationId);
const existing = await h.input('req-42', call);

Use c.waitForIdle(call) to observe that conversation's foreground cancellation closure, or h.waitForIdle(call) for all foreground tasks in the Session. These waits ensure resume but do not wait for background work. Background jobs/collapse still execute after foreground idle.

Input While Busy

A conversation is admission-busy while a live generation/tool/post_tools turn task owns transcript progress. A speculative background collapse has model-entry authority but is not admission-busy.

const follow = await c.send({ content: 'Then write the tests' }, call); // default while busy
const steer = await c.send({
  content: 'Focus on the tokenizer first',
  whenBusy: 'steer',
}, call);
mode lands asks for
steer next complete post_tools boundary, or final boundary joins the active input group when safe
followUp after the final assistant answer starts the successor input group
write next safe boundary passive transcript content; no generation

followUp is the busy-send default. While idle, whenBusy is irrelevant: send starts one current input group. whenBusy:'reject' throws without recording a request receipt. steer never mutates a provider request already snapshotted or in flight. write is a protected agent/plugin admission mode rather than a common public conversation method; built-ins compose it through internal transaction helpers when it must land atomically with other state. An idle write appends and becomes done with no task. Applications needing speculative staged context for a later explicit turn can keep it in their own sticky conversation list and fold it into that later send; Pico does not standardize that application policy.

Safe chronology is preserved in full:

send A while idle
TX[ user A; generation G1(inputs:[A]); result A=placed ]
kick -> G1

G1 returns calls X,Y
TX[ assistant calls; tool X; tool Y;
    post_tools P1(after:[X,Y],inputs:[A]); terminal G1 ]
kick -> X and Y concurrently

send B while busy -> TX[followUp B; result B=queued]
send S with steer -> TX[steer S; result S=queued]
internal write W  -> TX[write W; result W=queued]

X and Y finish in either order
TX[tool result X; terminal X]
TX[tool result Y; terminal Y]
model projection restores assistant call order

P1 terminal boundary
TX[write W; user steer S; generation G2(inputs:[A,S]); terminal P1]
kick -> G2

G2 final answer E2
TX[assistant E2; A/S=done(answer:E2); user followUp B; B=placed;
   generation G3(inputs:[B]); terminal G2]
kick -> G3

steeringMode and followUpMode independently use "all" | "one-at-a-time" and default to "one-at-a-time", matching the old lane harness. At each boundary, "all" selects every queued item of that tag and "one-at-a-time" selects only its oldest item. All writes are selected. Selected tags are merged in global inbox order; unselected items retain their relative order. Pico imposes no queue-size or drain-size bound in v1. No giant turn task and no task-per-send dependency chain exists. A logical turn remains generation -> parallel tools -> post_tools -> continuation generation, with input ownership transferred atomically at each stage. Each tool appends its own result entry atomically with terminalization. Transcript chronology may reflect parallel completion; model projection restores the assistant's call order before the continuation request.

The inbox exposes complete entry drafts to the authoritative conversation view. Any queued input can be withdrawn until placement wins:

const status = await follow.abort(call); // aborted | already_placed | not_found

Withdrawal and placement serialize. If withdrawal wins, only that element is removed and becomes unanswered/aborted; later items retain order. If placement wins, abort returns already_placed. No task dependency is rewired.

Aborting

await c.abort(call);          // active foreground graph in this cancellation ownership closure
await h.abortTask(id, call);  // one task, including a background job or schedule

Both operations first commit their durable mark/withdrawal, then non-abandoningly ensure the Session scheduler is resumed so fresh cleanup can progress. An abort mark is a durable request, not terminal settlement:

100 generation execute invocation A running
110 abort=true commits; A loses main/scratch/output write authority
    line releases; A's signal fires; provider exits; A returns
    scheduler releases A and reserves fresh abort invocation B
120 B commits display/result cleanup + full terminal aborted snapshot atomically

If normal settlement wins first, the task is terminal. If the mark wins, normal writes reject, the old invocation is joined, and only fresh abort() performs durable cleanup. Cancellation does not undo external effects.

Conversation abort marks its foreground cancellation closure, withdraws queued steer/followUp as unanswered/aborted, and preserves write. Task abort preserves all queued future input unless a specific built-in cleanup policy says otherwise. Abort terminalization never runs a normal final-answer boundary, so it cannot accidentally start a queued successor. Shutdown marks all live tasks but preserves all queued items/results.

Tool and generation kinds, not waiters or the scheduler, own their durable outcomes and input-result updates. They recover only committed checkpoint/scratch evidence; missing final usage is unknown, not zero. Runtime scratch writes reject after cancellation too: await or catch them. Harness producers drain their own pending writes and never recreate retired scratch.

Watching

Tool-facing convenience APIs remain provisional alongside the sink. Durable task output and per-commit watch delivery are normative; delivery coalescing is deferred.

The View

A UI never reads storage or parses commits. It watches a conversation and gets a view: a plain JSON object the harness keeps current, plus typed events that say what changed.

const w = await h.watch(c.id, { tail: 100, values: [myPlugin.config.mode] }, call);
w.view    // ConversationView, captured atomically with the subscription
w.start(listener);
w.unsubscribe();
// After overflow/failure, open a new watch and replace the client view atomically.
interface ConversationView {
  conversation: Conversation;
  tail: number;
  entries: Entry[];                         // newest logical fork-visible tail
  context: Id[];                            // model-visible entry IDs
  tasks: Task[];                            // live tasks directly in this conversation
  taskOutputs: WatchedTaskOutput[];         // shared live generation/tool/job/collapse output
  inbox: Element<QueuedInput>[];            // queued followUp/steer/write
  values: WatchedValue[];                   // exactly the requested values
  readAt: Seq;                              // coherent storage sequence barrier
}

Task output is where streaming lives. A generation output contains its partial assistant message; tool and job outputs contain their current output state. The same output ID may be referenced by more than one live task during durable handoff. Entries, tasks, task outputs, inbox and values are captured and updated as one authoritative replicated view.

Events

type InboxOp =
  | { type: 'append'; item: Element<QueuedInput> }
  | { type: 'remove'; id: Id }
  | { type: 'clear' };

type ConversationEvent =
  | { type: 'entry';       entry: Entry }
  | { type: 'task_start';  task: Task }
  | { type: 'task_update'; task: Task; previous: Task }
  | { type: 'task_end';    task: Task }
  | { type: 'task_output'; id: Id; kind: string; delta: Op[] }
  | { type: 'value';       value: WatchedValue }
  | { type: 'inbox';       ops: InboxOp[] }
  | { type: 'context';     ids: Id[] };

interface CommitEnvelope {
  first: Seq;
  last: Seq;
  events: ConversationEvent[];
}

The view is authoritative and the event is a wake-up: when the listener runs, w.view has already been folded. Deliveries are whole commit envelopes ordered after view.readAt. Inbox operations are combined per commit; an idle send's append and immediate remove emits no inbox event. A renderer may ignore the event payload entirely and be correct. Because every piece of work is a task of a known kind, four task events cover what used to need a name per case:

you want to know look at
currently in a turn view.tasks contains a fixed generation/tool/post_tools task after folding the whole commit; a continuation generation is not a new logical turn
a retry is scheduled task_update, checkpoint phase retry_wait, attempt and notBefore
the response is streaming task_output on the generation's output ID; view.taskOutputs
a tool is running / its output task_start / task_output / task_end on the tool task
compaction started / ended task_start / task_end where collapseKind.is(task)
the model changed value with addr === generationKind.config.model
queued input changed inbox

Events of one commit arrive together, in order, so a finished task and its successor never show as an idle gap.

Rendering

A renderer is a function of the view, diffing against what it last drew. Settled entries are keyed by id and only appended; live things are keyed by task id, and a tool block keeps its key when the task settles and its result entry appears, so nothing is torn down and rebuilt:

function render(view: ConversationView) {
  transcript.sync(view.entries);
  const outputById = new Map(view.taskOutputs.map(output => [output.id, output.value]));
  const valueByAddress = new Map(view.values.map(value => [value.address, value.value]));

  const gen = view.tasks.find(t => generationKind.is(t));
  streaming.set(gen?.output ? outputById.get(gen.output.id) as AssistantMessage : undefined);
  status.set(
    gen?.checkpoint?.phase === 'retry_wait' ? `retrying (attempt ${gen.checkpoint.attempt})` :
    gen?.checkpoint?.phase === 'deferred'   ? 'waiting for provider' :
    view.tasks.some(collapseKind.is) ? 'compacting…' : undefined);

  for (const t of view.tasks.filter(toolKind.is))
    toolBlocks.upsert(t.id, { call: t.input.call, phase: t.checkpoint?.phase ?? t.status,
      output: t.output ? outputById.get(t.output.id) as ToolOutputState : undefined });

  for (const t of view.tasks.filter(jobKind.is))
    jobBlocks.upsert(t.id, { tool: t.input.origin?.tool ?? 'job',
      output: t.output ? outputById.get(t.output.id) as ToolOutputState : undefined });

  queue.set(view.inbox.map(i => i.value));
  working.set(view.tasks.some(t => !t.background));
  statusLine.set({ model: valueByAddress.get(generationKind.config.model) });
}

Tool components are registered once per tool name and fed one shape, ToolOutputState, whether it comes from a live tool task's output, a settled tool_result entry, or a job the tool started (input.origin.tool says which component).

Remote Clients

The view is plain JSON and every event is proportional to its change, so a process without a Harness (mini's TUI, a phone) runs the same fold over complete commit envelopes. applyConversationCommit(view, commit) is exported and needs no kinds; task-output ops are Chord delta ops, which the client applies with the same module. Keep the original atomic watch subscribed. Do not snapshot, unsubscribe and invent a separate stream cursor; reconnect after overflow with a fresh atomic watch and replace the client view.

// worker                                               // UI process
const w = await h.watch(c.id, { tail: 100 }, call);     on('view', m => { view = m.view; render(view); });
send({ type: 'view', view: w.view });
w.start(delivery => send({ type: 'delivery', delivery }));
                                                       on('delivery', m => {
                                                         if (m.delivery.type === 'commit')
                                                           view = applyConversationCommit(view, m.delivery.commit);
                                                         render(view);
                                                       });

Session Watch

What isn't one conversation's: the conversation list, session values, usage totals, faults, and reports (a hook threw, a task kind misbehaved and was stopped).

const sw = await h.watch(call);
sw.view.conversations; sw.view.values; sw.view.readAt;
sw.start(delivery => {
  if (delivery.type !== 'commit') return;
  for (const event of delivery.commit.events)
    if (event.type === 'conversation') tree.refresh();
});

Forks

A fork is a new conversation whose transcript starts as a shared prefix of the source. Nothing is copied and nothing in the source is deleted. It carries the context, canonical prepared instructions and rewindable values visible at that entry. Its next preparation may append changes from current host sources; it does not rewrite inherited messages.

const alt = await c.fork({ at: answer.id }, call); // source work keeps running independently
const altInput = await alt.send({ content: 'Try a different implementation' }, call);
await altInput.wait(call);

const back = await c.fork({ at: earlier.id, abort: true }, call); // "go back": aborts the source's foreground first

Any transcript entry is a valid fork point, including an assistant with unanswered tool calls or one of several results. The request projection supplies missing results for a successful incomplete exchange without inheriting or executing the source tasks. Which conversation a UI treats as "current" is the UI's business; the harness only has conversations.

const all = await h.conversations(undefined, call);
const independent = all.items.filter(x => x.owner === undefined);   // root and forks; same Session scheduler
const children = await h.conversations({ parent: c.id }, call);           // forks of c; owned children use ownedFrom

Compaction and Reset

Compaction appends a summary entry with its model message and first retained entry ID stored on the entry. The context becomes the summary followed by the transcript from that boundary. Manual speculative compaction is background; threshold/overflow compaction is foreground. Either executes automatically, and speculative compaction may run while the model keeps working: ordinary entries landing meanwhile remain after the prepared boundary. Only a competing head makes the summary stale; edit entries do not.

const collapseId = await c.collapse(undefined, call);
const collapseId = await c.collapse({ instructions: 'keep the API decisions verbatim' }, call);

Threshold and overflow compaction happen inside the generation; nothing to call. Reset starts the context over, with or without a handoff message:

await c.reset({ handoff: 'Continue from here: we settled on a recursive-descent parser.' }, call);
await c.reset(undefined, call);                                                        // /clear

The transcript keeps everything either way; only the context changes.

Subagents

Gated: exact model-tool commands and owned-conversation helper types below are illustrative until the ordinary-tool capability design is settled. A subagent is a conversation. There is no separate object to talk to: the model gets one tool with a command argument, and the API gets a conversation handle.

// the model calls:
subagent({ command: 'run',    prompt: 'Audit the tests', context: 'fresh', tools: ['read', 'grep'] })  // waits for the answer
subagent({ command: 'spawn',  prompt: 'Profile the build' })                                            // returns the child's id
subagent({ command: 'send',   id: 88, text: 'also check CI' })
subagent({ command: 'status', id: 88 })
subagent({ command: 'wait',   id: 88 })
subagent({ command: 'stop',   id: 88 })
// the API
const childId = await c.spawn({ prompt: 'Profile the build', context: 'fresh',
                                values: { inherit: [generationKind.config.model] } }, call);
const child = await h.conversation(childId, call);
const childInput = await child.send({ content: 'also check CI' }, call);
await childInput.wait(call);
await child.abort(call);

run keeps the calling tool in flight while it waits for the child's InputHandle, so ownership makes the child part of the parent's foreground cancellation reach. spawn settles the tool at once; the child is excluded from that cleanup policy, runs while the parent goes on, and only child.abort() (or stop) ends it. The one Session scheduler executes either child automatically, concurrently with unrelated conversations. Both survive restart like any other durable work.

Jobs and Schedules

Gated: exact job payloads and convenience methods remain unsettled; the scheduler/task-output behavior here is required. A job is a background task that runs a process: durable, recoverable, killable, with its task output streamed through the authoritative view. The model normally gets one from bash (asked to background, or run past its budget) and controls it through the job tool:

job({ command: 'wait',   id: 91, budgetMs: 30_000 })
job({ command: 'status', id: 91 })
job({ command: 'stop',   id: 91 })
job({ command: 'list' })

From the API a job is a task; a schedule is a job with every:

const dev = await c.commit(tx => tx.task(jobKind, { background: true,
  input: { cmd: 'npm run dev', cwd } }), call);

const nightly = await c.commit(tx => tx.task(jobKind, { background: true,
  input: { cmd: 'npm test', cwd, every: 24 * 3600_000, notBefore: tonightAt(2) } }), call);

await h.abortTask(nightly, call);          // ends the schedule wherever it is

A schedule is one running task updating its kind-defined named checkpoint phases across occurrences; there is no trail of run IDs to chase. A job whose starting call returned early appends a notice entry when it finishes, so the model learns of it on its next turn without polling. Live output stays in shared task-output state; the job materializes terminal data into its outcome/notice before the final output reference retires.

Plugin State

Values and Lists

Declare an address once; read and write through handles or inside commits. The address carries the scope (session or conversation), the rewind policy and the payload type.

const planMode = conversationValue<boolean>('plan.mode', { rewind: true });
const moves    = conversationList<Move>('game.moves', { rewind: true });
const expanded = conversationValue<boolean>('ui.expanded', { rewind: false });
const name     = sessionValue<string>('pi.session.name');

await c.value(planMode).set(true, call);
const on = await c.value(planMode).get(call);
const then = await c.value(planMode).get(entryId, call);            // as of an entry: rewindable only

const elementId = await c.list(moves).append({ x: 1, y: 2 }, call);
const currentMoves = await c.list(moves).read(call);
const movesAtEntry = await c.list(moves).read(entryId, call); // rewindable only
await c.list(moves).remove(elementId, call);
await c.list(moves).clear(call);

await h.value(name).set('parser work', call);

Rewindable state is what makes plugins survive forks: a fork before the move doesn't see the move, a fork before plan mode was turned on doesn't have it on. Nothing to register, nothing to re-derive.

Atomic Commits

Anything that must land together goes in one commit: a closure on the session's write line. Reads inside it are asynchronous and see committed state; await them in an async builder. Writes are synchronous and ids are final when returned; a throw discards everything. Awaiting storage reads does not release the line. Never await external effects, task/input/idle waits or another commit inside a builder. Known nested Pico line operations reject before queueing instead of deadlocking.

const entry = await c.commit(tx => {
  tx.value(planMode).set(false);                                          // rewindable state first
  const id = tx.entry(myPlugin.noteKind, { data: { text: 'plan accepted' } });
  tx.value(expanded).set(true);                                           // sticky state may follow
  tx.task(myPlugin.reminderKind, { background: true,                      // tasks anywhere
    input: { about: id, at: Date.now() + 3600_000 } });
  return id;
}, call);

Appending Entries

tx.entry appends immediately and returns the entry id. Use it for entries that are part of what your task is doing, and for entries the model never sees (data only, no model).

For model-visible passive content written while a turn may be busy, trusted built-ins use the protected transaction write helper. It appends immediately when admission is idle and otherwise queues for the next complete post_tools or final-answer boundary. It is intentionally not a public conversation method; application user input goes through send. That is not a style preference: appending a model-visible entry between an assistant's tool calls and their results changes the prefix the next request replays, which providers reject and which invalidates Anthropic thinking signatures. tx.entry rejects that one case rather than corrupting the next request, and the error points here.

Only the fixed generation, tool, post_tools and collapse definitions receive the internal CoreTaskRuntime, whose commits provide direct model-entry and protected admission authority. Ordinary defineTask kinds receive TaskRuntime/BaseTaskTx without those methods. Generation, tool and post_tools are the fixed turn-task set used for admission; speculative collapse is privileged but outside that set, so it does not block an idle send.

Two more things when writing entries directly: appending a user entry is not the same as asking for an answer (use send), and a head entry may only narrow context, never widen it, and may not split an exchange.

Write Order

One rule: rewindable conversation value/list writes must precede entries in the same commit. That makes state written alongside an entry visible to a fork at that entry while excluding later commits. Session state and sticky conversation state may appear anywhere because forks never reconstruct their history; they may therefore reference a new entry id. Tasks may also appear anywhere. A violating builder call throws before anything is persisted.

Writing Tools

Gated: exact sink and ordinary-tool runtime names below are illustrative, not implementation instructions. Durable task output and the required streaming, bounded capture, diagnostics, typed details, usage, control outcomes and job/child cleanup semantics are normative. Do not implement this package until the final mediated tool API is appended to the normative handoff.

The Sink

A tool's execute returns nothing. Everything it produces goes through a sink, so output streams to the UI as it happens, is bounded once in one place, and settles into a ToolOutputState that the transcript, the model and every renderer share. Failure is a throw; the harness sets isError.

import { Type, type Tool } from '@earendil-works/pi-agent';

export const countLinesTool: Tool<{ i: string; path: string; pattern?: string }, { lines: number; matching: number }> = {
  name: 'count_lines',
  description: 'Count lines in a file, optionally only those matching a pattern',
  parameters: Type.Object({
    i: Type.String({ description: 'What you are trying to find out' }),   // intent: streams first, shows in the UI
    path: Type.String(),
    pattern: Type.Optional(Type.String()),
  }),
  output: { maxBytes: 64_000, maxLines: 200, retain: 'head' },            // the sink enforces this
  replay: 'safe',                                                         // read-only: may be rerun after a crash

  async execute(toolCallId, params, out, runtime, call) {
    const lines = getOrThrow(await runtime.env.readTextLines(params.path, {}, call));   // ExecutionEnv: FileSystem & Shell
    const re = params.pattern ? new RegExp(params.pattern) : undefined;
    let matching = 0;
    for (const [i, line] of lines.entries()) {
      if (re && !re.test(line)) continue;
      matching++;
      out.write(`${i + 1}: ${line}\n`);                                  // bounded by `output`; the sink truncates and diags
    }
    if (matching > 200) out.diag('warn', `showing 200 of ${matching} matching lines`, 'cap');
    out.details.lines = lines.length;                                     // typed, for UIs
    out.details.matching = matching;
  },
};

The sink:

call effect
write(text) / replace(text) / image(img) the content the model reads
details the tool's typed object for UIs; mutate it
usage(u) accumulates
addTools(names) change the loadout from the next turn on
terminate(true) stop the turn after this exchange, whether or not the call failed
handoff(message) ask for a context reset after the exchange (what new_context does)
delegate(jobId) this call's work continues as that job
diag(severity, message, code?) commentary about the call, kept out of the data

A tool never touches the transcript, its siblings or the inbox. Ordinary tool code receives no task or host commit authority. The built-in pi.tool adapter may eventually expose narrow keyed memo, child-task and owned-conversation operations. Those mediated operations atomically update the parent checkpoint internally; exact names and types remain gated.

Diagnostics

Anything that is about the call rather than its output goes through diag: truncation, a spilled file, a corrected path, "the file changed on disk since you read it". The harness emits the ones it owns (the sink itself reports truncation and spill); a tool adds only what it alone knows. Tool settlement puts the output first and the commentary after it and stores that exact message in entry model. Non-message details, usage, control flags, diagnostics and truncation metadata become entry data. A UI combines the two and renders diagnostics as callouts by severity:

...last matching line
<harness>
[warn] stopped at 500 matches
</harness>

Long-Running Tools

A process-running tool is job-first; Pico never adopts an arbitrary unfinished tool promise. The required trace is:

pi.tool task T owns task output O
internal TX[create background job B sharing O; checkpoint T with B + abortWithTool policy]
kick -> Session scheduler runs B automatically
ordinary tool observes B for its local budget
adapter terminal closure rereads B on the line
  B terminal -> materialize normal result from O
  B live     -> materialize continues-as-B result; terminalize T while B retains O
B eventually materializes final O, terminalizes its last ref, and emits any passive notice atomically

Never trust stale timeout or Promise.race state. Creation records cleanup policy before returning a child handle. Fresh tool abort reads that durable record and atomically marks a non-detached live job; an already terminal job counts as cleaned. The scheduler already runs the job, so waits only observe. The same shared task output lets the UI keep one bash component across tool-to-job handoff without copying a second live output stream. Exact ordinary-tool handle methods remain gated.

Hooks

Gated: exact hook names, task identity and namespaced scratch capability remain unsettled. The examples show required semantics, not a final API. Hooks belong to the kind that runs them. A kind declares its points and their types; you register handlers per kind and point, harness-wide or scoped to a conversation. Handlers run outside the write line and their decisions are re-validated inside a commit, so they may take as long as they like (a human approval is a hook that waits).

// policy: everywhere
h.hooks.on(toolKind, 'before_tool', async ({ toolName, args, conversationId }, call) => {
  const conversation = await h.conversation(conversationId, call);
  if (toolName === 'write' && await conversation?.value(planMode).get(call))
    return { block: { reason: 'plan mode: no edits' } };
  if (toolName === 'bash' && !(await ui.approve(args, { signal: call.abortSignal })))
    return { block: { reason: 'denied by user' } };
  return { args };                                       // may rewrite arguments
});

h.hooks.on(toolKind, 'after_tool', async ({ toolCallId, output }, call) => { metrics.record(output.usage); });

h.hooks.on(generationKind, 'before_request', async ({ request }, call) => ({ request: withTracing(request) }));

h.hooks.on(generationKind, 'on_yield', async ({ answer, conversationId }, call) => {
  if (await goalIncomplete(conversationId, call)) return { continue: 'The goal is not met yet; continue.' };
});

h.hooks.on(collapseKind, 'before_collapse', async ({ reason, entries }, call) => {
  if (reason === 'manual' && entries.length < 10) return { decline: true };
});

// instructions: per conversation (see System Prompt and Tool Loadout)
c.hooks.on(generationKind, 'system_instructions', handler, { subtree: true });

Points and their fail behaviour:

kind point returns on throw
generation system_instructions edits section draft; optional complete tools reported, skipped
generation before_request a transformed request reported, skipped
generation after_response nothing reported
generation on_yield { continue?: string } reported, skipped
tool before_tool { args? } or { block } blocks the tool
tool after_tool nothing reported
collapse before_collapse { decline? | instructions? | summary? } reported, skipped

These failure policies exclude cancellation control errors, which propagate to unwind the invocation. A handler receives the active Call as its final argument and must forward it to waits/effects. The harness awaits its actual return, not an abandoned raced promise. A handler may run again after a crash, so its external side effects need their own idempotence.

Approval/question policy, presentation and durable answer reuse belong to workspace/plugins, using existing scratch or scoped values as appropriate. Pico does not add a separate memo subsystem. How hooks receive task identity and authorized scratch access is still an integration question; the examples do not establish a new hook-payload API.

Writing Kinds

Kinds are how you extend the harness with new behaviour rather than new state. An entry kind is a typed name for an immutable transcript shape; a task kind says how a kind of work runs.

Entry Kinds

Entry facets combine. data is optional kind-specific JSON for logic and custom UI rendering; model is an optional stored Message[]; head and edits are optional stored context controls. No facet is derived while reading.

interface NoteData { text: string; pinned?: boolean }
type NoteEntry = EntryBase & EntryData<NoteData>;

export const noteKind = defineEntryKind<NoteEntry>('myplugin.note');

await c.commit(tx => tx.entry(noteKind, {
  data: { text: 'Parser plan accepted', pinned: false },
}), call);                                                        // transcript/UI only; the model sees nothing

A plugin that wants both typed data and a model message keeps the append-time conversion in an ordinary helper:

type PinnedEntry = EntryBase & EntryData<NoteData> & ModelProjection<UserMessage>;
export const pinnedKind = defineEntryKind<PinnedEntry>('myplugin.pinned');

function appendPinned(tx: ConversationTx, data: NoteData, timestamp: number): Id {
  return tx.entry(pinnedKind, {
    data,
    model: [{ role: 'user', content: `<pinned>${data.text}</pinned>`, timestamp }],
  });
}

Heads and edits are supplied the same way:

type WindowEntry = EntryBase & EntryData<{ retainFrom: Id }> & ContextHead;
export const windowKind = defineEntryKind<WindowEntry>('myplugin.window');

tx.entry(windowKind, {
  data: { retainFrom },
  head: retainFrom,                     // first retained entry, inclusive
});

type ToolResultEditEntry = EntryBase & ContextEdits;
export const toolResultEditKind = defineEntryKind<ToolResultEditEntry>('myplugin.tool_result_edit');

tx.entry(toolResultEditKind, {
  edits: replacement === undefined
    ? [{ target, action: 'omit' }]
    : [{ target, action: 'replace', messages: replacement }],
});

head: 'self' in an append draft stores the new entry id, which is how reset and handoff discard everything earlier. Commit validation requires a stored head boundary not to move before the previous visible boundary. Edits apply in transcript order; the newest edit per target wins. Append another head or edit when context should change; no entry-kind callback runs during context reads.

Reads are typed by the kind, or untyped and narrowed:

const note = await h.getEntry(noteKind, id, call);     // NoteEntry | undefined (also undefined for another kind)
const any = await h.getEntry(id, call);                // Entry | undefined
if (noteKind.is(any)) any.data.pinned;

A missing plugin removes that narrowing and its custom renderer, but not context behavior: the entry's model messages, head and edits are stored independently of the kind.

Task Kinds

An ordinary task kind declares immutable input, a complete optional checkpoint union, completed/failed/aborted payloads, optional task-output state and three methods. The generic scheduler interprets only pending/running/terminal, dependencies, background, abort mark and checkpoint presence. A checkpoint's phase belongs to the kind; it is not a global workflow step.

Here is a reminder that fires once:

type ReminderInput = { readonly about: Id; readonly at: number };
type ReminderCheckpoint = { readonly phase: 'firing'; readonly effectKey: string };

export const reminderKind = defineTask<
  ReminderInput,
  ReminderCheckpoint,
  { readonly fired: boolean },
  { readonly message: string },
  { readonly cleaned: boolean }
>()({
  kind: 'myplugin.reminder',

  async execute(task, runtime, call) {
    if (task.input.at > runtime.now()) await runtime.sleep(task.input.at, call);
    await runtime.commit(tx => {
      tx.checkpoint({ phase: 'firing', effectKey: `reminder:${task.id}` });
    }, call); // durable intent before the external effect

    await notifications.fire(task.input.about, `reminder:${task.id}`, call);
    return () => ({ status: 'completed', result: { fired: true } });
  },

  async recover(task, runtime, call) {
    if (!task.checkpoint) return reminderKind.execute(task, runtime, call);
    const fired = await notifications.recover(task.checkpoint.effectKey, call);
    return () => ({ status: 'completed', result: { fired } });
  },

  async abort() {
    return () => ({ cleaned: true });
  },
});

The scheduler reserves pending as running before execute, and reserves a restored running task for recover, including one with no checkpoint. Methods perform effects outside the line and return a terminal closure. The Harness invokes that closure once on the line and atomically commits its buffered writes, full terminal task snapshot and scratch/output retirement. A throwing closure commits nothing and faults the Session.

Pico's fixed privileged definitions use its internal defineCoreTask factory instead. Their callbacks receive CoreTaskRuntime, so runtime.commit supplies CoreTaskTx. That transaction uses the same tx.task(...) spelling but accepts both ordinary and fixed core tokens:

const generationKind = defineCoreTask<GenerationInput, GenerationCheckpoint,
  GenerationResult, GenerationFailure, GenerationAbort, GenerationOutput>()({
  kind: 'pi.generation',

  async execute(task, runtime, call) {
    // ...provider effect...
    return (tx, current) => {
      const toolTaskIds = completedAssistant.toolCalls.map(call =>
        tx.task(toolKind, { input: makeToolInput(current, call) }));
      tx.task(postToolsKind, {
        input: makePostToolsInput(current),
        after: toolTaskIds,
      });
      return { status: 'completed', result: generationResult };
    };
  },

  recover,
  abort,
});

An ordinary BaseTaskTx.task(...) accepts only ordinary defineTask tokens. defineCoreTask is not a plugin API; fixed core definitions cannot be registered, replaced or removed. The fixed dispatcher supplies the core runtime implementation. The kernel does not inspect core input/checkpoint schemas.

Use defineStateTask when one ordinary kind has several named durable recovery phases. It compiles to the same ordinary TaskKind; the scheduler still invokes one outer execute/recover operation and never interprets those phases. Do not create a giant turn task: built-in conversation work remains generation -> parallel individual tools -> post_tools -> continuation generation, plus independent collapse and job tasks.

Rules for task authors:

  1. Commit a complete checkpoint before an uncertain external effect. Recovery uses immutable input and the latest committed checkpoint; it does not recover a JavaScript stack.
  2. Effects, hooks, sleeps and waits happen outside transaction callbacks. Inside runtime.commit, perform explicit committed reads before the first buffered write and use only the supplied tx afterwards.
  3. Return a terminal closure only after effects and producers finish. Cancellation may discard an uncommitted normal closure; fresh abort owns cleanup.
  4. Prefer immutable after dependencies for task prerequisites. Task waits only observe and ensure Session resume. Direct self-wait and dependency cycles reject; never race away unfinished work.
  5. Keep generic configuration in scoped values, not task snapshots. Persist only compact evidence required to recover this kind's effect.

The one scheduler keeps the complete Session live/dependency index on the mutation line and invokes task methods outside it. Every successful main commit kicks it; scratch/task-output-only commits need not. It reserves all eligible tasks in every conversation, runs distinct IDs concurrently, and allows at most one invocation per task ID. Foreground and background both run. Foreground controls idle and ordinary abort reach only.

A kind may declare task output with TaskOutputSpec.initial(input). Runtime mutates the one authoritative Chord-tracked output; each nonempty delta persists and appears as task_output. Shared refs let a tool hand output to a background job without copying it. The last live reference materializes final data and retires the output atomically. Task output is not scratch and there is no separate live-output subsystem.

An unexpected task-contract error faults the Session: observation waits reject, admission stops, running invocations are signalled and joined, and the binding closes. Domain tool/provider failures must be returned as typed outcomes. Supply every required ordinary kind during open; a missing live kind is already orphaned before inspect returns and later registration cannot resurrect it. Fixed core kinds are never replaced.

Recovery

There is nothing to write. After a crash:

const h = await Harness.open(storage, opts, call);
await h.resume(call);
what was running what happens
a generation, streaming its task output contains the partial assistant state; recovery publishes it, then retries within budget or fails
a generation, between retries it sleeps out the remaining backoff and tries again
a tool with replay: 'safe' runs again
any other tool an "interrupted" error result; the turn continues
a run subagent tool finds its child and waits on the existing input result again
a job reruns if it said so, otherwise records lost
a scheduled job sleeps until its time

Successors are written in the same commit as the settlement that decides them, so a crash never leaves "the tools finished but nobody started the next turn": post_tools already exists and is startable.

Storage Backends

backend loads good for
MemoryStorage everything tests, ephemeral sessions
JsonlStorage main log plus live scratch/task-output sidecars local Sessions; the default
SqliteStorage only what queries return long sessions, many sessions in one file, servers

All three answer the same queries with the same results. Entries are immutable and read whole; indexed queries select the relevant entries before decoding. Values are stored whole on every set. Lists store append/remove/clear operations and return one complete coherent logical list in ascending element-ID order; v1 has no paged list scan. There is no automatic value diffing or Chord storage codec. Chord deltas are used for task-output/watch delivery. Streaming task output appends compact operations, not raw provider events with repeated growing partial snapshots.

JSONL reopens by replaying complete main commits, then sidecars for surviving live scratch and shared task outputs. Per-file Seq gaps are expected. Reopen initializes Storage.nextId() after the greatest conversation, entry, task or list-element ID in retained complete canonical writes, and separately restores the last applied sequence from the greatest retained complete record endpoint. Retired sidecars are ignored even if unlink failed; their later main settlement already proves retirement. Only unterminated final lines are discarded and removed before further appends; malformed complete main/live-scratch batches fail open.

main through Seq 100; live scratch through Seq 150 -> reopen applied Seq 150 and initializes nextId
main settles at Seq 151; unlink fails -> ignore retired scratch; applied Seq remains 151

The conformance suite compares one mutation stream across all backends. A repeated whole-value set can write quadratic bytes when the value grows; incremental lists avoid that without hidden encoding.

const storage = await SqliteStorage.open('./sessions.db', { session: 'parser-work' });