pi-mono/packages/agent/docs/pico/v3/view-and-events.md
2026-09-18 13:48:19 +02:00

32 KiB
Raw Blame History

Pico3 view and events

Status: proposal for the hardened V3 (pico3) watch protocol. Replaces the ConversationView { entries, tasks, rewindable, sticky } shape and the per-channel delta of the V3 sketch. Written against harness-v3.zip and the Codex app-server protocol (codex-rs/app-server-protocol/src/protocol/v2/*), read in full. Companion: plugins.md (harness extension surface, durability, Chord facets).

1. Principles

  1. One snapshot, then one envelope per commit. A watch captures the view and subscribes in one line operation; every later commit that touches the conversation produces exactly one envelope, delivered in commit order with a contiguous per-watch revision. No cursor, replay or acknowledgement: a gap means "open a fresh watch".
  2. Ops are the truth, events are annotations. The envelope carries Chord ops that transform the view document, and named events the kernel attaches because it produced those ops and knows why. Ops fully determine the replica: a client that folds only ops is never wrong. Events annotate cause and outcome and may carry transient facts the view deliberately does not retain; a client that consumes only events never scans the view.
  3. Path = event. Every field of the view has a stable path, so an op on that path is a typed notification. Nothing in the view is ever discovered by diffing.
  4. The view is rendering-shaped, not kernel-shaped. No task records, no checkpoints, no documents. In-progress work is described by the kind that owns it, in the shape a renderer wants.
  5. Atomic by construction. Everything a commit changes lands in one envelope: the assistant entry, the cleared streaming slot, the resolved inputs and the turn.ended event are never observable separately. This is the one property Codex's protocol lacks (item/completed and turn/completed are separate messages).

2. The view document

One JSON document per conversation. The kernel keeps a Chord tracker for it per watched conversation, mutates it through the proxy after each commit, and flush()es the ops into the envelope. Clients fold with applyImmutable.

interface ConversationView {
  conversation: { id: Id; parent?: { conversationId: Id; at: Id }; owner?: Id };

  /** Active transcript: newest head H, then every fork-visible entry with id >= H.head,
   *  chronological. All fork-visible entries when there is no head. See §2.1. */
  entries: Entry[];

  /** Resolved configuration, flat: every built-in and registered kind's declared keys,
   *  defaults applied. `model` is the only optional built-in. */
  config: {
    model?: ModelRef; thinkingLevel: ThinkingLevel; selectedTools: string[]; profile: string;
    threshold: number; keepRecent: number;
    retry: RetryPolicy; steeringMode: QueueMode; followUpMode: QueueMode;
    [pluginKindKey: string]: JsonValue;
  };

  /** Queued steer / followUp / passive writes, admission order. */
  inbox: QueuedInput[];

  /** Present while an input group is live (from placement to its end); absent when idle. */
  turn?: TurnView;

  /** Present while a pi.collapse task is live. */
  compaction?: { taskId: Id; reason: "threshold" | "manual" | "overflow"; stage: "summarizing" | "retrying"; attempt: number; retryAt?: number };

  /** Non-turn live tasks (jobs, plugin kinds), described by their kind. Removed on terminal. */
  tasks: { [taskId: string]: { kind: string; background?: true; marked?: true; status: JsonValue } };

  /** Plugin presentation slices, by namespace: the namespace's declared `view` projection, never raw state. */
  plugins: { [namespace: string]: JsonValue };
}

interface TurnView {
  inputs: Id[];                         // the input group
  generation?: GenerationStatus;        // present while a pi.generation is live
  message?: AssistantMessage;           // streaming assistant message (frames applied in place)
  tools: ToolSlot[];                    // tool calls of the newest assistant message in this turn, by call index
}

type GenerationStatus =
  | { stage: "waiting"; on: "compaction" }                                 // replacement generation pending behind a collapse
  | { stage: "preparing" }                                                 // before `prepared`
  | { stage: "requesting"; attempt: number }                               // request sent, no frame yet
  | { stage: "streaming"; attempt: number }                                // first frame applied
  | { stage: "retrying"; attempt: number; retryAt: number; lastError: string }
  | { stage: "deferred"; attempt: number; pollAt: number };

