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

60 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-v3.md is the design and the reference for why things are the way they are.

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 a status that changes as it runs and is written to storage at every step, which is what makes 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.

Every id is a session sequence number, minted when the write is built and never changed:

type Id = number;

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

Nothing runs by itself. Opening a session starts nothing. Driving a conversation is what makes its tasks execute, and a prompt is just accept + drive + read the answer. A UI watches a conversation and gets a view it can render from 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 waiting = conversation.drive(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 driver does not inherit a drive 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, 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 { builtinModels } from '@earendil-works/pi-ai/providers/all';

const call: Call = BACKGROUND_CONTEXT;

// One file per session. Reopening the same file resumes it.
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],
}, call);
const { generation: generationKind } = h.kinds;   // the registered kinds, for config and hooks

const c = await h.root(call);

// Config is declared by the kinds that read it. The generation kind declares model, thinking and
// selected tools; `settings` is shorthand for its config.
await c.settings.set({
  model: { provider: 'anthropic', modelId: 'claude-opus-5' },
  thinking: 'high',
  selectedTools: ['read', 'write', 'bash'],
}, call);

// The system prompt is not stored as config. Each turn the harness asks this hook what the
// instructions should be right now, compares with what the transcript says the model was told,
// and writes only the difference. See "System Prompt and Tool Loadout".
c.hooks.on(generationKind, 'system_instructions', async ({ config }, call) => ({
  sections: {
    identity: 'You are a careful engineer working in this repository.',
    cwd: `Working directory: ${process.cwd()}`,
  },
  tools: h.tools.select(config.selectedTools),   // complete tool definitions
}), { subtree: true });

// Watch the conversation. `view` is a plain object a UI renders from; attach whenever you like,
// the view is complete as of the moment you attach and events follow from there.
const w = await h.watch(c.id, { tail: 100 }, call);
w.start(event => {
  if (event.type === 'task_output') {
    // the generation's preview is the partial assistant message; a tool's is its output so far
    const preview = w.view.previews.get(event.task);
    process.stdout.write(renderPreview(preview));
  }
  if (event.type === 'entry') console.log(`\n[entry ${event.entry.id} ${event.entry.kind}]`);
});

// accept the input, drive the conversation until it is idle, return the answer to that input
const answer = await c.prompt({ input: 'Inspect the parser and list the public API' }, call);
console.log(answer?.model[0]?.content);

w.unsubscribe();
await h.close(call);   // cancels nothing durable; reopening the file continues exactly here

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 plugin entry and task kinds, added to the built-ins
replace a built-in kind swapped by name ({ generation: myGenerationKind }); it must keep the statuses and hook names
rootValues initial values for the root conversation on a fresh session

The built-in kinds are registered by open itself, because accept, prompt, steer and collapse cannot work without them. Refer to whatever is registered through h.kinds:

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

A replacement is usually a wrapper that delegates to the original for everything it doesn't change:

import { generationKind } from '@earendil-works/pi-agent/kinds';
const h = await Harness.open(storage, { models, tools, replace: {
  generation: { ...generationKind, async execute(task, runtime, call) { await audit(task); return generationKind.execute(task, runtime, call); } },
}}, call);

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. A live task kind is still required.

const { start, inflight, orphaned, parked } = await h.inspect(call);
// start: tasks that never began or must begin again (a planned tool, a retry, a scheduled job)
// inflight: tasks the last process was running when it stopped; recover() will handle them
// orphaned: foreground tasks whose kind is missing (a plugin was uninstalled); settled at open
// parked:   background tasks whose kind is missing; they resume when it is registered again

What Happens on Reopen

Nothing, until something drives a conversation. h.drive() starts and recovers eligible foreground and background work across the session; c.drive() does the same for one conversation's ownership scope. Each promise resolves when its scope has no live foreground task. Its attached background work remains served afterwards. A UI usually attaches a watch to what it shows and drives that.

const h = await Harness.open(storage, opts, call);
void h.drive(call).catch(error => console.error(error)); // resume; report session faults

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 by inference on reopen/drive.

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.

Configuration

Settings Declared by Kinds

There is no settings object. A task kind declares the values it reads, typed, and that declaration is the only place the address is spelled:

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 what a conversation is configured with by walking the registered kinds' config; a plugin declares its own values the same way (see Task Kinds).

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).

System Prompt and Tool Loadout

How It Works

