docs(agent): add approved pico implementation handoff

This commit is contained in:
Mario Zechner 2026-09-11 21:49:53 +02:00
parent f3c672245d
commit 4819cc877e
7 changed files with 2298 additions and 232 deletions

View file

@ -1,220 +0,0 @@
# Handoff: outstanding pico decisions
Remaining additions for `packages/agent/docs/pico/`, to reconcile with `pico-v3.md`,
`pico-usage-guide.md` and `pico-work.md` before implementation. Incorporated decisions and rejected
or superseded proposals are omitted. Two parts: harness additions, then presentation additions
that also require task provenance and typed preview access.
---
# Part 1 — Harness
## 1.1 Preview delivery is coalesced
Scratch commits are never delayed, but `task_output` delivery to watchers is coalesced per task to
a frame interval (the rules in `docs/mobile-handoff/01-harness/04-tool-output/rate-limiting.md`),
so a fast stream produces one event per frame instead of one per token. Without this a TUI redraws
per token.
Where: §9.4, next to the tracker paragraph.
## 1.2 Hook handlers receive the task's scratch
A handler that waits on a person (approval, a question) must be able to memoize its answer durably,
or a crash mid-wait re-asks and a crash after the answer asks again. Give handlers `scratch` and
the task id in their context, and state the pattern: read-then-set in one scratch commit, first
writer wins; a crash before the answer asks again, a crash after replays to the same answer; the
memo retires with the task. This replaces `plugins.md`'s `memoOnce` and invocation-memo API, and it
is the same shape the question extension needs.
```typescript
h.hooks.on(toolKind, 'before_tool', async ({ toolName, args, scratch, taskId }, call) => {
if (toolName !== 'bash') return;
const decision = scratchValue<'allow' | 'deny'>('approval');
let d = await scratch(sc => sc.value(decision).get(), call);
if (d === undefined) {
const answer = await approvals.ask(taskId, args, call); // keyed instance every presentation observes
d = await scratch(async sc => {
const stored = await sc.value(decision).get();
if (stored !== undefined) return stored;
sc.value(decision).set(answer);
return answer;
}, call);
}
return d === 'allow' ? { args } : { block: { reason: 'denied by user' } };
});
```
Where: §8.7 and the guide's Hooks section.
## 1.3 Budget ownership transfer
The work plan already flags arbitrary tool-work adoption as unresolved. Give it its own package
when the ownership design is settled: adopting a non-delegating tool's in-flight work after the
budget expires needs an explicit transfer of effect and sink ownership before the source invocation
releases its slot. The job-first path ships first.
Where: `pico-work.md`.
---
# Part 2 — Presentation
## 2.1 Task provenance is `byTaskId`, not `JobState.origin`
`origin: { tool, task, callId }` is a kind-specific enum for something the harness knows
generically. Replace it with a field the `Tx` fills from the committing invocation:
```typescript
interface Task { …; readonly byTaskId?: Id } // task whose commit created it; absent = created outside any task
```
A tool's job has `byTaskId` = the tool task, so a renderer reads that task, `toolKind.is(t)`, and
uses the tool's component. A schedule is a job with `every` in its own state; nothing else marks it.
Subagent tasks chain `byTaskId` up to the `run`/`spawn` tool. `Entry.byTaskId` already exists; this
makes tasks symmetric. Remove `origin` from `JobState`, from the bash example in §8.3 and from the
notice text (derive it from the creating task).
Where: §5.1, §8.3, §8.6.
## 2.2 `!cmd` is a client-owned task kind
User-run bash is initiated by the UI, not by the model or a tool, so it is its own kind owned by
the coding agent (or its bash plugin), sharing the exec-into-scratch helper with `jobKind`. Its
settlement records the transcript write in the same commit as `settle`, so a client that dies in
between leaves nothing to repair. Use `tx.write`: it appends immediately when safe, otherwise queues
until the next turn boundary:
```typescript
type UserBashInput = { cmd: string; cwd: string; includeInContext: boolean; limits?: ShellOutputLimits };
type UserBashStates = UserBashInput & (
| { status: 'planned' }
| { status: 'running' }
| { status: 'done'; exitCode: number }
| { status: 'killed' }
| { status: 'lost' }
);
function userBashInput(state: UserBashStates): UserBashInput {
return { cmd: state.cmd, cwd: state.cwd, includeInContext: state.includeInContext,
...(state.limits === undefined ? {} : { limits: state.limits }) };
}
const userBashKind = defineTaskKind<UserBashStates>()({
kind: 'pi.user_bash',
initialStatus: 'planned',
roles: { planned: 'start', running: 'inflight', done: 'terminal', killed: 'terminal', lost: 'terminal' },
preview: { init: async scratch => (await scratch.value(output).get()) ?? emptyOutput() },
async execute(task, runtime, call) {
await runtime.commit(tx => tx.patch(task, 'running', userBashInput(task.state)), call);
const result = await execIntoScratch(runtime, task.state, call); // same helper as jobKind
const out = runtime.preview.state;
await runtime.commit(tx => {
tx.write(userBashKind.entry, {
data: { cmd: task.state.cmd, exitCode: result.exitCode, output: out },
model: task.state.includeInContext ? [userBashMessage(task.state.cmd, out, result.exitCode)] : undefined,
});
tx.settle(task, 'done', { ...userBashInput(task.state), exitCode: result.exitCode });
}, call);
},
async recover(task, runtime, call) {
await runtime.commit(tx => tx.settle(task, 'lost', userBashInput(task.state)), call);
},
async abort(task, runtime, call) {
await runtime.commit(tx => tx.settle(task, 'killed', userBashInput(task.state)), call);
},
});
// the "!" handler
await c.commit(tx => tx.task(userBashKind, {
background: true, state: { status: 'planned', cmd, cwd, includeInContext, limits },
}), call);
// Escape → h.abortTask(id, call); the renderer's task_start / task_output / entry cases do the rest
```
This is the pattern for any client-owned "thing that runs and should appear in the transcript": a
kind, not a flag on a built-in. Use it as the guide's example of a client-defined task kind; it is
better than the reminder.
Where: the guide's "Writing Kinds".
## 2.3 Two rendering styles are first-class
§9.4 presents `apply(view)` diffing as the way to render. It is one of two, and the other is the
coding agent's interactive mode unchanged:
- **Event-driven**: `task_start` on a generation makes a streaming component; `task_output` updates
it from the view's preview; `task_start` on a tool makes a tool component; `task_end` retires it;
`entry` finalizes. Bookkeeping is one streaming component and one map, as today.
- **View-driven**: `apply(view)` diffing against what was last drawn, for clients that attach
mid-turn or resnapshot.
The view is authoritative in both: it is what a client renders on attach, and it has been folded
before the listener runs. Rewrite the "The view is authoritative and the events are wake-ups"
paragraph accordingly, and lead the guide's Rendering section with the event switch.
Mapping from `packages/coding-agent/src/modes/interactive/interactive-mode.ts` (`handleEvent`,
`renderSessionEntries`):
| interactive mode | pico |
|---|---|
| `agent_start` / `turn_start` / `message_start(assistant)` | `task_start`, `generationKind.is(t)` |
| `message_update`, including tool components born from streaming tool calls (keyed by call id) | `task_output` on the generation; preview is the partial `AssistantMessage` |
| `message_end`; aborted/error → pending tools get error results, else `setArgsComplete` | `entry`, `assistantKind.is(e)` |
| `tool_execution_start` / `_update` / `_end` | `task_start` / `task_output` / `task_end` on the tool task; result via `entry`, `toolResultKind.is(e)`, keyed by `e.key` |
| `compaction_start` / `_end` | `task_start` / `task_end`, `collapseKind.is(t)`; re-render on the summary `entry` |
| `auto_retry_start` / `_end` | `task_update` to/from `retry_wait`; failure on `task_end` `failed` |
| `summarization_retry_*` | `task_update` on the collapse task |
| `agent_end` / `agent_settled` | after any `task_end`: no live foreground task in the view |
| `queue_update` | `inbox` |
| `thinking_level_changed` | `value`, `addr === generationKind.config.thinking` |
| `session_info_changed` | session watch `value` |
| `entry_appended(custom)` | `entry` of a plugin or unregistered kind |
| `bash_execution_update` | `task_output` on a `pi.user_bash` task (2.2) |
One map more than today: tool task id → call id, because tool components are created while the
assistant message streams (by call id) and only later associated with their task.
## 2.4 Typed preview access
Renderers should not cast. The view offers a typed accessor witnessed by the kind, alongside the
raw map the reducer and the wire use:
```typescript
interface ConversationView {
readonly previews: ReadonlyMap<Id, JsonValue>; // raw, kind-free
preview<S extends TaskStateBase, H extends HookPoints, C extends ConfigSpec, P, R extends TaskRoles<S>>(
kind: TaskKind<S, H, C, P, R>, task: Id): P | undefined; // typed; undefined if missing or another kind
}
const msg = w.view.preview(generationKind, event.task); // AssistantMessage | undefined
const out = w.view.preview(toolKind, event.task); // ToolOutputState | undefined
```
`kind.is(task)` already narrows `task.state` in the `task_*` cases.
Where: §9.4 and every example that currently casts.
## 2.5 Renderer registry and layouts
Rendering has two jobs and the client keeps them apart:
- **Renderers**, one per kind, replaceable: `renderers.entry(kind, previous => …)` and
`renderers.task(kind, previous => ({ start, output?, update?, end }))`. Built-ins register the
same way, so a plugin replaces or wraps one by re-registering under the same kind and receiving
the previous renderer. An unregistered kind gets a generic fallback: model text or collapsed
data for entries, kind + status + preview for tasks. Renderers never parent components.
- **Layouts**, pure functions over the ordered list of rendered blocks:
`Layout = (blocks: Block[], view) => LayoutNode[]`, where a node is a block or a group
`{ key, label, collapsed, children }`. Applied in registration order; the client reconciles the
tree with the chat container by key and keeps open/closed state by key.
Grouping tool calls per exchange and collapsing a run until its final answer are each about ten
lines of layout, and they compose. Hydration on attach and live rendering share one path: build
blocks from `view.entries` and `view.tasks`, run the layouts, reconcile. The non-rendering
concerns of today's handler (Escape-handler swaps for retry and compaction, the working indicator,
the post-summary re-render, shutdown checks) stay in the mode; a plugin does not get to hijack
Escape.
This is presentation-side only, the contribution shape `plugins.md` already describes for tool
renderers, keyed by kind string on the wire and by kind object in code. It belongs in the guide's
Rendering section and in `pico-work.md` package 20 as the TUI's parity target.