interface ToolSlot {
  callId: string; name: string; args: JsonValue;
  status: "pending" | "running" | "done" | "error" | "aborted";
  waitingOn?: string;                   // namespace of the beforeTool handler that awaited BeforeToolApi.waiting(ctx); cleared when it returns
  output?: string;                      // bounded stream so far (api.stream)
  progress?: string; details?: JsonValue;
  continuedBy?: Id;                     // background task that took over; see tasks[continuedBy]
  entry?: Id;                           // the pi.tool_result entry once landed
}
// Memos and other slot working state are never in the view (plugins.md §2); only the fields above are rendered.

2.1 Active transcript

  • H = newest fork-visible entry with a head; from = H.head.
  • entries = every fork-visible entry with id >= from, chronological. Inherited parent entries are included when from points into a parent.
  • Older head entries inside the range stay (a renderer may mark them). Display-only entries (pi.assistant with stopReason error/aborted, pi.usage, model-less plugin entries) stay. edits are carried verbatim; the view is the transcript, not the model projection.
  • A new head entry truncates: the kernel emits one splice removing everything before head and one appending the entry (§4, entries).
  • No limit anywhere on the public surface; the capture pages internally.

2.2 What is deliberately not in the view

  • Task records, checkpoints, outcomes: available through getTask/scanTasks for tooling and tests, never pushed.
  • The two storage documents (rewindable, sticky): their split is a fork/history property; the view flattens their keys into config, inbox, turn, tasks, plugins. Which key lives in which document is declared per kind and matters only to stateAt(entry) / fork(at).
  • Section seed metadata on conversations.

3. Envelope

interface Envelope {
  revision: number;         // contiguous per watch: snapshot.revision + 1, +2, …; storage Seq is not exposed
  ops: Op[];                // Chord ops over ConversationView (§4)
  events: ViewEvent[];      // named annotations for this commit (§5); may be empty
}

revision is per watch, not the storage commit sequence: a conversation watch sees only commits that touch its conversation, so the global Seq would have legitimate gaps. Storage Seq stays internal unless a public commit-correlation use appears.

Delivery: folded into the kernel's view tracker on the line; the listener is invoked synchronously, in order, off the line, one envelope per commit that changed anything visible to this conversation. Envelopes arriving before start() are buffered (bounded; overflow closes the watch). Asynchronous consumers (the Chord bridge, application queues) own their own queue and backpressure policy (plugins.md §6.1). A listener throw closes that watch and reports through onError. stop is idempotent; nothing is delivered after stop.

Base flush: after the kernel rebases the view tracker (reopen of a cold watch is a fresh snapshot instead), an envelope may carry ["r", view] as its only op. Clients must handle it as "replace everything". Events in that envelope are still valid.

4. Ops by path

Chord op vocabulary: ["r", v] replace root, ["s", path, v] set, ["d", path] delete, ["a", path, str] string append, ["t", path, n] string truncate, ["p", path, index, deleteCount, items] array splice.

path ops you will see meaning
["entries"] ["p", ["entries"], n, 0, [e…]] entries appended (always at the end)
["entries"] ["p", ["entries"], 0, k, []] then ["p", …, n, 0, [head]] new head: transcript truncated, head entry appended
["config", key] s / d config changed; config.reset(keys) sets the declared default (only model, which has none, is deleted)
["inbox"] p queued, placed (removed), aborted (removed)
["turn"] ["s", ["turn"], {…}] / ["d", ["turn"]] input group started / ended
["turn","generation"] s / d generation stage changes / generation ended
["turn","message"] s (first frame), a/s on content paths, d streaming assistant message
["turn","message","content",i,"text"] a text delta
["turn","message","content",i,"arguments"] s tool-call arguments materialized at checkpoint/end
["turn","tools"] s (whole array, with the assistant entry) tool slots created for a new assistant message
["turn","tools",i,"status"] s pending → running → done/error/aborted
["turn","tools",i,"waitingOn"] s / d a beforeTool handler (namespace = value) is waiting on a human/service; cleared when it returns
["turn","tools",i,"output"] s bounded stream flush (whole string; retention may drop the head)
["turn","tools",i,"progress"], […,"details"], […,"continuedBy"] s api.progress
["compaction"] s / d collapse started / stage changed / ended
["tasks", id] s / d non-turn task appeared / status changed / terminal
["tasks", id, "status", …] any kind-described status detail (jobs: stdout, exitCode, …)
["plugins", ns, …] any the namespace's view projection