pi-ai models the system prompt as messages in the transcript: the first rendered prompt of a session is the provider's stable, cached baseline, and later changes are appended as SystemMessages that say what changed (and carry toolsAdded/toolsRemoved with complete tool definitions). pico is built on that, and keeps two things apart:

  • What you want right now. Config (model, thinking, selected tools, a profile) plus whatever the host has: cwd, skills, context files, the tool catalogue. The host answers a hook with it each turn. None of it is stored.
  • What the model has been told. system entries in the transcript: structured data for the baseline or delta and the exact materialized SystemMessage in model. These are immutable facts about requests that happened, so they fork and compact like every other entry.

At the top of every turn the generation renders the first, folds the second out of the context, and appends the difference, if any, with both its structured data and model message before it projects the request. The host keeps rendering "the whole prompt" the way it always did; the harness turns that into append-only deltas.

Answering the Hook

The hook is system_instructions on the generation kind. It returns keyed sections (rendered in the order given) and the tool definitions the model may call. Register it on the conversation with subtree: true so subagents inherit it unless they register their own:

c.hooks.on(generationKind, 'system_instructions', async ({ conversationId, config }, call) => ({
  sections: {
    identity:      IDENTITY,
    cwd:           `Working directory: ${cwd}`,
    context_files: await renderContextFiles(cwd, call),      // AGENTS.md and friends
    skills:        skills.render(),
  },
  tools: h.tools.select(config.selectedTools),         // complete definitions, from the catalogue
}), { subtree: true });

After the first turn the transcript holds what was sent:

10 user      "Inspect the parser"
11 system    { baseline, sections: [identity, cwd, context_files, skills], tools: [read, write, bash] }
12 assistant ...

The tool definitions are stored in full, not as names, and the model message is materialized in the same append. A later change to a tool's description is therefore a change the model gets told about, and an old transcript is sent the way it was originally recorded without running entry-kind code.

Changing the Loadout

Every change is either a config write or something the host renders differently. Nothing is written at that moment; the next generation notices and appends one entry:

await c.settings.set({ selectedTools: ['read', 'write', 'bash', 'grep'] }, call);
// next turn → 20 system { toolsAdded: [grep] }

await c.settings.set({ selectedTools: ['read'] }, call);
// next turn → 30 system { toolsRemoved: [write, bash, grep] }

// the user installs a skill; skills.render() changes
// next turn → 40 system { sections: [skills] }        "the skills section now reads: ..."

h.tools.replace('mcp', newDefinitions);                 // an MCP server restarted with a changed schema
// next turn → 50 system { toolsAdded: [the changed definition] }   same name, new definition = a change

The provider's cached prefix through the previous message is untouched by any of these. On models with native mid-conversation system messages the entry goes out as one; elsewhere pi-ai renders it as a tagged user turn.

Sections from Plugins

A plugin contributes sections from its own state. Handlers for the same point merge by key; undefined removes a key. Register harness-wide when the section applies to every conversation:

const planMode = conversationValue<boolean>('plan.mode', { rewind: true });

h.hooks.on(generationKind, 'system_instructions', async ({ conversationId }, call) => {
  const conversation = await h.conversation(conversationId, call);
  return { sections: {
    plan_mode: (await conversation?.value(planMode).get(call)) ? PLAN_GUIDANCE : undefined,
  } };
});

await c.value(planMode).set(true, call);
// next turn → 60 system { sections: [plan_mode] }     "the plan_mode guidance now applies: ..."
await c.value(planMode).set(false, call);
// next turn → 70 system { sections: [plan_mode removed] }

Conversation-scoped handlers run after harness-wide ones, innermost conversation last, so a child's sections win by key.

A tool can change the loadout from inside a call. It goes through the same path: post_tools updates the config, the next generation emits the delta.

// inside a discovery tool
out.addTools(['calculator']);
// post_tools:  selectedTools += calculator
// next turn →  80 system { toolsAdded: [calculator] }

Subagents Have Their Own

A subagent is a different agent: different instructions, different loadout, often a different model. spawn sets its config, and either the parent's subtree handler answers (branching on config.profile) or the child registers its own:

const childId = await c.spawn({
  prompt: 'Audit the tests',
  values: {
    inherit: [generationKind.config.model],
    set: [
      [generationKind.config.selectedTools, ['read', 'grep']],
      [generationKind.config.profile, 'auditor'],
    ],
  },
}, call);

const child = await h.conversation(childId, call);
child.hooks.on(generationKind, 'system_instructions', async ({ config }, call) => ({
  sections: { identity: AUDITOR_IDENTITY, cwd: `Working directory: ${cwd}` },
  tools: h.tools.select(config.selectedTools),
}));
// child's first turn → its own baseline: two sections, two tools