View file

@ -0,0 +1,305 @@
# Pico rendering
This document defines how a presentation renders a replicated Pico conversation. It covers local and
remote clients, initial attachment, live streaming, terminal handoff, renderer registration and stable
component identity. The harness model remains defined by `pico-v3.md`; the examples and client-facing
surface belong in `pico-usage-guide.md`.
The target is the directness of coding-agent interactive mode and `experimental/mini`, without their
hard-coded event vocabulary or duplicate hydration logic.
## 1. The presentation owns a replica, not agent state
A presentation owns one `ConversationView`. It does not read storage, parse commits or hold live task,
provider, tool or model objects.
Opening a watch atomically captures the view and registers its listener. The harness buffers changes
until delivery starts. Each committed change is folded into the view before listeners observe its
events. An in-process client uses the harness-owned view; a remote client applies the same exported
kind-free reducer before notifying its UI.
Delivery over one WebSocket or SSE connection is ordered and gap-free. If delivery fails or a bounded
queue overflows, that watch closes. Reconnection opens a new watch and receives a fresh view. There are
no cursors, epochs, acknowledgements, deduplication, replay requests or in-place resnapshot protocol.
The view is authoritative. Events identify the affected portion so a live renderer can avoid scanning
the whole view; they are not a second source of presentation state.
## 2. Local view access
The raw collections remain serializable. A local wrapper adds indexed and typed access:
```ts
interface ConversationView {
readonly conversation: Conversation;
readonly entries: readonly Entry[];
readonly tasks: readonly Task[];
readonly previews: ReadonlyMap<Id, JsonValue>;
readonly inbox: readonly Element<QueuedInput>[];
readonly values: ReadonlyMap<Address, JsonValue>;
readonly context: readonly Id[];
readonly faulted: boolean;
readonly readAt: Id;
entry(id: Id): Entry | undefined;
task(id: Id): Task | undefined;
entriesOf<K extends EntryDefinition>(kind: K): readonly EntryOf<K>[];
tasksOf<K extends TaskDefinition>(kind: K): readonly TaskOf<K>[];
preview<S extends TaskStateBase, H extends HookPoints, C extends ConfigSpec, P,
R extends TaskRoles<S>>(
kind: TaskKind<S, H, C, P, R>, taskId: Id,
): P | undefined;
}
```
`preview(kind, taskId)` returns `undefined` when the task is absent, has another kind or has no live
preview. Built-in and plugin renderers use it without casts. `task(id)` is sufficient to classify a
`task_output` event because the reducer has already folded that event before the listener runs.
A remote wire view may remain arrays and maps encoded as JSON. The wrapper builds its indexes locally;
those indexes are not another replicated representation.
## 3. Two rendering paths
Both paths use the same registered renderers and component keys. They differ only in how much of the
view they visit.
### 3.1 Hydration
Hydration runs on first attachment and after opening a replacement watch. It walks the current entries
and live tasks and upserts their blocks:
```ts
function hydrate(view: ConversationView): void {
for (const entry of view.entries) entryRenderers.render(entry, view);
for (const task of view.tasks) taskRenderers.render(task, view);
chrome.sync(view);
}
```
This is the view-driven path used by Mini. Existing blocks are reconciled by stable key, so a fresh
view need not tear down unchanged terminal components.
### 3.2 Live events
Normal streaming uses targeted event handling, as interactive mode does:
```ts
function handle(event: ConversationEvent, view: ConversationView): void {
switch (event.type) {
case "entry":
entryRenderers.render(event.entry, view);
break;
case "task_start":
case "task_update":
taskRenderers.render(event.task, view);
break;
case "task_output": {
const task = view.task(event.task);
if (task) taskRenderers.render(task, view);
break;
}
case "task_end":
taskRenderers.end(event.task, view);
break;
case "inbox":
case "value":
case "context":
chrome.sync(view, event);
break;
case "fault":
case "closed":
chrome.connection(event);
break;
}
}
```
A streamed token therefore updates one task renderer. It does not rebuild every transcript block or
run every layout. A client that does not need the optimization may ignore the event payload and call
`hydrate(view)` after every notification; it remains correct.
The renderer callbacks, not a mandatory full-tree diff, are the shared path between hydration and live
updates.
## 4. Renderer registry
Entry and task renderers are registered by kind. Registration is typed by the kind object even though
the wire and internal registry use its string token:
```ts
renderers.entry(noteKind, previous => ({
render(entry, host, view) { /* entry is NoteEntry */ },
}));
renderers.task(downloadKind, previous => ({
render(task, preview, host, view) { /* typed task and DownloadPreview */ },
end(task, host, view) { /* terminal typed task */ },
}));
```
The exact component type belongs to the presentation package, not Pico. The common contract is:
- `render` upserts components through the host using stable keys;
- `end` handles terminal live-state cleanup and does not need a retired preview;
- renderers do not directly parent components in the transcript container;
- registration may replace or wrap `previous`;
- built-ins and plugins use the same API;
- removing renderer code does not affect the replicated view.
An unregistered entry kind uses a generic renderer: model text when present, otherwise collapsed
kind/data. An unregistered task kind uses kind, status and a collapsed raw preview when present.
Unknown kinds must remain visible and must not break hydration.
The heterogeneous registry may erase renderer types internally. That cast or validation boundary is
owned once by the registry; renderer authors do not cast task state, entry data or previews.
## 5. Renderer host
The host owns component instances and placement. Renderers only describe or update blocks through it.
At minimum it maintains:
```text
entry id -> settled transcript block
live task id -> live task block or region
tool-call id -> tool component
inbox item id -> queued-input component
```
It provides keyed `get`, `upsert`, `move`/`adopt` and `remove` operations. A renderer can update an
existing component without knowing which container currently parents it. The host preserves expansion,
selection and other local UI state while adopting or moving a block.
No persistent `tool task id -> tool-call id` map is required. Every tool-task state variant carries
its call:
- on `task_output`, `view.task(event.task).state.call.id` identifies the component;
- on `task_end`, the event's terminal task carries the same call;
- tool-result entries use their indexed call key.
A renderer may cache expensive presentation-only products such as parsed Markdown. Such caches are
never authoritative and may be discarded on hydration.
## 6. Stable identity and terminal handoff
Every rendered object has a stable semantic key:
| Object | Key |
| --- | --- |
| transcript entry | entry ID |
| live generation | generation task ID |
| assistant content block | generation task ID + content index |
| tool component | tool-call ID |
| live tool preview lookup | tool task ID |
| inbox item | list-element/input ID |
| compaction, job or plugin task | task ID |
A component may change ownership without changing its semantic identity.
### Assistant handoff
A generation preview renders a partial assistant message under the generation task ID. The final
assistant entry has `byTaskId` equal to that generation task. Its renderer adopts the existing live
component, applies the immutable final message and then associates the settled transcript position
with the entry ID. It does not append a duplicate assistant component.
If no matching live component exists, as on initial attachment, the entry renderer creates the same
settled component directly.
### Tool handoff
Tool-call components are born while the assistant preview streams and are keyed immediately by the
call ID. A later tool-task start marks that same component running. Tool output updates it from the
typed preview. The terminal tool-result entry updates the same call-ID component with immutable final
output. No phase creates a second tool component.
### Other tasks
A task that has no terminal entry removes or finalizes its live block in `end`. A task kind that
requires a durable transcript representation writes that entry in the same commit in which it
settles. The view is already folded to the whole commit before listeners run, so no client-visible idle
or missing-result state exists between those events.
## 7. Task provenance required by rendering
Task creation records generic provenance just as entry creation does:
```ts
interface Task {
// ...
readonly byTaskId?: Id;
}
```
`ConversationTx` fills `byTaskId` from the invocation whose commit created the task; it is absent for
tasks created outside a task invocation. Task kinds do not write it themselves.
This replaces kind-specific provenance such as `JobState.origin`. A job created by a tool has
`byTaskId` equal to the tool task. A renderer follows that task and narrows it with `toolKind.is` to
select the originating tool renderer. Nested delegated work follows the chain as needed.
## 8. Structured previews
A preview is the complete current render model for one live task, not a text delta.
The generation preview is the full partial `AssistantMessage` assembled from
`AssistantMessageFrame`s. It retains ordered content blocks, text, thinking, signatures, partial tool
arguments, provider metadata, usage and diagnostics. `task_output.ops` only transports changes; the
renderer reads the already-updated message through `view.preview(generationKind, task.id)`.
A tool preview is the complete current `ToolOutputState`: text or structured content, plugin details,
images, usage, delegation, handoff, added tools, termination, diagnostics and truncation metadata. Tool
renderers own plugin-specific interpretation. The generic host does not flatten it to text.
Terminal rendering uses immutable entries or terminal task state because settlement retires the live
preview.
## 9. Layouts are optional presentation policy
Grouping tool calls, collapsing a run until its final answer and similar structures are useful, but a
general composable layout/reconciliation pipeline is not required by the Pico watch or renderer
contract.
A presentation may add layouts above the keyed renderer host. Layouts must preserve block keys and
must not make preview delivery depend on rebuilding the complete transcript. They should initially run
only for structural changes unless a particular layout explicitly depends on live preview content.
The first coding-agent client should reach feature parity with direct keyed placement. Add a general
layout API only after at least two concrete layouts demonstrate that a shared abstraction is smaller
than their local implementations.
## 10. Client-owned visible work
A client operation that runs over time and should survive in the transcript is an ordinary task kind,
not a presentation-only event stream. For example, `!cmd` is a `pi.user_bash` task owned by the coding
agent or bash plugin. It shares process-output helpers with jobs, publishes a typed preview, and writes
its terminal entry in the same commit as settlement.
The presentation starts it with a command and then handles its generic `task_start`, `task_output`,
`task_end` and `entry` events. Escape calls `abortTask(id)`. Plugins use exactly the same pattern for
client-initiated work.
## 11. Mapping from interactive mode
| Interactive-mode event | Pico rendering |
| --- | --- |
| `agent_start` / `turn_start` / assistant `message_start` | generation `task_start` |
| `message_update` | generation `task_output`; read typed assistant preview |
| `message_end` | assistant `entry`; adopt by `byTaskId` |
| `tool_execution_start` | tool `task_start`; identify component by call ID |
| `tool_execution_update` | tool `task_output`; read typed tool preview |
| `tool_execution_end` | tool `task_end` plus tool-result `entry` |
| `compaction_start` / `_end` | collapse `task_start` / `task_end`; summary `entry` |
| retry events | generation or collapse `task_update` |
| `agent_end` / `agent_settled` | no live foreground task after the commit |
| `queue_update` | `inbox` |
| thinking/model change | typed configuration `value` |
| custom entry | registered entry renderer or generic fallback |
| `bash_execution_update` | `pi.user_bash` `task_output` |
The generic vocabulary changes dispatch, not component behavior. Compared with interactive mode, Pico
removes the second independently authored hydration implementation. Compared with Mini, it replaces
hard-coded operation fields with typed task kinds and replaceable renderers while retaining the same
small keyed-component model.

