docs(agent): refine Pico5 plugin state API

This commit is contained in:
Mario Zechner 2026-09-17 11:24:54 +02:00
parent fde6d778f8
commit 2b04ce27fb
4 changed files with 422 additions and 190 deletions

View file

@ -30,7 +30,10 @@ terminal and never participate in conversation forks.
Examples build on one another. Pico5 names (`defineDoc`, `defineDocFamily`,
`Session`, `DocumentSource`, `Id`, `TaskRuntime`, `DocumentObserver`)
refer to normative contracts, without a specified import path or runnable Pico5
package. These Chord imports exist today:
package. In those contracts, `ConversationRecord`, `EntryRecord`, and
`TaskRecord` are persisted records, while `Conversation` is the public
conversation object and `Entry`/`Task` are typed definitions. These Chord imports
exist today:
```ts
import {
@ -39,6 +42,7 @@ import {
type ReplicatedState,
} from "@earendil-works/chord";
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
import { applyImmutable } from "@earendil-works/chord/delta";
// The spec calls this JsonObject; current Chord exports JsonValue, not JsonObject.
type JsonObject = { [key: string]: JsonValue };
@ -49,8 +53,8 @@ declare function documentReplicatedState<T extends JsonObject>(
): Promise<{ readonly state: ReplicatedState<T | null>; dispose(): void }>;
```
The adapter must register Chord-recognized state, atomically hydrate matching
committed value/revision and subscribe, and forward committed ops without another
The adapter must register Chord-recognized state, atomically hydrate a matching
committed value and adapter-owned delivery sequence, and forward committed ops without another
tracker or re-diff. Disposal releases observation, not the document. Even Chord's
hydration requests must never flush an uncommitted draft.
@ -282,14 +286,17 @@ async function observeJob(
): Promise<void> {
const target = jobOutputTarget(producerTaskId);
const watch = await api.watchDoc(JobOutputDoc, target, context);
let value = watch.value;
console.log(value === null ? "retired" : value.stdout);
try {
console.log(watch.revision, watch.value.stdout); // Consume captured initial value.
watch.start((value, ops, revision, _context) => {
console.log(revision, value === null ? "retired" : value.stdout, ops);
}); // Drain buffered changes in order, then continue live delivery.
watch.start(async (ops, _context) => {
value = applyImmutable(value, ops);
await render(value === null ? "retired" : value.stdout);
}); // Serialized callbacks never overlap.
await finished; // Caller-supplied observation lifetime; outside any commit.
} finally {
watch.stop(); // Idempotent; discards any remaining buffer.
watch.stop(); // Idempotent; prevents another callback from starting.
await watch.closed; // Wait for an in-flight callback to settle.
}
}
```
@ -300,16 +307,21 @@ includes that interface. Acquisition validates the target task is live and
derives its conversation from the task record.
```text
watchDoc: capture value V at revision 8 + register listener atomically on Session line
producer commits revision 9 before start: buffer 9
consumer reads watch.value (V), calls start: deliver 9, then subsequent revisions
producer becomes terminal: same commit retires output; watch receives null + ["r", null]
watchDoc: capture immutable V0 + register for later committed operation batches
producer commits D1 and D2 before start: queue D1, D2
queue exceeds its limit: replace pending suffix with [["r", V2]]
start: caller has initialized from V0; deliver the reset through the async listener
producer commits D3 while listener awaits: queue D3; never overlap callbacks
producer becomes terminal: queue [["r", null]], deliver it, then close as retired
```
Watches automatically stop when their invocation ends. A Session-acquired watch
is caller-owned and stops on Session close. Retirement ends the incarnation's
stream; recreation requires a new watch/source and a new numeric
document ID. A terminal task cannot create more output. Task-scoped documents
is caller-owned and stops on Session close. Previously delivered snapshots never
mutate. Slow or unstarted delivery may coalesce an undelivered suffix into a
complete reset, so a watch is convergent state observation rather than a
transition journal. Retirement ends the incarnation's stream; recreation
requires a new watch/source and a new numeric document ID. A terminal task cannot
create more output. Task-scoped documents
never copy into forks. Preserve required output in result entries or Session- or
conversation-scoped documents in the terminal commit before retirement. Use a
task-scoped family only when one task needs several independently keyed documents.
@ -337,9 +349,9 @@ dynamic service should then withdraw its instance rather than expose stale data.
addStroke -> hold Session mutation line -> await tx.doc -> mutate tracked draft
callback succeeds -> tracker flush: incremental ops + candidate value
atomic storage commit: all document and record writes; checkpoint predicate selects ops or base
storage succeeds -> adopt committed baseline + enqueue source publication, still on line
storage succeeds -> materialize immutable published value + enqueue value/ops, still on line
release line -> deliver committed source ops -> adapter -> local/remote Chord consumers
late subscriber -> atomically capture committed value/revision + subscription
late subscriber -> atomically capture committed value + adapter sequence + subscription
```
No visible-undurable path exists. Callback failure restores the unchanged
@ -354,7 +366,10 @@ nothing and poisons the open Session; close and reopen it instead of continuing.
Await document access there, not models, processes, network calls, or humans.
- Normal `snapshot`, `documentSource`, and `watchDoc` reads are get-or-create.
Family `initial` is first-creation input, not an update.
- An unstarted watch buffers without a fixed bound. Start or stop promptly.
- Initialize from the fixed `watch.value` before `start()`. Slow or unstarted
delivery may coalesce an undelivered suffix into a root replacement, omitting
intermediate states. Do not use a watch as an audit log.
- A listener may call `stop()`, but must not await its own `closed` promise.
- Definitions own checkpoints, not storage heuristics. Revise the counting predicates
above if mutations change. Keep schema IDs, versions, fork policies, and public
paths stable; schema changes require migration, not a source-only rename.

View file