Compaction, Forks and Restarts

Nothing to do in any of them.

  • Compaction / handoff / reset append a head; the context after it has no baseline, so the next turn writes a fresh one from the current answer to the hook. The summary never has to describe the prompt, and the provider's prefix was changing at the head anyway. An older delta that a compaction kept in its tail is subsumed by the new baseline and dropped at projection.

  • A fork carries the system entries in its shared prefix and its own (rewindable) config; its first turn diffs the two:

    const b = await c.fork({ at: 12 }, call);                       // config as of 12: tools [read, write, bash]
    await b.settings.set({ selectedTools: ['read'] }, call);
    await b.prompt({ input: '...' }, call);                         // b's turn: toolsRemoved [write, bash]; the prefix's 11 is still the baseline
    
  • A restart with a changed host (new skill, different cwd) emits "these sections now apply" and nothing else; a host that comes back the same emits nothing.

Prompting

prompt, accept, drive

prompt is three things: accept the input, drive the conversation until it is idle, read the explicit result for that input.

const answer = await c.prompt({ input: 'Inspect the parser' }, call);
// AssistantEntry | undefined (the run ended without an answer)

The pieces are available separately. inputId is always the id of the accepted pi.inbox list element, even when idle acceptance places and removes it in the same commit. Generation and post_tools carry input ids explicitly, so result lookup never scans the transcript:

const { inputId } = await c.accept({ input: 'Inspect the parser', requestId: 'req-42' }, call);
const outcome = await c.drive(call);                          // 'idle' | 'closed'
const result = await c.result(inputId, call);                 // one sticky-value point read
const answer = result?.status === 'done' && result.answer
  ? await h.getEntry(assistantKind, result.answer, call)
  : undefined;

A result moves from queued to placed (its entry is in the transcript), then to done or unanswered. done carries the answering entry when one was owed; a context-only write is done with no answer, in the commit that placed it. unanswered names the cause:

switch (result.status) {
  case 'queued':     return 'waiting to start';
  case 'placed':     return 'working';
  case 'done':       return result.answer ? render(result.answer) : 'noted';
  case 'unanswered':
    return result.reason === 'terminated' ? 'the run stopped itself'
         : result.reason === 'aborted'    ? 'cancelled'
         : 'the model could not be reached';
}

Terminal results never change.

accept is "the user hit enter": idle, it places the entry and creates a generation in one commit; busy, it queues, as followUp by default. Pass whenBusy: 'steer' to interrupt the running turn instead, or whenBusy: 'reject' if the caller insists on knowing. Either way you get an inputId and result(inputId) tells you which happened, so a caller never has to check whether a run is in progress first.

A request key names one acceptance for the life of the session. A caller that lost the response retries with the same key and gets the same inputId back; nothing is written twice and nothing is compared:

const { inputId } = await c.accept({ input, requestId: 'req-42' }, call);   // safe to repeat
const existing = await h.acceptance('req-42', call);                        // or look it up explicitly

c.drive(call) resolves when the conversation's foreground set is idle: no generation, tool or automatic collapse in that ownership chain is live. h.drive(call) resolves when no foreground task is live anywhere in the session. Both start eligible background work and keep serving it after they resolve. A separate full-quiescence wait may intentionally never return while a recurring schedule is live.

Input While Busy

accept covers the common case. queueInput is the explicit form when the caller wants a specific mode, and it is one method taking a tagged union rather than four methods:

const steer  = await c.queueInput({ mode: 'steer', input: 'Focus on the tokenizer first' }, call);
const follow = await c.queueInput({ mode: 'followUp', input: 'Then write the tests' }, call);
const next   = await c.queueInput({ mode: 'nextRun', input: 'Remind me to commit' }, call);
const note   = await c.queueInput({ mode: 'write', kind: noteKind,
                                    entry: { data: { text: 'user stepped away' } } }, call);
mode lands asks for
steer next post_tools, or a final answer joins the running group at post_tools; starts the next one after an answer
followUp after a final answer starts the next group
nextRun the next idle accept joins that acceptance's group
write next safe boundary nothing; done with no answer

When a generation emits calls, its settlement creates every tool plus exactly one post_tools carrying the current input ids. That task places writes and steering, extends the ids and creates the continuation generation. A final-answer generation resolves its current group, then places steer/followUp items into a new group. nextRun remains queued until a later idle accept.

