Files
stack/packages/conversation/README.md
T
jason.woltjeandClaude Opus 5.5 243e153c8b feat(conversation): CHAT-03 I1, mediated control of a sealed headless Pi (#1507)
Controller, claim store, live-session guard, engine link and seal,
turn tracker, cohort force stop and recovery, client library,
transcript and mediated terminal, with the fake engine and tests.
Fixtures only; no live cutover.

Dewey built it. Darkwing (comment 26690) and Filbert (comment 26694)
approved round 2. Manifest I1-r2-manifest.sha256 (2b48e333, 27 files).
Suites on an export: conversation 152/152, control-board 124, webui 14,
seat 19, chat-00/01/01c checks, and all nine scripts/test-*.sh green.
Follow-ups for I3 are in DEFERRED. Gate E stays with Jason.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 15:47:53 -05:00

506 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# conversation
Two layers over Pi conversations, issue #1507 (row 5). Plain ESM, no
dependencies, Node 24 or newer.
- **The reader (CHAT-02).** Read-only histories for the Console: a catalogue
of approved session files, full branch history in CHAT-01 pages, and
cursors. Opening a conversation never resumes, forks, launches or controls
anything, and the reader writes no file. Brief:
`agents/dewey/work/chat-02/BRIEF.md` R4. A library with no server: the
control board serves it on two GET routes (D3).
- **Mediated control (CHAT-03, increment I1).** One controller process per
execution owns a sealed headless Pi's stdin and serves a local socket;
clients observe, prompt, take control, interrupt, force stop and recover
through it. Fixture sessions only. Brief:
`agents/dewey/work/chat-03/BRIEF.md` (pinned 1ef15ac0) with lead decisions
30–34 and 36. See [Mediated control](#mediated-control-chat-03).
## API
```js
import { rootsFromSpecs, createReader } from "@mosaic/conversation";
const reader = createReader({ roots: () => rootsFromSpecs(specs, registrations) });
reader.catalogue(); // { ok, conversations, refusedRoots, generatedAt }
reader.open({ conversation, branch? }); // first page
reader.next({ cursor, conversation, branch }); // next page, or a follow
```
`open` and `next` return `{ ok: true, page, cursor, follow, view }` or
`{ ok: false, refusal: { code, reconcile, message } }`.
- `page` is a CHAT-01 `page`, and `cursor` and `follow` are CHAT-01 `cursor`
records. `page.nextCursor` is `cursor.id` when more parts remain in the
snapshot.
- On the last page, `follow` replaces `cursor`. Calling `next` with it takes
a fresh snapshot and returns only what was appended since (see Follow).
- `view` holds what the Console needs beyond CHAT-01: `defaultBranch`,
`branches`, `incomplete` (a truncated trailing line),
`forkedFromEarlierSession` and `unreadableLines`.
- `actor` defaults to `local-operator` and `purpose` to `history`. Any other
value is refused.
## Sources
Roots come from the board's repository specs only:
`<projectRoot>/.pi/state/<seat>/sessions`, with the project named after the
root directory. Fleet and connector specs are not roots, and there is no
global scan. A conversation id is `pi-` plus a hash of project root, seat and
file name. It is resolved by listing the roots again, so no path comes from
the caller.
A seat registration is seat-written, so it is a hint, not authority:
- It counts for a root only when seat, layout `repo`, project and
`sessionsDir` all match (`samePath`). Anything else is ignored.
- It supplies `engineStartedAt` for conversations created at or after its
`startedAt`.
- A non-Pi `harness` makes the root unsupported. The root appears as one
catalogue row with `unsupportedReason: "unsupported-harness"` (D2), and its
directory is never read.
- A seat on another harness has no Pi sessions directory, so the board has no
spec for it. Its registration adds that placeholder row when it names the
standard directory under a project root that is already approved. This is
Rocko's case today: `claude-code`, no `sessions`.
Deviation from brief §2.1: the brief speaks of a registration `sessionFile`.
Registrations have no such field, only `sessionsDir`, so the rule above
applies to the directory. A registration never adds a root or names a file.
## Opening a file
`src/safe-fs.mjs`:
- Every component from the project root down to the sessions directory must
be a real directory (lstat).
- A session file must be a regular `*.jsonl` directly in the root.
- It is opened `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, and the descriptor's
(dev, ino) must equal the lstat taken before the open.
- The components are checked again after the open. Node has no `openat`, so
a directory swapped between the checks and the open is detected afterwards,
not prevented.
The project root itself may be a symlink (the compatibility path to this
checkout). The header `cwd` must be the project or inside it. Real paths are
compared, and a `cwd` that no longer exists is compared as written. That
comparison calls `realpath` on the recorded `cwd`, which resolves the path but
opens nothing.
`SessionManager.open` is never used.
## Parser
`src/pi.mjs`, pinned against `@earendil-works/pi-coding-agent` 0.85.1
(`docs/session-format.md`, `dist/core/session-manager.js`):
- Line 1 must be the session header.
- Entries form an `id`/`parentId` tree. Where an id repeats, the later entry
wins, as in Pi's index.
- The default leaf is the last valid entry in file order, as Pi loads it.
Each leaf ends one branch; other branches are read-only and opened by
`branch`.
- Branch names do not change while the file grows. The first root's line is
`main`, even before the file has entries. Each later root (Pi's
`resetLeaf`, or an entry whose parent is missing) and each later child at a
fork starts a branch named `b.<entry id>`; the earliest child in file order
continues its parent's branch. An inner entry is not a branch.
- A malformed line becomes a notice at its file position on every branch,
after the leaf too, and reading continues. The same placement on every
branch keeps a branch's earlier parts unchanged while the file grows.
- A missing parent stops the history with a notice. When unreadable lines sit
just before the entry, the notice names them as the likely place of the
parent. The history is never joined across the gap: the entries before it
may belong to another branch, and they read as their own branch. A loop
stops with a notice.
- `parentSession` gives a "forked from an earlier session" notice and is
never opened.
- Redacted reasoning (`redacted: true`) is `unavailable` with empty text.
`thinkingSignature` is never read.
- A header `cwd` must be absolute and inside the project; a relative one is
refused as `foreign-project`.
- A compaction is a marker in place, and the full history stays on the path.
`retainedTail` entries are already on the path, so they are not rendered
twice.
- Model and thinking-level changes, labels, session names and extension state
are not shown. `custom_message` shows only with `display: true`. An unknown
entry type or role becomes a notice.
- Ids that do not fit the CHAT-01 id pattern are hashed (`h-` plus 40 hex).
OpenAI tool call ids such as `call_x|fc_y` are the real case. Entry ids are
namespaced (`n.` for native, `x.` for notices), so no native id can collide
with a notice.
- `get_entries` order is not used.
## Pages
`src/parts.mjs` applies the CHAT-01 limits:
- at most 100 parts and 8 MiB of serialized UTF-8 per page;
- at most 64 blocks per part;
- at most 262144 characters per string.
Strings split into fragments. A fragment is also cut at 1 MiB of JSON, and
blocks group into parts of at most 4 MiB, so one part always fits a page.
Content is never clipped, and a surrogate pair is never cut.
## Snapshots, epochs, cursors
- A snapshot is the file up to its last newline when the descriptor was read.
A trailing partial line is left out and sets `view.incomplete`.
`snapshotDigest` is the SHA-256 of those bytes.
- `sourceEpoch` is a hash of (dev, ino) and the digest at open. Follows carry
it forward while the prefix verifies, so growth keeps the epoch. A new open
after growth hashes the longer prefix and gets a different id for the same
epoch, so an epoch id is comparable only within one cursor chain.
- Every `next` re-reads the pinned prefix and refuses `source-replaced` (with
reconcile) when dev or ino changed, the file is shorter, or the prefix
digest differs (an in-place rewrite with the same inode).
- Cursors live in memory, with an LRU cap (1000) and a 10-minute TTL. They are
bound to actor, purpose, conversation, branch, snapshot, epoch and expiry.
- Refusals: `cursor-unknown` (never issued or evicted), `cursor-expired`,
`cursor-foreign` (any binding differs) and `source-replaced`. All carry
`reconcile: true`. A foreign attempt does not consume the cursor, so the old
view keeps working.
- `local-operator` is the only actor on this unauthenticated loopback route.
That is not multi-actor safety. Authenticated actors come with CHAT-04R.
### Follow
A follow cursor verifies the old prefix, then takes a fresh snapshot and
reads the same branch in it. `page.branch` is always the cursor's branch.
- The parts before the cursor must be unchanged (compared as a digest of
entry ids). If they are, the page holds only the parts appended to this
branch, which is empty when the conversation continued on another branch.
`view.defaultBranch` shows where Pi's default leaf is now.
- If the branch is gone or its earlier parts changed (a duplicate id that
replaces an entry, or a missing parent that turns up later), it refuses
`source-replaced`.
## Refusal codes
| Code | Meaning | Reconcile |
|---|---|---|
| `unknown-conversation` | not in the approved roots now | yes |
| `unknown-branch` | not a branch of this conversation | yes |
| `unavailable` | the session root no longer exists | no |
| `unsupported-harness` | non-Pi seat (D2) | no |
| `unsafe-path` | symlink, bad name, not a regular file, swapped | no |
| `foreign-project` | header `cwd` outside the project | no |
| `unreadable` | permission denied on a file, a root or a directory above it inside the approved roots | no |
| `not-a-pi-session`, `incomplete-header` | first line is not a complete Pi header | incomplete: yes |
| `too-large` | over 256 MiB | no |
| `cursor-unknown`, `cursor-expired`, `cursor-foreign`, `source-replaced` | see above | yes |
| `unknown-actor`, `unsupported-purpose` | not `local-operator` / `history` | no |
## Costs
Each page reads and hashes the whole pinned prefix. A parse cache and a
branch-entries cache (two snapshots each) avoid re-parsing. On this checkout's
18 roots (largest file 18.6 MB), reading every page of every conversation took
at most 680 ms per conversation.
## Tests
`node --test packages/conversation/tests/` runs both layers. For the reader,
`reader.test.mjs` covers fixtures F1–F15 and F17 of the CHAT-02 brief (F16 is
in the control-board suite, which serves the routes). The CHAT-03 suites are
listed under [Mediated control tests](#mediated-control-tests).
- Every reader call runs inside a fingerprint of the fixture tree: size,
SHA-256, mtime, (dev, ino), mode and every directory listing, before and
after.
- Files that no read may touch are mode 000, so an attempted open would throw
`EACCES` rather than refuse.
- Every page and cursor is validated against
`docs/plans/chat-01/contracts.schema.json` with Python `jsonschema`.
# Mediated control (CHAT-03)
Increment I1 of CHAT-03. The approval races H5–H8 and the Claude adapter are
I4; recorded runs against a real model (I3) wait on Jason's go. Everything
here runs on fixture sessions in temporary directories. No code path opens a
live session, and the controller never writes a session file (W11).
## Pieces
| File | Role |
|---|---|
| `src/controller.mjs` | `Controller`: claim, launch, socket, admission, dispatch, stop chain, recovery |
| `src/client.mjs` | `ConversationClient`: the library every client uses, the terminal included |
| `src/transcript.mjs` | `Transcript`: page plus live stream, with the seam rules below |
| `src/terminal.mjs` | the mediated terminal, `node packages/conversation/src/terminal.mjs --socket <path> [--grant <id>]` |
| `src/claim.mjs` | `ClaimStore`: the writer claim per seat key and session key, revisions `r<10 digits>.json` |
| `src/cohort.mjs` | `ScopeLauncher` (systemd user scope plus `shim.mjs`), `PgroupLauncher`, force stop, cohort and boot proofs |
| `src/shim.mjs` | the scope's first process, outside the `engine` cgroup; ops `hello`, `events`, `members`, `term`, `freeze`, `kill`, `release`; ignores SIGTERM |
| `src/guard.mjs` | `LiveSessionGuard`: refuses live sessions and paths under live roots |
| `src/pi-pin.mjs` | the Pi pin (0.85.1 and its integrity) and the seal |
| `src/engine.mjs`, `src/framing.mjs` | the engine link and LF framing |
| `src/turns.mjs` | `Tracker`: runs, the dispatch slot, overlap signals O1–O6 |
| `src/events.mjs` | native event to CHAT-01 event mapping |
| `src/text-policy.mjs` | slash refusal and the prefix table |
| `src/records.mjs` | CHAT-01 v2 records, hashes, `FixtureVerifier` |
```js
import { Controller } from "./src/controller.mjs";
import { ConversationClient } from "./src/client.mjs";
import { Transcript } from "./src/transcript.mjs";
const ctrl = new Controller({ fixtureRoot, claimRoot, socketDir, sessionFile, seat, verifier });
await ctrl.start(); // claim, launch, K8 load check, listen
const client = new ConversationClient({ socketPath: ctrl.socketPath });
const view = new Transcript({ client }); // reads the page on every welcome
await client.connect(); // starts as an observer
await client.takeover(); // becomes the controller
const r = await client.prompt("hello"); // { outcome: "admitted", receipt } or { outcome: "refused:<code>", refusal }
await client.interrupt();
await client.confirmed("force-stop"); // issue, answer, then use a confirmation
```
The four required paths have no defaults: a missing one refuses
`configuration`. The session file must sit at
`<project>/.pi/state/<seat>/sessions/<name>.jsonl` inside `fixtureRoot`.
## Choices
Each is a reading of the brief or a lead decision, recorded so a reviewer
can disagree with it.
- **Seal.** The argv is `--mode rpc --no-extensions --no-prompt-templates
--no-themes --session <absolute file>`, then optional `engine.extraArgs`.
The seal is an allow-list: extraArgs may carry only `--model`,
`--provider` and `--thinking`, each at most once with one plain value
(not starting with `-` or `@`). Anything else refuses `unsealed-engine`
at construction and again at bind, before spawn: an `-e`/`--extension`
argument, a missing `--no-*` flag, a second `--mode` or `--session` (Pi
keeps the last of each), a session or output flag (`--no-session`,
`--fork`, `--export`, `--print`, `--continue`, ...) or a bare word, which
Pi reads as a prompt (lead decision 31; N24, including the missing flag
through `checkSeal`).
- **Engine command.** `engine.command` and `engine.preArgs` default to
`node <pinRoot>/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js`.
A non-default value is a test hook for the fake engine. The pin check
reads only the lock files under `pinRoot` and the seal checks only Pi's
arguments, so neither says what runs under an overridden command. The
binding's `argvDigest` records the full command line. `preArgs` carrying
`--extension` still refuses.
- **Session key.** The claim's session key is the Pi header ID (D1), read
at construction. A hard link or a copy of a session under another seat
has a different conversation ID but the same header ID, so its
controller refuses `already-active` (W4). Every later read of the
session refuses `target` if the header ID changed.
- **Live roots.** The guard protects `~/.pi`, `~/.claude` and
`~/.mosaic-dev` through `os.homedir()`, which follows `$HOME`. Under a
scratch `HOME` the real directories are protected only by the
fixture-root containment, or by passing `guardOptions.homes`.
- **Seal and attribution.** Under the
seal the Mosaic `prompt` is the only input path, so `working` is attributed
through the seal: the slot's run is the one whose first user message
follows the ack with no overlap signal.
- **Overlap.** `aborted` with no stop in progress is an overlap signal, never
a stop link (lead decision 34, N9). An `aborted` links to the stop in
progress only once the controller wrote an abort in that stop's chain (the
stop or one it superseded). One that lands after the fence but before any
abort is the same overlap (N9). An overlap signal (O1–O6) closes
admission, makes the binding `uncertain` and settles the slot's receipt
with reason `run-overlap`.
- **Interrupt.** It fences admission, clears the queue, then aborts. With no
slot and no run it refuses `no-turn` and lifts only the fence it set. It
never reopens admission that a concurrent force stop, overlap signal or
revocation closed (Rocko's build note, brief line 288; H10). It checks for
an overlap again right before the abort, so an overlap read during the
pause (an O5 in the same chunk as the clear's response) means no abort,
which would run what was queued (H10). A stop that ends `uncertain` while
its queue state was still pending records `nativeQueue: "unknown"`.
- **Force stop** supersedes a running Interrupt: one stop chain (H10). One
escalation runs at a time: a second force stop while one runs refuses
`fenced`, and only the running stop's phases reach the claim. After an
escalation ends `uncertain`, a fresh confirmation can retry it (H10, H17).
The scope launcher runs a TERM phase first: the shim sends SIGTERM to each
member and waits a bounded grace for `populated 0`. The kill phase then
freezes `engine`, waits for `frozen 1`, enumerates the members for the
proof, writes `cgroup.kill` and waits for `populated 0` (K3, K12). The
shim sits in a `supervisor` cgroup outside `engine` and ignores SIGTERM
only so that a stray TERM never drops the scope's anchor. A missing or
unreadable `engine` cgroup is an absent observation, never an empty one:
the stop ends `uncertain` with no proof (K15). The process-group
fallback can't enumerate a member that left the group, so its force stop
ends `uncertain`, never `stopped` (K2). The fake launcher's `forceStop`
is fixture-only.
- **Confirmations** bind the target, operation and stop, and are consumed on
use (K6). One issued before the stop changed is refused (H17).
- **Recovery.** A confirmed `recover` after a proven stop returns a
single-use eligibility record, and the launcher calls `launch` with it.
A second call, a record from another incarnation, or a reserved claim that
changed refuses `eligibility` (K17); a session leaf or branch that moved
since eligibility refuses `target` (K18). In K7, "incarnation" means the execution:
a recovery mints a new execution and controller incarnation, generation
+1, on the same leaf. An orphan (H20) has no controller: its
`controllerConnection` is null.
- **Approval.** A stop's `approvalDisposition` is `resolved` unless a Pi
dialog was seen during the execution, then `uncertain`. Pi dialogs (`select`, `confirm`,
`input`, `editor`) are pushed to connected clients disabled with a reason
and never answered (lead decision 30, P3). A dialog pushed before a client
connected is not replayed to it; the controller's evidence keeps the list.
- **Run order.** N8 pins the wire order with the fake's `run-start` hold
point: the Mosaic ack, another run's `agent_start` and user message, then
the losing Mosaic settle, which is O3.
- **Startup.** `G2` refuses a symlinked live path at construction; `G3`
refuses a swap at bind. The K8 load check reads `get_state`
(`sessionFile`, `sessionId`) and `get_tree` (`leafId`) after launch. If the
engine loaded another file or leaf, the binding goes `uncertain`, admission
never opens, the claim stays held until a proven stop, and a prompt refuses
`preflight` (K8).
- **Observe.** `limit` (1–100) is advisory and marked `limitAdvisory: true`;
the reader's page size governs. Drafts are client-local: the library sends
a draft ID and revision 1 with each prompt and keeps no draft state.
- **Escape hatches.** K13 (a member writing its pid into another cgroup) is
refused by the cgroup namespace the shim gives the engine (`unshare
--cgroup`), on a host whose cgroup2 is mounted `nsdelegate`. Nothing
checks `nsdelegate` at runtime; on a host without it the refusal isn't
shown, and that is a portability question for the cutover increment.
## The seam
Replay is unavailable in CHAT-03, and page entries and live events carry
different IDs, so overlap at the seam can't be deduplicated (CHAT-01 lines
104–110). The controller's `observe` reply carries
`seam: { replay: "unavailable", streamEpoch, fromSequence, reconcile: true, quiet }`.
- `quiet` is true when no run was visible and no prompt held the slot as the
page was read. Pi persists each message on `message_end`, before the run
settles (agent-session.js 386–398), so a quiet cut misses nothing.
- A cut that isn't quiet puts a reconcile marker at the seam. Near it a
message may repeat or be missing. `Transcript` re-reads the page after the
next `run-settled` and clears the marker once a read is quiet (E4).
- A sequence gap, a new stream epoch, or a repeated event ID with different
bytes marks the seam and re-reads at once. Events past a gap are held,
never concatenated. An identical repeat is dropped (E3).
- A tool call shows while it runs. Once a finished message carries its
result, from the stream or the page, the progress item is hidden.
- `thinking_level_change` entries are not messages and never appear in pages.
## Slash text
`textPolicy` refuses any prompt whose text, after leading whitespace, starts
with `/`: pinned Pi runs extension commands, skills (`/skill:`) and prompt
templates from index 0 (S1, S2). `!`, `!!` and `@` are interpreted only by
Pi's interactive mode and CLI, not on the RPC prompt path, so they are
admitted and sent as text. A `/` on a later line is not interpreted
(`LATER_LINE_SLASH_INTERPRETED = false`, S3). The table with its source lines
is `PREFIXES` in `src/text-policy.mjs`; S2 iterates the same table.
## The terminal
A thin view over the client library; it renders the same `Transcript` (E7).
- Enter submits, Ctrl-J or Alt-Enter adds a newline, Ctrl-T takes control,
Ctrl-G interrupts, Ctrl-O reconnects if needed and re-reads the page,
PageUp and PageDown scroll, Ctrl-C or Ctrl-D quits.
- The composer is local. It clears after each submit and whenever the
controller changes (S4). An observer's Enter shows
`not admitted: controller` and sends nothing; the buffer is kept (S5).
- Enter takes the composer at that key: text after it in the same input
chunk starts the next message.
- A bracketed paste is inserted literally, newlines included, and never
submits by itself. A paste marker split across input chunks, even right
after its ESC, is still a paste marker; a lone trailing ESC waits for the
next chunk.
- Engine text is shown with C0 and C1 controls, DEL, U+2028, U+2029, bidi
controls (U+061C, U+200E, U+200F, U+202A–U+202E, U+2066–U+2069) and
invisible characters (U+200B, U+2060–U+2064, U+FEFF, tag characters
U+E0000–U+E007F) made visible (`^[`, `<U+202E>`), so transcript content
can't drive or spoof the operator's terminal. ZWJ and ZWNJ pass, for emoji
sequences and joining scripts. The header, status, notices and dialogs
also show LF as `^J`, so each stays one line.
- A request whose outcome is lost shows `outcome unknown, check the
transcript`. Nothing is resent and no resend is offered.
## Refusals
Replies are `{ outcome: "refused:<code>", refusal: "<code>" }`. Admission
runs in CHAT-01 `check.mjs` order, then the CHAT-03 narrowings.
| Code | When |
|---|---|
| `malformed` | a line that isn't JSON, a message other than `hello` first, or a request that fails the envelope check |
| `grant` | the hello names no active grant |
| `channel` | the connection isn't connected or its channel isn't authenticated; also the library's reply when its socket is closed |
| `unsupported-capability` | a private-host transport, or an operation I1 doesn't verify |
| `scope`, `mapping`, `audit` | grant scope, source mapping or audit sink doesn't hold |
| `capability` | the grant lacks the operation's capability |
| `target` | the target names another conversation, execution or branch; the session header ID changed since construction (W4); the leaf moved since proof (recover) or since eligibility (launch, K18) |
| `generation` | the request's controller generation is stale (H1, H2) |
| `controller` | the connection isn't the controller |
| `conflicting-request` | a request ID reused with other content (H13) |
| `stale-incarnation` | the request carries an old controller incarnation (H21) |
| `fenced` | admission is closed (Interrupt, force stop, overlap, revocation, uncertain), or a force stop while one escalation runs (H10) |
| `busy` | a prompt already holds the dispatch slot; CHAT-03 has no broker queue (H18) |
| `text-policy` | slash text (S1, S2) |
| `draft` | prompt text missing or longer than 262,144 characters |
| `no-turn` | Interrupt with no slot and no run (N16) |
| `already-controller`, `controller-present` | self-takeover (H4); recovery control while a controller is connected |
| `confirmation` | missing, reused, foreign, expired or stop-changed confirmation (H17) |
| `stop-proof` | recovery without a verified stop |
| `preflight` | the K8 load check didn't pass; admission never opened |
| `cursor` | an observe cursor the reader refuses (`detail` names the reader code) |
| `eligibility` | launch with a used or unknown eligibility record, one from another incarnation, or a changed reservation (K17) |
| `already-active`, `unsafe-replacement`, `foreign-host` | the writer claim (W2, W3, W17) |
| `live-session-refused` | the live-session guard, at construction, bind and launch (G1–G3) |
| `unsealed-engine`, `engine-pin-mismatch` | the seal or the Pi pin (N24) |
| `configuration` | a required constructor path is missing or misplaced, the session file is unreadable, or the session header ID isn't a CHAT-01 ID |
`malformed`, `eligibility` and `preflight` are names this build adds; the
brief describes those refusals without naming them.
The controller, claim store, live-session guard and engine pin throw
`ControlRefusal` (`safe-fs.mjs`), a subclass of the reader's `Refusal`. These
codes reach a socket client, never the reader's HTTP routes, so the control
board's status map (`REFUSAL_STATUS`, which its tests check against every
`new Refusal("…")` in `src/`) covers only the reader table above. If the
board ever serves the controller, these codes need statuses there first.
Receipt reason codes, not refusals: `transport-unknown`,
`handled-without-run`, `ack-without-start`, `interrupted`, `run-overlap`.
## Findings against pinned Pi
From `tests/smoke.test.mjs`, which starts the pinned binary sealed, in a
scratch home with an empty agent dir and no inherited environment, and sends
no prompt:
- It starts with no credentials and answers `get_state`, `get_commands`,
`clear_queue`, `abort` and `get_tree`. Every field the fake engine sends
for those commands, pinned Pi sends with the same type. `clear_queue` emits
`queue_update` with empty queues before its response, as the fake does.
Pi writes `auth.json` (`{}`) and `models-store.json` into the agent dir.
- **Startup append.** Pi appends to the session at startup in two cases
(sdk.js 82–83 and 240–252). A session with messages but no
`thinking_level_change` on its branch gains one. A session with no
messages takes Pi's new-session branch and gains a `thinking_level_change`
at every start, plus a `model_change` when a model is set, whether or not
it already has a thinking entry. That covers a Pi-created session that was
opened and never prompted. Either way the leaf moves after launch, the K8
load check fails, the binding goes `uncertain` and prompts refuse
`preflight`. A pre-spawn check would refuse both kinds before launch; that
is a later increment, not built here.
- **One inline extension command.** Under the seal, `get_commands` still
lists `/llama` (source `extension`, inline: Pi's bundled llama.cpp router).
Slash text is refused at admission, so it can't be invoked. The smoke test
pins the list so a change shows.
## Mediated control tests
| Suite | Covers |
|---|---|
| `claim.test.mjs` | W1–W17, W20, G1–G3: the writer claim, crash barriers, the guard |
| `races.test.mjs` | H1–H4, H9–H23: takeover, Interrupt and force stop, retries, incarnations |
| `turns.test.mjs` | N1–N25: the turn tracker against the fake engine's Pi behaviors |
| `cohort.test.mjs` | K1–K18: scopes, force stop, proofs, recovery, eligibility (needs a systemd user manager) |
| `flows.test.mjs` | S1–S7, P3, E1–E7, the terminal, and a CHAT-01 schema check of every record produced |
| `smoke.test.mjs` | the pinned Pi binary, as above |
`fake-pi.mjs` models pinned Pi's RPC mode, including the startup append, and
`ctrl-child.mjs` runs a controller in a child process for the crash tests.
Fixtures live in temporary directories and are removed after each file.