@ -15,9 +15,12 @@ facades, membranes, document routing, view projection, events, or clone chains.
## 1. Records, cursors, and memory 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.
Implement IDs, sequences, Session metadata with stable root ID,
`ConversationRecord`, `EntryRecord`, inputs, live/terminal `TaskRecord` values,
document metadata, storage writes, cursors, and detached `MemoryStorage` tables.
Reserve `Conversation` for the public conversation object, `Entry` for the typed
entry definition, and `Task` for the typed executable definition returned by
`defineTask()`.
Test atomic root-metadata/conversation creation, missing or dangling root
metadata rejection, mixed atomic commits, rollback, detached reads/writes,
@ -63,11 +66,14 @@ never reclaimed and default no-fsync behavior matches the specification.
Keep one tracker per loaded document. Add transaction begin, callback rollback
from the unchanged baseline, flush, fatal post-flush failure, committed baseline
adoption, and eviction.
adoption, and eviction. After storage succeeds, materialize one immutable
published value per changed document with `applyImmutable(previous, ops)`,
separate from mutable tracker and borrowed storage candidates.
Do not add membranes, transaction proxy graphs, capability facades, or defensive
full-document clones. Test callback failure, no-op transactions, storage failure
poisoning, and unload/reload.
poisoning, immutable prior published values, replacement-payload ownership, and
unload/reload.
## 7. Document definitions and access
@ -114,19 +120,29 @@ unchanged previous immutable snapshots. Internal flushes must remain one commit.
Add the thin opaque-source adapter to `ReplicatedState`. It must use committed
value/ops directly, with no tracker or re-diff.
Test contiguous revisions, atomic hydrate/subscribe, attachment races,
retirement ending one incarnation, recreation requiring reacquisition, and
listener isolation.
Test contiguous Chord adapter delivery sequences, atomic hydrate/subscribe,
attachment races, retirement ending one incarnation, recreation requiring
reacquisition, and listener isolation. Reuse the transaction core's immutable
published value; do not materialize another document copy.
## 12. Document watches
Implement get-or-create `watchDoc`: capture value/revision and register its
listener atomically, buffer without a cap until `start()` or `stop()`, and always
deliver value plus ops.
Implement get-or-create `watchDoc` as an incarnation-bound `WatchHandle` that
atomically captures one fixed immutable value and registers for later committed
operation batches. `start()` installs one serialized asynchronous listener.
Bound the pending queue by batch count and estimated retained payload; compact an
overdue undelivered suffix into one root replacement using the matching latest
immutable published value from package 6; never retain mutable tracker or borrowed
storage candidates.
Test ordered draining, live delivery, retirement as absence, idempotent stop,
listener errors, and explicit cleanup. Invocation-owned cleanup is integrated in
package 15.
Test updates between acquisition/return/start; asynchronous consumer
initialization; no callback overlap; listener-initiated commits; compaction
before start and behind an in-flight callback; one oversized replacement;
immutable earlier values; retirement before start and while active; recreation;
idempotent stop; second-start rejection; cancellation during acquisition;
cancellation/close during a callback; listener-error settlement; `closed`
self-join misuse; and invocation-owned
cleanup in package 15.
## 13. Conversations and entries
@ -144,7 +160,8 @@ tool-result ordering, and missing post-fork tool results. Replay `content`,
ordered named `sections` with `null` removal, then tool removals/additions.
Test model-less and excluded-stop-reason entries, replacements/omissions,
multiple heads, section replacement/removal/re-addition order, rejection of
multiple heads, section replacement/removal/re-addition order, order-only
configuration changes between separate request preparations, rejection of
integer-like section keys, tool addition/removal/replacement order, and raw-view
versus model context.
@ -198,16 +215,19 @@ specification.
Implement `{ conversation, entries, docs }`. Test direct task writes, one
publication per Session commit, atomic
entry/preview settlement, head changes, contiguous revisions, stable public
paths, retry/collapse late-join status, bounded-output truncation metadata, and
absence of semantic projection. Specify which diagnostics become entries,
paths, immutable acquisition snapshots, asynchronous consumer initialization,
serialized updates, reset compaction, retry/collapse late-join status,
bounded-output truncation metadata, and absence of semantic projection. Specify which
diagnostics become entries,
terminal details, or bounded document state.
## 20. Registries, hooks, and sections
Implement task/tool/entry/section registries, Session and owned-subtree hooks,
positional PR #9548 section/tool updates, complete baselines after a head cut,
and preparation revision checks. Do not add a public event stream or
plugin-state router.
and preparation revision checks. Do not add a Session-kernel semantic event
journal or plugin-state router; package 25 adds the thin product notification
adapter from specification §9.4.
Test registration lifetimes, hook replay with memos, exact persisted rendered
section strings, minimal section patches and `null` removals, complete baseline
@ -265,22 +285,38 @@ state or entries, and plugin handler recovery.
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
`Conversation` objects, 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
Expose service withdrawal/client detach and product wiring. Implement the §9.4
agent-mode notification adapter directly from uncoalesced committed publication,
without another tracker or persistence authority. Migrate TUI hydration to the
structural conversation watch, make print await its own `InputHandle`, and expose
JSON/RPC correlated commands plus ordered committed notifications. Test that
watch reset compaction cannot erase a subscribed notification lifecycle, late
clients use structural hydration rather than event replay, progress notifications
reflect durable throttled state rather than every provider frame, and stdout
backpressure/disconnect policy stays in the mode adapter.
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, stops watches, joins in-flight watch
callbacks and task/tool/hook invocations outside the Session line, writes no abort
or terminal outcome, starts no fresh abort invocation, and does not run old and
new generations concurrently. Include cancellation during watch acquisition and
a non-cooperative watch callback in shutdown/plugin-reload quiescence tests.
Test stable persisted root identity; atomic conversation/config/section/input
creation; fork seed overrides; concrete-entry forks; collapse task-ID return;
creation; default `"off"` thinking; every configuration getter/setter; explicit
active-tool seed duplicate/unregistered rejection; default active registry
snapshot; as-of fork inheritance including unavailable historical names; durable
`missing_active_tool` settlement; 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;

View file