The queue exposes complete entry drafts so a UI can render text and images directly. Durable and watch updates are append/remove/clear operations, not whole-array replacements. Any item can be withdrawn until it lands:

await c.abortInput(steer.inputId, call);              // 'aborted' | 'not_found'

Aborting

await c.abort(call);          // every live foreground task of this conversation, and of conversations they own
await h.abortTask(id, call);    // one background task: a job, a schedule

An abort mark is a durable request, not terminal settlement:

100 generation streaming, execute invocation A running
110 abort=true commits; A can no longer write main state or scratch
    line releases; A's signal fires; provider exits; A returns
    driver removes A and starts abort invocation B with a fresh Call
120 B commits optional display-only partial, cancelled input results, terminal aborted status
    scratch is retired; B returns

There is no mark/signal branch in normal settlement. If the mark wins, runtime.commit rejects TaskCancelled before its closure runs; execute unwinds and the kind's abort() writes the durable cancellation result. If normal settlement wins first, the task is already terminal. Standard effects observe the signal cooperatively; no effect gate exists, so an operation may start before the signal is delivered and then cancel. Cancellation does not undo external effects.

abortTask marks exactly one task; if not owned or attached, cleanup awaits a later drive. Conversation abort marks the current foreground ownership closure. Fresh parent cleanup explicitly cancels its foreground children and recorded non-detached jobs. Queued steer/followUp are removed and marked cancelled; write/nextRun remain. This is the explicit conversation-abort policy. Shutdown and built-in task abort cleanup mark tasks only and preserve all queued items and their queued results.

A generation's abort handler updates its explicit input group in the same commit as settlement. Here inputResult(id) is the sticky value address holding that input's result:

async abort(task, runtime, call) {
  await runtime.commit(async tx => {
    for (const id of task.state.inputs) {
      const address = inputResult(id);
      const r = await tx.value(address).get();
      if (r?.status !== 'placed') throw new Error(`Invalid active input ${id}`);
      tx.value(address).set({ status: 'unanswered', requestId: r.requestId, entry: r.entry, reason: 'aborted' });
    }
    tx.settle(task, { status: 'aborted', ...common(task) });
  }, call);
}

The driver never resolves input results itself. Tool and generation kinds own their respective outcomes. They can recover only committed scratch; missing final usage is unknown, not zero. Runtime scratch writes reject after cancellation too: await/catch them. Harness sinks handle and drain their own scratch promises and drop late callbacks, never silently recreate retired scratch.

Watching

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.resnapshot(call);     // fresh capture, same subscription (if the client fell behind)
w.unsubscribe();
interface ConversationView {
  conversation: Conversation;
  entries: Entry[];                        // the last `tail` entries; page older ones with h.entries(id, { before })
  context: Id[];                           // what the model currently sees, as entry ids
  tasks: Task[];                           // live tasks, typed by kind
  inbox: Element<InboxItem>[];             // queued input
  values: Map<Address, JsonValue>;         // every value the registered kinds declare, plus the ones you asked for
  previews: Map<Id, JsonValue>;            // per live task: what it is producing right now
  faulted: boolean;
  readAt: Id;
}

previews is where streaming lives. Each task kind defines what its preview is: the generation's is the partial AssistantMessage, a tool's is its ToolOutputState so far, a job's is the same shape for its process output.

Events

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