View file

@ -0,0 +1,38 @@
# Pico implementation state
## Authority
[`pico-simple-handoff.md`](pico-simple-handoff.md) is the sole normative implementation specification.
Read it completely before implementation. Other Pico documents and prototypes are historical inputs only.
Astra reviewer `e5771f` reviewed the complete rewritten specification through multiple iterations and
returned explicit `APPROVED`. Keep the reviewer available for implementation follow-up.
## Git state
- Branch: `pico`
- Branch base when created: `f3c672245`
- No production implementation has started.
- The documentation preservation commit includes the pre-implementation Pico design cleanups and the
approved simple handoff.
- Implement and commit one small work package at a time for user review.
## Next action
Start WP1 from `pico-simple-handoff.md`: core types and kind witnesses under
`packages/agent/src/harness/pico/`, including compile-time tests. Delegate implementation with provider
`openai-codex`, model `gpt-5.6-sol`. Run the focused test and full `npm run check`, then use retained Astra
for review. Commit only WP1 files after the package boundary is approved.
## Adopted core
The harness owns `pending -> running -> terminal`. Task input is immutable typed JSON. A checkpoint is an
optional full replacement with `phase: string`. Execute/recover return typed terminal closures applied
atomically with outcome and scratch retirement. Cancellation is mark, revoke writes, signal, join, fresh
abort, then a restricted abort closure.
`turn: true` is a trusted replaceable task-kind capability. Background is independent because speculative
manual collapse is background plus turn. Non-turn tasks use limited transactions and `accept`/`write`;
turn tasks may append model-affecting entries directly. Every missing live kind becomes orphaned at open.
Input groups belong to built-in generation/post_tools. V1 is job-first and excludes arbitrary unfinished
promise adoption. Tool/job output sharing remains gated.

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,9 @@ Durable agent harness for pi: one session file, any number of conversations, eve
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.
why things are the way they are. Tool/sink/preview interfaces and their examples are provisional:
we still need a simpler authoring surface before implementing them. Their required capabilities and
storage/cancellation guarantees are retained; the separate steps proposal has not been adopted.
## Table of Contents
@ -629,7 +631,10 @@ const answer = await c.prompt({ input: 'Inspect the parser' }, call);
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:
`post_tools` carry input ids explicitly, so result lookup never scans the transcript. This is built-in
harness behavior; a replacement for these built-in kinds must retain their input-result responsibilities.
Removing an executable tool does not remove its task kind or the input owner: the built-in tool task
writes an unavailable-tool result and the turn continues through `post_tools`.
```typescript
const { inputId } = await c.accept({ input: 'Inspect the parser', requestId: 'req-42' }, call);
@ -766,6 +771,9 @@ drain their own scratch promises and drop late callbacks, never silently recreat
## Watching
Tool-facing preview APIs remain provisional alongside the sink. Preview delivery coalescing is
deferred; the current per-commit watch behavior is unchanged.
### The View
A UI never reads storage or parses commits. It watches a conversation and gets a **view**: a plain
@ -1089,6 +1097,10 @@ anywhere. A violating builder call throws before anything is persisted.
## Writing Tools
**Provisional:** the sink, runtime and preview interfaces are preserved below for discussion, not as
final implementation instructions. We will simplify them while retaining streaming output, bounded
capture, diagnostics, typed details, usage, control outcomes and durable job/child cleanup.
### The Sink
A tool's `execute` returns nothing. Everything it produces goes through a sink, so output streams
@ -1258,6 +1270,11 @@ A handler receives the active Call as its final argument and must forward it to
harness awaits its actual return, not an abandoned raced promise. A handler may run again after a
crash, so its external side effects need their own idempotence.
Approval/question policy, presentation and durable answer reuse belong to workspace/plugins, using
existing scratch or scoped values as appropriate. Pico does not add a separate memo subsystem. How
hooks receive task identity and authorized scratch access is still an integration question; the
examples do not establish a new hook-payload API.
## Writing Kinds
Kinds are how you extend the harness with new behaviour rather than new state. An entry kind is a

View file

@ -6,6 +6,11 @@ not a package export. In snippets, `call` is the current `Call` (Chord Context,
asynchronous public/runtime operations; `runtime` is the task/tool capability object. Transaction
builders do not take another Call.
**Design status:** the tool/sink/preview interfaces and related examples remain provisional and need
simplification before implementation. They preserve the capabilities to cover, not a final authoring
surface. Storage, atomic publication, cancellation ownership and bounded-output requirements remain
constraints on that redesign. The separate steps/returned-plans proposal has not been adopted.
## 1. Goals
**Few concepts, replaceable behaviour.** A session is conversations, immutable entries, durable
@ -1420,7 +1425,10 @@ records `terminated`; an abort of the run, or `abortInput` on a queued item, rec
Foreground abort withdraws queued steer and followUp while preserving write and nextRun. Failure
and terminate may place safe writes but do not consume queued inputs that require a successor.
Exactly one live generation or `post_tools` owns an active group, and ownership transfers in the
same commit that settles the previous owner.
same commit that settles the previous owner. Input groups/results are built-in harness behavior,
not payload fields the generic driver infers from arbitrary plugin tasks. These built-in kinds are
registered at open; a compatible replacement retains their input-result responsibilities. A missing
tool implementation is handled by the surviving tool task kind, not by orphaning the input owner.
The list is stored as append/remove/clear operations, not as a rewritten array. Inbox watch events
carry the same operations; an idle same-commit append/remove emits none. Memory and SQLite may
@ -1685,7 +1693,9 @@ tool execute:
```
The tool never touches context, siblings or queues. A throwing `before_tool` blocks the tool. An
ordinary tool throw is an error result, not a cancellation.
ordinary tool throw is an error result, not a cancellation. A missing executable tool is also an
ordinary unavailable-tool result: the built-in `tool` kind remains registered and `post_tools` can
continue or finish the input group. This differs from a genuinely missing task kind (§6.4).
```text
recover: replay only if the stored policy allows; else an interrupted result from the checkpoint
@ -1929,6 +1939,11 @@ and a crash simply asks again. A plugin kind declares its own points the same wa
that replaces a built-in kind keeps its hook names so existing handlers keep working. Commit
listeners are not hooks: they observe, never decide, and never await a commit on the same line.
Approval/question workflows and durable answer reuse belong to the workspace/plugin layer. Pico
supplies task identity and existing storage capabilities, not a separate memo or approval subsystem.
The exact hook access to task id and authorized scratch remains an integration question; no extra
hook-payload fields or answer-replay protocol are established here.
## 9. API
### 9.1 Handles
@ -2189,6 +2204,11 @@ code.
### 9.3 Task and tool integration
**Provisional API:** the sink, tool runtime and output-state shapes below are the current sketch.
Simplify them together with previews before implementing. Preserve streaming text/images, typed
details, bounded capture/spill, diagnostics, usage, control outcomes, jobs and cooperative cleanup;
this section does not settle the final division of responsibilities or arbitrary-work adoption.
```ts
interface Tool<TParams, TDetails extends JsonValue> {
readonly name: string; readonly description: string; readonly parameters: JsonSchema<TParams>;
@ -2273,6 +2293,9 @@ copying the message into `data`.
### 9.4 Watch
The tool-facing preview API below is provisional (§9.3). Delivery coalescing is deferred, not an
initial implementation requirement; the current per-commit publication rules remain unchanged.
The transport is the commit stream, gap-free and replayable. The interface is typed events derived
from it by the harness, and a view the harness keeps current, so a client renders and never parses
commits:

View file

@ -11,6 +11,12 @@ completed dependency audit. After the remaining conceptual blockers are settled,
whole-document review and final work-package decomposition described below. `pico-v3.md` is the
reference design; `pico-usage-guide.md` shows the intended surface.
Next design discussions: evaluate status-indexed task authoring and returned plans separately, then
simplify the tool/sink/preview contract. The steps proposal has not been adopted. Groups 10, 14 and 15
preserve capability/test coverage, but their current API sketches are not ready-to-implement contracts.
Preview delivery coalescing is deferred. Approval/question workflows and durable answer reuse belong
to workspace/plugins; only their access to existing task identity/scratch is a Pico integration question.
## 1. Types and ids
`Id`, `EntryIdentity`, `EntryBase`, the composable `EntryData` / `ModelProjection` /
@ -345,6 +351,9 @@ Tests:
## 10. Tools, post_tools, exchanges
**API redesign pending:** retain the capabilities and behavioral tests below; simplify the tool,
sink and preview authoring interfaces together before implementing them.
The tool kind with the sink (`ToolOutput`, `ToolOutputState`, limits enforced by the sink, `diag`,
`delegate`, `handoff`, `addTools`, `terminate`), tool-result entries with structured data plus their
materialized model message, `before_tool` (fail-closed) and `after_tool`,
@ -361,7 +370,9 @@ before any handoff/user entry in the settlement commit and appears in the next t
a throwing tool → error result, `terminate` still honoured; truncation diag from the sink; a lost
accept response is recovered through `acceptance(requestId)`; a duplicate create reports the first
receipt without comparing payloads or modes; results remain point-readable after further turns.
post_tools handles orphaned tool tasks by writing unavailable-tool results from their common fields.
A missing tool implementation produces an ordinary error result through the registered tool task kind;
the generation/post_tools input owner remains present. Separately, post_tools handles orphaned tool
tasks by writing unavailable-tool results from their common fields.
Turn-task entry appends remain immediate; outside-turn model-visible writes queue while inTurn is
nonempty and land at post_tools/final boundaries. Data-only entries are never blocked.
@ -371,7 +382,9 @@ nonempty and land at post_tools/final boundaries. Data-only entries are never bl
user content or a write's entry draft and optional request id; append/remove/clear watch operations;
queued/placed/done/unanswered result variants; the three placement points; carried generation/post_tools
input groups; `queueInput` and `abortInput`; abort draining steer and followUp while preserving write
and nextRun. A write reaches done without an answer in its placement commit.
and nextRun. A write reaches done without an answer in its placement commit. Input-group ownership
and results are built-in harness behavior, implemented by generation/post_tools; compatible replacements
retain that contract. No generic plugin-payload inference or new input-owner protocol is needed.
Tests: idle append/remove is one commit and emits no inbox event; busy image payload is one append
operation; the modes table; steer joins at post_tools but starts a group after a final answer;
@ -403,6 +416,10 @@ cleanup tolerates already-terminal child/job; `stop`; nested children; child's o
## 14. Jobs and the budget
**Authoring API provisional:** job-first execution remains the initial supported approach. How tool
code starts, observes and delegates work is part of the tool/sink/preview simplification; the current
helper/sink signatures are not final.
`jobKind` on `ExecutionEnv.exec` with output into its scratch, `waitForTask` / `jobOutput`, `bash`
delegating first and waiting with the budget, `notify` and the `notice` entry, the `job` tool,
schedules.
@ -416,10 +433,15 @@ ignores background recurrence; recover → lost or safe rerun; abort kills non-d
Remaining integration design: arbitrary-promise budget adoption must explicitly transfer effect and
sink ownership before the tool invocation releases. A raced, abandoned promise is not permitted.
Preserve the capability (crash outcome lost), but implement job-first execution until transfer is settled.
Preserve the capability (crash outcome lost), but do not turn arbitrary-work adoption into an
implementation package until the simpler tool contract and ownership transfer are settled.
## 15. Previews
**API redesign pending:** retain incremental live output and reconstruction from durable scratch.
The tracker/sink-facing API below is a sketch to simplify, not a separate framework to implement first.
Delivery coalescing is deferred; do not add a frame scheduler or delay scratch durability for it.
`runtime.preview` as a Chord tracker per task; the generation applies stream events to a partial
message, the tool's preview is its sink state, the job's the same; `preview.init` on attach and
reopen; flush after each scratch commit.
@ -549,10 +571,11 @@ global; a kind that changes its shape bumps its own version, which is also the m
Needed when a client is not JavaScript. Not part of the gate.
Not packages: permissions and approval policy are plugin territory (`before_tool` can block or
rewrite args and may wait for a person, scratch holds the durable memo, values hold whatever the
plugin remembers, and a keyed service instance shows the question to every attached presentation),
and session migration is a non-issue while this is experimental.
Not Pico packages: approval/question policy, presentation, durable answers and their replay are
workspace/plugin responsibilities. `before_tool` may block, rewrite args or wait; existing scratch
and scoped values provide persistence. The workspace/plugin layer chooses answer keys and lifetimes;
Pico's remaining integration question is authorized hook access to task identity/scratch, not a new
memo protocol. Session migration remains outside this experimental scope.
## What comes from the lane harness, and how
@ -566,7 +589,7 @@ Copy (under `packages/agent/src/harness/` unless noted):
|---|---|---|---|
| `ExecutionEnv` / `FileSystem` / `Shell` types, the Node env, capture and spill | `types.ts`, `env/`, `tools/tool-context.ts` (03-execenv) | `pico/env/` | 10, 14 |
| shell output limits, `applyShellOutputUpdate`, truncation totals | `utils/` | `pico/env/output.ts` | 10 |
| the built-in tools (`read`, `write`, `edit`, `bash`, `image`) | `tools/*.ts` | `pico/tools/`, rewritten to the sink signature | 10, 14 |
| the built-in tools (`read`, `write`, `edit`, `bash`, `image`) | `tools/*.ts` | `pico/tools/`, adapted after the tool/output interface is simplified | 10, 14 |
| system prompt helpers, skills, context files, templates | `system-prompt.ts`, `skills.ts`, `prompt-templates.ts` | host handlers refresh typed base payloads; pure section renderers format them, without a source-read framework | 9, 20 |
| telemetry span helpers | `telemetry.ts` | `pico/telemetry.ts` | 19 |