@ -301,7 +301,7 @@ many independently attached presentations</code></pre>
<span class="kw">const</span> storage: <span class="type">Storage</span> = <span class="kw">await</span> openAppStorage(context);
<span class="kw">const</span> options: <span class="type">HarnessOptions</span> = {
models, <span class="comment">// pi-ai Models</span>
tools: [read, bash],
tools: [read, bash], <span class="comment">// register; new root defaults both active</span>
taskKinds: [Reindex],
documents: [PlanDoc, JobOutputDoc],
sections,
@ -311,7 +311,7 @@ many independently attached presentations</code></pre>
<span class="kw">const</span> harness = <span class="kw">await</span> Harness.open(storage, options, context);
harness.resume();
<span class="kw">const</span> conversation = <span class="kw">await</span> harness.root(context);</code></pre>
<p class="subtitle step">Definitions are installed before migration, recovery, and task dispatch.</p>
<p class="subtitle step">Registration declares code; only conversation configuration changes an existing active loadout.</p>
</div></section>
<section class="slide" data-slide="21"><div class="frame wide">
@ -385,18 +385,20 @@ console.log(task.state.outcome);</code></pre>
<section class="slide" data-slide="25"><div class="frame wide">
<h2>Observe the conversation as committed state</h2>
<pre class="code-small"><code><span class="kw">const</span> watch = <span class="kw">await</span> conversation.watch(context);
render(watch.value);
<span class="kw">let</span> view = watch.value;
<span class="kw">await</span> render(view);
watch.start((view, ops, revision, _context) =&gt; {
render(view);
console.log(revision, ops);
watch.start(<span class="kw">async</span> (ops, _context) =&gt; {
view = applyImmutable(view, ops);
<span class="kw">await</span> render(view);
});
<span class="comment">// A Session facet may forward the same committed ops.</span>
<span class="kw">function</span> disposeUi() { <span class="comment">// on UI teardown</span>
<span class="kw">async function</span> disposeUi() { <span class="comment">// on UI teardown</span>
watch.stop();
<span class="kw">await</span> watch.closed;
}</code></pre>
<p class="subtitle step">Capture and listener registration are atomic; close stops the watch.</p>
<p class="subtitle step">The fixed snapshot plus serialized async updates converge without overlapping callbacks.</p>
</div></section>
<section class="slide" data-slide="26"><div class="frame wide">
@ -642,8 +644,12 @@ commit append assistant entry E
<span class="comment">// taskId identifies the supplied live producer task.</span>
<span class="kw">const</span> target = { scope: <span class="str">"task"</span>, taskId } <span class="kw">as const</span>;
<span class="kw">const</span> watch = <span class="kw">await</span> api.watchDoc(JobOutput, target, context);
consume(watch.value);
watch.start((value, ops) =&gt; consume(value, ops));
<span class="kw">let</span> value = watch.value;
<span class="kw">await</span> consume(value);
watch.start(<span class="kw">async</span> (ops, _context) =&gt; {
value = applyImmutable(value, ops);
<span class="kw">await</span> consume(value);
});
<span class="comment">// Producer commit: output.stdout = (output.stdout + chunk).slice(-50_000)</span>
<span class="comment">// Terminal task commit retires the document.</span></code></pre>
<p class="subtitle step">The producer bounds stdout on append; checkpoints only bound replay cost.</p>
@ -669,7 +675,7 @@ crash anywhere → durable marks remain → reopen continues abort</code></pre>
<table class="table"><thead><tr><th>Operation</th><th>Durable writes</th><th>Distribution and reopen</th></tr></thead><tbody>
<tr class="step"><td>Cancel a Context</td><td>None by itself</td><td>Requests cancellation of one call/wait; remote propagation depends on transport. Committed work is not undone.</td></tr>
<tr class="step"><td>Abort task/conversation</td><td>Abort marks, then terminal outcomes</td><td>Traverses ownership; reopen finishes marked aborts.</td></tr>
<tr class="step"><td>Close Session</td><td>No abort/terminal writes caused by close</td><td>Admitted commits may settle; then join, stop watches, close storage, and withdraw services. Reopen resumes checkpoints.</td></tr>
<tr class="step"><td>Close Session</td><td>No abort/terminal writes caused by close</td><td>Seal admission; signal invocations and stop watches; settle commits and join callbacks; close storage and withdraw services. Reopen resumes checkpoints.</td></tr>
</tbody></table>
<p class="subtitle step">Disconnecting a client does not silently abort shared work.</p>
</div></section>
@ -704,8 +710,8 @@ crash anywhere → durable marks remain → reopen continues abort</code></pre>
<section class="slide" data-slide="46"><div class="frame wide">
<h2>The conversation view is a mount</h2>
<pre class="code-small"><code><span class="kw">type</span> <span class="type">ConversationView</span> = {
conversation: <span class="type">Conversation</span>;
entries: <span class="kw">readonly</span> <span class="type">Entry</span>[]; <span class="comment">// raw active head → tail</span>
conversation: <span class="type">ConversationRecord</span>;
entries: <span class="kw">readonly</span> <span class="type">EntryRecord</span>[]; <span class="comment">// raw active head → tail</span>
docs: <span class="type">Readonly</span>&lt;<span class="type">Record</span>&lt;<span class="type">string</span>, <span class="type">JsonObject</span>&gt;&gt;;
};
@ -715,7 +721,7 @@ crash anywhere → durable marks remain → reopen continues abort</code></pre>
</div></section>
<section class="slide" data-slide="47"><div class="frame wide">
<h2>Status is state, not an event</h2>
<h2>Status is state, not only an event</h2>
<pre><code>live.collapse = { stage: "summarizing", through: 920 }
live.collapse = { stage: "retrying", retryAt: 1730000000 }
delete live.collapse
@ -723,7 +729,7 @@ delete live.collapse
late client during retry:
hydrates the current value
knows compaction is running and when it retries</code></pre>
<p class="subtitle step">A “started” event cannot help a client that attaches later.</p>
<p class="subtitle step">Active JSON/RPC clients may receive committed notifications; late clients hydrate state.</p>
</div></section>
<section class="slide" data-slide="48"><div class="frame wide">
@ -750,7 +756,7 @@ late client during retry:
<h2>The deliberate sharp edges</h2>
<div class="grid2">
<div class="panel step"><h3>Ownership</h3><ul class="tight"><li>draft/proxy escape</li><li>inserted and duplicated aliases</li><li>table read after table write</li><li>large values in terminal task records</li></ul></div>
<div class="panel step"><h3>Liveness</h3><ul class="tight"><li>external or nested waits on the line</li><li>get-or-create “reads”</li><li>unstarted watch buffering</li><li>checkpoint starvation</li><li>continuing after storage failure</li></ul></div>
<div class="panel step"><h3>Liveness</h3><ul class="tight"><li>external or nested waits on the line</li><li>get-or-create “reads”</li><li>watch callback self-join</li><li>checkpoint starvation</li><li>continuing after storage failure</li></ul></div>
</div>
<p class="subtitle step">Stable definition IDs, mounted paths, and fork settings are persisted protocol.</p>
</div></section>
@ -792,7 +798,7 @@ escaped!.strokes.push(otherStroke); <span class="bad">// wrong: transaction ende
<span class="kw">await</span> session.snapshot(Doc, target, context); <span class="warn">// may create + commit</span>
<span class="kw">const</span> watch = <span class="kw">await</span> session.watchDoc(Doc, target, context);
<span class="comment">// never start()/stop(): buffered commits are unbounded</span>
<span class="comment">// initialize from watch.value; slow pending suffixes may compact to a reset</span>
checkpointWhen: () =&gt; <span class="kw">false</span> <span class="bad">// replay/storage can grow while live</span>
known post-flush/checkpoint/storage failure → <span class="bad">close + reopen</span>

View file

@ -5,10 +5,11 @@ Pico5 uses existing package types as follows:
```ts
import type { Context, JsonValue } from "@earendil-works/chord";
import type { Op } from "@earendil-works/chord/delta";
import { applyImmutable, type Op } from "@earendil-works/chord/delta";
import type {
Message as ModelMessage,
Models,
ModelThinkingLevel,
TextContent,
Tool,
ToolReference,
@ -66,7 +67,7 @@ these contracts.
type Id = number;
type Seq = number;
type Conversation = {
type ConversationRecord = {
readonly id: Id;
readonly parent?: {
readonly conversationId: Id;
@ -107,7 +108,7 @@ type ContextEdit = {
readonly messages?: readonly RequestMessage[];
};
type Entry = {
type EntryRecord = {
readonly id: Id;
readonly conversationId: Id;
readonly kind: string;
@ -118,7 +119,7 @@ type Entry = {
readonly byTaskId?: Id;
};
type EntryDraft = Omit<Entry, "id" | "conversationId" | "byTaskId" | "head"> & {
type EntryDraft = Omit<EntryRecord, "id" | "conversationId" | "byTaskId" | "head"> & {
readonly head?: Id | "self";
};
@ -199,9 +200,10 @@ type ConversationSpec = {
readonly parent?: { readonly conversationId: Id; readonly at: Id };
readonly model?: ModelRef;
readonly sections?: readonly SectionSeed[];
readonly activeTools?: readonly string[];
};
type AnyTaskKind = {
type AnyTask = {
readonly definition: {
readonly name: string;
readonly version: number;
@ -216,7 +218,7 @@ type AnyTaskKind = {
type HarnessOptions = {
readonly models: Models; // the pi-ai Models interface
readonly tools?: readonly ToolRegistration[];
readonly taskKinds?: readonly AnyTaskKind[];
readonly taskKinds?: readonly AnyTask[];
readonly documents?: readonly AnyDocToken[];
readonly sections?: readonly SystemSection<JsonValue>[];
readonly plugins?: Readonly<Record<string, PluginHandler>>;
@ -226,16 +228,16 @@ type HarnessOptions = {
readonly onReport?: (error: unknown) => void;
};
interface EntryKind<E extends Entry = Entry> {
interface Entry<E extends EntryRecord = EntryRecord> {
readonly kind: string;
is(entry: Entry | undefined): entry is E;
is(entry: EntryRecord | undefined): entry is E;
}
function defineEntry<E extends Entry>(kind: string): EntryKind<E>;
function defineEntry<E extends EntryRecord>(kind: string): Entry<E>;
type ContextView = {
readonly head: Entry | undefined;
readonly entries: readonly Entry[];
readonly head: EntryRecord | undefined;
readonly entries: readonly EntryRecord[];
readonly messages: readonly RequestMessage[];
};
@ -252,30 +254,37 @@ interface InputHandle {
): Promise<"aborted" | "already_placed" | "not_found">;
}
type SettledTask<R> = Task<JsonValue, JsonValue, R> & {
type SettledTask<R> = TaskRecord<JsonValue, JsonValue, R> & {
readonly state: TerminalTask<R>;
};
interface ConversationWatch {
readonly value: ConversationView;
readonly revision: number;
start(listener: (
value: ConversationView,
ops: readonly Op[],
revision: number,
context: Context,
) => void): void;
stop(): void;
}
type ConversationWatch = WatchHandle<ConversationView>;
type HooksOf<K> = K extends TaskKind<infer _I, infer _S, infer _R, infer H>
type HooksOf<K> = K extends Task<infer _I, infer _S, infer _R, infer H>
? H
: never;
interface ConversationHandle {
interface Conversation {
readonly id: Id;
send(input: SendInput, context: Context): Promise<InputHandle>;
write(entry: EntryDraft, context: Context): Promise<InputHandle>;
getModel(context: Context): Promise<ModelRef | undefined>;
setModel(model: ModelRef | undefined, context: Context): Promise<void>;
getThinkingLevel(context: Context): Promise<ModelThinkingLevel>;
setThinkingLevel(level: ModelThinkingLevel, context: Context): Promise<void>;
getActiveTools(context: Context): Promise<readonly string[]>;
setActiveTools(names: readonly string[], context: Context): Promise<void>;
getSection<T extends JsonValue>(
section: SystemSection<T>,
context: Context,
): Promise<T | undefined>;
setSection<T extends JsonValue>(
section: SystemSection<T>,
value: T | undefined,
context: Context,
): Promise<void>;
commit<T>(
change: (tx: Tx) => T | Promise<T>,
context: Context,
@ -285,14 +294,14 @@ interface ConversationHandle {
at: Id,
spec: Omit<ConversationSpec, "parent">,
context: Context,
): Promise<ConversationHandle>;
): Promise<Conversation>;
collapse(instructions: string | undefined, context: Context): Promise<Id>;
reset(handoff: string | undefined, context: Context): Promise<void>;
abort(context: Context): Promise<void>;
waitForIdle(context: Context): Promise<void>;
hooks<K extends AnyTaskKind>(
hooks<K extends AnyTask>(
owner: string,
kind: K,
task: K,
handlers: Partial<HooksOf<K>>,
options?: { readonly subtree?: boolean },
): () => void;
@ -306,31 +315,31 @@ interface Harness extends Session {
hold(): () => void;
registerDocument(token: AnyDocToken): () => void;
registerTaskKind(kind: AnyTaskKind): () => void;
registerTaskKind(task: AnyTask): () => void;
registerTool(tool: ToolRegistration): () => void;
registerSection(section: SystemSection<JsonValue>): () => void;
registerEntryKind(kind: EntryKind): () => void;
hooks<K extends AnyTaskKind>(
registerEntryKind(entry: Entry): () => void;
hooks<K extends AnyTask>(
owner: string,
kind: K,
task: K,
handlers: Partial<HooksOf<K>>,
): () => void;
root(context: Context): Promise<ConversationHandle>;
onConversation(listener: (conversation: ConversationHandle) => void): () => void;
conversation(id: Id, context: Context): Promise<ConversationHandle | undefined>;
root(context: Context): Promise<Conversation>;
onConversation(listener: (conversation: Conversation) => void): () => void;
conversation(id: Id, context: Context): Promise<Conversation | undefined>;
createConversation(
spec: ConversationSpec & { readonly input?: UserInput },
context: Context,
): Promise<ConversationHandle>;
): Promise<Conversation>;
entries(
query: EntryQuery,
cursor: EntryCursor | undefined,
limit: number,
context: Context,
): Promise<Page<Entry, EntryCursor>>;
): Promise<Page<EntryRecord, EntryCursor>>;
getTask(id: Id, context: Context): Promise<Task<JsonValue, JsonValue, JsonValue> | undefined>;
getTask(id: Id, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
abortInput(
id: Id,
context: Context,
@ -385,12 +394,49 @@ 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.
configuration and section/tool seeds, and optional input admission. For a fork,
omitted model, section, and active-tool values follow their document definition's
fork policy; 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.
The built-in conversation configuration document contains the selected model,
thinking level, section values, and active tool names. Its initial thinking level
is `"off"`. It is rewindable with `fork: "asOf"`, so a child starts from the
configuration visible at its selected entry unless explicit creation seeds
override it. Conversation creation eagerly
creates it, so `getModel()`, `getThinkingLevel()`, `getActiveTools()`, and
`getSection()` return detached committed snapshots without a get-or-create
write. Each setter performs one ordinary Session commit against that document;
`setSection(section, undefined)` removes the value. A setter does not start
generation or append a system entry. Request preparation later compares the
desired committed configuration with transcript history and appends the required
positional system baseline or delta.
Tool declarations and executable functions are process-local Harness registry
entries, supplied through `HarnessOptions.tools` or `registerTool()`. The durable
configuration document stores only active tool names. A new independent
conversation defaults to every tool registered when its creation is admitted;
`ConversationSpec.activeTools` overrides that default. A fork with no explicit
`activeTools` uses the configuration document's `asOf` value.
`registerTool()` changes only the runtime registry. It does not activate the tool
in existing conversations or write a document. `setActiveTools()` is the only
host operation that replaces one existing conversation's durable active set.
Explicit `ConversationSpec.activeTools` and `setActiveTools()` values must contain
unique names that are registered when their commit is admitted; otherwise the
whole operation rejects without a write. Inherited historical names are not
revalidated during a fork and may later be unavailable after registry movement.
Unregistering a tool does not rewrite any conversation.
If request preparation finds unavailable active names, it performs no provider
request. It atomically terminalizes the generation task as `failed` with
`detail: { code: "missing_active_tool", names }`, makes its placed inputs
`unanswered` with reason `missing_active_tool`, clears matching turn control, and
appends a visible model-less diagnostic entry. It does not silently change the
durable loadout. Historical system entries remain replayable because they store
the exact declarations actually offered to prior requests.
A `Conversation.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`.
@ -405,7 +451,7 @@ 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
invocation. `Conversation.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
@ -434,14 +480,18 @@ 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.
`Conversation.watch()` atomically captures an immutable structural view and
registers for later complete Session commits. Its `WatchHandle` uses the same
serialized asynchronous update and reset-compaction contract as `watchDoc()` in
section 9.2. 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.
`close()` is equivalent to `suspend()` for v1. It seals mutation admission and
task reservation, signals invocations, and stops watches. Outside the Session
line it lets already-admitted storage commits settle, joins task/tool/hook
invocations and in-flight watch callbacks, then closes sources and storage. It
writes no task outcome. Handles belong to that open Harness and must be
reacquired after reopen. A non-cooperative watch callback can delay graceful
close just like a non-cooperative task invocation.
## 3. Documents
@ -633,21 +683,21 @@ interface Session extends DocumentObserver {
}
interface Tx {
conversation(id: Id): Promise<Conversation | undefined>;
entry(id: Id): Promise<Entry | undefined>;
conversation(id: Id): Promise<ConversationRecord | undefined>;
entry(id: Id): Promise<EntryRecord | undefined>;
input(id: Id): Promise<Input | undefined>;
task(id: Id): Promise<Task<JsonValue, JsonValue, JsonValue> | undefined>;
scanEntries(query: EntryQuery): Promise<readonly Entry[]>;
scanTasks(query: TaskQuery): Promise<readonly Task<JsonValue, JsonValue, JsonValue>[]>;
task(id: Id): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
scanEntries(query: EntryQuery): Promise<readonly EntryRecord[]>;
scanTasks(query: TaskQuery): Promise<readonly TaskRecord<JsonValue, JsonValue, JsonValue>[]>;
createConversation(value: Omit<Conversation, "id">): Conversation;
appendEntry(conversationId: Id, value: EntryDraft): Entry;
createConversation(value: Omit<ConversationRecord, "id">): ConversationRecord;
appendEntry(conversationId: Id, value: EntryDraft): EntryRecord;
createInput(value: Omit<Input, "id">): Input;
setInput(value: Input): void;
createTask<I, S extends { phase: string }, R, H extends object>(
kind: TaskKind<I, S, R, H>, input: I, options?: TaskOptions,
task: Task<I, S, R, H>, input: I, options?: TaskOptions,
): TaskRef<R>;
setTask(value: Task<JsonValue, JsonValue, JsonValue>): void;
setTask(value: TaskRecord<JsonValue, JsonValue, JsonValue>): void;
doc<T extends JsonObject>(token: DocToken<T>, target: DocTarget): Promise<T>;
doc<T extends JsonObject, I extends JsonValue>(token: DocFamilyToken<T, I>, target: FamilyTarget<I>): Promise<T>;
@ -722,12 +772,19 @@ callback succeeds
Storage.commit evaluates each ordinary mutation's checkpoint predicate exactly once
and persists the selected representation while the Session line remains held
storage succeeds
adopt committed baselines and enqueue publication while holding the line
materialize each changed immutable published value with applyImmutable(previous, ops)
adopt committed baselines and enqueue value/ops publication while holding the line
release the line; invoke listeners later
post-flush or storage failure
poison Session; publish nothing; close/reopen required
```
The immutable published value is separate from the tracker's mutable working
object and is created once per changed document, not once per watch. Initial
publication similarly applies the complete base operation to `undefined`.
Storage's detached retained copy does not satisfy this Session-side ownership
requirement.
No defensive document copy is required solely for storage failure because the
open Session cannot continue after that failure.
@ -890,7 +947,7 @@ type TerminalTask<R> = {
readonly outcome: TaskOutcome<R>;
};
type Task<I, S, R> = {
type TaskRecord<I, S, R> = {
readonly id: Id;
readonly conversationId: Id;
readonly kind: string;
@ -903,7 +960,7 @@ type Task<I, S, R> = {
readonly memos?: Readonly<Record<string, JsonValue>>; // live tasks only
};
type RunningTask<I, S, R> = Task<I, S, R> & {
type RunningTask<I, S, R> = TaskRecord<I, S, R> & {
readonly state: LiveTask<S> & { readonly status: "running" };
};
@ -940,7 +997,7 @@ type TaskDefinition<I, S extends { phase: string }, R, H extends object> = {
readonly phases: {
[P in S["phase"]]: PhaseHandler<I, Extract<S, { phase: P }>, S, R, H>;
};
abort(task: Task<I, S, R>, runtime: TaskRuntime<I, S, R, H>, context: Context): Promise<void>;
abort(task: TaskRecord<I, S, R>, runtime: TaskRuntime<I, S, R, H>, context: Context): Promise<void>;
migrate?(input: JsonValue, checkpoint: JsonValue, fromVersion: number): {
input: I;
checkpoint: S;
@ -948,7 +1005,7 @@ type TaskDefinition<I, S extends { phase: string }, R, H extends object> = {
readonly hooks?: H;
};
interface TaskKind<I, S extends { phase: string }, R, H extends object> {
interface Task<I, S extends { phase: string }, R, H extends object> {
readonly definition: TaskDefinition<I, S, R, H>;
}
@ -962,7 +1019,7 @@ type TaskOptions = {
function defineTask<I, S extends { phase: string }, R, H extends object = {}>(
definition: TaskDefinition<I, S, R, H>,
): TaskKind<I, S, R, H>;
): Task<I, S, R, H>;
```
The phase map is exhaustive and phase-narrowed. A handler may perform several
@ -989,11 +1046,13 @@ 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.
`close()` marks the runtime closing, seals admission and reservation, signals and
joins invocations, rejects later runtime commits, writes no task outcome, and
then closes storage. Closing starts no fresh phase or abort invocation. It does not set
abort marks, terminalize tasks, retire task documents, or publish document
retirement. Session-owned watches stop. The hosting layer withdraws services and
`close()` marks the runtime closing, seals admission and reservation, signals
invocations, and stops watches. Outside the Session line it settles admitted
commits and joins invocations plus in-flight watch callbacks before closing
storage. Later runtime commits reject, and close writes no task outcome. Closing
starts no fresh phase or abort invocation. It does not set abort marks,
terminalize tasks, retire task documents, or publish document retirement. The
hosting layer withdraws services and
detaches clients; reconnecting to a reopened Session hydrates the last committed
state and resumes recovery from its durable checkpoints.
@ -1175,7 +1234,7 @@ type ToolExecutionResult = {
readonly control?: ToolControl;
};
interface OwnedConversationHandle {
interface OwnedConversation {
readonly id: Id;
send(input: SendInput, context: Context): Promise<InputHandle>;
abort(context: Context): Promise<void>;
@ -1192,17 +1251,17 @@ interface ToolExecutionApi extends DocumentObserver {
memo<T extends JsonValue>(name: string, context: Context): Promise<T | undefined>;
memo<T extends JsonValue>(name: string, candidate: T, context: Context): Promise<T>;
createTask<I, S extends { phase: string }, R, H extends object>(
kind: TaskKind<I, S, R, H>,
task: Task<I, S, R, H>,
input: I,
options: Omit<TaskOptions, "conversationId">,
context: Context,
): Promise<TaskRef<R>>;
getTask<R>(ref: TaskRef<R>, context: Context): Promise<Task<JsonValue, JsonValue, R> | undefined>;
getTask<R>(ref: TaskRef<R>, context: Context): Promise<TaskRecord<JsonValue, JsonValue, R> | undefined>;
waitForTask<R>(ref: TaskRef<R>, context: Context): Promise<SettledTask<R>>;
createConversation(
spec: Omit<ConversationSpec, "parent"> & { readonly inherit?: boolean },
context: Context,
): Promise<OwnedConversationHandle>;
): Promise<OwnedConversation>;
}
type PluginHandler = (
@ -1308,7 +1367,7 @@ Pico stores prompt and tool changes directly as PR #9548 `SystemMessage` values
at their transcript positions:
```ts
type SystemEntry = Entry & {
type SystemEntry = EntryRecord & {
readonly kind: "pi.system";
readonly model: readonly [SystemMessage];
};
@ -1342,6 +1401,15 @@ existing section; `null` removes it, and a later re-addition appends it to the
ordered section map. Within one message, tool removals happen before additions,
so a same-name replacement gets the new declaration and position.
The built-in configuration document preserves section insertion order.
`setSection(section, undefined)` removes the key; setting it later appends it at
the end. Preparation compares both values and order. If values can be patched
without changing order, it emits the minimal patch. If effective and desired
section order differ, one commit appends two `pi.system` entries: the first
removes every effective section with `null`, and the second re-adds every desired
section in desired order. This makes order-only changes and deletion/re-addition
between requests replay exactly; merely restating equal values is insufficient.
A PR #9548 `SystemMessage` is always a patch, not a reset: it cannot remove
previous `content` or restore section order merely by restating current values.
Therefore, when a head removes the previous request-visible baseline, the new
@ -1349,10 +1417,12 @@ Therefore, when a head removes the previous request-visible baseline, the new
entry still retained after the cut. Its own message is then a complete baseline
containing the base `content`, every desired section in order, and every effective
tool declaration. Model-context replay sees the new baseline instead of the
omitted retained deltas. Otherwise preparation emits only changed section
values, `null` removals, and tool additions/removals. Same-name tool replacements
remove before adding. Registry or document changes while preparation hooks run
cause preparation to retry against a new snapshot.
omitted retained deltas. Head rebaselining takes precedence over ordinary
order/value patching. Without a head cut, an order mismatch uses the two-entry
remove/re-add sequence above; only when order already matches does preparation
emit the minimal changed values, `null` removals, and tool additions/removals.
Same-name tool replacements remove before adding. Registry or document changes
while preparation hooks run cause preparation to retry against a new snapshot.
Conversation creation may seed section values or explicit removals. Preparation
uses a mutable section draft with get/set/delete/wrap; each throwing hook loses
@ -1373,7 +1443,8 @@ In v1, changing host plugin code is a Harness generation boundary:
1. Stop new admission and task reservation.
2. Close the Harness, signalling and joining its active task, tool, and hook
invocations without writing abort marks or terminal outcomes.
invocations plus in-flight watch callbacks without writing abort marks or
terminal outcomes.
3. Dispose the old facets and registrations.
4. Construct a new Harness over the same storage.
5. Register the complete new definition set before open performs task/document
@ -1481,18 +1552,23 @@ interface DocumentSource<T extends JsonObject> {
readonly [documentSourceType]: T;
}
interface DocumentWatch<T extends JsonObject> {
readonly value: Readonly<T>;
readonly revision: number;
start(listener: (
value: Readonly<T> | null,
ops: readonly Op[],
revision: number,
context: Context,
) => void): void;
type WatchEnd =
| { readonly reason: "stopped" | "cancelled" | "session_closed" | "retired" }
| { readonly reason: "listener_error"; readonly error: Error };
interface WatchHandle<T> {
/** Immutable acquisition snapshot. This reference never changes. */
readonly value: T;
/** Installs the sole serialized asynchronous listener. */
start(listener: (ops: readonly Op[], context: Context) => Promise<void>): void;
/** Prevents another callback from starting and signals an in-flight callback. */
stop(): void;
/** Settles after termination and any in-flight callback. */
readonly closed: Promise<WatchEnd>;
}
type DocumentWatch<T extends JsonObject> = WatchHandle<Readonly<T> | null>;
interface DocumentObserver {
watchDoc<T extends JsonObject>(token: DocToken<T>, target: DocTarget, context: Context): Promise<DocumentWatch<T>>;
watchDoc<T extends JsonObject, I extends JsonValue>(token: DocFamilyToken<T, I>, target: FamilyTarget<I>, context: Context): Promise<DocumentWatch<T>>;
@ -1520,9 +1596,11 @@ and ends that incarnation's stream. The service may then withdraw itself; if it
remains exposed, consumers see `null`, never stale state. A later recreation
requires acquiring a new source/watch.
Each document source has a contiguous revision independent of the sparse global
Session commit sequence. Hydration atomically captures matching value/revision
and subscribes before releasing the Session line.
Each Chord adapter assigns its own in-memory contiguous delivery sequence; Pico
does not persist or expose that sequence through `WatchHandle`. Hydration
atomically captures a matching immutable value and adapter sequence before
subsequent source operations are delivered. Reopen creates a new source
lifetime and hydration.
### 9.2 `watchDoc`
@ -1530,23 +1608,81 @@ Tasks, hooks, and tools may observe any document for which their code has a toke
and target. There is no additional subtree permission system inside trusted
Session code.
`watchDoc()` is available on task, hook, and tool APIs. It atomically registers
the internal listener and captures the current value/revision on the Session
line. It returns a stopped delivery surface:
`watchDoc()` is available on task, hook, and tool APIs. Acquisition performs
normal get-or-create and, on the Session line, binds one concrete incarnation,
captures its immutable committed value, and registers the handle for every later
committed operation batch. `watch.value` is that fixed acquisition snapshot and
never changes.
```ts
const watch = await api.watchDoc(JobOutputDoc, target, context);
consume(watch.value);
watch.start((value, ops) => consume(value, ops));
let value = watch.value;
try {
await initializeConsumer(value, context);
watch.start(async (ops, deliveryContext) => {
value = applyImmutable(value, ops);
await consume(value, deliveryContext);
});
} catch (error) {
watch.stop();
await watch.closed;
throw error;
}
```
Updates between capture and `start()` are buffered in order without a fixed
bound. `start()` drains them and continues live delivery. `stop()` discards the
buffer and is idempotent. Watches acquired by an invocation stop when it ends;
watches acquired from `Session` are caller-owned and stop on `Session.close()`.
Listener failures are isolated and cannot affect committed state or other
listeners. Retirement delivers `null` with `["r", null]`, the JSON
representation of document absence.
The caller initializes from `value` before `start()`. `start()` synchronously
installs the sole listener, changes the prepared handle to active, and schedules
delivery; it never invokes the listener inline. A second `start()`, or `start()`
after stop, throws.
One per-watch delivery line awaits each callback before starting the next, so
callbacks never overlap. Commits never wait for callback settlement: while one
batch is in flight, later batches append to the pending queue. An update accepted
between acquisition and return, or between return and `start()`, is therefore not
lost. The listener's promise covers all work the watch serializes; fire-and-forget
work started by the listener is outside that guarantee.
The pending queue is bounded by operation-batch count and estimated retained
payload. When either threshold is exceeded, the handle leaves the in-flight batch
untouched and replaces the entire pending suffix with one newly allocated root
replacement batch:
```ts
[["r", latestImmutablePublishedValue]]
```
Later deltas append behind that reset. A single reset may exceed the payload
threshold because compaction cannot bound document size; while it remains
pending, further overload replaces it with a newer reset rather than accumulating
an unbounded suffix. This watch observes convergent committed state, not every
intermediate transition.
The replacement value is the immutable publication value corresponding exactly
to the last batch compacted into it. After storage succeeds, Pico materializes
that value once per changed document with
`applyImmutable(previousPublishedValue, ops)`; it never retains mutable
`Tracker.state`, `Tracker.target`, or a borrowed storage candidate. Watches may
share the immutable publication value and operation payloads. A mutable consumer
must detach them before using mutable `apply()` or `track()`.
A watch remains bound to its original incarnation. Retirement replaces the
pending suffix with `[["r", null]]`. Before start, `value` remains the original
snapshot and the terminal reset becomes the first delivered batch. While active,
the reset follows any in-flight callback. Successful terminal delivery closes as
`retired`; recreation requires another watch.
`stop()` is idempotent, unregisters the handle, discards pending batches,
prevents another callback from starting, and signals the watch delivery context.
An in-flight callback is allowed to settle; `closed` resolves only afterward.
The acquisition `Context` governs the watch lifetime. Cancellation during
acquisition cleans up any registration before rejecting; an already-admitted
get-or-create commit still settles and is not undone. Cancellation immediately
after successful acquisition may therefore return an already-stopped handle,
whose `start()` throws and whose `closed` reports `cancelled`. Invocation
termination and Session close stop owned watches similarly. Listener rejection
is reported, discards pending work, and closes only that watch as
`listener_error`. The first termination reason wins, and every late rejection is
observed.
### 9.3 Conversation view
@ -1554,8 +1690,8 @@ The public view is a fixed structural mount of selected built-in documents:
```ts
type ConversationView = {
readonly conversation: Conversation;
readonly entries: readonly Entry[];
readonly conversation: ConversationRecord;
readonly entries: readonly EntryRecord[];
readonly docs: Readonly<Record<string, JsonObject>>;
};
```
@ -1573,15 +1709,48 @@ document op ["s", ["message"], value]
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.
projection. It materializes one immutable published view per batch with
`applyImmutable(previousView, mountedOps)` so every conversation watch can share
that value for reset compaction. A Chord adapter assigns a contiguous in-memory
delivery sequence per view source lifetime.
`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.
`Conversation.watch()` exposes that mount through the same immutable acquisition
snapshot, serialized asynchronous listener, and reset-compacted pending queue as
`watchDoc()`. Each operation batch represents one complete Session commit. Chord
Session facets may forward the captured value and committed operations through
services, but that product wiring is not part of the Harness facade and must not
add another tracker or semantic event envelope.
### 9.4 Agent-mode notifications
The Session kernel and Chord structural sources do not maintain a semantic event
journal. Coding-agent JSON/RPC compatibility uses a thin agent-mode adapter
derived from each uncoalesced committed publication before per-watch queue
compaction. It owns no tracker or persistence and emits notifications only after
the commit that makes them true.
The adapter protocol covers run start/settlement, committed assistant progress,
message entry settlement, tool intent/progress/result, input queue/outcome,
retry/deferred/compaction state, configuration changes, and faults. One commit
may produce an ordered batch. Progress notifications represent Pico's throttled
durable partials, not every raw provider frame. The exact legacy `AgentEvent`
wire format is not preserved.
Notifications have no hydration or replay contract. A consumer requiring a
complete lifecycle subscribes before admitting the input; a late or reconnecting
consumer hydrates structural state and history instead. Product adapters apply
these rules:
- TUI hydrates and renders `ConversationView`, then applies structural updates;
notifications may drive transient animation but are not its authority.
- Print awaits its submitted `InputHandle` and prints that input's answer.
- JSON/RPC expose correlated commands plus the ordered agent notification
protocol, with transport backpressure and disconnect policy owned by that
adapter.
This adapter is allowed even though a public Session-kernel semantic stream is a
non-goal. It must not derive notifications from a lossy, reset-compacted watch
when complete subscribed lifecycle delivery is promised.
## 10. Storage contract
@ -1640,9 +1809,9 @@ type SessionMetadata = {
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<JsonValue, JsonValue, JsonValue> }
| { readonly type: "conversation"; readonly value: ConversationRecord }
| { readonly type: "entry"; readonly value: EntryRecord }
| { readonly type: "task"; readonly value: TaskRecord<JsonValue, JsonValue, JsonValue> }
| { readonly type: "input"; readonly value: Input }
| {
readonly type: "document.create";
@ -1664,15 +1833,15 @@ interface Storage {
mintId(): Id;
metadata(context: Context): Promise<SessionMetadata | undefined>;
conversation(id: Id, context: Context): Promise<Conversation | undefined>;
scanConversations(cursor: ConversationCursor | undefined, limit: number, context: Context): Promise<Page<Conversation, ConversationCursor>>;
conversation(id: Id, context: Context): Promise<ConversationRecord | undefined>;
scanConversations(cursor: ConversationCursor | undefined, limit: number, context: Context): Promise<Page<ConversationRecord, ConversationCursor>>;
entries(ids: readonly Id[], context: Context): Promise<ReadonlyMap<Id, Entry>>;
scanEntries(query: EntryQuery, cursor: EntryCursor | undefined, limit: number, context: Context): Promise<Page<Entry, EntryCursor>>;
entries(ids: readonly Id[], context: Context): Promise<ReadonlyMap<Id, EntryRecord>>;
scanEntries(query: EntryQuery, cursor: EntryCursor | undefined, limit: number, context: Context): Promise<Page<EntryRecord, EntryCursor>>;
entryCommit(id: Id, context: Context): Promise<Seq | undefined>;
task(id: Id, context: Context): Promise<Task<JsonValue, JsonValue, JsonValue> | undefined>;
scanTasks(query: TaskQuery, cursor: TaskCursor | undefined, limit: number, context: Context): Promise<Page<Task<JsonValue, JsonValue, JsonValue>, TaskCursor>>;
task(id: Id, context: Context): Promise<TaskRecord<JsonValue, JsonValue, JsonValue> | undefined>;
scanTasks(query: TaskQuery, cursor: TaskCursor | undefined, limit: number, context: Context): Promise<Page<TaskRecord<JsonValue, JsonValue, JsonValue>, TaskCursor>>;
input(id: Id, context: Context): Promise<Input | undefined>;
inputByRequest(conversationId: Id, requestId: string, context: Context): Promise<Input | undefined>;
@ -1786,9 +1955,15 @@ 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()` and `ConversationHandle.watch()` buffer
every committed update until `start()` or `stop()`. Delaying both can consume
unbounded memory.
- **Watch activation:** `watch.value` is the immutable acquisition snapshot.
Initialize the consumer from it before `start()`; queued operations are based
on exactly that value.
- **Coalesced watches:** slow or unstarted watches may replace an undelivered
suffix with a complete reset, omitting intermediate committed states. A facet
that must audit every transition must persist each fact as an immutable entry
or in its own journal and scan that history explicitly.
- **Watch self-join:** a listener may call `stop()`, but must not await its own
`closed` promise or a Session close that joins that listener.
- **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
@ -1808,7 +1983,7 @@ Pico5 initially has no:
- whole-Session DOM;
- visible-undurable publication;
- public semantic event stream;
- Session-kernel semantic event journal or independently maintained event state;
- transaction membrane;
- session-scoped rewindable documents;
- automatic checkpoint heuristic;