The view is authoritative and the event is a wake-up: when the listener runs, w.view has already been folded. Inbox operations are combined per commit; an idle acceptance'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
a turn started / ended task_start / task_end where generationKind.is(task)
a retry is scheduled task_update, status retry_wait, state.attempt, state.notBefore
the response is streaming task_output on the generation; view.previews.get(task)
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, event?: ConversationEvent) {
  transcript.sync(view.entries);

  const gen = view.tasks.find(t => generationKind.is(t));
  streaming.set(gen ? view.previews.get(gen.id) as AssistantMessage : undefined);
  status.set(
    gen?.status === 'retry_wait' ? `retrying (${gen.state.attempt}/${gen.state.maxAttempts})` :
    gen?.status === '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.state.call, phase: t.status, output: view.previews.get(t.id) as ToolOutputState });

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

  queue.set(view.inbox.map(i => i.value));
  working.set(view.tasks.some(t => !t.background));
  statusLine.set({ model: view.values.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 preview, a settled tool_result entry, or a job the tool started (state.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 on the same events. applyEvent(view, event) is exported and needs no kinds; preview ops are Chord delta ops, which the client applies with the same module.

// worker (has the harness)                            // 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(e => send({ type: 'event', event: e }));       on('event', m => { applyEvent(view, m.event); render(view, m.event); });

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.faulted;
sw.start(e => {
  if (e.type === 'usage')  status.setCost(e.totals);
  if (e.type === 'report') log.warn(e.kind, e.task, e.error);
  if (e.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 as it was at that entry, the rewindable values in force there, and the prompt-as-sent, so its first turn is exact.

const alt = await c.fork({ at: answer.id }, call);                           // the source keeps running, untouched
await alt.prompt({ input: 'Try a different implementation' }, 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: their own drive scopes
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. It runs as a background task and 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

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);
await child.accept({ input: 'also check CI' }, call);
await child.drive(call);          // or let h.drive() / the parent's drive carry it
await child.abort(call);

run keeps the calling tool inflight while it drives the child, so the child is part of the parent's foreground: aborting the parent reaches it. spawn settles the tool at once; the child is detached, runs while the parent goes on, and only child.abort() (or the stop command) ends it. Either way the child is driven by whatever drives the parent's tree, and it survives a restart like any conversation.

Jobs and Schedules

A job is a background task that runs a process: durable, recoverable, killable, with its output streamed into its preview. 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,
  state: { status: 'planned', cmd: 'npm run dev', cwd } }), call);

const nightly = await c.commit(tx => tx.task(jobKind, { background: true,
  state: { status: 'planned', 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 task looping planned → running → planned; 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. Its output stays in the task (preview while live, terminal state after), not in the transcript.

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 page = await c.list(moves).read({ limit: 50 }, call);
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, driver waits or another commit inside a builder.

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
    state: { status: 'scheduled', about: id, at: Date.now() + 3600_000 } });
  return id;
}, call);

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

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 queue. What it may do is create things in its own conversation through runtime.commit(..., call): a job, a child conversation. The runtime provides conversation lookup, reads and cancellation methods, not a raw host-lifecycle Harness.

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 call may not block a turn forever. Tools that run processes create a job first and wait on it with the budget from config; if the budget runs out, the call settles with what it has and the work goes on:

async execute(toolCallId, params, out, runtime, call) {
  const job = await runtime.commit(tx => {
    const id = tx.task(jobKind, { background: true,
      state: { status: 'planned', cmd: params.cmd, cwd: params.cwd ?? runtime.env.cwd,
               origin: { tool: 'bash', task: runtime.taskId, callId: toolCallId } } });
    tx.patch(task, { jobId: id, cancelJobOnAbort: !params.background });   // partial: still 'running'
    return id;
  }, call);

  if (params.background) { out.delegate(job); out.write(`started job ${job}`); return; }

  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.details.exitCode = output.exitCode;
    if (output.exitCode) out.diag('warn', `exit code ${output.exitCode}`, 'exit');
  } else {
    out.delegate(job);
    out.diag('info', `still running as job ${job}; use job wait / status / stop`, 'budget');
  }
}

The job owns its output from the first byte; the tool copies a snapshot. Creation stores the cleanup reference on the tool atomically. A durable abort rejects execute mutations, including catch-handler writes, so the fresh tool-kind abort handler reads that reference and cancels the non-detached job. Already-terminal jobs count as successful cleanup; check-and-mark must be atomic or handle that result.

The tool kind's successful delegation settlement marks the job detached and requests a notice through a separate sticky notification record keyed by job id. Job completion consumes it atomically; if the job is already terminal when delegation commits, delegation places the notice itself. Apply this protocol to exited, killed and lost outcomes, including abort and recovery. The parent never patches a running job's state. This covers either completion/delegation order.

A UI renders the job's output with bash's component. Adopting an arbitrary unfinished tool promise after budget expiry is still an open integration design: it requires an explicit effect/sink ownership transfer. The initial job-first path above does not race or abandon any invocation.

Hooks

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 sections + 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.

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

A task kind declares its statuses and their roles, its config, its hooks, and three functions. Task writes materialize the mapped role on the durable task row; storage and the driver read that field without running kind code. Status graphs may contain cycles because one task is one logical operation: retries, deferred polls and recurring schedules keep their stable task id. Recovery uses only the current status, state, role and scratch.

Here is a reminder that fires once:

type ReminderStates =
  | { status: 'scheduled'; about: Id; at: number }
  | { status: 'firing';    about: Id; at: number }
  | { status: 'done';      about: Id; at: number; fired: boolean }
  | { status: 'aborted';   about: Id; at: number };

export const reminderKind = defineTaskKind({
  kind: 'myplugin.reminder',
  initialStatus: 'scheduled',
  roles: { scheduled: 'start', firing: 'inflight', done: 'terminal', aborted: 'terminal' },
  config: { intervalMs: conversationValue<number>('myplugin.reminder.interval', { rewind: false }) },
  hooks: { before_fire: { failClosed: false } },

  async execute(task, runtime, call) {
    if (task.state.at > runtime.now()) await runtime.sleep(task.state.at, call);        // throws on cancellation
    await runtime.commit(tx => tx.patch(task, { status: 'firing', ...common(task) }), call);   // intent before any effect
    const { skip } = await runtime.hooks(reminderKind).run('before_fire', { about: task.state.about }, call);
    await runtime.commit(tx => {
      if (!skip) tx.entry(noticeKind, { model: [noticeMessage(`Reminder: see entry ${task.state.about}`)] });
      tx.settle(task, { status: 'done', ...common(task), fired: !skip });               // a marked task's commit rejects
    }, call);
  },

  async recover(task, runtime, call) { return this.execute(task, runtime, call); },      // safe to redo
  async abort(task, runtime, call)   {
    await runtime.commit(tx => tx.settle(task, { status: 'aborted', ...common(task) }), call);
  },

  // no preview: nothing to show while sleeping
});

const common = (t: Task<ReminderStates>) => ({ about: t.state.about, at: t.state.at });

State is a tagged union over status: one variant per status carrying exactly the fields that exist in it, so a reader narrows instead of checking optionals, and patch within a variant takes a partial (tx.patch(task, { at: later })) while a transition takes the whole variant. The harness adds an orphaned terminal variant with the fields common to all of yours, for the case where this plugin is not installed when the session is opened; you never write it, and a task that depends on yours handles it in the same switch that handles your other terminal statuses.

The rules an execution follows, and the driver enforces:

  1. Commit an inflight status before any external effect. A crash before that re-runs execute; a crash after goes through recover.
  2. You may block on the world: a provider stream, a process, a child conversation, a sleep. The driver runs executions concurrently; a blocked one holds up nothing.
  3. Make a committed status transition or settle before returning, except cancellation/close unwind. The live task's epoch counts actual transitions, so planned → running → planned is valid. Same-status/state-only patches and abort marks do not count. An abort handler must settle.
  4. Prefer after for prerequisites; drive children or use the bounded job-wait API. Forward Call to every wait. Known self/dependency waits reject; never race away an unfinished task/tool/hook.

The scheduler runs on the commit line, keeps live-task/dependency indexes updated from whole committed batches, and starts effects outside the line. It scans storage once at open, not after every call. Only one invocation owns each task, but different tasks run concurrently. Repeated drives share an attachment and create only temporary waiters. Historical completed children are never traversed.

An unexpected error escaping a task kind or an unchanged return faults the session: pending drives reject immediately, admission stops, running invocations are signalled and joined, then storage closes. Domain tool/provider errors must be settled by their kinds. Reopen starts nothing; inspect and mark work before driving, or install a replacement kind that can settle a broken persisted task.

A kind with a preview decides what a UI sees while the task runs: preview.init(scratch) builds it once on attach or reopen, and after that the kind mutates runtime.preview.state in place (the generation applies stream events to a partial message; a tool's preview is its sink). The harness flushes the Chord delta tracker after each scratch commit into task_output ops, so a token costs one append op, not a diff of the whole object.

Recovery

There is nothing to write. After a crash:

const h = await Harness.open(storage, opts, call);
await h.drive(call);
what was running what happens
a generation, streaming its frames were in scratch: the partial is published, then retry within budget or fail
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 drives it 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 everything, from one append-only file plus a scratch file per live task 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. There is no automatic value diffing or Chord storage codec. Chord deltas are only for preview/watch delivery. Streaming scratch appends compact assistant frames or explicit output operations, not raw provider events with repeated growing partial snapshots.

JSONL reopens by replaying complete main batches, then scratch for surviving live tasks. Per-file sequence gaps are expected; the next id follows the maximum complete batch endpoint across both. Retired scratch is ignored even if unlink failed; its later main settlement already covers its ids. Only unterminated final lines are discarded and removed before further appends; malformed complete main/live-scratch batches fail open.

main ends at 100; live scratch ends at 150 → reopen lastSeq=150, next write=151
main settles task at 151; scratch unlink fails → ignore retired scratch, lastSeq=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' });