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]>
28 KiB
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.mdR4. 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.
API
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 } }.
pageis a CHAT-01page, andcursorandfolloware CHAT-01cursorrecords.page.nextCursoriscursor.idwhen more parts remain in the snapshot.- On the last page,
followreplacescursor. Callingnextwith it takes a fresh snapshot and returns only what was appended since (see Follow). viewholds what the Console needs beyond CHAT-01:defaultBranch,branches,incomplete(a truncated trailing line),forkedFromEarlierSessionandunreadableLines.actordefaults tolocal-operatorandpurposetohistory. 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 andsessionsDirall match (samePath). Anything else is ignored. - It supplies
engineStartedAtfor conversations created at or after itsstartedAt. - A non-Pi
harnessmakes the root unsupported. The root appears as one catalogue row withunsupportedReason: "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, nosessions.
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
*.jsonldirectly 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/parentIdtree. 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'sresetLeaf, or an entry whose parent is missing) and each later child at a fork starts a branch namedb.<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.
parentSessiongives a "forked from an earlier session" notice and is never opened.- Redacted reasoning (
redacted: true) isunavailablewith empty text.thinkingSignatureis never read. - A header
cwdmust be absolute and inside the project; a relative one is refused asforeign-project. - A compaction is a marker in place, and the full history stays on the path.
retainedTailentries 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_messageshows only withdisplay: 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 ascall_x|fc_yare the real case. Entry ids are namespaced (n.for native,x.for notices), so no native id can collide with a notice. get_entriesorder 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.snapshotDigestis the SHA-256 of those bytes. sourceEpochis 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
nextre-reads the pinned prefix and refusessource-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) andsource-replaced. All carryreconcile: true. A foreign attempt does not consume the cursor, so the old view keeps working. local-operatoris 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.defaultBranchshows 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.
- 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
EACCESrather than refuse. - Every page and cursor is validated against
docs/plans/chat-01/contracts.schema.jsonwith Pythonjsonschema.
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 |
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 optionalengine.extraArgs. The seal is an allow-list: extraArgs may carry only--model,--providerand--thinking, each at most once with one plain value (not starting with-or@). Anything else refusesunsealed-engineat construction and again at bind, before spawn: an-e/--extensionargument, a missing--no-*flag, a second--modeor--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 throughcheckSeal). - Engine command.
engine.commandandengine.preArgsdefault tonode <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 underpinRootand the seal checks only Pi's arguments, so neither says what runs under an overridden command. The binding'sargvDigestrecords the full command line.preArgscarrying--extensionstill 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 refusestargetif the header ID changed. - Live roots. The guard protects
~/.pi,~/.claudeand~/.mosaic-devthroughos.homedir(), which follows$HOME. Under a scratchHOMEthe real directories are protected only by the fixture-root containment, or by passingguardOptions.homes. - Seal and attribution. Under the
seal the Mosaic
promptis the only input path, soworkingis attributed through the seal: the slot's run is the one whose first user message follows the ack with no overlap signal. - Overlap.
abortedwith no stop in progress is an overlap signal, never a stop link (lead decision 34, N9). Anabortedlinks 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 bindinguncertainand settles the slot's receipt with reasonrun-overlap. - Interrupt. It fences admission, clears the queue, then aborts. With no
slot and no run it refuses
no-turnand 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 endsuncertainwhile its queue state was still pending recordsnativeQueue: "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 endsuncertain, 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 forpopulated 0. The kill phase then freezesengine, waits forfrozen 1, enumerates the members for the proof, writescgroup.killand waits forpopulated 0(K3, K12). The shim sits in asupervisorcgroup outsideengineand ignores SIGTERM only so that a stray TERM never drops the scope's anchor. A missing or unreadableenginecgroup is an absent observation, never an empty one: the stop endsuncertainwith no proof (K15). The process-group fallback can't enumerate a member that left the group, so its force stop endsuncertain, neverstopped(K2). The fake launcher'sforceStopis 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
recoverafter a proven stop returns a single-use eligibility record, and the launcher callslaunchwith it. A second call, a record from another incarnation, or a reserved claim that changed refuseseligibility(K17); a session leaf or branch that moved since eligibility refusestarget(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: itscontrollerConnectionis null. - Approval. A stop's
approvalDispositionisresolvedunless a Pi dialog was seen during the execution, thenuncertain. 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-starthold point: the Mosaic ack, another run'sagent_startand user message, then the losing Mosaic settle, which is O3. - Startup.
G2refuses a symlinked live path at construction;G3refuses a swap at bind. The K8 load check readsget_state(sessionFile,sessionId) andget_tree(leafId) after launch. If the engine loaded another file or leaf, the binding goesuncertain, admission never opens, the claim stays held until a proven stop, and a prompt refusespreflight(K8). - Observe.
limit(1–100) is advisory and markedlimitAdvisory: 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 mountednsdelegate. Nothing checksnsdelegateat 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 }.
quietis true when no run was visible and no prompt held the slot as the page was read. Pi persists each message onmessage_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.
Transcriptre-reads the page after the nextrun-settledand 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_changeentries 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: controllerand 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,abortandget_tree. Every field the fake engine sends for those commands, pinned Pi sends with the same type.clear_queueemitsqueue_updatewith empty queues before its response, as the fake does. Pi writesauth.json({}) andmodels-store.jsoninto 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_changeon its branch gains one. A session with no messages takes Pi's new-session branch and gains athinking_level_changeat every start, plus amodel_changewhen 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 goesuncertainand prompts refusepreflight. 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_commandsstill lists/llama(sourceextension, 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.