Rule for kernel authors: mutate the tracked view through its proxy so the tracker records minimal ops; never rebuild subtrees. Streaming applies each frame to turn.message in place (same reducer as pi-ai's frame reducer).

5. Events

Events are emitted by core code inside the commit that causes them (tx.emit(event), core authority). Extensions emit { type: "plugin.<ns>.<name>", … } through their namespace token from their own commits. Rule: ops fully determine the replica; events annotate the cause and outcome of the commit and may carry transient facts the view does not retain (turn.ended.reason, task.ended.outcome, compaction.failed, warning). An event must never be the only place a late joiner could learn something needed to render steady state.

type ViewEvent =
  // transcript
  | { type: "entry.added"; entry: Entry }                                  // one per appended entry, in order
  | { type: "head.moved"; entry: Entry }                                   // summary / reset / handoff landed
  // input group
  | { type: "turn.started"; inputs: Id[] }
  | { type: "turn.ended"; inputs: Id[]; status: "done"; answer: Id }
  | { type: "turn.ended"; inputs: Id[]; status: "unanswered"; reason: "terminated" | "aborted" | "failed" | "stale"; detail?: string }
  | { type: "input.queued"; input: Id; mode: "steer" | "followUp" | "write" }
  | { type: "input.placed"; input: Id; entry: Id }
  | { type: "input.aborted"; input: Id }
  // generation
  | { type: "generation.started"; taskId: Id; attempt: number }            // request sent
  | { type: "generation.retrying"; taskId: Id; attempt: number; retryAt: number; error: string }
  | { type: "generation.deferred"; taskId: Id; pollAt: number }
  | { type: "generation.completed"; taskId: Id; entry: Id; toolCalls: number }
  | { type: "generation.failed"; taskId: Id; reason: "provider" | "overflow" | "retries_exhausted" | "no_model"; detail: string; entry?: Id }
  // tools
  | { type: "tool.waiting"; taskId: Id; callId: string; on: string }        // a beforeTool handler is waiting; `on` is its namespace
  | { type: "tool.started"; taskId: Id; callId: string; name: string }
  | { type: "tool.finished"; taskId: Id; callId: string; entry: Id; isError: boolean; control?: ToolControl }
  | { type: "tool.aborted"; taskId: Id; callId: string; entry: Id }
  // compaction
  | { type: "compaction.started"; taskId: Id; reason: "threshold" | "manual" | "overflow"; through: Id }
  | { type: "compaction.retrying"; taskId: Id; attempt: number; retryAt: number; error: string }
  | { type: "compaction.finished"; taskId: Id; summary: Id }
  | { type: "compaction.failed"; taskId: Id; reason: "stale" | "declined" | "provider" | "retries_exhausted" | "no_model"; detail: string }
  // other tasks
  | { type: "task.started"; taskId: Id; kind: string; background?: true }
  | { type: "task.ended"; taskId: Id; kind: string; outcome: "completed" | "failed" | "aborted" | "orphaned" }
  // settings and diagnostics
  | { type: "config.changed"; keys: string[] }
  | { type: "warning"; source: string; message: string }                   // hook skipped, tool output truncated, selected tool missing, …
  | { type: `plugin.${string}`; data: JsonValue };

warning has no state counterpart at all; it exists for exactly the occurrences Codex models as warning / error{willRetry} notifications.

6. Commit-by-commit mapping

What each kernel moment writes, the ops it produces, and the events attached. Ops are abbreviated; every row is one atomic envelope.

6.1 Admission

moment ops events
send, idle entries +pi.user; turn = { inputs, tools: [] }; (older queued items placed first: inbox splice, entries +…) input.placed, entry.added, turn.started
send, busy (steer/followUp) inbox +item input.queued
write, idle entries +entry (+ head truncation if it carries a head) entry.added (+ head.moved)
write, busy inbox +item input.queued{mode:"write"}
InputHandle.abort while queued inbox -item input.aborted
config.set config.<key> set/delete config.changed{keys}

6.2 Generation

moment ops events
task created (successor/replacement) turn.generation = { stage: "preparing" } (turn already present) task.started is not emitted for turn kinds
prepared commit optional entries +pi.system; turn.tools = []; turn.message deleted; turn.generation = { stage: "requesting", attempt } entry.added (system)
requesting checkpoint turn.generation.attempt generation.started
frames (coalesced ≥256 B / 100 ms) first: turn.message = partial, turn.generation.stage = "streaming"; then a/s on content paths none
retry decision entries +pi.usage; turn.message deleted; turn.generation = { stage: "retrying", … } entry.added, generation.retrying
deferred turn.generation = { stage: "deferred", pollAt } generation.deferred
response with tool calls (closure) entries +pi.assistant; turn.message deleted; turn.generation deleted; turn.tools = slots(status:"pending"); (threshold: compaction = {…}) entry.added, generation.completed{toolCalls:n}, (compaction.started). Slots are pending; tool.started fires at each tool's started checkpoint (§6.3)
final answer, no triggers, no continuation (closure) entries +pi.assistant; turn deleted; boundary placements (inbox splices, entries +writes) entry.added, generation.completed, turn.ended{done, answer}, input.placed/entry.added for placed writes
final answer, onYield continuation entries +pi.assistant, entries +pi.user{continuation}; turn.generation = { stage: "preparing" } entry.added×2, generation.completed; no turn.ended (same group continues)
final answer with queued followUp/steer triggers as "no triggers", then entries +pi.user, turn = { inputs: triggers, tools: [] }, turn.generation = { stage:"preparing" } turn.ended{done}, input.placed, entry.added, turn.started
terminal error / aborted-by-provider (closure) entries +pi.assistant (display-only; model absent, message in data); group resolved unanswered/failed; final boundary runs: placed writes, selected triggers; turn deleted, or replaced by a successor turn for triggers entry.added, `generation.failed{provider
no_model, overflow with nothing collapsible as above; overflow additionally entries +pi.notice (placed immediately: the closing task counts as gone). no_model is not special-cased: a successor for queued triggers fails fast and resolves its own group generation.failed, turn.ended{unanswered, failed}, entry.added, …
overflow with collapse (closure) compaction = { reason: "overflow", … }; turn.generation = { stage: "waiting", on: "compaction" } (replacement created after collapse) generation.failed{overflow}, compaction.started; turn continues
abort (fresh abort closure) optional entries +pi.assistant (display-only partial, only if content was streamed); turn deleted (entry.added), turn.ended{unanswered, aborted}

6.3 Tool

moment ops events
beforeTool handler awaits api.waiting(ctx) (BeforeToolApi, plugins.md §4.2) turn.tools[i].waitingOn = ns (its own commit) tool.waiting{callId, on: ns}
started checkpoint (after beforeTool allowed, before invocation) turn.tools[i].waitingOn deleted; turn.tools[i].status = "running" (one commit) tool.started
handler blocks/throws waitingOn deleted atomically with the synthetic error result below
handler unwinds on abort/suspend waitingOn deleted in its own commit none
stream flush (≤ every 100 ms) turn.tools[i].output = text none
api.progress turn.tools[i].{progress,details,continuedBy} none
result (closure) entries +pi.tool_result; `turn.tools[i].status = done error; turn.tools[i].entry`
synthetic error before invocation (not offered / missing / invalid / blocked) same as result with status: "error" entry.added, tool.finished{isError:true}
abort closure entries +pi.tool_result(aborted); turn.tools[i].status = "aborted" entry.added, tool.aborted
recovery: started + unsafe/current-unsafe/schema-invalid as synthetic error (interrupted) tool.finished{isError:true}

6.4 Post-tools

moment ops events
continuation (default) boundary placements (inbox splices, entries +pi.user for steer, +writes); turn.inputs += triggers; turn.generation = { stage: "preparing" } input.placed…, entry.added…; no turn event
synthesized missing results (orphaned/aborted tool) entries +pi.tool_result per missing call entry.added, warning{source:"post_tools"}
addTools config.selectedTools config.changed{["selectedTools"]}
terminate turn deleted; boundary(final) placements; successor turn if triggers turn.ended{done, answer: assistant}, then as §6.2 "with triggers"
handoff entries truncated + +pi.handoff (head); turn deleted; placements; successor turn head.moved, entry.added, turn.ended{done}, …
queued self-head write ended the group entries truncated + +reset/handoff; turn deleted; earlier queued steer/followUp removed from inbox head.moved, turn.ended{unanswered, terminated}, input.aborted… (stale)
abort closure turn deleted turn.ended{unanswered, aborted}

6.5 Collapse

moment ops events
task created compaction = { taskId, reason, stage: "summarizing", attempt: 1 } compaction.started{through}
retry compaction.stage = "retrying", retryAt compaction.retrying
summary lands (closure) entries truncated to head + +pi.summary; compaction deleted head.moved, entry.added, compaction.finished{summary}
stale / declined / failed compaction deleted compaction.failed{reason}
hook-supplied summary same as "summary lands" without a provider stage same

Manual collapse (c.collapse) is background; it appears in compaction exactly the same way. At most one live collapse and exactly one live generation per conversation are core invariants (rejected before persistence, never generalized to maps in the view): a manual collapse while one is live rejects CollapseInProgress; a threshold trigger while one is live is skipped; NothingToCollapse rejects the call and produces no envelope. An overflow replacement generation waits behind its collapse as turn.generation = { stage: "waiting", on: "compaction" }.

6.6 Jobs and plugin kinds

moment ops events
task created tasks[id] = { kind, background, status: describe(task) } task.started
checkpoint / slot update tasks[id].status… (job: stage, stdout, stderr, exitCode, occurrence) none
job notice (notify: true) entries +pi.notice (immediately if idle, else via inbox) entry.added or input.queued{write}
terminal tasks[id] deleted task.ended{outcome}
marked tasks[id].marked = true none

describe(task) is declared on the kind next to its checkpoint union; the kernel calls it whenever the task record changes and writes the result to tasks[id].status. Turn kinds do not use it: their status lives in turn.

6.7 Conversation-level

moment ops events
abort(conversation) inbox steer/followUp removed; then per-task envelopes as above input.aborted…, then turn.ended{aborted} etc.
fork created (new conversation; own watch) none on the parent
owned conversation created by a tool turn.tools[i].details if the tool records it; nothing else on the parent none

7. Codex mapping

For anyone coming from codex app-server.

Codex pico3
Thread conversation; thread/status/changed ≈ turn present/absent (+ tasks non-empty for background activity)
Turn { id, items, status, error } turn (live) + turn.ended{status}; items are the entries appended during the group
turn/started, turn/completed turn.started, turn.ended (in the same envelope as the last entry, never separate)
ThreadItem::UserMessage pi.user entry
ThreadItem::AgentMessage + item/agentMessage/delta turn.message (streaming) → pi.assistant entry; ops on ["turn","message",…]
ThreadItem::Reasoning + deltas thinking blocks inside turn.message.content / the assistant entry
ThreadItem::CommandExecution { status, aggregatedOutput, exitCode } + item/commandExecution/outputDelta turn.tools[i] (status, output) → pi.tool_result entry
ThreadItem::McpToolCall, DynamicToolCall, FileChange same: a tool slot; the tool declaration decides details
ThreadItem::SubAgentActivity, CollabAgentToolCall tool slot with details/continuedBy; the child is its own conversation/watch
ThreadItem::ContextCompaction compaction (live) + pi.summary entry + compaction.finished
error { willRetry: true } generation.retrying and turn.generation.stage = "retrying" (visible to late joiners)
error { willRetry: false } generation.failed, turn.ended{failed}
thread/tokenUsage/updated usage on the assistant entry; pi.usage entries for failed attempts
warning, deprecationNotice warning event
thread/queue/changed ops on ["inbox"], input.* events
thread/settings/updated ops on ["config",…], config.changed
thread/resume (history + active_turn_snapshot) watch snapshot: entries + turn captured atomically with the subscription
thread/items/list, thread/turns/list pagination h.entries(scan) for history beyond the active transcript
process/outputDelta, process/exited tasks[id].status for pi.job
item/started → item/completed (same id) tool: callId is stable across the slot and the result entry; assistant: atomic swap, no id needed

What pico3 has that Codex does not: atomic multi-field envelopes; retry and compaction state visible in a snapshot; one replica protocol for state-driven and event-driven clients.

What Codex has that pico3 does not: turn-level unified diff (turn/diff/updated), plans, approvals (waitingOnApproval). Approvals are a beforeTool hook waiting on user input; the kernel shows that as turn.tools[i].waitingOn and the tool.waiting event (see plugins.md §4.2 for the approval plugin).

8. Consumption patterns

Event-driven (imperative UI, third-party client):

w.start(({ ops, events }) => {
  view = applyImmutable(view, ops);           // keep the replica current for lookups
  for (const e of events) switch (e.type) {
    case "entry.added":          transcript.append(e.entry); break;
    case "turn.started":         status.set("working"); break;
    case "turn.ended":           status.set(e.status === "done" ? "idle" : `stopped: ${e.reason}`); break;
    case "generation.retrying":  status.set(`retrying at ${e.retryAt}`); break;
    case "compaction.started":   banner.show("compacting…"); break;
    case "compaction.finished":  banner.hide(); break;
    case "tool.started":         transcript.showRunning(e.callId); break;
    case "tool.finished":        transcript.settle(e.callId, e.entry); break;
  }
});

State-driven (React/Solid): useSelector(v => v.turn?.message), useSelector(v => v.compaction). applyImmutable preserves identity of untouched branches, so selectors are O(1) and no traversal happens.

Path router (when neither fits): dispatch each op on its first one or two path segments; the table in §4 is the complete list of paths.

Presentation services (Chord RPC): keep a derived tracked document in the shape the client wants (e.g. Codex-style turns[]), update it from envelopes, ship its own ops. Project image blobs out of entries[].model there, not in the kernel.

9. Watchers, late joiners, multiple clients

9.1 One view per conversation, many watchers

  • The kernel keeps one tracked view document per conversation that has at least one watcher (not one per watch). It is built on the line the first time a watch is opened and dropped when the last watch stops.
  • After every commit that touches the conversation, the kernel mutates that one document through its proxy, flush()es once, attaches the commit's events, and hands the same Envelope to every watcher. Fan-out cost is per conversation; the kernel never knows how many clients exist or what they render.
  • watch(ctx) returns { view, revision, start, stop } where view is a structuredClone of the tracker target taken on the line, revision is this watch's starting revision, and the subscription is installed in the same line operation. The first envelope a watcher receives is therefore exactly revision + 1; there is no window in which a commit can land between snapshot and subscription.

9.2 Late joiners

A client that attaches mid-flight needs no history and no events: everything in progress is state and is in the snapshot.

in flight at attach time where the late joiner finds it
assistant message streaming turn.message (partial), turn.generation.stage === "streaming"
provider backoff turn.generation = { stage: "retrying", retryAt, lastError }
tool running turn.tools[i].status === "running", output so far
tool waiting for approval / a question turn.tools[i].waitingOn === ns (+ the plugin's own Chord service instance, plugins.md §6)
compaction compaction
background job tasks[id].status
queued follow-ups inbox

This is the concrete difference from an event protocol: Codex must answer the same question with thread/resume + active_turn_snapshot() + activeFlags, and still cannot show "retrying".

9.3 Reconnect

A client keeps lastRevision. If an envelope arrives with revision !== lastRevision + 1 (only possible on a transport drop between a Chord RPC service and its client; in that transport Chord's own ReplicatedStateDelivery.sequence plays this role), the client discards its replica and opens a fresh watch. Nothing is client-specific, so there is nothing to resume, acknowledge or replay.

9.4 Concurrent decisions

Any action that answers something the view shows (approve a tool, abort an input, change config) is a commit on the line. Two clients acting on the same item are serialized: the first commit records the outcome, the second sees the recorded outcome inside its own transaction and rejects with a typed error ("already decided", already_placed, …). Both then receive the same envelope and converge. Plugin APIs must therefore do their check-and-record inside one commit (plugins.md §2, memoOnce), never check-then-commit.

9.5 Owned conversations

A subagent is its own conversation with its own watch. The parent view shows only what the parent's tool put in its slot (details, continuedBy); a UI that wants to render the child watches the child. Presentation services may merge the two into one derived document if a client wants a tree.

10. Plugins

Everything an extension can contribute (tools, hooks, task kinds, entry kinds, namespaces), where its durable state lives, how it appears in this view, and how it is packaged as Chord facets is specified in plugins.md. The view-facing rules are:

  • an extension's presentation is only what it projects: the namespace's declared view projection under plugins[ns] (nothing by default), the kind's describe() output under tasks[id].status, the core-rendered tool slot fields (status, waitingOn, output, progress, details, continuedBy, entry), and entries. Raw namespace state, memos and other slot working state never appear;
  • extension events are plugin.<ns>.*, emitted with the namespace token from the extension's own commits; core events remain core-only;
  • nothing about Chord services is visible in the view. A keyed service instance (an open approval dialog) is a channel, not state; the corresponding state is waitingOn on the slot.

11. Kernel implementation notes

  • One tracked view document per watched conversation (§9.1); the kernel applies each commit's effects to it (entries, config keys, inbox, turn, compaction, tasks, plugins) through the proxy and flushes.
  • Core closures record events with tx.emit(event); the kernel attaches the list to the envelope. Events are core authority; extensions emit only through a namespace token (plugins.md §3).
  • turn.generation, compaction and tasks[id].status are derived from task records by kind-owned describe(task); the raw records never leave the kernel.
  • Display-only assistant entries carry the message in data.display and no model, so context derivation needs no special case.
  • Envelope assembly happens on the line; listener dispatch off the line, synchronous and in order; a listener throw closes that watch only.
  • No AsyncLocalStorage: line re-entry is detected through the Chord context the Session hands to its callbacks (hardening-handoff.md §7).
  • Chord bridge (plugins.md §6.1): a bounded ordered adapter applies one raw envelope's ops to the Chord MutableReplicatedState and calls publish() in the same synchronous block (Chord's subscribe() publishes pending mutations itself, so apply and publish must not be separated by an await); overflow or publish failure closes and respawns that instance; a persisted commit is never failed by a listener.
  • Tests: for every row in §6, assert the exact ops and events of that commit (memory backend, fake provider), plus snapshot-equals-fold after every envelope and a head-in-parent capture case.

12. Open items

  • warning payload shape (source enum vs free string).
  • Application-level backpressure for slow asynchronous consumers is TBD; the raw watch itself is not (synchronous listener, bounded pre-start buffer).
  • Whether turn.tools should keep finished slots of the previous assistant message when the continuation generation prepares (current: cleared at prepared; Codex keeps all items of the turn). Recommendation: keep them, keyed by call index within turn.exchanges[] only if a UI needs it; otherwise entries already hold the results.
  • Whether plugin events need a schema registry for RPC clients (the plugin token could carry TypeBox schemas per event name).
  • Whether plugin-declared extra documents are needed for large plugin state; start with slices in the core documents and add shards only with a demonstrated cost.