From b02eef418672317f30247093a8e68797b8cfe144 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Tue, 15 Sep 2026 19:27:23 +0200 Subject: [PATCH] docs(agent): specify Pico5 harness API --- packages/agent/docs/pico-v5-handoff.md | 54 ++- packages/agent/docs/pico-v5-slides/index.html | 308 ++++++++---- packages/agent/docs/pico-v5.md | 458 +++++++++++++++++- 3 files changed, 712 insertions(+), 108 deletions(-) diff --git a/packages/agent/docs/pico-v5-handoff.md b/packages/agent/docs/pico-v5-handoff.md index 9c3eddf20..12dc7681f 100644 --- a/packages/agent/docs/pico-v5-handoff.md +++ b/packages/agent/docs/pico-v5-handoff.md @@ -15,10 +15,13 @@ facades, membranes, document routing, view projection, events, or clone chains. ## 1. Records, cursors, and memory tables -Implement IDs, sequences, conversations, entries, inputs, live/terminal tasks, -document metadata, storage writes, cursors, and detached `MemoryStorage` tables. +Implement IDs, sequences, Session metadata with stable root ID, conversations, +entries, inputs, live/terminal tasks, document metadata, storage writes, cursors, +and detached `MemoryStorage` tables. -Test mixed atomic commits, rollback, detached reads/writes, cursor boundaries, +Test atomic root-metadata/conversation creation, missing or dangling root +metadata rejection, mixed atomic commits, rollback, detached reads/writes, +cursor boundaries, fork-aware entry scans through deep ancestor caps, head lookup, entry-to-commit lookup, and full task replacement. @@ -170,8 +173,10 @@ Implement durable abort marks, signal/join/fresh-abort invocation, owned conversation creation, subtree traversal, background behavior, and idle waits. Test commit rejection after a run task is marked, crashes at every abort stage, -close precedence over a previously marked task, deep ownership trees, and atomic -retirement of task-scoped documents. +close precedence over a previously marked task, deep ownership trees, atomic +retirement of task-scoped documents, default non-inheritance, inheritance from +the current committed tail, an empty source conversation, document fork +policies, and explicit model/section seed overrides. ## 18. Inputs and positional inbox @@ -222,14 +227,18 @@ progress, owned APIs, interrupted/replay-safe recovery, result entries, post-tools joining, controls, and boundaries using fake tools. Test recovery from every phase, both stored/current replay-policy directions, -and atomic assistant/tool/post-tools settlement. Use a fake generation -successor; package 22 replaces it and reruns integration. +default and overridden bounds, streamed-content fallback, progress replacement +and coalesced commit settlement, drain-before-terminal ordering, +abort/close with buffered output, invocation-bound owned handles, and atomic +assistant/tool/post-tools settlement. Use a fake +generation successor; package 22 replaces it and reruns integration. ## 22. Generation task Implement preparation, request intent, durable throttled partials, attempts, retry policy, response classification, continuation, and deferred polling/ -cancellation with the faux provider. +cancellation through pi-ai's exported `Models` interface. Do not add a Pico +model adapter. The faux test double implements that same interface. Test every phase before and after reopen, aborted partial conversion, overflow through a fake collapse kind, input settlement, and no visible-undurable update. @@ -254,13 +263,32 @@ state or entries, and plugin handler recovery. ## 25. Harness integration -Expose conversation/input/task handles, watches, dynamic registries, close, -suspend, service withdrawal/client detach, and product wiring. Implement the v1 +Implement the exact Pico3-shaped public surface in specification §2.2: +`Harness.open/resume/suspend/close`, lifecycle gates, root/create/lookup +conversations, ordered `write` handles, send/input handles, conversation-bound +commits, fork/collapse/reset/abort/idle, typed task wait/abort, generic document +access, registries, and structural conversation watches. Do not restore Pico3's +namespace router, fixed document accessors, semantic view events, or manual Chord +view bridge. + +Expose service withdrawal/client detach and product wiring. Implement the v1 host-plugin reload path as stop admission, close/join, dispose, rebuild with all new definitions, reopen/migrate, and resume. Test that closing seals commit and get-or-create admission, lets storage settlement for already-flushed admitted -commits finish despite caller cancellation, writes no abort or terminal outcome, starts no fresh abort -invocation, and does not run old and new generations concurrently. +commits finish despite caller cancellation, writes no abort or terminal outcome, +starts no fresh abort invocation, and does not run old and new generations +concurrently. -Run all package-specific tests and the repository check. Verify a local +Test stable persisted root identity; atomic conversation/config/section/input +creation; fork seed overrides; concrete-entry forks; collapse task-ID return; +busy reset admission and later placement; mark-only versus signalling abort; +conversation abort/join with surviving passive writes and background tasks; +quiescence with eligible work; listener initial/future delivery and isolation; +and runtime registration between open and resume without resurrection of a task +settled during open. + +Compile-test every §2.2 signature and every usage sequence shown in slides +20–26. The erased registry test must include a concrete task with narrowed +input, multiple checkpoint phases, and custom hooks. Run all package-specific +tests and the repository check. Verify a local coding-agent turn and a reopened interrupted turn, then stop for final review. diff --git a/packages/agent/docs/pico-v5-slides/index.html b/packages/agent/docs/pico-v5-slides/index.html index e9272f36e..5a6f69cd9 100644 --- a/packages/agent/docs/pico-v5-slides/index.html +++ b/packages/agent/docs/pico-v5-slides/index.html @@ -132,89 +132,92 @@ Recovery records them as interrupted, then retries only when safe.
-

What Lane got right

-
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 solved a narrower problem well

+
+

Session

  • immutable entry tree
  • typed current values and lists
  • atomic commits
  • usage ledger
+

AgentLane

  • one configured branch
  • queued input
  • at most one core operation
  • durable restart state
+
+

Lane is our durable conversation runtime. One operation is an agent run, compaction, or navigation.

-

Lane’s state machine was one fixed machine

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

-
- -
-

Lane’s application storage was values + lists

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

-
- -
-

Canvas with a Lane value: naive split read/write

-
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
    -
  • Read, copy, and serialize the complete canvas per stroke.
  • -
  • Use Lane’s existing session.mutate() to serialize read-modify-write.
  • -
  • Maintain a separate replicated UI copy.
  • +
  • Lane already supports multiple lanes and parallel tools.
  • +
  • Missing: plugin-defined tasks with their own checkpoints, recovery, cancellation, and dependencies.
-
-

Canvas with a Lane list

-
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 primitiveCanvas writeApplication must add
value<Canvas>session.mutate(), copy and replace full canvasnested deltas and replica updates
list<CanvasOp>append one small stroke operationfold, 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

@@ -302,7 +426,7 @@ many independently attached presentations
ConceptWhat it storesLifetime
EntryTranscript and permanent factsSession history
-
+

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

@@ -340,7 +464,7 @@ head entry → model context begins here; old entries remain stored
SettingMeaning
history: latestOnly current value matters; old records can be reclaimed after a base.
-
+

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

@@ -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
OperationDurable writesDistribution and reopen
Cancel a ContextNone by itselfRequests cancellation of one call/wait; remote propagation depends on transport. Committed work is not undone.