TX 10 operation = { phase: "requesting", requestId: "r1" }
- // durable intent
-
- provider request runs
- // process may die here
-
-TX 11 assistant entry + operation = { phase: "tools", calls: [...] }
- // durable outcome
- Atomic transactions and explicit restart points were correct.
+Lane is our durable conversation runtime. One operation is an agent run, compaction, or navigation.
type OperationState =
- | Starting | Checkpoint | AssistantReady | AssistantEffectPending
- | AssistantRetryWait | Tools | DeferredSuspended | DeferredEffectPending
- | SummaryDeciding | SummaryReady | SummaryEffectPending
- | SummaryRetryWait | NavigationReadyToCommit;
+ Products need independently registered durable tasks
+ generation G7
+├─ tool T8 ───────────────┐
+├─ tool T9 ───────────────┴─ post-tools P10 ─ generation G11
+└─ collapse C12 independent background work
-switch (state.at) {
- // one core driver understands every operation and recovery edge
-}
- Adding a first-class durable operation kind required changing the core graph and recovery.
- value<T> set or replace the complete value
-list<T> append one element or delete the whole list
-entry append immutable transcript content
-transaction commit all writes atomically
- Good storage primitives. Poor extension programming model for nested shared state.
-const Canvas = value<CanvasState>("app.canvas");
-
-const stored = await session.getValue(Canvas, context);
-const next = stored?.value ?? { strokes: [] };
-next.strokes.push(stroke);
-await session.setValue(Canvas, next, context);
+background job J20 ─ poll ─ output ─ reschedule
+plugin task K30 ─ owned conversation ─ child tasks
session.mutate() to serialize read-modify-write.const CanvasOps = list<CanvasOp>("app.canvas.ops");
-await session.appendList(CanvasOps, {
- type: "stroke.added", stroke,
-}, context);
+
+ New first-class operation kinds require core changes
+ // selected states from the fixed core union
+type OperationState =
+ | Starting | AssistantEffectPending | AssistantRetryWait
+ | Tools | DeferredEffectPending
+ | SummaryEffectPending | SummaryRetryWait
+ | NavigationReadyToCommit;
-// The extension now owns:
-foldOperations(); checkpoint(); compact(); restore(); publishToClients();
- Small writes—but every extension starts building its own document store.
+switch (state.at) {
+ // one driver owns every phase, recovery edge, and terminal path
+}
+ A plugin job can store markers—but first-class scheduling means editing Lane itself.
+
+
+
+ Concrete example: a durable plugin job
+ download(url), independent of an agent run
+ 1 persist intent + stable request ID
+ 2 start remote work; persist returned handle
+ 3 poll handle + checkpoint bounded progress
+ 4 restart: poll, deduplicate if supported, or report interruption
+ 5 abort and clean up
+ 6 persist a terminal result for dependent jobs
+
+Lane tools already have checkpoints, memos, cancellation, and recovery.
+Independent jobs still need plugin-owned scheduling,
+recovery, cancellation, and dependency handling.
+ That is a second task runtime hidden inside an extension.
+
+
+
+ Values and lists are storage—not documents
+ Lane primitive Canvas write Application must add
+ value<Canvas>session.mutate(), copy and replace full canvasnested deltas and replica updates
+ list<CanvasOp>append one small stroke operation fold, base, compact, restore, hydrate
+
+ Both are valid primitives. Neither is a tracked JSON document with a committed change stream.
- Durability and UI replication split
- await session.setValue(ReviewValue, next, context); // durable copy
+ Application replication is a second pipeline
+ Lane application value/list application-owned Chord/UI mirror
+──────────────────────────── ─────────────────────────────────
+atomic durable writes materialized nested document
+current reads + recovery ────────→ snapshots + incremental updates
+serialized mutation line subscriptions + remote hydration
-uiState.state.review = next; // second mutable copy
-uiState.publish(context); // second publication
-
-// On restart: read storage, reconstruct uiState, avoid races.
- Every facet owns the seam between storage and Chord.
+for every application:
+ define the mapping, startup hydration, ordering, and failure handling
+ Lane exposes its own snapshots/events; arbitrary application values do not become ReplicatedState.
- Lane lacked entry-anchored application-state history
- conversation A: e1 ─ e2 ─ e3 ─ e4
- └── fork B at e3
+ Forking cannot recover application state at an entry
+ source: canvas A ── e1 ── add B ── e2 ── delete A ── e3
-transcript at e3: available
-current plan-mode value: available
-plan-mode value at e3: not available
-review comments at e3: not available
- Tree forks copy current application state; branch forks exclude it. Neither reconstructs state at e3.
+ fork requested at e2 after e3 exists
+
+Lane branch fork transcript through e2; application values/lists excluded
+Lane tree fork whole entry tree; current application state: only B
+needed for product transcript through e2; application state: A + B
+ Current values have no replaced/deleted history to reconstruct the state at e2.
@@ -293,6 +296,127 @@ many independently attached presentations
+ The specified Pico5 host API
+ // Normative public surface; implementation is package 25.
+const storage: Storage = await openAppStorage(context);
+const options: HarnessOptions = {
+ models, // pi-ai Models
+ tools: [read, bash],
+ taskKinds: [Reindex],
+ documents: [PlanDoc, JobOutputDoc],
+ sections,
+ root: { model: { provider: "anthropic", modelId: "claude-sonnet-4-5" } },
+};
+
+const harness = await Harness.open(storage, options, context);
+harness.resume();
+const conversation = await harness.root(context);
+ Definitions are installed before migration, recovery, and task dispatch.
+
+
+
+ Send input and await its durable answer
+ const input = await conversation.send({
+ content: "Summarize the repository.",
+ requestId: "slack:1719432.0021",
+ whenBusy: "followUp",
+}, context);
+
+const settled = await input.wait(context);
+if (settled.status === "done") {
+ console.log("answer entry", settled.answer);
+} else {
+ console.log("unanswered", settled.reason);
+}
+ Send returns after durable admission. Cancelling wait does not withdraw the input.
+
+
+
+ Write without starting another turn
+ const first = await conversation.write({
+ kind: "app.note", data: { text: "Build passed" },
+}, context);
+const second = await conversation.write({
+ kind: "app.note", data: { text: "Deploy started" },
+}, context);
+
+const [a, b] = await Promise.all([
+ first.wait(context), second.wait(context),
+]);
+console.log(a.entry, b.entry); // passive writes settle with entry, not answer
+
+ - Idle: append immediately.
+ - Busy: queue, then append at a safe boundary in input-ID order.
+ - A passive write never triggers generation.
+
+
+
+
+ Create, wait for, or abort a custom task
+ const ref = await conversation.commit(
+ tx => tx.createTask(Reindex, { path: "/repo" }, {
+ background: true, // conversationId defaults to this handle
+ }),
+ context,
+);
+
+// Request durable cancellation, then observe terminal settlement.
+await harness.abortTask(ref.id, context);
+const task = await harness.waitForTask(ref, context);
+console.log(task.state.outcome);
+ Dependencies use terminal task records; no separate receipt store.
+
+
+
+ Fork at an entry and inspect historical state
+ // PlanDoc is rewindable; checkpointEntryId is visible in the parent.
+const child = await conversation.fork(checkpointEntryId, {}, context);
+
+const historicalPlan = await harness.snapshotAsOf(
+ PlanDoc, conversation.id, checkpointEntryId, context,
+); // undefined when no instance existed at that commit
+
+const childPlan = await harness.snapshot(PlanDoc, {
+ scope: "conversation", conversationId: child.id,
+}, context); // normal access may initialize
+ asOf, current, or initial determines the child document.
+
+
+
+ Observe the conversation as committed state
+ const watch = await conversation.watch(context);
+render(watch.value);
+
+watch.start((view, ops, revision, _context) => {
+ render(view);
+ console.log(revision, ops);
+});
+
+// A Session facet may forward the same committed ops.
+function disposeUi() { // on UI teardown
+ watch.stop();
+}
+ Capture and listener registration are atomic; close stops the watch.
+
+
+
+ Close and reopen without aborting work
+ await harness.close(context);
+// Close causes no abort/terminal write. Admitted commits settle;
+// remaining work and checkpoints stay durable.
+
+const reopenedStorage = await openAppStorage(context);
+const recovered =
+ await Harness.open(reopenedStorage, options, context);
+recovered.resume();
+
+const recoveredConversation =
+ await recovered.conversation(conversation.id, context);
+// Old handles/sources are closed; reacquire from recovered.
+ Plugin reload also disposes old facets and constructs the new definition set before reopen.
+
+
+
Four durable concepts
Concept What it stores Lifetime
Entry Transcript and permanent facts Session history
@@ -302,7 +426,7 @@ many independently attached presentations
-
+
Conversations are cheap branches
Conversation A
entries: e1 ─ e2 ─ e3 ─ e4
@@ -314,7 +438,7 @@ head entry → model context begins here; old entries remain storedNo transcript copy. No whole-Session tree in memory.
-
+
A document declares its semantics
type Stroke = { color: string; points: { x: number; y: number }[] };
type CanvasState = { strokes: Stroke[] };
@@ -329,7 +453,7 @@ head entry → model context begins here; old entries remain storedScope defines lifetime. Only conversation documents choose history and fork.
-
+
Conversation history and fork are explicit
Setting Meaning
history: latestOnly current value matters; old records can be reclaimed after a base.
@@ -340,7 +464,7 @@ head entry → model context begins here; old entries remain stored
-
+
One mutation, one authority
const target = { scope: "session" } as const;
@@ -356,7 +480,7 @@ head entry → model context begins here; old entries remain stored
-
+
Chord: the live delivery half
- Facet — one feature’s setup unit.
@@ -367,7 +491,7 @@ head entry → model context begins here; old entries remain storedChord transports state. Pico5 commits it.
-
+
Canvas service: acquire once
interface CanvasService {
readonly state: ReplicatedState<CanvasState | null>;
@@ -381,7 +505,7 @@ head entry → model context begins here; old entries remain stored// documentReplicatedState is the small Pico5→Chord adapter.
-
+
Canvas service: provide durable methods
const CanvasFacet = defineFacet({
id: "app.canvas/session",
@@ -400,7 +524,7 @@ head entry → model context begins here; old entries remain storedThe method resolves after durability—not after every UI callback.
-
+
Consumers see snapshots, then updates
const CanvasConsumer = defineFacet({
id: "app.canvas/consumer",
@@ -421,7 +545,7 @@ head entry → model context begins here; old entries remain storedLate join: current value. Connected client: ordered committed updates.
-
+
A generation task commits what clients see
// Schematic: LiveDoc is the conversation-scoped presentation document.
// Read provider frames off the Session line.
@@ -438,7 +562,7 @@ head entry → model context begins here; old entries remain storedStorage and every Chord client receive the same committed operations.
-
+
Task kinds replace the fixed graph
type GenerationState =
| { phase: "prepare" }
@@ -455,7 +579,7 @@ head entry → model context begins here; old entries remain storedCore scheduler knows tasks. Each kind knows its own phases.
-
+
The generation task graph grows by commits
Selected business commits; reservation/preparation/progress omitted
@@ -475,7 +599,7 @@ F ordinary post-tools settlement:
The scheduler knows dependencies. Generation, tools, and post-tools create the graph.
-
+
The effect sandwich stays
successful response with no tool calls
@@ -488,7 +612,7 @@ commit append assistant entry E
replace task with completed result { entryId: E }
-
+
Private task state ≠ presentation state
Task record
{
@@ -505,7 +629,7 @@ commit append assistant entry E
-
+
Task scope carries bounded output
type JobOutputState = { stdout: string; chunks: number };
@@ -525,7 +649,7 @@ watch.start((value, ops) => consume(value, ops));
The producer bounds stdout on append; checkpoints only bound replay cost.
-
+
Abort is durable and semantic
conversation C
├─ generation G7 ──owns──→ sub-conversation D ── tool T9
@@ -540,7 +664,7 @@ crash anywhere → durable marks remain → reopen continues abort
Clients observe committed consequences; process-local abort signals are not replicated state.
-
+
Close is not abort
Operation Durable writes Distribution and reopen
Cancel a Context None by itself Requests cancellation of one call/wait; remote propagation depends on transport. Committed work is not undone.
@@ -550,7 +674,7 @@ crash anywhere → durable marks remain → reopen continues abort
Disconnecting a client does not silently abort shared work.
-
+
Hooks and tools remain extensible
Hooks
- declared by task kind
- replacement chains or first decision
- may rerun before their consuming commit
- memos persist idempotency receipts
@@ -559,7 +683,7 @@ crash anywhere → durable marks remain → reopen continues abort
V1: client facets may reload; Session-side plugin code closes and reopens the Harness.
-
+
Target contract: open pi-ai PR #9548
// e10 — complete baseline
{ role: "system", content: basePrompt,
@@ -577,7 +701,7 @@ crash anywhere → durable marks remain → reopen continues abort
Replay in transcript order; stored rendered sections remain authoritative.
-
+
The conversation view is a mount
type ConversationView = {
conversation: Conversation;
@@ -590,7 +714,7 @@ crash anywhere → durable marks remain → reopen continues abort
Path prefixing, not a second tracker or semantic projection.
-
+
Status is state, not an event
live.collapse = { stage: "summarizing", through: 920 }
live.collapse = { stage: "retrying", retryAt: 1730000000 }
@@ -602,7 +726,7 @@ late client during retry:
A “started” event cannot help a client that attaches later.
-
+
Storage is one contract, two products
SQLite
- long-running/cloud Sessions
- one SQL transaction per commit
- indexed base + delta ranges
- current task rows
@@ -611,7 +735,7 @@ late client during retry:
Backends receive the same entries, task records, document ops, and candidate values.
-
+
Build it in reviewable slices
1
storage
memory · SQLite · JSONL→
@@ -622,7 +746,7 @@ late client during retry:
Each slice has intrinsic conformance tests and stops for sign-off.
-
+
The deliberate sharp edges
Ownership
- draft/proxy escape
- inserted and duplicated aliases
- table read after table write
- large values in terminal task records
@@ -631,7 +755,7 @@ late client during retry:
Stable definition IDs, mounted paths, and fork settings are persisted protocol.
-
+
Footguns: drafts have one owner
let escaped: CanvasState | undefined;
await session.commit(async tx => {
@@ -651,7 +775,7 @@ escaped!.strokes.push(otherStroke); // wrong: transaction ende
Do not retain drafts, nested proxies, bound methods, or assigned mutable aliases.
-
+
Footguns: do not block or outrun the line
// Independent mistake 1: external effect on the Session line.
await session.commit(async _tx => requestModel(context), context);
@@ -676,7 +800,7 @@ callback failure before flush → baseline resto
Also remember: normal document reads may create, and IDs/paths are protocol.
-
+
Entries preserve history.
Tasks preserve work.
Documents preserve current truth.
diff --git a/packages/agent/docs/pico-v5.md b/packages/agent/docs/pico-v5.md
index e51336a69..02db2f9b2 100644
--- a/packages/agent/docs/pico-v5.md
+++ b/packages/agent/docs/pico-v5.md
@@ -8,9 +8,12 @@ import type { Context, JsonValue } from "@earendil-works/chord";
import type { Op } from "@earendil-works/chord/delta";
import type {
Message as ModelMessage,
+ Models,
TextContent,
Tool,
ToolReference,
+ ToolResultMessage,
+ UserMessage,
} from "@earendil-works/pi-ai";
type JsonObject = { [key: string]: JsonValue };
@@ -168,6 +171,278 @@ Views carry raw active entries. UI reduction and model-context reduction are
separate consumers. Older stored history is available through cursor-based
entry scans.
+### 2.2 Public Harness surface
+
+This is the v1 host-facing API. Pico5 is not implemented yet, but implementations
+must expose this shape rather than inventing a different facade during package
+25.
+
+```ts
+type ModelRef = {
+ readonly provider: string;
+ readonly modelId: string;
+};
+
+type UserInput = UserMessage["content"];
+
+type SendInput = {
+ readonly content: UserInput;
+ readonly requestId?: string;
+ readonly whenBusy?: "steer" | "followUp" | "reject";
+};
+
+type SectionSeed =
+ | { readonly key: string; readonly value: JsonValue }
+ | { readonly key: string; readonly remove: true };
+
+type ConversationSpec = {
+ readonly parent?: { readonly conversationId: Id; readonly at: Id };
+ readonly model?: ModelRef;
+ readonly sections?: readonly SectionSeed[];
+};
+
+type AnyTaskKind = {
+ readonly definition: {
+ readonly name: string;
+ readonly version: number;
+ readonly initial: unknown;
+ readonly phases: Readonly>;
+ readonly abort: unknown;
+ readonly migrate?: unknown;
+ readonly hooks?: object;
+ };
+};
+
+type HarnessOptions = {
+ readonly models: Models; // the pi-ai Models interface
+ readonly tools?: readonly ToolRegistration[];
+ readonly taskKinds?: readonly AnyTaskKind[];
+ readonly documents?: readonly AnyDocToken[];
+ readonly sections?: readonly SystemSection[];
+ readonly plugins?: Readonly>;
+ readonly processHost?: ProcessHost;
+ readonly now?: () => number;
+ readonly root?: Omit;
+ readonly onReport?: (error: unknown) => void;
+};
+
+interface EntryKind {
+ readonly kind: string;
+ is(entry: Entry | undefined): entry is E;
+}
+
+function defineEntry(kind: string): EntryKind;
+
+type ContextView = {
+ readonly head: Entry | undefined;
+ readonly entries: readonly Entry[];
+ readonly messages: readonly RequestMessage[];
+};
+
+type SettledInput = Input & {
+ readonly status: "done" | "unanswered";
+};
+
+interface InputHandle {
+ readonly id: Id;
+ result(context: Context): Promise;
+ wait(context: Context): Promise;
+ abort(
+ context: Context,
+ ): Promise<"aborted" | "already_placed" | "not_found">;
+}
+
+type SettledTask = Task & {
+ readonly state: TerminalTask;
+};
+
+interface ConversationWatch {
+ readonly value: ConversationView;
+ readonly revision: number;
+ start(listener: (
+ value: ConversationView,
+ ops: readonly Op[],
+ revision: number,
+ context: Context,
+ ) => void): void;
+ stop(): void;
+}
+
+type HooksOf = K extends TaskKind
+ ? H
+ : never;
+
+interface ConversationHandle {
+ readonly id: Id;
+ send(input: SendInput, context: Context): Promise;
+ write(entry: EntryDraft, context: Context): Promise;
+ commit(
+ change: (tx: Tx) => T | Promise,
+ context: Context,
+ ): Promise;
+ context(context: Context): Promise;
+ fork(
+ at: Id,
+ spec: Omit,
+ context: Context,
+ ): Promise;
+ collapse(instructions: string | undefined, context: Context): Promise;
+ reset(handoff: string | undefined, context: Context): Promise;
+ abort(context: Context): Promise;
+ waitForIdle(context: Context): Promise;
+ hooks(
+ owner: string,
+ kind: K,
+ handlers: Partial>,
+ options?: { readonly subtree?: boolean },
+ ): () => void;
+ watch(context: Context): Promise;
+}
+
+interface Harness extends Session {
+ resume(): void;
+ suspend(context: Context): Promise;
+ quiescent(): boolean;
+ hold(): () => void;
+
+ registerDocument(token: AnyDocToken): () => void;
+ registerTaskKind(kind: AnyTaskKind): () => void;
+ registerTool(tool: ToolRegistration): () => void;
+ registerSection(section: SystemSection): () => void;
+ registerEntryKind(kind: EntryKind): () => void;
+ hooks(
+ owner: string,
+ kind: K,
+ handlers: Partial>,
+ ): () => void;
+
+ root(context: Context): Promise;
+ onConversation(listener: (conversation: ConversationHandle) => void): () => void;
+ conversation(id: Id, context: Context): Promise;
+ createConversation(
+ spec: ConversationSpec & { readonly input?: UserInput },
+ context: Context,
+ ): Promise;
+ entries(
+ query: EntryQuery,
+ cursor: EntryCursor | undefined,
+ limit: number,
+ context: Context,
+ ): Promise>;
+
+ getTask(id: Id, context: Context): Promise | undefined>;
+ abortInput(
+ id: Id,
+ context: Context,
+ conversationId?: Id,
+ ): Promise<"aborted" | "already_placed" | "not_found">;
+ abortTask(id: Id, context: Context): Promise<"marked" | "terminal">;
+ markTask(id: Id, context: Context): Promise<"marked" | "terminal">;
+ waitForTask(ref: TaskRef, context: Context): Promise>;
+ waitForIdle(context: Context): Promise;
+}
+
+declare const Harness: {
+ open(
+ storage: Storage,
+ options: HarnessOptions,
+ context: Context,
+ ): Promise;
+};
+```
+
+This intentionally retains the useful Pico3 host shape. It removes Pico3's
+fixed `rewindable()`/`sticky()` accessors, namespace router, semantic view events,
+and manual Chord view bridge. Typed Pico5 documents and the structural
+conversation watch replace those surfaces. `write()` now returns an
+`InputHandle`, because a busy passive write may remain queued before it receives
+an entry ID.
+
+`Harness.open()` installs built-in definitions followed by supplied documents,
+task kinds, tools, sections, and plugins. It then completes document migration,
+changes surviving `running` tasks to `pending`, migrates task records, and
+settles unknown or unmigratable live kinds as `orphaned` before resolving. IDs
+and names must be unique. No handler dispatches during open. Dynamic registration is available
+after open and does not resurrect a task already settled by that pass.
+
+Storage contains one stable root conversation ID in Session metadata. Empty
+storage creates the conversation and metadata together from `options.root`;
+reopen reads that ID rather than inferring it from a conversation scan.
+`options.root` never overwrites existing state. `root()` returns the stored
+handle. A conversation with no configured model produces a durable `no_model`
+generation failure.
+
+`resume()` is idempotent while running and only enables scheduling. It does not
+repeat open-time reconciliation. `suspend()` is terminal for that Harness
+instance and follows the close semantics below; `resume()` after suspend/close
+rejects. `quiescent()` means no task invocation is currently executing; eligible
+or delayed durable tasks may still exist. `hold()` is available only while
+quiescent and pauses reservation until its idempotent release function runs.
+
+Runtime registration rejects duplicate IDs/names and returns an idempotent
+function that unregisters only that exact token. It supports declaration
+movement during normal operation, not replacement of executing plugin code;
+section 7.4 governs code reload.
+
+Conversation creation atomically commits the conversation, built-in
+configuration and section seeds, and optional input admission. For a fork,
+omitted model/section values follow their document definitions' fork policies;
+provided values override those forked built-in values in the same commit. Other
+documents follow their own definitions without special handling.
+
+A `ConversationHandle.commit()` is a Session commit bound to that conversation.
+`tx.createTask()` defaults `TaskOptions.conversationId` to the bound conversation.
+Generic Session-wide document operations remain available directly on `Harness`
+because `Harness extends Session`.
+
+`fork()` requires a concrete visible parent entry and applies section 3.7.
+`collapse()` returns the newly admitted background collapse task ID, not its
+future summary entry. `reset()` durably admits a passive self-head reset or
+handoff write and then resolves; while busy, placement follows section 6 and may
+occur later. Observe its placement through the conversation watch. An idle wait
+does not guarantee placement of queued passive writes.
+
+`markTask()` only commits `abortRequested`; it neither signals nor joins an
+active invocation. The scheduler notices the mark on its next drain.
+`abortTask()` also signals and joins an active run before starting the abort
+invocation. `ConversationHandle.abort()` withdraws queued non-write inputs, marks
+non-background tasks in the conversation and owned subtree, signals them, and
+resolves only after that subtree is ordinarily idle. Passive writes and
+background tasks survive. Conversation idle means no non-background live task
+in that conversation or its ownership subtree; Harness idle applies the same
+rule Session-wide. Pending dependency- or deadline-blocked work is still live
+and therefore not idle. Cancelling an idle wait aborts only that waiter.
+
+`onConversation()` synchronously visits the currently loaded committed
+conversations in ascending ID order, then reports each later creation after its
+commit. Listener failures go to `onReport` and do not stop other listeners. Its
+idempotent disposer removes the listener; Harness close removes all remaining
+listeners.
+
+`send()` and `write()` return after durable admission, not settlement. `send`
+creates a user message with the admission timestamp. `whenBusy` defaults to
+`followUp`. `write()` uses the ordered passive path in section 6 and never starts
+generation. A completed passive write identifies its entry in `Input.entry`; a
+completed send identifies the answer in `Input.answer`. Cancelling `wait()` only
+cancels that wait. It does not withdraw or abort the input; `InputHandle.abort()`
+is the explicit queued-input withdrawal operation.
+
+`abortTask()` durably requests cancellation and returns `marked` after the mark
+is committed, any active run invocation has joined, and an abort invocation has
+been scheduled; it does not await terminal settlement. `waitForTask()` observes
+the terminal receipt. Aborting an already terminal task returns `terminal`; an
+unknown ID rejects. Explicit task abort includes a background task. Cancelling a
+task or idle wait does not abort work.
+
+`ConversationHandle.watch()` atomically captures the current structural view and
+subscribes to later complete Session commits. It has the same stopped-start,
+unbounded pre-start buffering, listener isolation, and close ownership as
+`watchDoc()`. It carries no semantic events and owns no second tracker.
+
+`close()` is equivalent to `suspend()` for v1: it stops reservation, signals and
+joins invocations, closes watches and storage, and writes no task outcome. Handles
+belong to that open Harness and must be reacquired after reopen.
+
## 3. Documents
### 3.1 Definitions
@@ -710,7 +985,7 @@ settles, the scheduler rereads the task and applies the first matching rule:
6. Checkpoint unchanged: write terminal `faulted` because no durable progress
was made.
-On open, orphan reconciliation changes surviving `running` tasks back to
+On open, running-task reconciliation changes surviving `running` tasks back to
`pending`, preserving their checkpoint and abort mark. Task migration then runs
before dispatch. One callback handles every supported older version; newer or
unmigratable live tasks become orphaned.
@@ -886,6 +1161,89 @@ semantic event channel; current UI status is document state.
### 7.2 Tools
+```ts
+type ToolControl = {
+ readonly addTools?: readonly string[];
+ readonly terminate?: true;
+ readonly handoff?: string;
+};
+
+type ToolExecutionResult = {
+ readonly content?: ToolResultMessage["content"];
+ readonly isError?: boolean;
+ readonly details?: JsonValue;
+ readonly control?: ToolControl;
+};
+
+interface OwnedConversationHandle {
+ readonly id: Id;
+ send(input: SendInput, context: Context): Promise;
+ abort(context: Context): Promise;
+ waitForIdle(context: Context): Promise;
+}
+
+interface ToolExecutionApi extends DocumentObserver {
+ readonly taskId: Id;
+ readonly conversationId: Id;
+ readonly callId: string;
+ stream(chunk: string | Uint8Array): void;
+ commit(change: (tx: Tx) => T | Promise, context: Context): Promise;
+ progress(value: JsonObject, context: Context): Promise;
+ memo(name: string, context: Context): Promise;
+ memo(name: string, candidate: T, context: Context): Promise;
+ createTask(
+ kind: TaskKind,
+ input: I,
+ options: Omit,
+ context: Context,
+ ): Promise>;
+ getTask(ref: TaskRef, context: Context): Promise | undefined>;
+ waitForTask(ref: TaskRef, context: Context): Promise>;
+ createConversation(
+ spec: Omit & { readonly inherit?: boolean },
+ context: Context,
+ ): Promise;
+}
+
+type PluginHandler = (
+ input: JsonValue,
+ api: ToolExecutionApi,
+ context: Context,
+) => Promise;
+
+type ToolRegistration = Tool & {
+ readonly replay?: "safe" | "unsafe";
+ readonly output?: {
+ readonly maxBytes?: number;
+ readonly maxLines?: number;
+ readonly retain?: "head" | "tail";
+ };
+ execute(
+ args: JsonValue,
+ api: ToolExecutionApi,
+ context: Context,
+ ): Promise;
+};
+```
+
+Omitted `replay` is `unsafe`. Omitted output bounds are 64 KiB, 200 lines, and
+`retain: "head"`. `stream()` synchronously accepts UTF-8 output into that
+invocation-owned bounded buffer and throws after invocation end. Throttled
+commits publish the retained output and dropped byte/line counts in the tool
+presentation document. If `execute()` omits `content`, the final retained stream
+becomes one text content item; no stream becomes an empty content list. Explicit
+text in explicit result content is bounded by the same limits before transcript
+persistence; non-text content is retained as declared by its pi-ai type.
+
+`progress(value)` replaces the invocation's complete JSON `progress` payload; it
+does not merge keys. Its promise resolves after the corresponding or coalesced
+document commit. During normal settlement, accepted output updates drain before
+the tool-result entry and terminal task record commit. Abort and close obey
+invocation and Session admission gates: uncommitted buffered updates may be
+discarded, while admitted commits settle. Pre-flush cancellation or callback
+failure does not poison the Session. Post-flush, checkpoint, or storage failure
+follows the fatal Session rule.
+
Tools are dynamically registered declarations with name, description, JSON
schema, replay policy, and execute function. A tool call is accepted only if it
was offered in the request's effective system/tool history. Arguments are
@@ -902,8 +1260,25 @@ A tool executes in a durable task. It may:
- create and wait for tasks;
- create owned conversations;
- observe documents for which it has a token/reference;
+- mutate authorized documents through `commit()`;
- return bounded model content and separate diagnostic details.
+Tool and plugin operations use the invoking task's admission and
+invocation-lifetime gates. Task creation defaults to that task's conversation.
+Trusted document access follows sections 3.3 and 9.2; there is no additional
+document subtree authorization layer. `createConversation()` records the
+invoking task as owner and returns an invocation-bound handle. `inherit` defaults
+to `false`. When true, creation atomically selects the invoking conversation's
+newest committed visible entry as `parent.at`; if no entry exists, it creates no
+history parent. Conversation documents then apply section 3.7 at that entry, and
+explicit model/section seeds override their inherited built-in values. With no
+parent, documents initialize normally. That handle's
+operations reject after the invocation ends. An `InputHandle` returned by its
+`send()` is invocation-bound in the same way; the admitted input itself remains
+durable after those methods reject. Invocation-owned document watches stop when
+the invocation ends. A
+plugin handler receives the same API; `callId` is its durable invocation key.
+
A tool result may request `addTools`, `terminate`, or `handoff`. Post-tools
applies added tool names to configured loadout, uses a final boundary for
terminate/handoff, and writes a headed handoff entry when requested.
@@ -917,6 +1292,18 @@ tool terminal outcomes retain their tool-result entry ID for post-tools.
### 7.3 System sections and dynamic tools
+```ts
+interface SystemSection {
+ readonly key: string;
+ render(value: T): string;
+}
+
+function defineSystemSection(definition: {
+ readonly key: string;
+ render(value: T): string;
+}): SystemSection;
+```
+
Pico stores prompt and tool changes directly as PR #9548 `SystemMessage` values
at their transcript positions:
@@ -1011,6 +1398,43 @@ isolation and is not a v1 promise.
## 8. Built-in tasks
+```ts
+type ProcessSpec = {
+ readonly command: string;
+ readonly args: readonly string[];
+ readonly cwd: string;
+ readonly env?: Readonly>;
+};
+
+type ProcessStatus =
+ | {
+ readonly status: "running";
+ readonly stdout: string;
+ readonly stderr: string;
+ readonly droppedStdout: number;
+ readonly droppedStderr: number;
+ }
+ | {
+ readonly status: "exited";
+ readonly exitCode: number;
+ readonly stdout: string;
+ readonly stderr: string;
+ readonly droppedStdout: number;
+ readonly droppedStderr: number;
+ }
+ | { readonly status: "unknown" };
+
+interface ProcessHost {
+ start(key: string, spec: ProcessSpec, context: Context): Promise;
+ status(key: string, context: Context): Promise;
+ kill(
+ key: string,
+ signal: "SIGTERM" | "SIGKILL",
+ context: Context,
+ ): Promise;
+}
+```
+
The initial implementation provides:
| kind | responsibility |
@@ -1022,6 +1446,14 @@ The initial implementation provides:
| job | run a process, persist bounded output, delay or reschedule |
| plugin | run a registered durable handler when needed |
+Generation uses `HarnessOptions.models` without a Pico-specific model adapter. It
+resolves `models.getModel(ref.provider, ref.modelId)`, builds a pi-ai `Context`
+from the prepared prompt/messages/tools, and calls `models.streamSimple()` with
+the task invocation's abort signal and configured reasoning/options. Deferred
+continuation calls `models.fetchDeferred()` and `models.cancelDeferred()` with
+that same model and signal. Missing models and synchronous/streamed pi-ai errors
+are classified into the durable generation outcomes below.
+
Generation and tool progress are throttled durable document commits. A crash may
lose only the uncommitted throttle window. Recovery converts committed partials
to normal interrupted/aborted transcript entries, clears presentation state,
@@ -1143,6 +1575,14 @@ Entry appends/head changes and every changed mounted document are included in
the same publication. The mount owns no tracker and performs no semantic
projection. Its revision is contiguous per view.
+`ConversationHandle.watch()` exposes that mount directly. Capture of
+`ConversationView`, revision, and listener registration is atomic on the Session
+line. Like `watchDoc()`, it returns stopped, buffers without a fixed bound until
+`start()` or `stop()`, drains in order, isolates listener failures, and stops on
+Harness close. Chord Session facets may forward the captured value and committed
+ops through services, but that product wiring is not part of the Harness facade
+and must not add another tracker or semantic event envelope.
+
## 10. Storage contract
Ordered scans are cursor-based. Exact identity lookups are keyed.
@@ -1194,7 +1634,12 @@ type StoredDocument = {
];
};
+type SessionMetadata = {
+ readonly rootConversationId: Id;
+};
+
type StorageWrite =
+ | { readonly type: "session.metadata"; readonly value: SessionMetadata }
| { readonly type: "conversation"; readonly value: Conversation }
| { readonly type: "entry"; readonly value: Entry }
| { readonly type: "task"; readonly value: Task }
@@ -1218,6 +1663,7 @@ interface Storage {
commit(writes: readonly StorageWrite[], context: Context): Promise;
mintId(): Id;
+ metadata(context: Context): Promise;
conversation(id: Id, context: Context): Promise;
scanConversations(cursor: ConversationCursor | undefined, limit: number, context: Context): Promise>;
@@ -1238,6 +1684,11 @@ interface Storage {
}
```
+`metadata()` is absent only for empty storage. The first root creation commits
+its conversation and metadata together. Existing records with missing metadata,
+or metadata naming a missing conversation, make open fail rather than guessing a
+root.
+
`EntryQuery` supports conversation ancestry, a strict `before` entry cursor,
kind filtering, and `withHead`. Document queries support definition, scope,
instance, current/as-of membership, and retired membership. Task queries support
@@ -1335,8 +1786,9 @@ These are contracts, not invitations to add defensive machinery:
- **Schema stability:** definition IDs, mounted document IDs, and visible paths
are persisted/public protocol. Value migration cannot rename an ID; an ID
change requires explicit copy and retirement.
-- **Unstarted watches:** `watchDoc()` buffers every committed update until
- `start()` or `stop()`. Delaying both can consume unbounded memory.
+- **Unstarted watches:** `watchDoc()` and `ConversationHandle.watch()` buffer
+ every committed update until `start()` or `stop()`. Delaying both can consume
+ unbounded memory.
- **Durable progress cadence:** clients see only committed progress. A crash may
lose the current uncommitted throttle window.
- **Large terminal results:** terminal task records remain queryable. Put large