77 KiB
CHAT-03 brief: live adapters and mediated terminal (#1507, row 5)
Author: Dewey, 2026-09-26. R2, for review. This is the brief only. It
includes no source, no contract edits and no seat changes. R1
(5dd447f7) is frozen as BRIEF-r1-5dd447f7.md. §0 lists what changed.
Jason chartered CHAT-03 on 2026-09-26 (docs/plans/2026-09-26_lead-decisions.md
item 22). Plan row: docs/plans/2026-09-13_webui-session-chat.md line 215.
It depends on CHAT-01 (28d4e98a) and implements part of the CHAT-01C
companion (b023841c). The layout follows docs/plans/BRIEF-TEMPLATE.md,
and the depth follows the CHAT-02 brief (agents/dewey/work/chat-02/BRIEF.md,
sha256 636b0fac…). The six carry-forwards
Sage set are mapped to their evidence under "Carry-forwards".
Line numbers and hashes below are at 40a02d2b.
0. Changes since R1
R2 answers three reviews of R1:
- Rocko's adversarial review (
agents/rocko/work/chat-03-r1-adversarial-2026-09-26.md,89752c2b) and his addendum narrowing finding 3 (…-addendum-2026-09-26.md,e5b6bd00); - Filbert's review (
agents/filbert/work/chat-03-brief-review-2026-09-26.md,ec00544e).
Sage's rule for R2: where a guarantee can't be proven on pinned Pi, the
brief refuses or reports uncertainty instead of claiming it. The full
disposition table is in REVIEW-REQUEST.md.
| Finding | What changed |
|---|---|
| Filbert B1, Rocko 3 and addendum: R3-1 premise | §3 R3-1 rewritten from the source. A mediated prompt can't enter Pi's queues: without streamingBehavior it throws while isStreaming (agent-session.js lines 860–863, 617, 773, 348). clear_queue returns only external input. The residual risks are a fence during preflight and an ack with no run. No text-identity machinery. External input queued after the last clear is stated as a limit (rule 6, Limit 7). N1–N13 rebuilt. |
| Filbert B2, Rocko 6: no entry IDs on the stream | §2: the drift check compares disk entries with the engine's own get_entries list (rpc.md lines 717–745), not with stream IDs. It runs at idle. §3 advertises replay as unavailable. W10, W18 and W19. |
| Filbert B3: schema gaps | C-1 is repurposed as a turnProof field for external items that clear_queue removed. Until it lands, a non-empty clear leaves the stop uncertain. C-4 is an optional event type for unknown native events, with an interim rule. |
| Filbert B4 | §2 live-session guard (live-session-refused, symlinks resolved), G1–G3, mutant 14. CHAT-07 lifts it. |
| Filbert B5: order and gate | Each increment starts when its "Needs first" column is met. Gate adds C-1 adoption or carry, a B1-not-passed trigger and owner, and what happens if C-2 is declined. Increments renamed I1–I4 so they don't collide with CHAT-03D. |
| Filbert B6, Rocko 1: claim gaps | §2 rewritten: one claim ID across both keys, atomic link() publication, the more conservative key wins, restart rules for reserved and half-done pairs, a scope named from the claim ID, and a no-unit path to stopped that a spawn marker closes. W4, W5, W12–W17, W20. The restart dedup is recorded as deviation V-1 for Sage. |
| Rocko 2: pending prompts, partial writes | §1 pending dispatch slot and three write outcomes. A partial or failed write poisons the pipe. H3, H18–H20. |
| Rocko 4: cohort proof | §6 rewritten: shim-held scope, engine child cgroup, invocation-ID epoch, freeze, enumerate, cgroup.kill, unavailable distinct from empty, the K13 migration test, and fixture-grade verification (CHAT-01 lines 330–333). K1–K16. |
| Rocko 5: dedup across restart | §1 incarnation token and stale-incarnation. H21, H22. Deviation V-1. |
| Rocko 7 (non-blocking) | §6 single-use eligibility with a new reservation. K8, K17, K18. |
| Filbert's notes N1–N12 (non-blocking; not the N fixtures) | Skills are live (§4, S2). Refusal names generation and controller reused. Citations corrected. B1 floating sources fixed by hash. Smoke isolated. Suite count by glob. |
CHAT-03: live adapters and mediated terminal
Problem
CHAT-02 made every repository Pi conversation readable. Nothing can drive a
conversation yet except the old paths: the Pi TUI in a tmux pane, and board
Reply or agent-send, which paste into that pane. Those paths have no
controller, no generation fence and no stop proof. At 2026-09-26T20:10:57Z a
leftover / in Pi's composer was concatenated in front of a board reply.
Pi read the result as plain text that time, but the same path could run a
reply as a slash command, because tools/tmux/send-message.sh (lines 45–54) pastes onto
whatever the composer holds and then presses Enter (docs/plans/DEFERRED.md,
"Board send can turn into a Pi slash command").
CHAT-01 defines the records and commands for one controller per
conversation. It defers the writer-claim record (chat-01/README.md:342),
and CHAT-01C defers R3-1 (chat-01c/README.md:217). Lead decisions item 8
moves both here. The Claude catalogue still refuses unsupported-harness
(packages/conversation/src/reader.mjs: constant at line 24, refusals at
lines 51 and 65). Jason ruled that CHAT-03 owns it
after B1. Claude on this host reports 2.1.283, but the plan's evidence named
2.1.269 (plan line 159). The binary changes under any adapter that does not
pin it.
Owner and reviewer
- Source author:
<slot>. The plan has Darkwing name the backend author (line 215; lead decisions item 22). Darkwing is on queue A1/A2, so the author is named after this brief is approved. The brief does not pick one. - Brief author: Dewey.
- Filbert reviews this brief, then the exact candidate of each increment.
- Rocko does an adversarial pass on §5 (control races) and §6 (stop and recovery), in this brief and later in the code of each increment. Rocko also reviews the B1 packet (§8), because it is evidence that a false pass would turn into a wrong adapter.
- No one reviews their own work. If Darkwing names Filbert or Rocko as author, Sage names a replacement for that review.
- Tracking: #1507.
Files owned
The source author may create or change only these paths.
| Path | Contents |
|---|---|
packages/conversation/src/** |
New modules for the controller, claim, supervisor shim, Pi adapter, events, transport and mediated terminal. The Claude parser and adapter come in I4. reader.mjs changes only to lift the Claude refusal for the pinned version. |
packages/conversation/tests/** |
Tests, fake engines, fixture extensions and redacted recordings |
packages/conversation/README.md, packages/conversation/package.json |
Documentation and the new refusal names. No new dependencies. |
agents/<author>/work/chat-03/** |
Review packets, evidence and the B1 packet |
Module names inside src/ are the author's call (plan lines 150–151). Any
path outside this table needs a brief amendment.
Excluded, and why:
tools/tmux/**. Plan line 244 excludes it, and §4 shows the mediated path needs no change there.packages/control-board/**. No board edit is needed (§4, S6). Tests importreplyToRowread-only by relative path, the same way the queue-as-data plan importsscan.mjs.packages/seat/**,scripts/agent-host-dev.sh,agents/*/launch.sh. No seat migrates, so no seat or launcher changes. The launcher handoff belongs to CHAT-07.packages/webui/**. The chat UI is CHAT-05.docs/plans/chat-0*/**. Contracts change only through the C items below.roles/**,contracts/**, root files, auth and~/.mosaic.
No overlap with queue-as-data or the ledger:
| Owner | Paths | Overlap |
|---|---|---|
A1, Darkwing (agents/darkwing/work/queue-a1/build-manifest.sha256) |
packages/queue/**, scripts/queue-commit.sh, scripts/test-queue.sh, scripts/git-hooks/pre-commit, docs/plans/BRIEF-TEMPLATE.md |
none |
A2, Darkwing (Filbert's queue-as-data plan 282fabbb… §8.1 and lines 130 and 776–777; lead decisions item 20) |
scripts/mosaic dispatch, docs/plans/queue.json, docs/plans/QUEUE.md, scripts/test-{darkwing,rocko}-launch.mjs |
none. The mediated terminal runs as node packages/conversation/src/<cli>.mjs. It is not a scripts/mosaic verb. |
| Piece B, after A | AGENTS.md, agents/*/CONTEXT.md |
none |
| Ledger | packages/ledger/** |
none |
The author may import the process-identity helpers in
packages/discord/src/journal.mjs read-only, as A1 does. That changes
neither package.
Contracts implemented
These are unchanged since the commits named. The sha256 values are at
40a02d2b, and git status shows no local edits.
| Path | sha256 | Commit |
|---|---|---|
docs/plans/chat-00/README.md |
991663e607404c2f022716bd45b40d5b3092d53c3f9f61bbafd455c0659b832f |
370823b3 |
docs/plans/chat-00/sources.json |
1a07ae88de45fd3219eca10acae13637ca75416cb1c598aa9080825bd7598af9 |
370823b3 |
docs/plans/chat-00/fixtures.json |
ad9d4fe94131b2c4bb30691ad846698c5b0818f0935d20a38de73fa3f267bd80 |
370823b3 |
docs/plans/chat-00/check.mjs |
568f004f5a0cfce64d65ff202c420ab491e716919a93989377a7bb9e7b8be622 |
370823b3 |
docs/plans/chat-01/README.md |
61aba7d60f380ff8a135a04a2e851f11c250795ead11cca2e594566849483163 |
28d4e98a |
docs/plans/chat-01/contracts.schema.json |
38382e08c97635f8864e6953b6cf44ec324040397a1aa8fc3f86e279abe36db1 |
28d4e98a |
docs/plans/chat-01/fixtures.json |
403c8ae91963a379de17805380bc1425bc4e96c1ef34094385b703d17cf5ce7d |
28d4e98a |
docs/plans/chat-01/check.mjs |
2e164e4bfa61963bb5dd8639e26e67407e64d19278cc56ed8a4f77e430f1dee5 |
28d4e98a |
docs/plans/chat-01c/README.md |
63d9f9edffc2aa1aa3bc99364b864e8c6f234eedc8ad2f9da231531970d54250 |
b023841c |
docs/plans/chat-01c/contracts.schema.json |
da132f0a02f29281344cc5350396af53c893d7895308bc430f9bf3ca3b3785b9 |
b023841c |
docs/plans/chat-01c/fixtures.json |
00639a219b00b67c5e0ab904163a6d945481f6b0df45a058b32e00f1f1326611 |
b023841c |
docs/plans/chat-01c/check.mjs |
5971ed5f11a32f0dff5720db03d8d4bc4c007a5d2654ba50726bb35ba753eba6 |
b023841c |
Design inputs, which CHAT-03 does not implement in full:
docs/plans/foundation-v1-candidate/RUNTIME.md §§3–5 (b1a2b4d0…) and the
plan itself (48142829…).
What CHAT-03 takes from each contract:
- CHAT-01. The records
binding,connection,clientRequest,request,receipt,event,stop,cohortProof,effectReport,turnProof,nativeDecision,approvalandconfirmation. The commandsobserve,prompt,takeover,acquire-recovery-control,approval,interrupt,force-stop,recover,issue-confirmationandanswer-confirmation. The draft, upload and queue-edit commands belong to CHAT-04. - CHAT-01C. Only the confirmation restoration rules (lines 92–104). The
private-state pages, upload ranges and
recover-refused-draftneed CHAT-04's durable store.
The CHAT-01 text at line 342 stays as published. Lead decisions item 8 records the move.
Contract changes, each a separate reviewed item. Each one lands and is approved before the code that needs it. Sage assigns the author; Filbert and Dewey review, as they did for CHAT-01C.
- C-1: a
turnProoffield for external items thatclear_queueremoved (R3-1). Mosaic input never enters Pi's queues (§3). Anythingclear_queuereturns came from outside Mosaic, for example an extension's follow-up.turnProofhas no field for it, and its only list,nativePending, must be empty for reconciliation (chat-01/check.mjs:315). C-1 adds a list of removed external items, as digests and byte counts, with the text in controller evidence. Until C-1 lands, a non-empty clear leaves the stopuncertain. The gate says when C-1 must land. - C-2: the CHAT-03D native dialog companion. It covers Pi
confirmandselectwith exact labels;inputandeditorstay unsupported. CHAT-01 lines 239–242 and 292 block non-permission forms until it exists. Increment I2 waits for it. - C-3: Claude record fields. Needed only if B1 shows that CHAT-01 lacks
a field, for example the capability-negotiation result on
binding. Otherwise C-3 is void. - C-4 (optional; Sage decides): an event type for an unrecognized native
event.
event.typeis a closed enum, andunavailableis only avisibilityvalue. Until C-4, an unknown native event gets no client event. It is recorded in controller evidence (type name and byte count) and counted, and the terminal shows the count. So it is not silent, and no record gets a type it doesn't have. Mapping it to an existing type withvisibility: unavailablewas the other option Filbert offered. It isn't taken because each existing type has meaning a client acts on, for example tool-start opening an effect.
Deviation V-1, for Sage to rule on. CHAT-01 line 145 says an exact
retry after reconnect returns the existing receipt. CHAT-01C line 212 says
a reconnect or restart retry returns the original result, for recovery
requests. CHAT-03 has no durable receipts, so an exact retry across a
controller restart refuses stale-incarnation (§1). That is safe, because
nothing is replayed, but it departs from the contract. It is recorded here
the way the CHAT-02 newer deviation was. If Sage does not accept it, it
becomes a C item or waits for CHAT-04's durable receipts.
Not contract changes:
- The writer-claim storage record (§2) is internal and never crosses the
wire. It stores the CHAT-01
bindingfields it needs. If one is missing, that becomes a C item, not an edit. - New refusal and reason names (
busy,stale-incarnation,engine-pin-mismatch,session-drift,live-session-refused,foreign-host,transport-unknown,handled-without-run,ack-without-start) are bounded draft IDs, like the existing names CHAT-01 line 363 describes. Where CHAT-01 already has a name, it is reused:generation(check.mjs:205) andcontroller(check.mjs:230). The package README lists them.
What ships
Four increments. Each is its own candidate and review round, as with the queue-as-data A1/A2 split. An increment starts when everything in its "Needs first" column is met, so I2 and I3 can run in parallel after I1.
| Increment | Content | Needs first |
|---|---|---|
| I1 | Controller, transport, writer claim, Pi adapter on a fake Pi engine, incremental events, mediated terminal, interrupt, force stop and recovery. Fixtures in §2–§6 and §9, except the approval races H5–H8; the §10 checks. | Brief approved; author named |
| I1b | C-1 adoption: the controller fills the new turnProof field, and reconciled may follow a non-empty clear |
I1 approved; C-1 approved |
| I2 | Pi native dialogs (§7) and the approval races H5–H8 on Pi dialogs | I1 approved; C-2 approved |
| I3 | The B1 evidence packet for Claude (§8) | I1 approved. Jason's go for any run that calls a model or reads Claude auth. |
| I4 | Claude adapter, catalogue and history (§7, §8), and H5–H8 on Claude permission requests | I3 approved, so B1 passed; C-3 if it exists |
If B1 does not pass. Sage records B1 as not passed when any of these happens:
- Jason declines the go for the I3 recordings;
- Filbert or Rocko refuses the B1 packet, and no path to a pass exists on the pinned version;
- Jason says to close CHAT-03 without Claude.
CHAT-03 then closes without I4. Claude keeps refusing
unsupported-harness, and CHAT-06's both-harness gate stays blocked with
CHAT-03 as its owner. The outcome is recorded, not a silent narrowing.
If C-2 is declined. If Sage or Jason declines C-2, I2 is void. Pi non-permission dialogs stay disabled, as CHAT-01 lines 239–242 require. Sage records that, and CHAT-03 can close without I2.
1. Controller and transport
-
One controller process per execution owns the engine's stdin. Pi runs in
--mode rpcwith no TUI, so a mediated engine has no native composer or terminal left to fence. That is how CHAT-03 meets plan lines 133–134: a losing native TUI can't stay writable because none exists. -
Engine output is split on LF only (
rpc.mdlines 30–38). Nodereadlineis not used, because it also splits on U+2028 and U+2029. The ledger refused a live line for that class of bug (lead decisions item 18). Fixture E2 covers it. -
Clients connect over a Unix socket in a 0700 directory. Every connection starts as an observer. The browser does not reach the socket in CHAT-03; the WebUI side is CHAT-05.
-
Actor. There is one actor,
local-operator, as in CHAT-02. The socket directory's mode is the only boundary, and every process with the same uid passes it. That includes agent seats, which run as the same user. So CHAT-03 cannot stop a same-uid agent from taking control of another conversation. See Limit 1. This blocks any live use until a local owner-channel design passes review (plan lines 181–185; B4). -
No broker queue. CHAT-04 owns durable queues (plan line 216). In CHAT-03, a prompt sent while the engine is busy is refused with
busy, and the text stays in the client. The controller never sendsstreamingBehavior,steerorfollow_up, so mediated input never enters Pi's native queues (rpc.mdlines 56–65). Tradeoff: there are no queued follow-ups (Q6) until CHAT-04, but I1 has no native-queue ambiguity of its own making. -
Admission and dispatch both recheck, under one dispatch lock: binding state, open admission, the controlling connection and its generation, the execution incarnation, the controller incarnation token and the text policy. CHAT-01 line 133 requires serialized dispatch, lines 153–160 the dispatch recheck of controller, fence and policy, and lines 181–183 the text policy. The single lock is this brief's way of meeting them.
-
Pending dispatch slot. There is one slot. Under the dispatch lock, the controller reserves it before writing a prompt. The engine counts as busy while the slot is held, and not only from
agent_start. The slot is released only by reconciliation: an error response; or an ack, thenagent_settledfor the run it started; or an ack, then aget_stateshowing no run with noagent_startin between (handled without a run, §3). A second prompt sent before that refusesbusy. This keeps "at most one unstarted item" true, which R3-1 depends on. -
Write outcomes. The controller records three outcomes:
- written: the whole line was accepted by the pipe;
- acknowledged: Pi's
promptresponse arrived; - unknown: the write was partial, returned an error (EPIPE), the controller died before recording the result, or the line was written but no response came within the bound.
A write accepted by the pipe is not native consumption. After an unknown outcome, the pipe counts as poisoned, because a partial line would join the next write. The controller never writes to it again. The receipt becomes
delivery-unknownwith reasontransport-unknown, admission closes, and the binding goes touncertain. Only force stop moves it on. Nothing is retried automatically and nothing is called unsent. -
Dedup key and incarnation token. The key is the actor, conversation and client request ID (CHAT-01 line 144). The index lives only as long as one controller incarnation. Each controller start mints a random incarnation token, and
observereturns it. Every command carries it. A command with an old token refusesstale-incarnation, whatever its request ID or text. So after a restart, an old retry is refused, even though the empty index can't recognize its ID. The client library marks requests pending under the old token as outcome-unknown. It never resubmits them under the new token. Durable receipts are CHAT-04. -
Text only. Images and files are CHAT-04.
2. Writer-claim record (D1)
CHAT-01 lines 336–343 allow at most one non-stopped binding per conversation or native session identity, and they defer the record. RUNTIME.md §3 item 3 adds one claim per (agent, project, workspace). The record:
-
Keys and claim ID. Each claim has two keys, and both must be held: the seat tuple (seat, project, workspace) and the native session identity (the Pi header ID or the Claude session UUID). A claim ID, random and minted at acquisition, ties them together. Every revision on either key names it. Keys are always taken seat first, then session, and released in the reverse order. A contender reads both keys before publishing. If the session key turns out held after it has published on the seat key, it follows that revision with
stoppedand a no-unit proof reference (it never spawned). A key counts as held when its highest revision is anything other thanstoppedwith a proof reference. The pair is free only when both keys are free. -
Pair state. Neither key's chain is authoritative alone. The pair's state is the more conservative of the two, in this order:
uncertain,stopping,active,reserved,stopped. Two keys naming different claim IDs are both held, and acquisition refusesunsafe-replacementuntil each is resolved under its own claim ID. -
Atomic publication. Each key is a directory under the claim root, and each revision is a numbered file in it. To publish, the controller writes the complete revision to a temporary file in the same directory, fsyncs it, then calls
link()to the next revision name and fsyncs the directory.link()fails if the name exists, which makes publication exclusive, and a revision name only ever points at complete contents. A losinglink()means another writer published first: the controller re-reads and re-decides. Revisions are never rewritten. -
Unreadable revision. If the highest revision exists but won't parse, for example after disk damage, the key stays held as
uncertain, and acquisition refusesunsafe-replacement. The controller never skips a damaged revision to reuse an olderstoppedone. -
Fields. A revision stores:
- the key, the claim ID and the binding ID;
- the harness, conversation, and branch and leaf at launch;
- the config pins: engine version, binary sha256 and launch argv digest;
- the host identity (
/etc/machine-id) and boot ID; - the owning controller: pid, process start time and incarnation token;
- the intended scope unit name, derived from the claim ID, recorded before anything is spawned;
- once known, the scope's systemd invocation ID and the engine pid and start time;
- the controller generation;
- the state:
reserved,active,stopping,uncertainorstopped; - the proof reference that allowed
stopped. There are three kinds: a cohortProof (§6), a boot proof (§6), and a no-unit observation, which is valid only when no spawn marker was published.
-
Lifecycle. Each step publishes the new state on the seat key, then on the session key:
- reserve both keys, recording the intended unit name;
- publish a spawn marker on both keys (still
reserved), then start the engine inside that scope (§6), then record the invocation ID and engine identity; - publish
activeon both keys.
Release publishes
stopping, thenstoppedwith the proof, on each key. -
Owner check. A controller acts on a claim only if it owns the claim, or if the recorded owner is proven gone: a different boot on the same host, or no process with the recorded pid and start time. A paused or slow owner that still exists is never repaired, reclassified or orphaned by a second controller. The second controller refuses
already-active. -
Restart and half-done pairs. A controller that finds a claim whose owner is proven gone does not launch. It completes or classifies the pair under the same claim ID:
- Host differs from the recorded machine ID. The pair is treated as
uncertainand held, as the queue lock treats a foreign host (queue-as-data plan 8.4), and acquisition refusesforeign-host. Nothing is written, and a copied claim root never becomesstopped. - Same host, different boot ID. Every process of the recorded boot is
gone. The supervisor issues a boot proof (§6), and both keys move to
stopped. Tool calls with no recorded end becomeuncertaineffects. - Same boot,
reserved, keys possibly half-done. The intended unit name is looked up.- No spawn marker and no unit: nothing was started under that claim.
The reservation moves to
stoppedwith a no-unit observation. - Spawn marker and no unit: the engine may have run and exited, and a
collected scope is an absent observation (§6), not proof that every
process ended. The pair stays
uncertain. Only a boot proof moves it on in CHAT-03. Any other rule is a reviewed change. - A unit exists: the pair is
uncertain, and only force stop (§6) moves it on.
- No spawn marker and no unit: nothing was started under that claim.
The reservation moves to
- Same boot, engine or unit alive. This is an orphan: the stdin pipe
is gone, so no controller can attach. The state is
uncertain, and only a confirmed force stop of that recorded scope moves it on. - Same boot, keys disagree (for example one
stopped, onestopping). The pair stays held. The restart continues the unfinished transition for that claim ID and never starts a new one.
- Host differs from the recorded machine ID. The pair is treated as
-
Location and live-session guard. The claim root and every session path are constructor arguments. CHAT-03 has no production default. At construction and again at bind time, the controller resolves real paths. It refuses
live-session-refusedfor any session file or claim root that is not inside the explicit fixture root it was given. It also refuses any path under the repository's.pi/state/,~/.pi,~/.claude, the configured data root, or a path named in any seat registration. This is what makes CHAT-03 fixture-only in code and not just by convention. The guard comes out only at cutover (CHAT-07), when the live location is reviewed as a data-map change. -
What never releases a claim. Disconnect never changes a claim.
agent_settled, EOF, SIGTERM, idle and an abort acknowledgement never release one. CHAT-00 line 65 says settled must not release a writer claim. CHAT-01 line 325 says SIGTERM, EOF, an abort acknowledgement and idle don't prove death, so none of them can support astoppedrevision either. -
Session drift, not foreign-writer attribution. Pinned Pi emits
message_endto RPC listeners beforeSessionManager.appendMessagecreates the entry ID (dist/core/agent-session.jslines 386–398).entry_appendedfires only for extension custom entries (line 2033). So the stream carries no ID that tells the engine's own appends from another writer's. The drift check compares the file with the engine's own list instead:get_entriesreturns every entry the engine holds, in append order, with stable IDs (rpc.mdlines 717–745;rpc-mode.jsline 505).- The pinned
SessionManagerreads the file only at construction orsetSessionFile(session-manager.jslines 606–684). It never re-reads it mid-session, so an entry another writer appends is on disk but not in the engine's list. The author confirms that from the pinned source before I1 code. - Before the first assistant message, Pi keeps entries in memory and
writes the file only when that message arrives (
_persist, lines 739–767). The engine's list can run ahead of the disk. That is not drift.
The check runs when the engine is idle: after
agent_settled, with no command in flight. It readsget_entries, then the file. Drift is any of:- an entry ID on disk that the engine's list lacks, or an ID that appears
twice (the session header is excluded, since
get_entriesomits it,rpc.mdline 719; the header is checked separately against the claim's session identity); - disk entries that aren't a prefix of the engine's list, in order;
- the file shrinks, is replaced (inode change), or has a line that isn't a valid entry.
The engine's own settings, model-change, label and compaction entries are in its list, so they never count as drift. On drift, admission closes, the binding goes to
uncertainwith reasonsession-drift, and views get a reconcile marker. Plan lines 179–180 require refusing a second writer until the prior execution is reconciled. The file comparison and the marker are this brief's design for that. Ifget_entriesfails or times out, the check reportsuncertainand admission stays closed.Stated limits:
- A foreign write that lands mid-turn is caught at the next idle check, not when it happens.
- A foreign writer that rewrites the file in place with the same entry IDs and inode isn't distinguishable from the engine's own writes. The content isn't compared.
- The claim excludes only controllers that use it. A
pi --sessionrun outside Mosaic isn't prevented, and the live-session guard is what keeps CHAT-03 off real sessions.
-
No writes to sessions. The controller never writes a session file and never uses
SessionManager.open(CHAT-00 line 67).
| # | Fixture | Expected |
|---|---|---|
| W1 | Two processes acquire the same pair at once | Exactly one claim. The other refuses already-active. |
| W2 | Acquire while a claim is active or reserved |
already-active |
| W3 | Acquire while stopping, uncertain, or stopped without proof |
unsafe-replacement |
| W4 | Same session with a different seat tuple, and the reverse | Both refuse. A loser that already published on the seat key follows it with stopped (no-unit). It never spawns, and the winner's revisions are untouched. |
| W5 | SIGKILL between every publication barrier of acquire, transition and release: after the temp write, after its fsync, after link(), after the directory fsync, and between the two keys |
After restart: never two holders and never a lost claim. Every visible revision is complete. A half-done pair is completed or classified under its claim ID, as in "Restart". |
| W6 | Controller killed mid-turn and restarted while the engine is alive | uncertain. No launch, and prompts refuse. |
| W7 | Recorded boot ID differs, same machine ID | stopped with a boot proof. Open tool calls become uncertain. |
| W8 | Resume after a proven stop with the same pins | New claim ID, generation +1, same conversation, branch and leaf |
| W9 | Resume with a changed binary, argv digest, branch or leaf | Refused. The claim is unchanged. |
| W10 | A valid entry with a new ID appended while the fake engine is idle; a duplicate ID appended; the file truncated; the file replaced | Each gives session-drift at the idle check: admission closed, uncertain, reconcile marker. The controller writes nothing to the engine or the file. |
| W11 | Controller writes to session files | None. The CHAT-02 F17 check runs over the fixture session directory. The fake engine's own appends are recorded separately and excluded. |
| W12 | A live owner paused with SIGSTOP; a second controller starts | The second refuses already-active. The paused owner's revisions are unchanged after it resumes. |
| W13 | Crash after the engine spawns but before active is published |
Restart finds the reservation and the live unit: uncertain, force stop only. No second spawn. |
| W14 | Crash after reservation, before the spawn marker | No unit and no marker: stopped with a no-unit observation. The pair is free. |
| W20 | Crash after the spawn marker; the engine exits and the scope is collected before restart | No unit, but a marker: uncertain. No launch until a boot proof. |
| W15 | Crash between the two keys during release | The pair stays held. Restart finishes the release under the same claim ID. |
| W16 | A highest revision that won't parse | Held as uncertain, and acquisition refuses. The older stopped revision is not reused. |
| W17 | A claim root copied from a fixture "other host" (different machine ID) | foreign-host. Nothing is promoted. |
| W18 | Own appends generated with the pinned SessionManager append path into a temp directory (never SessionManager.open, never a live file), including settings, model-change and compaction entries and delayed persistence after message_end |
No session-drift |
| W19 | Engine list ahead of the disk before the first assistant message; get_entries times out at the idle check |
The first gives no drift. The second gives uncertain with admission closed. |
| G1 | Session path or claim root under .pi/state/, ~/.claude, the data root, or named in a registration |
live-session-refused at construction |
| G2 | A symlink inside the fixture root pointing at a live session file | live-session-refused at bind (real-path check) |
| G3 | A fixture path that is swapped for a live path after construction | Refused at bind |
3. Pi adapter, incremental events and R3-1
Pinned inputs for Pi 0.85.1, under
node_modules/@earendil-works/pi-coding-agent/:
docs/rpc.md(15fcd26bee72777b373fd5f2edd77091a01cadd4de95e48b08422ced0552a28d)dist/modes/rpc/rpc-mode.js(e7e4724aa55c5aac73cf36793653b26736200e5c59d58373990fc31028f86477)dist/modes/rpc/rpc-types.d.ts(e968e5be01dc7ad9615f938ae867ef136fa495f13dcf169942e9f781a299d9eb)dist/core/agent-session.js(fb8a3981c20c8c0bbd42231b1c99a10335fb3858b659056b341954de9cfa467f)dist/core/session-manager.js(ccace64949db25379a43971ecea750c1b7ec6344e1bc31b9d5fe596ac2f1c9f3)
The plan requires reading the Pi docs completely before Pi implementation (lines 163–164). The author does that before any I1 code.
-
The fake engine. A fixture process that speaks the pinned protocol:
- prompt acceptance and refusal;
- message and tool events;
agent_endarriving beforeagent_settled, and retries;clear_queueandabort;- dialogs;
- scripted pause points, so a test can land a race at an exact step.
Its behavior is checked against the pinned files, not guessed.
-
Real-binary smoke. The pinned
pistarts in--mode rpcin a scratch directory, with a scratchHOMEand a scratch Pi agent directory, so the default~/.piauth can't be found, and no model call. The setup checks that no auth file is reachable before starting the binary. It answersget_state,get_commands, andclear_queueandabortwhile idle. The recorded exchange shows that the fake's framing matches the binary. If the binary won't start without auth, the author records that, and the packet says plainly that I1 rests on the fake alone. -
Events. Native events map onto CHAT-01
eventrecords: message-start, text and thinking deltas, tool-start, tool-update and tool-end, message-end withupdateMode: replace, and run-settled.- Every event carries the execution incarnation and a sequence number for that execution.
- An unknown native event gets no client event until C-4. It is recorded in controller evidence and counted, and the terminal shows the count. It is never dropped silently and never passed through raw.
agent_settledmaps to run-settled, never to cohort termination.
-
Joining history and the stream. CHAT-01 lines 105–112 require an atomic cut between a history page and the stream; otherwise streaming is advertised as unavailable. Pinned Pi emits
message_endbefore it persists the entry (agent-session.jslines 386–398), so a page read just after the event can miss that message. Unless the author shows a cut from the pinned source, I1 advertises replay as unavailable. A client gets the page, then live events from the moment it subscribes, with a reconcile marker at the seam. Stream events carry no entry ID, so the seam can't be deduplicated by ID; the marker tells the client to reconcile by re-reading the page once the run settles. Fixture E4 covers both cases.
R3-1: dispatched input when a fence lands. R1 assumed a Mosaic prompt
could wait in Pi's queue. It can't, and R2 drops that premise. From the
pinned source (dist/core/agent-session.js, hash above):
isStreamingis_isAgentRunActive(lines 616–617)._runAgentPromptsets it at line 773, and thefinallythat follows always clears it through_emitAgentSettled(line 348).prompt()withoutstreamingBehaviorthrows "Agent is already processing" whileisStreamingis true (lines 860–863). The controller never sendsstreamingBehavior, so a Mosaic prompt is either refused, handled without a run, or run at once. It never enters Pi's steer or follow-up queues.- The RPC handler reports success when preflight passes, before the run
(
rpc-mode.jslines 298–320). An error before preflight comes back as an error response. An error after it is swallowed, but the run'sfinallystill emitsagent_settled. - On success,
preflightResult(true)(line 948) writes the response, and_runAgentPromptis called in the same synchronous step, soisStreamingis already true when the next command is read. The author confirms this from the source before I1 code. If it doesn't hold, the author records that, and aget_stateread taken just after an ack is not evidence.
So clear_queue returns only input from outside Mosaic, for example an
extension's follow-up or steer. The controller never treats that text as
a Mosaic item and builds no text matching. The real risks are in the
pending slot (§1): a fence can land while a Mosaic prompt is in preflight,
or after an ack that produced no run.
The rule:
-
Order. Interrupt closes admission, sends
clear_queue, thenabort.abortwould run anything still queued (rpc.mdline 158). -
Clear timeout or failure.
abortis not sent, because it would run whatever is queued. The turnProof recordsnativeQueue: unknown, the stop isuncertain, and admission stays closed. Force stop stays available. It needs no cooperation from the engine (§6). -
What the clear returned. An empty clear gives
nativeQueue: cleared. A non-empty clear removed external input. CHAT-01 has no field for that (C-1). Until C-1 lands, the stop staysuncertain,reconciledisn't published, admission stays closed (CHAT-01 lines 311–312), and force stop is the way on. The removed items go to controller evidence as digests and byte counts. They are never attributed to a request and never resent. -
The pending slot. Each case settles the Mosaic item without reading
clear_queue:Slot state when the fence lands What the controller does Receipt Preflight still in flight (no response yet) Waits for the response, bounded Error response: failed, native error kept. Ack: a run started, so the controller repeats clear, then abort (rule 1), at most three times, and the receipt follows the interrupted turn. If the run is still going after the third,nativeQueue: unknown, the stop isuncertain, and force stop is the way on. No response in bounds: an unknown write outcome (§1).Run visibly started before the fence ( agent_startand the usermessage_startarrived while the slot was held)Nothing extra Follows the interrupted turn ( turnState: interrupted)Ack, then no run: get_stateafter the ack says not streaming, and noagent_startarrived between the ack and that replyExtension-handled input (lines 832 and 845) delivery-unknown, reasonhandled-without-runAck, run started and threw with no user message_start;agent_settledarrivedSee the note below the table failed, reasonack-without-startWrite outcome unknown Pipe poisoned (§1) delivery-unknown/transport-unknownPi persists a run's user message only in the
message_endhandler, after emitting the event to listeners (lines 386–398). The only otherappendMessagecalls are bash messages (lines 2411 and 2441). So a user message can't reach the session file unless its events reached the controller first. That is why the throw case isfailed. If the author finds another persistence path in the pinned source, that case becomesdelivery-unknowninstead, and N11 changes with it.A
delivery-unknownitem is never relabelled unsent and never resent. The client shows it as "outcome unknown". The actor may copy the text into a new request, and the client doesn't present that as a safe resend. -
Reopening.
reconciledis published and admission reopens only when all of these hold:- the pending slot is settled by the table above, or the pipe is
poisoned, in which case the stop stays
uncertain; agent_settledarrived after the lastabort, orget_stateshows no run and none started;- a
clear_queuesent after that returns empty; - every clear in the sequence was empty, until C-1 lands (rule 3).
A non-empty post-settle clear repeats abort, settle and clear, at most three times. After that the turnProof records
nativeQueue: unknown, the stop staysuncertain, and admission stays closed. - the pending slot is settled by the table above, or the pipe is
poisoned, in which case the stop stays
-
External insertion after the last clear. An extension can queue input after the final empty clear. CHAT-03 can't rule that out on pinned Pi. The proof covers the queue as of the last clear, the turnProof says so with its
observedAt, and anything queued later belongs to the next turn. It is not claimed as part of the stopped turn. -
Late native events. A user
message_startthat arrives after the fence is recorded as an event. The receipt moves only as CHAT-01's receipt transitions allow, and never to a state claiming the item wasn't sent.
Outside a fence, the slot's item becomes working at the first user
message_start after its ack. Because Pi runs a Mosaic prompt at once or
not at all, that message is the item's, unless an extension replaced the
input. The adapter doesn't compare text. The README says working is
inferred from order.
| # | Fixture | Expected |
|---|---|---|
| N1 | An extension follow-up is queued; Interrupt | clear_queue goes before abort. The follow-up doesn't run. The clear was non-empty, so until C-1 the stop stays uncertain, no reconciled is published, prompts refuse, and evidence holds the item's digest. No Mosaic receipt changes. |
| N2 | N1 with abort sent first (mutant) |
The fake runs the external item, so the test fails. This is the ordering guard. |
| N3 | Fence while the Mosaic prompt is in preflight; preflight then errors | Receipt failed with the native error. The slot is released. |
| N4 | Fence while the Mosaic prompt is in preflight; the ack arrives after the first abort |
A second abort is sent. The receipt follows the interrupted turn. Admission waits for a post-settle empty clear. |
| N5 | Mosaic prompt acked but handled by an extension, with no run | get_state shows no run and no agent_start arrived. The receipt is delivery-unknown, reason handled-without-run. Nothing is resent. |
| N6 | An extension queues between clear_queue and abort, and again on each cycle |
Bounded at three cycles, then nativeQueue: unknown, stop uncertain, admission closed |
| N7 | clear_queue times out |
No abort sent. nativeQueue: unknown, stop uncertain, admission closed. A confirmed force stop still ends the cohort (K1). |
| N8 | Mosaic prompt written just before the controller reads an external run's agent_start |
Pi refuses it (line 860). Receipt failed with native evidence. The slot is released, and the text is kept for an explicit resend. |
| N13 | Mosaic prompt after the controller has seen an external run's agent_start |
busy at admission. Zero engine bytes. |
| N9 | The item's run started before the fence | The receipt follows the interrupted turn (turnState: interrupted), not delivery-unknown |
| N10 | Fake conformance | The fake throws on a prompt while streaming, acks before running, and emits agent_settled from a finally, matching lines 773, 860–863 and 948. A fake that queues a Mosaic prompt fails the test. |
| N11 | Ack, then the run throws before any user message_start; agent_settled arrives |
Receipt failed, reason ack-without-start. The session file gains no user entry. |
| N12 | An extension queues after the final empty clear | The stop records the clear's observedAt. The queued item runs as the next turn and is not part of the stopped turn's proof. |
4. The slash path (carry-forward 3)
There are two separate hazards: text left in a composer gets concatenated with a new message, and Pi interprets certain prefixes.
- Concatenation.
send-message.shpastes onto whatever the composer holds. The mediated path has no engine-side composer. Each prompt is one JSONL record, and itsmessagefield holds the whole text. Nothing left over can prefix it. The mediated terminal's own composer is a local buffer. It is empty at start, cleared after each submit and on control transfer, and it submits only while its connection is the controller. - Interpretation. RPC still acts on a leading
/. Extension commands run immediately, even while streaming, and skill and template commands expand (rpc.mdlines 67–69).- Repository seats load one extension command,
/goal(extensions/goal/index.ts:230, loaded through the.pi/extensionspath atscripts/agent-host-dev.sh:137). Prompt templates are off (line 136). Skills are live:--no-skillsstops discovery only, and the ten explicit--skillpaths still load (agent-host-dev.shlines 77–83 and 138; Pidocs/skills.mdline 42). So/skill:<name>expands on repository seats. - Admission refuses
text-policywhen the first non-whitespace character is/(CHAT-01 lines 181–183). Dispatch checks again. - CHAT-01 lines 368–370 leave
!,@and slashes on later lines open under B3. I1 settles them from the pinned source. The author lists every prefix that Pi 0.85.1 RPCpromptinterprets, and the list becomes a fixture. Admission refuses each listed prefix, and any prefix the author can't classify.
- Repository seats load one extension command,
- Board and agent-send for a mediated seat. A mediated engine has no
tmux pane. The board already refuses a reply to a registration without a
tmux session before the tool runs (
packages/control-board/src/serve.mjs:84; testserve.test.mjs:810). CHAT-03 relies on that and changes neither the board nortools/tmux. No real seat registers as mediated in CHAT-03, so the refusal first applies to a real seat at cutover. - Seats still on tmux keep the hazard. The DEFERRED entry stays open. It
closes when a seat moves to the mediated path (CHAT-07), or when a CHAT-03I
charter covers
tools/tmux(CHAT-01 lines 232–238). Neither happens in CHAT-03.
| # | Fixture | Expected |
|---|---|---|
| S1 | /goal x, and the same with leading spaces or a tab |
text-policy at admission. Zero bytes reach the fake engine. |
| S2 | Each prefix on the author's list, including /skill:ms-unslop |
Refused. The test reads the same list the code uses. |
| S3 | /goal on the second line |
The fixture pins the result from the pinned source: admitted if Pi doesn't interpret later lines, refused otherwise |
| S4 | The terminal composer holds a / from an abandoned edit, then control transfers and returns |
Composer cleared. The next submit sends only the new text, and the fake engine records the exact bytes. |
| S5 | An observer terminal gets a paste and then Enter, as send-message.sh does |
Not admitted: controller. Zero engine bytes. |
| S6 | A mediated-shaped registration (no tmux) passed to replyToRow with a recording exec |
409, "no tmux session". exec is never called. |
| S7 | A prompt containing ESC, bracketed-paste markers or U+2028 | Sent as one JSON string. The fake engine receives the exact text, and no record splits. |
5. Control races (Rocko)
Each race uses the fake engine's pause points to land at an exact step. Every race asserts the engine bytes, the receipts and the events.
| # | Race | Expected |
|---|---|---|
| H1 | Two takeovers with the same expected generation | One wins, and the generation goes up by 1. The other refuses generation. |
| H2 | The old controller's prompt arrives after a takeover commits | Refused. Zero engine bytes. |
| H3 | A takeover commits while a prompt holds the dispatch lock | The prompt either completed its write before the commit, and the receipt keeps the old actor, or it is refused. If the write outcome is unknown, the §1 rule applies: delivery-unknown, pipe poisoned, uncertain. Never dispatched under both controllers. |
| H4 | Self-takeover | Refused (CHAT-01 line 269) |
| H5 | The old controller answers an approval after a takeover | Refused. The decision is answered once, through the new projection. |
| H6 | Duplicate approval answers with the same content | One native response |
| H7 | Conflicting approval answers with the same request ID | The second is refused |
| H8 | An approval answer after a native timeout or cancel | Refused. The state comes from native evidence. |
| H9 | Interrupt racing a prompt's dispatch | Before the write: dispatch-refused. After it: the R3-1 rules in §3. |
| H10 | Interrupt and force stop at the same time | One stop chain. Force stop supersedes (CHAT-01 lines 319–322). |
| H11 | The controller disconnects mid-turn | Work continues and the claim is unchanged. Control stays with the disconnected connection until an observer takes over explicitly. Nothing happens automatically. |
| H12 | Exact retry of a prompt after reconnecting to the same controller incarnation | The same receipt. No second dispatch. |
| H13 | Retry with the same request ID and different text | Refused |
| H14 | Late stdout from the old engine after a replacement | Dropped by execution incarnation and counted in the evidence. Never rendered. |
| H15 | A revoked connection sends a command | Refused, with the revocation fence of CHAT-01 lines 271–280 |
| H16 | A second controller process for the same session | Refused as in W1 and W2. The first controller is untouched. |
| H17 | A confirmation reused, answered from another connection, or answered after the stop context changed | Refused. Confirmations are single-use (CHAT-01 lines 264–268; CHAT-01C lines 92–100). |
| H18 | Two prompts sent before any native output from the first | The second refuses busy. One engine write. |
| H19 | A large prompt line under backpressure; the pipe closes (EPIPE) mid-line, or the controller dies mid-write | delivery-unknown, reason transport-unknown. Pipe poisoned, no later write, uncertain. No retry. |
| H20 | The line is written completely, but the native acknowledgement is lost when the controller dies | After restart: the claim is an orphan (W6). The request's outcome is unknown, and nothing is resent. |
| H21 | Crash after native dispatch, before the client gets its receipt; restart; the client reconnects and retries the exact request with the old token | stale-incarnation. No second engine write. |
| H22 | After H21 and a valid recovery, the client sends a new request with the new token | Admitted normally |
6. Stop, cohort proof and recovery (Rocko)
CHAT-01 lines 330–333: a self-posted hash is not trust, and a real
producer/verifier and complete cohort containment remain B3/B4. CHAT-03
builds the producer and its observation procedure. The verifier stays the
fixture's trusted digest registry, as in CHAT-01. So no CHAT-03 proof is
live authority. Where the procedure below can't be carried out, the stop
ends at uncertain, not stopped.
-
Producer. A supervisor shim in
packages/conversation/src/starts withsystemd-run --user --scope, delegated, under the intended unit name recorded in the claim (§2). Inside the scope it moves itself into asupervisorchild cgroup and execs the engine in anenginechild cgroup. So the engine is contained from its first instruction, with no window before it could fork. The cohort is theenginecgroup. The shim is not a member. It holds the scope open, so systemd can't garbage-collect the cgroup before emptiness is read. -
Identity and epoch. The cohort reference is the host (
/etc/machine-id), the boot ID, the unit name and the scope's systemd invocation ID. The invocation ID is the membership epoch. A unit with the right name but a different invocation ID is a different cohort. The supervisor sends it no signals and reports evidence unavailable. -
Containment against migration. A same-uid process can move a pid between cgroups in the user's delegated tree, so a member could leave the scope and survive a "complete" kill. The candidate defence: run the engine in a cgroup namespace rooted at
engine. This host mounts cgroup2 withnsdelegateand allows unprivileged user namespaces, and withnsdelegatea namespaced process can't migrate pids outside its namespace root. The author must show this with K13. If K13 can't be made to refuse the escape, real cohorts never reachstoppedin CHAT-03: K1 then expectsuncertain, and the packet says so. A same-uid process outside the cohort that moves members out is Limit 4 and B2, not something CHAT-03 claims to stop. -
Unavailable is not empty. Emptiness is a readable
engine/cgroup.eventsshowingpopulated 0, for a scope whose invocation ID matches, read by the live shim. A missing or unreadable path, a gone shim, or an invocation-ID mismatch is evidence unavailable, and the stop staysuncertain. The shim stays a member of the scope in its own leaf, so systemd doesn't collect the scope while the shim readsengine. A collected scope or an absentenginecgroup is an absent observation, never an empty one. -
Proof.
stoppedneeds a cohortProof with every CHAT-01 field (schemacohortProof): stop, authority, conversation, execution, cohort reference and membership epoch,membershipComplete, members with boot, pid and start time and a death time each, observation time, and verification digest. Membership is complete as of the freeze in the kill phase below, when no member can fork. A process that exited before the freeze isn't listed; its effects belong to the effect report, not the death proof.membershipComplete: trueis written only when the freeze, enumeration andpopulated 0all succeeded. SIGTERM, EOF, an abort acknowledgement,agent_settledand idle never promote a stop tostopped(line 325). -
Effects. A tool-start with no tool-end at stop time is an
uncertaineffect. Killing never counts as rollback (line 334). Network and remote effects stayuncertain. -
Interrupt (Q20). As in §3: close admission,
clear_queue,abort, settle the pending slot, a post-settle empty clear, the turnProof,reconciled, and reopen admission under current control (lines 307–313). If a clear removed external items, the turnProof can't record them until C-1 lands. The stop staysuncertain,reconciledisn't published, and prompts refuse, as CHAT-01 lines 311–312 require before that transition. Force stop is the way on. If it hangs or the clear times out, force stop stays available. -
Force stop (Q8). Needs an answered confirmation. The stop ID, the answered confirmation and the current phase go into the
stoppingrevision before any signal. Then:- check the scope's invocation ID against the claim;
- TERM phase: SIGTERM to every member of
engine, then a bounded grace period; - kill phase: freeze
engine(cgroup.freeze, waiting forfrozen 1), enumerate every member with pid and start time, writecgroup.kill, and wait forpopulated 0; - build the proof.
It acts only on the selected execution's
enginecgroup, and other executions survive. If a kernel feature the author relies on (cgroup.freeze,cgroup.kill, delegation) isn't available, the stop ends atuncertain. -
Controller death during force stop. A restart (owner proven gone, §2) never assumes TERM or kill happened. It checks the invocation ID. If that matches, it re-runs the escalation from the TERM phase for the same stop record and the same scope. That continues the recorded stop; it doesn't reuse the confirmation for a new one. If identity can't be checked, it sends no signals, and the stop is
uncertain. -
Boot proof. For a claim whose recorded boot differs on the same host, the supervisor issues a cohortProof whose evidence is the boot change. It goes through the same verifier. A different machine ID is
foreign-host, never a boot proof. -
Recover (Q15). Recover reports eligibility only. It needs:
- a stopped proof;
- effects reconciled or explicitly uncertain;
- an exact confirmation;
- the same pins (lines 336–343).
Recover publishes a new
reservedclaim, under a new claim ID, on both keys. The eligibility record names that claim, the stop, the pins, the leaf and the controller incarnation token, and it is single-use. So no one can take the pair between eligibility and launch. Launching is a separate library call made by a launcher. It revalidates the record against the claim and the current leaf, consumes it once, and spawns. No client command launches, and a browser or controller crash can't start an engine. -
Pi resume. Resume reuses the same session file and leaf. The author confirms from the pinned docs how Pi selects the leaf. Before admission opens, the adapter checks
get_stateandget_treeand refuses if the engine loaded any other leaf (RUNTIME.md §3 item 4). A wrongly loaded engine stays under the claim and its scope until a force stop proves it stopped.
| # | Fixture | Expected |
|---|---|---|
| K1 | Force stop an engine whose tool child calls setsid |
The child is killed. stopped with a proof accepted by the fixture verifier if K13 refuses the escape; otherwise uncertain, recorded. |
| K2 | K1 on the process-group fallback (no scope) | uncertain, never stopped |
| K3 | SIGTERM acknowledged while a member is still alive | Stays stopping until the kill phase. Never stopped from TERM alone. |
| K4 | Two fake engines; force stop one | The other survives, shown by independent observation: its own cgroup is populated and it still answers get_state |
| K5 | Stop during a tool call | The effect is uncertain and shown |
| K6 | Recover without proof, without confirmation, or with changed pins | Refused |
| K7 | Recover after proof, then launch | New claim ID and incarnation, generation +1, same leaf. The cancelled prompt is not replayed. |
| K8 | The engine loads a different leaf on resume | Refused before admission opens. The engine stays claimed and contained until force stop proves it stopped. |
| K9 | An interrupt that never settles | Stays uncertain. Force stop stays available, and ordinary takeover is refused while fenced. |
| K10 | Controller killed between the TERM and kill phases | Restart checks the invocation ID and re-runs from TERM for the same stop. Nothing is recorded as killed that wasn't observed. |
| K11 | Controller killed after the confirmation is recorded, before TERM | Same as K10 |
| K12 | A member that forks in a loop during enumeration and kill | The freeze stops forking. The enumeration is complete, and populated 0 follows cgroup.kill. |
| K13 | A member writes its own pid to another cgroup's cgroup.procs |
Refused by the namespace, and the kill is complete. If not refused, stopped is unavailable for real cohorts, and K1 expects uncertain. |
| K14 | A unit with the recorded name but a different invocation ID | Evidence unavailable. No signals, uncertain. |
| K15 | engine cgroup path missing or unreadable, or the shim gone |
Evidence unavailable, not empty. uncertain. |
| K16 | Boot proof requested for a claim from a different machine ID | foreign-host. No proof. |
| K17 | Two launcher calls with one eligibility record | One launch. The other refuses, and no second engine starts. |
| K18 | The leaf changes after eligibility, before launch | Launch refused. The reservation stays until released with proof. |
7. Native approval dialogs (I2 after C-2; Claude in I4)
- Pi has no built-in permission system. Approvals come from extensions
through
extension_ui_request(rpc.mdlines 1186–1210). Repository seats load no extension that raises dialogs today, so I2 tests with a fixture extension. - CHAT-01 lines 239–242 and 292 block non-permission forms until CHAT-03D
exists, and C-2 is that companion. After C-2:
confirmandselectrender the exact native labels and answer with the exact request ID;inputandeditorstay unsupported and show as disabled, with a reason;- nothing is answered on the user's behalf, and a generic "yes" is not accepted.
- Cancel is enabled only where C-2 proves it can't mean allow. A Pi select cancel does not inherit confirm's meaning (lines 293–295).
- Pending dialogs don't survive a Pi restart (CHAT-00 line 69). After a takeover, the controller reprojects each dialog: a new projection ID for the same decision (lines 282–287).
- Claude, in I4 after B1.
can_use_toolmaps to allow-once and deny. Choices that widen permissions are disabled, and there is no invented cancel (lines 294–295). Pending permission requests are re-read from initialize on reconnect, but only if B1 shows that the pinned version supports it.
| # | Fixture | Expected |
|---|---|---|
| P1 | confirm dialog answered by the controller |
One extension_ui_response with the exact ID and value |
| P2 | select with labels containing markup and bidi controls |
Exact labels, rendered inert |
| P3 | input or editor dialog |
Disabled, with a reason. No response is sent. |
| P4 | A native timeout before the answer | uncertain, then resolved from native evidence. A late answer is refused (H8). |
| P5 | Takeover while a dialog is pending | New projection ID and the old one superseded. One native answer. |
| P6 | Interrupt while a dialog is pending | The approval becomes uncertain, not denied (lines 297–299) |
| P7 | A Claude permission request (I4, recorded fixture) | Allow-once and deny only. Widening choices disabled. |
8. Claude: B1 and the catalogue (carry-forward 2)
What B1 is. docs/plans/chat-00/README.md lines 193–195: prove the
exact Claude CLI and protocol combination, capability negotiation,
transcript branch selection and the image and file input shapes. SDK and
source examples narrow the uncertainty, but they don't certify the
installed binary. CHAT-00 line 66 names the gap for history: Claude's
persisted branch format and leaf selection.
What proves it (I3). A B1 packet under agents/<author>/work/chat-03/b1/,
approved by Filbert and Rocko, with five parts:
-
Pin. The exact version string and the sha256 of the binary the adapter will run. Today
claude --versionreports 2.1.283; the plan saw 2.1.269. The adapter checks both at launch and otherwise refuses withengine-pin-mismatch. It runs the engine with auto-update off, and the author cites the setting from the pinned docs. -
Sources. The official protocol sources for that exact version, by hash. These are the C-* entries in
chat-00/sources.json, re-pinned to that version. C-HEADLESS and C-REF are floating documents (sources.jsonlines 106–119) and can't be tied to a version. The packet fixes them as fetched copies, each with its sha256 and fetch date, and says plainly that they aren't version-bound. -
Recordings. Made from that binary, in a scratch config directory with no repository credentials:
- initialize and its capability answer, including whether
interrupt_cancel_queued_v1andpending_permission_requestsexist; - a text prompt and an image prompt;
- a
can_use_toolallow and a deny; - an interrupt.
They are redacted, hash-pinned and used as the I4 fixtures. The plan rules out model calls for discovery (line 166). Any recording that calls a model or reads Claude auth therefore needs Jason's go first, because it spends money and touches credentials. Without that go, I3 stops after parts 1 and 2, and B1 stays open.
- initialize and its capability answer, including whether
-
Branch selection. Two persisted transcripts from that binary, one of them with a fork (an edit or a rewind). The packet states the leaf rule and shows that
--resumecontinues the leaf the parser picks. -
Negative. The parser refuses a transcript from another version with
unsupported-harness.
The catalogue after B1 (I4).
- Rocko's launcher runs Claude with the default config directory
(
agents/rocko/launch.sh:86, noCLAUDE_CONFIG_DIR). So its transcripts sit in the same~/.claude/projects/<cwd slug>/directory as every other Claude session started in this checkout, including T3 threads and Jason's own sessions. - The catalogue therefore never lists that directory. It opens only session
IDs the repository launcher recorded for that seat
(
.pi/state/rocko/session-idandlaunches/*/session-id). Each ID must resolve to exactly one file under the approved root, opened with the CHAT-02 safe-open rules. - A launcher receipt is a hint, not membership. CHAT-01 lines 62–64 require an approved project and workspace mapping, and say OS readability, cwd and seat labels don't establish it. The access mapping stays under B4 review.
- The CHAT-02 guarantees still apply: read-only, no writes (F17), cursors, byte caps, continuation parts and inert rendering.
| # | Fixture | Expected |
|---|---|---|
| L1 | A Claude transcript at the pinned version | Default leaf shown and other branches readable, as in F12 |
| L2 | A transcript from another version | unsupported-harness |
| L3 | A decoy session file in the same directory, named in no receipt | Never listed, never opened |
| L4 | A receipt naming a file outside the root, a symlink or another project | Refused, as in F7–F9 |
| L5 | Sidechain or subagent entries | Kept separate, not merged into the main branch |
| L6 | No-write check | F17 over the Claude directory |
| L7 | The binary's sha256 or version differs at launch | engine-pin-mismatch. No launch. |
The I4 adapter maps Claude stream-json onto CHAT-01 events. The §5 and §9 fixtures then run again against the recordings.
9. Return flow and events
| # | Fixture | Expected |
|---|---|---|
| E1 | The plan §6 return flow on the fake engine: send, acknowledge, user, toolCall, toolResult, new final answer | Shown once in the same conversation, with no refresh, no duplication, and the draft and reading position kept |
| E2 | U+2028 and U+2029 inside JSON strings, and CRLF | Each parsed as one record |
| E3 | Multipart final, two blocks, null request correlation, duplicate delivery | The CHAT-01 cases at lines 110–112 |
| E4 | A page read just after a message_end but before its entry is persisted, then a subscription; again with a gap or a new epoch |
Replay is advertised unavailable. The client shows a reconcile marker at the seam and re-reads the page after agent_settled. After that, each message appears exactly once. A gap or new epoch also reconciles. If the author shows an atomic cut from the source, overlap is deduplicated instead, and the fixture pins that case. |
| E5 | An unknown native event | No client event until C-4. Controller evidence records its type and byte count, and the terminal count goes up. No record fails the schema. |
| E6 | A tool result delayed across a pause and a reconnect | Reconciled without a manual refresh |
| E7 | The mediated terminal as observer, then as controller | Renders the same stream as the library client, and submits only as controller |
The browser leg of the return flow is CHAT-05. I1 proves it at the library and the terminal.
10. Checks and mutation pass
-
Suites. These must pass:
node --test packages/conversation/tests/;- the control-board, webui and seat package tests;
node docs/plans/chat-00/check.mjs,chat-01/check.mjsandchat-01c/check.mjs;- every
scripts/test-*.shsuite at the candidate's base (lead decision 20 addsqueuewhen A1 lands).
-
Nested runners. A test that starts
node --testitself clearsNODE_TEST_CONTEXTfor that child, as in the DEFERRED entry on nested runners (Filbert's A1 review6933b885, note N13), or it can hide failures. -
Isolation. Every fixture runs in temporary directories. No fixture touches live registrations,
.pi/state,~/.claudeor a live seat (plan line 272). -
Mutation pass. Run in a scratch copy, as for the CHAT-02 Console. Each of these mutants must fail at least one test:
- dispatch skips the generation recheck;
abortis sent beforeclear_queue;- a revision is published by rename or overwrite, so an existing revision name can be replaced;
- the late-event filter ignores the incarnation;
- the text policy checks only the first character, missing leading whitespace;
- an acknowledged SIGTERM promotes a stop to
stopped; - the process-group fallback can reach
stopped; - a confirmation is not consumed on use;
- disconnect releases control;
- the composer isn't cleared on transfer;
- engine output is read with
readline; - a revision is published by exclusive create in place of the
temp-file-then-
link()sequence, so a partial file becomes visible; - a second controller reclassifies a claim whose owner is alive;
- the live-session guard skips the real-path check;
- the pending slot is released at
agent_startinstead of at reconciliation; - a pipe is written again after an unknown write outcome;
- an ack followed by no run marks the item
workingorfinishedinstead ofdelivery-unknown(N5); - admission reopens without the post-settle empty clear;
abortis sent after aclear_queuetimeout;- the incarnation token check is skipped;
- a missing cgroup path counts as empty;
- a unit with a different invocation ID is signalled;
- a restart during force stop records the kill phase as done;
- an eligibility record can be used twice;
- a disk entry ID missing from
get_entriesis ignored (and the reverse: an own settings or compaction entry counts as drift); - a non-empty
clear_queuereachesreconciledbefore C-1 lands; - the pending slot's in-flight preflight is abandoned at the fence instead of awaited, so a late ack's run is never aborted (N4);
- the fake engine queues a Mosaic prompt while streaming instead of throwing (N10's conformance check must catch it);
- a no-unit observation frees a pair that has a spawn marker (W20).
I4 adds two more: the pin check is skipped, and the decoy file is listed.
11. Choices in this brief, for review
| Choice | Tradeoff |
|---|---|
No broker queue; a busy engine refuses busy |
No queued follow-ups until CHAT-04, and no native queueing of mediated input |
No streamingBehavior, steer or follow_up, and one pending slot |
Same as above. On pinned Pi a Mosaic prompt then never enters a native queue (lines 860–863), so R3-1 reduces to the slot's preflight and ack cases. |
The slot settles from preflight and run evidence, never from clear_queue text |
An ack with no run stays delivery-unknown: no "unsent" state and no safe-resend button |
C-1 for removed external items; uncertain until it lands |
Until C-1, any external item at an Interrupt means a force stop to move on. Honest, but costly for sessions whose extensions queue input. |
| Poison the pipe on an unknown write | One transport glitch needs a force stop, with no guessing about half-written lines |
Drift by comparing the file with get_entries at idle |
Catches any foreign entry with a new ID, but only at the next idle check. An in-place rewrite with the same IDs and inode isn't caught. |
| Live-session guard by path | Fixture-only is enforced in code. The guard needs a reviewed change to lift at CHAT-07. |
| No default claim root | Nothing live can use I1 before the CHAT-07 data-map decision |
Cohort is an engine child cgroup in a delegated user scope, held by a shim, with a cgroup namespace against migration |
Depends on systemd-run --user, delegation, cgroup.freeze, cgroup.kill and user namespaces. Without them, or if K13 fails, stops end at uncertain. The verifier is fixture-grade until B3/B4. |
One actor, local-operator, guarded only by socket-directory mode |
Same-uid agents are not kept out (Limit 1) |
| Dedup lasts one controller incarnation, fenced by a token | A retry across a restart refuses stale-incarnation instead of returning the receipt |
| Four increments, with I3/I4 gated on B1 | CHAT-03 can close Pi-only, with that outcome recorded |
| Claude catalogue from launcher receipts only | A Claude session started outside the launcher never appears |
Carry-forwards
| # | Sage's item | Where | Acceptance evidence |
|---|---|---|---|
| 1 | D1 writer-claim record and R3-1 (lead decisions item 8) | §1, §2, §3 | W1–W20, G1–G3, N1–N13 and H18–H22 pass. Mutants 2, 3, 12–20 and 25–29 fail tests. R3-1 settles each slot case from preflight and run evidence. External items removed by clear_queue leave the stop uncertain until C-1 lands or is carried (Gate). |
| 2 | The Claude catalogue after B1 | §8; increments I3 and I4 | The B1 packet (parts 1–5), approved by Filbert and Rocko. L1–L7 pass. |
| 3 | The board send that can become a Pi slash command | §4 | S1–S7 pass. Mutants 5 and 10 fail tests. The DEFERRED entry stays open for seats still on tmux. |
| 4 | The CHAT-01/01C contracts, by path and hash | "Contracts implemented" | The hash table matches sha256sum at approval. C-1 to C-4 are separate reviewed items, none folded in. Deviation V-1 has Sage's ruling. |
| 5 | Path authority | "Files owned" | The overlap table. Every changed path in each candidate is inside "Files owned". |
| 6 | The source author | "Owner and reviewer" | <slot>, named by Darkwing after this brief is approved |
Limits
- Same-uid control. Any process running as the same user can connect to the socket and take control. Agent seats run as that user, so an agent could take over another conversation. CHAT-03 is fixture-only, so no live conversation is exposed. Live use waits for a reviewed local owner-channel design (B4).
- Fake engines. They model the pinned docs. The real-binary smoke covers framing only. I1 makes no model call, so real-engine behavior is unproven until CHAT-06.
- B2 untouched. Tool isolation and trust and settings parity (CHAT-00 lines 196–197) are untouched. Any real engine for a real seat waits on B2.
- Containment. The cgroup covers local processes. Network and remote
effects stay
uncertain. A same-uid process outside the cohort can still move members out. The verifier is the fixture registry. No CHAT-03 stop proof is live authority until B3/B4 and B2. - Sole writer. Pinned Pi gives no stream ID for its own appends. The
get_entriescomparison catches a foreign entry at the next idle check, not during a turn, and misses an in-place rewrite that keeps IDs and inode. The claim binds only controllers that use it. Real sessions stay off-limits through the live-session guard. - Durability. W5 kills processes at each barrier. Real power loss
isn't tested; the fsync-then-
link()order is the stated basis. - External queue after the last clear. An extension can queue input after the final empty clear. The stop proof covers the queue as of that clear (§3 rule 6), not after it.
- Takeover has nothing to recover. With no broker queue, a takeover has no queued input to turn into drafts. Q13 is CHAT-04's.
Out of scope
- Durable queues, drafts, uploads, queue edit and cancel, takeover-to-draft (CHAT-04).
- Remote control (CHAT-04R) and the chat UI (CHAT-05).
- The candidate, cutover and any real seat migration (CHAT-06, CHAT-07).
- Changes to
tools/tmux, the board, the seat package, launchers,roles/,contracts/and thedocs/plans/chat-0*contracts. - The CHAT-03I fleet-communications charter (CHAT-01 lines 232–238).
- Authenticated multi-actor control and the local owner-channel design (B4).
- The Claude adapter and history, unless B1 passes.
- Filbert's idsDigest note, which stays in
agents/dewey/work/chat-02/FOLLOWUPS.md.
Gate
- Brief.
- Filbert approves the exact
BRIEF.mdhash. - Rocko's pass on §5 and §6 leaves no blocking finding open.
- Sage commits and pins the brief. Darkwing then names the author.
- Filbert approves the exact
- Each increment.
- Filbert approves the exact candidate hashes. Rocko passes the §5 and §6 code in I1 and the B1 packet in I3.
- Every §10 suite is green on an index export, and every mutant is killed.
- Sage commits. A push needs Jason's word.
- Contract items. Each is a separate reviewed change to the CHAT-01
files, never folded into an increment.
- C-1 is approved and adopted through I1b, or Sage records that it
moves to CHAT-04 with the interim rule (a non-empty clear leaves the
stop
uncertain). Either one is needed before CHAT-03 is done. I1 ships with the interim rule. - C-2 is approved before I2 starts, or declined (see "What ships").
- C-3, if B1 shows a gap, is approved before I4 starts.
- C-4 is optional. Without it the interim rule in "Contracts implemented" holds.
- Deviation V-1: Sage rules before I1 is approved.
- C-1 is approved and adopted through I1b, or Sage records that it
moves to CHAT-04 with the interim rule (a non-empty clear leaves the
stop
- I3. Jason's go comes before any model call.
- CHAT-03 done. All of these:
- I1 is approved and committed;
- I2 is approved and committed, or C-2 is recorded as declined;
- I1b is approved and committed, or C-1 is carried to CHAT-04 with the interim rule;
- I4 is approved and committed, or B1 is recorded as not passed (the triggers are in "What ships"), with Claude still refusing and CHAT-06 blocked.
- No live check. No real seat migrates, so there is none. The live proof belongs to CHAT-07.