From 243e153c8b0202f0f2c51bee7d8469ae6f8dab84 Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Sun, 4 Oct 2026 15:47:53 -0500 Subject: [PATCH] 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 --- BUILD-LOG.md | 17 + agents/dewey/work/chat-03/BUILD-I1-r2.md | 305 +++ agents/dewey/work/chat-03/BUILD-I1.md | 215 +++ agents/dewey/work/chat-03/I1-manifest.sha256 | 27 + .../dewey/work/chat-03/I1-r2-manifest.sha256 | 27 + .../work/chat-03-i1-review-r2-2026-10-04.md | 140 ++ docs/SESSIONS.md | 2 + docs/plans/DEFERRED.md | 20 + packages/conversation/README.md | 316 +++- packages/conversation/package.json | 10 +- packages/conversation/src/claim.mjs | 354 ++++ packages/conversation/src/client.mjs | 234 +++ packages/conversation/src/cohort.mjs | 235 +++ packages/conversation/src/controller.mjs | 1655 +++++++++++++++++ packages/conversation/src/engine.mjs | 126 ++ packages/conversation/src/events.mjs | 121 ++ packages/conversation/src/framing.mjs | 68 + packages/conversation/src/guard.mjs | 143 ++ packages/conversation/src/pi-pin.mjs | 92 + packages/conversation/src/records.mjs | 101 + packages/conversation/src/safe-fs.mjs | 5 + packages/conversation/src/shim.mjs | 189 ++ packages/conversation/src/terminal.mjs | 280 +++ packages/conversation/src/text-policy.mjs | 41 + packages/conversation/src/transcript.mjs | 304 +++ packages/conversation/src/turns.mjs | 225 +++ packages/conversation/tests/claim.test.mjs | 707 +++++++ packages/conversation/tests/cohort.test.mjs | 716 +++++++ packages/conversation/tests/ctrl-child.mjs | 88 + packages/conversation/tests/fake-pi.mjs | 694 +++++++ packages/conversation/tests/flows.test.mjs | 618 ++++++ packages/conversation/tests/harness.mjs | 219 +++ packages/conversation/tests/races.test.mjs | 872 +++++++++ packages/conversation/tests/smoke.test.mjs | 179 ++ packages/conversation/tests/turns.test.mjs | 983 ++++++++++ 35 files changed, 10317 insertions(+), 11 deletions(-) create mode 100644 agents/dewey/work/chat-03/BUILD-I1-r2.md create mode 100644 agents/dewey/work/chat-03/BUILD-I1.md create mode 100644 agents/dewey/work/chat-03/I1-manifest.sha256 create mode 100644 agents/dewey/work/chat-03/I1-r2-manifest.sha256 create mode 100644 agents/filbert/work/chat-03-i1-review-r2-2026-10-04.md create mode 100644 packages/conversation/src/claim.mjs create mode 100644 packages/conversation/src/client.mjs create mode 100644 packages/conversation/src/cohort.mjs create mode 100644 packages/conversation/src/controller.mjs create mode 100644 packages/conversation/src/engine.mjs create mode 100644 packages/conversation/src/events.mjs create mode 100644 packages/conversation/src/framing.mjs create mode 100644 packages/conversation/src/guard.mjs create mode 100644 packages/conversation/src/pi-pin.mjs create mode 100644 packages/conversation/src/records.mjs create mode 100644 packages/conversation/src/shim.mjs create mode 100644 packages/conversation/src/terminal.mjs create mode 100644 packages/conversation/src/text-policy.mjs create mode 100644 packages/conversation/src/transcript.mjs create mode 100644 packages/conversation/src/turns.mjs create mode 100644 packages/conversation/tests/claim.test.mjs create mode 100644 packages/conversation/tests/cohort.test.mjs create mode 100644 packages/conversation/tests/ctrl-child.mjs create mode 100644 packages/conversation/tests/fake-pi.mjs create mode 100644 packages/conversation/tests/flows.test.mjs create mode 100644 packages/conversation/tests/harness.mjs create mode 100644 packages/conversation/tests/races.test.mjs create mode 100644 packages/conversation/tests/smoke.test.mjs create mode 100644 packages/conversation/tests/turns.test.mjs diff --git a/BUILD-LOG.md b/BUILD-LOG.md index c329b3d7..c12c8b85 100644 --- a/BUILD-LOG.md +++ b/BUILD-LOG.md @@ -3546,3 +3546,20 @@ Manifest `agents/dewey/work/chat-03/I1-r2-manifest.sha256` (2b48e333, 27 files), packet `agents/dewey/work/chat-03/BUILD-I1-r2.md`. Row 5 back to in-progress (rev 56), then in-review round 2 (rev 57); request posted as comment 26689 on #1507 (rev 58). Nothing committed or pushed. + +## 2026-10-04: CHAT-03 I1 committed (#1507, row 5, Sage) + +Darkwing (comment 26690) and Filbert (comment 26694) approved round 2, +with nothing to fix before the commit. Sage checked the 27 files against +manifest `I1-r2-manifest.sha256` (2b48e333, 27 OK) and ran every suite on +an index export with the candidate laid over HEAD, one run each, output +kept in `~/sage-scratch/r5-out/`: +- conversation 152/152, control-board 124/124, webui 14/14, seat 19/19; +- the chat-00 (48), chat-01 (R3) and chat-01c checks pass; +- auth 15, conductor 17, config 24, discord 64, extension-package 18, + foundation 44, release 14 and task 90 pass; +- queue 27 passes with node 148/148. Its two live checks skip outside the + canonical root. + +The reviewers' follow-ups are in DEFERRED for the I3 brief. Row 5 goes to +waiting-on-jason for Gate E. diff --git a/agents/dewey/work/chat-03/BUILD-I1-r2.md b/agents/dewey/work/chat-03/BUILD-I1-r2.md new file mode 100644 index 00000000..fcb30835 --- /dev/null +++ b/agents/dewey/work/chat-03/BUILD-I1-r2.md @@ -0,0 +1,305 @@ +# CHAT-03 I1 build, review request round 2 (#1507, row 5) + +From Dewey, 2026-10-04. Both reviewers asked for changes in round 1. Darkwing's +verdict is comment 26681 (queue rev 53) and Filbert's is comment 26683 (queue +rev 54). This is the last round (item 27): if blocking findings remain, Sage +cuts scope. The round-1 packet `BUILD-I1.md` still holds except where this +file says otherwise. + +Darkwing's review and Sage's message cite #1508 for comment 26681. The row's +issue is #1507, and Filbert's verdict is on #1507. I'm flagging the mismatch, +not resolving it. + +## Candidate + +- Manifest: `agents/dewey/work/chat-03/I1-r2-manifest.sha256`, sha256 + `2b48e333a0f09185364359ae6f8277cc88c0b9ff39058de45cc2c1f0ec9d5c4a`, the same + 27 files as round 1. +- Base `1c724958`, as in round 1. `git diff 1c724958 HEAD` shows no change + under `packages/conversation`, `docs/plans/chat-0*`, `contracts/` or + `roles/`, so the round-1 export method still works: `git archive + 1c724958` plus the 27 files. +- Nothing is staged or committed. + +## Darkwing's findings + +- **B1, the seal is a deny-list.** `checkSeal` (`src/pi-pin.mjs`) is now an + allow-list. The argv must start with exactly `--mode rpc`, the three + `--no-*` seal flags and `--session `. After that only + `--model`, `--provider` and `--thinking` (`ENGINE_OPTIONS`) may follow, + each at most once with one value. A value may not be empty or start with + `-` or `@`. Anything else refuses `unsealed-engine` before spawn. That + covers a second `--mode` or `--session`, every session or output flag you + listed, `--approve`, `--no-extensions` repeated, a bare word (a prompt) and + `@file`. A non-list `engine.preArgs` or `engine.extraArgs` refuses too. A + non-default `engine.command` or `preArgs` is documented as a test hook: + the pin and the seal don't bind under it, and `argvDigest` records the + whole command line (README "Engine command"). N24 now has cases for + `--session`, `--mode json|text|rpc` and `--no-session` in extraArgs, plus + the rest of the list. Your repro `/tmp/r5/seal-escape.mjs session|json` + now refuses at construction; no Pi process starts. +- **B2, the session key is the conversation ID.** The session key is the + Pi header ID, read at construction (`controller.mjs`, constructor). A + later read refuses `target` if the header ID changed. New controller-level + W4 tests: a hard link and a copy of one session under another seat. The + second controller refuses `already-active` and launches nothing. On a copy: + the brief keys on the native session identity, and a copy carries the same + header ID, so it counts as the same session. Your + `/tmp/r5/hardlink.mjs` gives B `already-active` with 0 launches. +- **n1.** The startup-append finding now states Pi's rule + (`messages.length > 0`) and names both cases (README "Findings against + pinned Pi", corrected below). +- **n2.** README "Live roots" says the guard follows `$HOME`. + +## Filbert's findings + +- **B1, text after Enter joins the message.** The composer is taken at the + Enter key, so text after it in the same chunk starts the next message + (`terminal.mjs` `key`, `#take`). New flows test: `first\rsecond\r` sends + two prompts; `abc\rdef` sends `abc` and leaves `def` in the composer. +- **B2, a paste-start split after its ESC.** A lone trailing ESC is carried + to the next chunk; a lone Escape has no action, so holding it costs + nothing. New flows test: the marker split at every cut 1–5 is still a + paste. +- **B3, a second force stop runs a parallel escalation.** One escalation + runs at a time (`controller.escalating`). A second force stop while one + runs refuses `fenced` and doesn't consume its confirmation. Once an + escalation ends `uncertain`, a fresh confirmation can retry. New H10 test: + hold at `phase-term`, a second force stop is fenced, `launcher.stops` is 1, + and every claim revision carries only the first stop's phases. Its second + block retries after an `uncertain` end. H17's reuse-during-the-stop case + now expects `fenced`. +- **B4, decision 34 untested.** New N9 test: a run that ends `aborted` with + no stop in progress raises `aborted-without-stop`, the binding goes + `uncertain` with admission closed, and the receipt is outcome unknown + (`run-overlap`), never `failed`. Mutant r2-D34 (your line 163) is in the + table. +- **B5, K12 doesn't prove the freeze.** K12 now checks two things that don't + depend on timing. First, `engine/cgroup.freeze` still reads 1 after the + stop; the shim holds the scope, so a freeze never written reads 0. Second, + the fork loop logs each child's pid and its own SIGTERM. Every child forked + after that TERM, which nothing ends before the kill, must be in the + proof's member list, and there must be at least one. Mutant r2-B5 (no + freeze write, `frozen 1` answered) is killed. Mutant r2-B5b (freeze + written, not waited on) survives, and `frozen 1` is not asserted before + enumeration. Both are explained under "Mutation pass". +- **B6, no missing-path case in K15.** A third K15 block moves the engine's + processes to a sibling cgroup and removes `engine`. `events` and + `members` both answer unavailable with ENOENT, the force stop ends + `uncertain` with no proof, and the engine is still alive. Mutants r2-B6 + (your ENOENT-as-`populated 0`) and r2-B6b (ENOENT as empty for both + `events` and `members`) are in the table. +- **n1.** An `aborted` links to the stop in progress only once an abort was + written in that stop's chain. New N9 test: an `aborted` after the fence + but before any abort is the overlap. Mutant r2-n1. The fix hid round-1 + mutant 31 from N14, so a second N14 test now has the run complete after + the abort is written; the receipt stays `finished`. +- **n2.** The interrupt checks `tr.overlapped`, the gap and the poisoned + link again after the `before-abort` pause. New H10 test: an O5 read during + the pause means no abort and the queued item never runs. Mutant r2-n2. +- **n3.** A stop that ends `uncertain` with `nativeQueue` still `pending` + records `unknown`. N1 (non-empty clear) asserts it. Mutant r2-n3. +- **n8.** README "Escape hatches" now says K13's refusal comes from the + shim's `unshare --cgroup` on an `nsdelegate` host, and that nothing checks + `nsdelegate` at runtime. +- **n13.** `visible()` now also shows U+061C, U+200B, U+2060–U+2064, U+FEFF + and tag characters U+E0000–U+E007F. ZWJ and ZWNJ pass, because emoji + sequences and joining scripts need them. The header, status, notices and + dialogs show LF as `^J`. New flows test; mutants r2-n13 and r2-n13b. +- **n19, n20.** README force-stop text now follows the code: a TERM phase, + then freeze, enumerate, `cgroup.kill`, `populated 0`. The shim ignores + SIGTERM only to keep the scope's anchor. The pieces table lists the shim's + ops. + +## Notes not taken + +Recorded here as limits or bounded follow-ups. None widens I1. + +- n4 (labels after K9 and after supersession): labels only, as you say. +- n5, n18 (test gaps): `rounds-exhausted`, O5 from a `get_state` count, the + two O2 variants, the socket-directory refusals, an overlong or split + multibyte engine line, events in H12 and H13. Follow-up. n18's escaping + gap is partly closed by the new `visible()` test. +- n6 (counted, no signal): outside O1–O6. +- n7 (TERM resume after restart not asserted; K3 doesn't assert TERM + delivery): follow-up test. +- n11 (orphan's tool map taken at restart), n12 (answer vs use after a stop + change): low impact, outcome matches. +- n14 (`LineSplitter` limit overshoot of at most one chunk): follow-up. +- n15 (5 s ack timeout for real Pi): for I3. It fails closed. +- n16 (compaction summaries appear only after a re-read): follow-up for the + seam rules. +- n17 (two interrupts with one request ID; no `onOverflow` or backpressure): + follow-up. + +## Suites + +Run on the candidate bytes in the canonical checkout, one run each, no +reruns for failures. The conversation suite ran twice, both 152/152: the +second run came after a one-character fix to N15's title (a space my N14 +insertion dropped), so the frozen bytes have their own run. Logs are in `~/dewey-scratch/suites/` this session; `/tmp` was full +(other seats' files, 17 of 19 GB), so my scratch moved out of it. + +| Suite | Result | +|---|---| +| `node --test packages/conversation/tests/` | 152 pass, 0 fail, 0 skipped | +| `packages/control-board` tests | 124 pass, 0 fail | +| `packages/webui` tests | 14 pass, 0 fail | +| `packages/seat` tests | 19 pass, 0 fail | +| `docs/plans/chat-00/check.mjs` | 48 checks passed | +| `docs/plans/chat-01/check.mjs` | R3 PASS | +| `docs/plans/chat-01c/check.mjs` | PASS | +| `scripts/test-auth.sh` | 15 passed, 0 failed | +| `scripts/test-conductor.sh` | 17 passed, 0 failed | +| `scripts/test-config.sh` | 24 passed, 0 failed | +| `scripts/test-discord.sh` | 64 passed, 0 failed | +| `scripts/test-extension-package.sh` | 18 passed, 0 failed | +| `scripts/test-foundation.sh` | 44 passed, 0 failed | +| `scripts/test-queue.sh` | 29 passed, 0 failed (node 148/148) | +| `scripts/test-release.sh` | 14 passed, 0 failed | +| `scripts/test-task.sh` | 90 passed, 0 failed | + +Conversation tests went from 141 in round 1 to 152. The new and extended +tests are named under each finding above. + +## Contracts + +The twelve contract files in the brief's table (`docs/plans/chat-00`, +`chat-01`, `chat-01c`) hash to the brief's values (`sha256sum -c`, 12 OK). +The pinned brief hashes to `1ef15ac0ed31…cbc1`. `git diff 1c724958 HEAD` +and `git status` show no change under `docs/plans/chat-0*`, `contracts/`, +`roles/` or `packages/conversation` outside the 27 candidate files. + +## Mutation pass + +Run in scratch copies under `~/dewey-scratch`, never the served tree. Each +mutant is applied alone, then the whole conversation package runs. The +round-1 table's 41 mutants were rerun against this candidate, plus 20 new +ones that guard the round-1 fixes. All 61 anchors are unique +(`mutants.py check`). Timeout is 900 s per run; "killed (timeout after +failures)" means tests had already failed when the timer ran out, as with +13 and 14 in round 1. + +The pass ran on the candidate minus one test: the N14 abort-pause test +below, added after the pass. A test added can only kill more, so every +other row stands. + +Two survived the pass: + +- **31**, a run that ended `stop` relabelled `failed interrupted` when it + links to a stop. Round 1's N14 killed it. The n1 fix made N14 blind to it: + N14's run completes before any abort is written, so `stopLink()` now + returns null there and the mutant changes nothing. New N14 test: the run + completes after the abort is written but before Pi applies it, and the + receipt stays `finished`. It passes on the candidate and fails under + mutant 31 (whole package under mutant 31: 151 pass, 1 fail, the new test). +- **r2-B5b**, the shim writes `cgroup.freeze` but answers `frozen 1` + without waiting. It still survives. On a real cgroup the kernel finishes + the freeze within microseconds, so nothing the suite can observe changes. + Stopped, traced and vfork-waiting tasks count as frozen, so an + unprivileged fixture can't stall a freeze with those. I tried a member + spinning under `cpu.max 1000 1000000`. The freeze stalled in 5 of 7 runs + and completed in 2, too flaky for the suite, so that test is not in the + candidate. r2-B5, Filbert's no-freeze mutant, is killed by K12's + `cgroup.freeze` check. Filbert's fix text asked for `frozen 1` asserted + *before enumeration*, and the candidate doesn't do that literally. The + only seams are the claim's phase names (a new phase changes the claim) and + the shim socket path (the controller takes it from the launcher). The wait + guards forks still in flight when the freeze is written. Follow-up: a FUSE + or privileged fixture that holds a member in uninterruptible sleep, so the + freeze can't finish. + +Result: 60 of 61 killed, one survivor (r2-B5b). + +| # | Mutant | Result | Failing tests (first 60 chars each) | +|---|---|---|---| +| 1 | dispatch skips the generation recheck | killed | H3: a takeover while a prompt holds the dispatch lock: writt | +| 2 | abort is sent before clear_queue | killed | H10: an overlap during the pause before the abort: no abort,; H10: Interrupt and force stop together: one stop chain, forc; N14: the run completes while clear_queue is in flight: finis … | +| 3 | a revision is published by rename, so an existing revision can be replaced | killed | W1: two processes acquire the same pair at once; exactly one; W1: two writers publish the same revision at once: one wins, | +| 4 | the late-event filter ignores the incarnation | killed | H14: late stdout from the old engine after a replacement is | +| 5 | the text policy checks only the first character | killed | S1: `/goal x`, with leading spaces or a tab, refuses text-po | +| 6 | an acknowledged SIGTERM promotes a stop to stopped | killed | K10: controller killed between the TERM and kill phases: res; K11: controller killed after the confirmation is recorded, b; K12: a member forking in a loop: the freeze stops it, enumer … | +| 7 | the process-group fallback can reach stopped | killed | H21: a retry of the exact request with the old token after a; K2: K1 on the process-group fallback ends uncertain, never s | +| 8 | a confirmation is not consumed on use | killed | K6: recover without proof, without confirmation, or with cha | +| 9 | disconnect releases control | killed | H11: the controller disconnects mid-turn: work continues, th | +| 10 | the composer isn't cleared on transfer | killed | S4: a `/` left in the composer is cleared when control trans | +| 11 | engine output is read with readline | killed | E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; S7: ESC, bracketed-paste markers and U+2028/U+2029 travel as | +| 12 | a revision is published by exclusive create in place, so a partial file is visible | killed | W1: a revision name appears only after its bytes are synced;; W1: two writers publish the same revision at once: one wins, | +| 13 | a second controller reclassifies a claim whose owner is alive | killed (timeout after failures) | H16: a second controller for the same session refuses alread; packages/conversation/tests/claim.test.mjs; packages/conversation/tests/races.test.mjs … | +| 14 | the live-session guard skips the real-path check | killed (timeout after failures) | G2: a symlink inside the fixture root to a live session file; G3: a fixture path swapped for a live path after constructio; packages/conversation/tests/claim.test.mjs | +| 15 | the pending slot is released at agent_start | killed | E1: send, ack, user, toolCall, toolResult, final answer: sho; E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; E3: a multipart final, two blocks, null request correlation … | +| 16 | a pipe is written again after an unknown write outcome | killed | H19: the link itself never writes again after an unknown out | +| 17 | an ack followed by no run marks the item finished | killed | N15: the fence lands in preflight, then an input handler tak; N5: an input handler takes the prompt: ack, no run, delivery | +| 18 | admission reopens without the post-settle empty clear | killed | N25: ordinary Interrupt reconciles; a non-empty queue_update; N4: the ack arrives after the first abort and a run starts: | +| 19 | abort is sent after a clear_queue timeout | killed | N7: clear_queue answers an error: no abort, nativeQueue unkn | +| 20 | the incarnation token check is skipped | killed | H21: a retry of the exact request with the old token after a; H22: after H21 and a valid recovery, a new request with the | +| 21 | a missing cgroup path counts as empty | killed | K15: the shim gone, engine/cgroup.events unreadable, or the | +| 22 | a unit with a different invocation ID is signalled | killed | K14: a unit with the recorded name but another invocation ID | +| 23 | a restart during force stop records the kill phase as done | killed | K10: controller killed between the TERM and kill phases: res | +| 24 | an eligibility record can be used twice | killed | K17: two launcher calls with one eligibility record: one lau | +| 26 | a non-empty clear_queue reaches reconciled | killed | N1: an extension's follow-up queued after the fence is clear | +| 27 | the pending slot's in-flight preflight is abandoned at the fence | killed | N15: the fence lands in preflight, then an input handler tak; N3: the fence lands in preflight, preflight errors, no run: ; N4: the ack arrives after the first abort and a run starts: | +| 28 | the fake engine queues a Mosaic prompt while streaming instead of throwing | killed | N10: fake conformance | +| 29 | a no-unit observation frees a pair that has a spawn marker | killed | W20: crash after the spawn marker, scope collected; uncertai | +| 30 | a settled run with empty clears is taken as interruption evidence without aborted | killed | N14: the run completes while clear_queue is in flight: finis; N17: the run fails on its own during the exchange: failed, F; N18: no final assistant message_end, or a lost line: working | +| 31 | a receipt whose run ended stop is relabelled failed interrupted during a stop | survived the pass; killed by the new N14 test (full package, 151/152) | N14: the run completes after the abort is written, before Pi | +| 32 | turnState input-reconciled is used to reconcile an Interrupt | killed | N4: the ack arrives after the first abort and a run starts: ; N9: a run that started before the fence and ends aborted: fa | +| 33 | Interrupt with no slot and no run creates a stop | killed | H10: a no-turn Interrupt lifts only its own fence; admission; H9: Interrupt racing a prompt's dispatch: before the write, ; N16: Interrupt with no slot and no run refuses no-turn: no s | +| 34 | a run is attributed to the slot with the overlap checks skipped | killed | N18: no final assistant message_end, or a lost line: working; N8: an extension prompt starts a run during Mosaic preflight | +| 34b | an overlap signal leaves the binding active | killed | N12: input that starts a run after the final empty clear is ; N13: agent_start with no slot held is O1; a later prompt ref; N19: a losing extension prompt settles inside the Mosaic run … | +| 35 | a settle that doesn't close the slot's own run is the slot's settle (open agent_start) | killed | N19: a losing extension prompt settles inside the Mosaic run; N8: an extension prompt starts a run during Mosaic preflight | +| 35b | a settle with no agent_start since the ack is the slot's settle | killed | N19: a losing extension prompt settles inside the Mosaic run | +| 36 | agent_start or agent_settled with no slot held is ignored | killed | N12: input that starts a run after the final empty clear is ; N13: agent_start with no slot held is O1; a later prompt ref; N21: a losing settle after the receipt settled finished is O | +| 37 | the launch seal check accepts --extension and missing --no-* flags | killed | N24: the seal is an allow-list: --extension, a missing --no- | +| 37b | the binding launches without the --no-* flags | killed | E1: send, ack, user, toolCall, toolResult, final answer: sho; E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; E3: a multipart final, two blocks, null request correlation … | +| 38 | an ack-without-start run settles failed | killed | N11: the run fails before any user message_start: delivery-u | +| 39 | an empty clear is recorded as proof no external input was removed | killed | N22: an agent-level custom message is dropped by the clear w | +| r2-B1 | the seal ignores everything after the prefix (extraArgs unchecked) | killed | N24: the seal is an allow-list: --extension, a missing --no- | +| r2-B1b | the seal skips the prefix order check | killed | N24: the seal is an allow-list: --extension, a missing --no- | +| r2-B1c | the seal accepts a relative --session value | killed | N24: the seal is an allow-list: --extension, a missing --no- | +| r2-B1d | the seal accepts a repeated option | killed | N24: the seal is an allow-list: --extension, a missing --no- | +| r2-B1e | the seal accepts an option value that is a flag or @file | killed | N24: the seal is an allow-list: --extension, a missing --no- | +| r2-B2 | the session key is the conversation ID again | killed | W4: a copy of one session under another seat is the same ses; W4: a hard link of one session under another seat is the sam | +| r2-B2b | a changed header ID after construction is not refused | killed (timeout after failures) | packages/conversation/tests/claim.test.mjs; W4: a session header ID that changes after construction refu | +| r2-D34 | an aborted with no stop in progress raises no overlap (lead decision 34) | killed | N9: an aborted that lands after the fence but before any abo; N9: decision 34: a run that ends aborted with no stop in pro | +| r2-n1 | an aborted links to any stop in progress, abort written or not | killed | N9: an aborted that lands after the fence but before any abo | +| r2-n2 | no overlap recheck after the pause before the abort | killed | H10: an overlap during the pause before the abort: no abort, | +| r2-n3 | an uncertain stop keeps nativeQueue pending | killed | N1: an extension's follow-up queued after the fence is clear | +| r2-B3 | a second force stop runs while the first escalation runs | killed | H10: a second force stop while the first escalation runs ref; H17: a confirmation reused, answered from another connection | +| r2-TB1 | Enter takes the composer when the send runs, not at the key | killed | terminal: text after Enter in the same input chunk starts th | +| r2-TB2 | a lone trailing ESC is not carried to the next chunk | killed | terminal: a paste-start marker split right after its ESC sti | +| r2-n13 | zero-width, BOM and tag characters pass visible() | killed | terminal: invisible and bidi characters are made visible; he | +| r2-n13b | one-line output keeps LF | killed | terminal: invisible and bidi characters are made visible; he | +| r2-B5 | the shim skips the freeze write and answers frozen 1 | killed | K12: a member forking in a loop: the freeze stops it, enumer | +| r2-B5b | the shim writes the freeze but doesn't wait for frozen 1 | **SURVIVED** | none (see above) | +| r2-B6 | a missing engine/cgroup.events reads as populated 0 | killed | K15: the shim gone, engine/cgroup.events unreadable, or the | +| r2-B6b | a missing engine cgroup reads as empty: populated 0 and no members | killed | K15: the shim gone, engine/cgroup.events unreadable, or the | + +## Findings against pinned Pi 0.85.1 (corrected) + +- **Startup append** (replaces the round-1 text, Darkwing n1). Pi's rule is + `hasExistingSession = messages.length > 0` (sdk.js 82–83). A session with + messages but no `thinking_level_change` entry gains one at every start. A + session with no messages takes the new-session branch and gains a + `thinking_level_change` at every start, plus `model_change` when a model is + set. That includes a Pi-created session that was opened and never + prompted. In both cases the leaf moves after launch, so K8 fails closed: + the binding goes `uncertain` and prompts refuse `preflight`. A pre-spawn + check covering both cases is a later increment's decision. +- The inline `/llama` command finding is unchanged. + +## Limits (added) + +- A member can ask the user systemd manager for a new unit outside the scope + (`systemd-run --user` lands in `app.slice`). That is a same-uid escape + (Filbert n9; brief limits 3 and 4). +- The shim channel is not authenticated. A same-uid engine that kills the + shim and binds its socket could answer as it (Filbert n10; limit 1). + Recording the shim's pid and start time in the claim would narrow it. That + is a follow-up. +- The guard's home roots follow `$HOME` (Darkwing n2). +- A cgroup throttled by `cpu.max` can hold off both the freeze and + `cgroup.kill` for seconds. Seen in the r2-B5b fixture attempt, with the + harness writing `engine/cpu.max`. The stop then ends `uncertain` at the + freeze, which fails closed. Whether a member can throttle itself from + inside its cgroup namespace is untested. diff --git a/agents/dewey/work/chat-03/BUILD-I1.md b/agents/dewey/work/chat-03/BUILD-I1.md new file mode 100644 index 00000000..b0ab7209 --- /dev/null +++ b/agents/dewey/work/chat-03/BUILD-I1.md @@ -0,0 +1,215 @@ +# CHAT-03 I1 build, review request round 1 (#1507, row 5) + +From Dewey, 2026-10-04. Sage assigned the build under lead decision 36. +Reviewers are Filbert and Darkwing (rev 40). Darkwing takes the controller +binding and the extension-load refusal, which Rocko had before decision 42. +There are two rounds at most (item 27). Row 5's gate is Jason's (Gate E), so +after the reviewers approve, the row goes to waiting-on-jason, not done. + +## Candidate + +- Manifest: `agents/dewey/work/chat-03/I1-manifest.sha256`, sha256 + `1404341eaeaf7d1e684c9f27e76718061ed08c25a52da5f190425feb1274ba69`, + 27 files. +- Base `1c724958`. Nothing is staged or committed; the files are untracked + or modified in the canonical checkout. They won't change during the + round. A change means a new manifest and a new round. +- Spec: the pinned brief `agents/dewey/work/chat-03/BRIEF.md`, sha256 + `1ef15ac0…`, plus lead decisions 30–34 and 36. Nothing outside the brief + is built. +- Scope: increment I1 only. H5–H8 and the Claude adapter are I4. I3 + (recorded runs against a real model) waits on Jason's go. + +What the manifest lists: + +- `packages/conversation/src/`: the CHAT-03 modules (`claim`, `client`, + `cohort`, `controller`, `engine`, `events`, `framing`, `guard`, `pi-pin`, + `records`, `shim`, `terminal`, `text-policy`, `transcript`, `turns`) and + `safe-fs.mjs`, which gains `ControlRefusal`; +- `packages/conversation/tests/`: `claim`, `cohort`, `flows`, `races`, + `smoke` and `turns` test files, plus `fake-pi.mjs`, `harness.mjs` and + `ctrl-child.mjs`; +- `packages/conversation/README.md`: the "Mediated control (CHAT-03)" part; +- `packages/conversation/package.json`: the description and four new + `exports` entries (`controller`, `client`, `transcript`, `terminal`), no + new dependency. + +Where to start: the README's "Choices" section lists each reading of the +brief that a reviewer could disagree with. "Refusals" lists every code, +including the three names this build adds (`malformed`, `eligibility`, +`preflight`). "Findings against pinned Pi" has the two findings below. + +## For Darkwing: binding and extension-load refusal + +- The seal: `src/pi-pin.mjs` (`buildPiArgs`, `checkSeal`, `SEAL_FLAGS`) + and `src/controller.mjs` (`SEAL_BASIS`, and the K8 load check in + `#launchInto`). Any `-e`/`--extension` argument or a missing `--no-*` flag + refuses `unsealed-engine` before spawn. Bound seats get no explicit + extensions (lead decision 31). +- The pin: `src/pi-pin.mjs` requires `package-lock.json` and + `node_modules/.package-lock.json` to name Pi 0.85.1 with the pinned + integrity (n1 bundle pin, decision 32). A mismatch refuses + `engine-pin-mismatch` at start and at recovery (K6). +- Tests: N24 (seal), K6 and K8 (pin and load check), mutants 37 and 37b. + `smoke.test.mjs` starts the pinned binary sealed with no credentials and + no prompt. +- The controller binding: claim (`src/claim.mjs`), the live-session guard + (`src/guard.mjs`), and the takeover and generation path in + `src/controller.mjs` (`#evaluateOp`, `#recheck`, `#transfer`). Tests: + W1–W20, G1–G3, H1–H4. + +## Suites + +Run on the candidate bytes in the canonical checkout, logs kept under +`/tmp/dewey-suites/` for this session: + +| Suite | Result | +|---|---| +| `node --test packages/conversation/tests/` | 141 pass, 0 fail | +| `packages/control-board` tests | 124 pass, 0 fail | +| `packages/webui` tests | 14 pass, 0 fail | +| `packages/seat` tests | 19 pass, 0 fail | +| `docs/plans/chat-00/check.mjs` | 48 checks passed | +| `docs/plans/chat-01/check.mjs` | R3 PASS | +| `docs/plans/chat-01c/check.mjs` | PASS | +| `scripts/test-auth.sh` | 15 passed, 0 failed | +| `scripts/test-conductor.sh` | 17 passed, 0 failed | +| `scripts/test-config.sh` | 24 passed, 0 failed | +| `scripts/test-discord.sh` | 64 passed, 0 failed | +| `scripts/test-extension-package.sh` | 18 passed, 0 failed | +| `scripts/test-foundation.sh` | 44 passed, 0 failed | +| `scripts/test-queue.sh` | 29 passed, 0 failed (node 148/148) | +| `scripts/test-release.sh` | 14 passed, 0 failed | +| `scripts/test-task.sh` | 90 passed, 0 failed | + +The nine `scripts/test-*.sh` suites are the brief's "every suite at the +base". One run each, no reruns. + +## Contracts + +The twelve contract files in the brief's table (`docs/plans/chat-00`, +`chat-01`, `chat-01c`) hash to the brief's values (`sha256sum -c` on the +table, 12 OK). `git diff 1c724958` +shows no change under `docs/plans/chat-0*`, `contracts/` or `roles/`. + +## Mutation pass + +Run in scratch copies under `/tmp`, never the served tree: every mutant +applied alone, then the whole conversation package run. The first pass ran +on the source before the new tests. Seven mutants survived it, and each now +has a test that kills it: + +| # | Why it survived | Kill test | +|---|---|---| +| 1 | H3's refused branch accepted `controller` or `generation`, and the controller check alone caught it | H3, third block: control goes A, B, A while the prompt waits, so only the generation check refuses | +| 3 | No test raced two writers on one revision | W1: two stores publish revision 1 at once; one wins, the other gets null, the winner's file is intact | +| 8 | H17's reuse case changed the stop, so the stop check refused it first | K6: a confirmed `recover` used twice; the second refuses `confirmation` | +| 12 | Nothing looked for the revision name before the bytes were synced | W1: held at `temp-written`, no revision file is visible, only the temp file | +| 14 | The mutated line was unreachable: the loop above already tested `realPath(p)`, so the first mutant was equivalent | `guard.check` now tests the real path in one place; mutant 14 removes that check and the real-path overlap. G2 and G3 kill it | +| 16 | Every controller path checks `poisoned` before writing, which masked the link's own guard | H19: an `EngineLink` whose first write fails EPIPE refuses the next write and sends nothing | +| 19 | A `clear_queue` timeout poisons the link, so the mutant's abort never reached the pipe | N7: `clear_queue` answers `success: false`, the one failure that leaves the link unpoisoned; no abort is sent | + +Every mutant fails at least one test now. Table: + +| # | Mutant | First pass | Final | Failing tests (first 60 chars each) | +|---|---|---|---|---| +| 1 | dispatch skips the generation recheck | SURVIVED | killed | H3: a takeover while a prompt holds the dispatch lock: writt | +| 2 | abort is sent before clear_queue | killed | killed | H10: Interrupt and force stop together: one stop chain, forc; N14: the run completes while clear_queue is in flight: finis; N15: the fence lands in preflight, then an input handler tak … | +| 3 | a revision is published by rename, so an existing revision can be replaced | SURVIVED | killed | W1: two processes acquire the same pair at once; exactly one; W1: two writers publish the same revision at once: one wins, | +| 4 | the late-event filter ignores the incarnation | killed | killed | H14: late stdout from the old engine after a replacement is | +| 5 | the text policy checks only the first character | killed | killed | S1: '/goal x', with leading spaces or a tab, refuses text-po | +| 6 | an acknowledged SIGTERM promotes a stop to stopped | killed | killed | K10: controller killed between the TERM and kill phases: res; K11: controller killed after the confirmation is recorded, b; K12: a member forking in a loop: the freeze stops it, enumer … | +| 7 | the process-group fallback can reach stopped | killed | killed | H21: a retry of the exact request with the old token after a; K2: K1 on the process-group fallback ends uncertain, never s | +| 8 | a confirmation is not consumed on use | SURVIVED | killed | K6: recover without proof, without confirmation, or with cha | +| 9 | disconnect releases control | killed | killed | H11: the controller disconnects mid-turn: work continues, th | +| 10 | the composer isn't cleared on transfer | killed | killed | S4: a '/' left in the composer is cleared when control trans | +| 11 | engine output is read with readline | killed | killed | E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; S7: ESC, bracketed-paste markers and U+2028/U+2029 travel as | +| 12 | a revision is published by exclusive create in place, so a partial file is visible | SURVIVED | killed | W1: a revision name appears only after its bytes are synced;; W1: two writers publish the same revision at once: one wins, | +| 13 | a second controller reclassifies a claim whose owner is alive | killed | killed (timeout after failures) | H16: a second controller for the same session refuses alread; W12: a live owner paused with SIGSTOP; a second controller r; W2: acquire while a claim is reserved or active refuses alre | +| 14 | the live-session guard skips the real-path check | SURVIVED | killed (timeout after failures) | G2: a symlink inside the fixture root to a live session file; G3: a fixture path swapped for a live path after constructio | +| 15 | the pending slot is released at agent_start | killed | killed | E1: send, ack, user, toolCall, toolResult, final answer: sho; E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; E3: a multipart final, two blocks, null request correlation … | +| 16 | a pipe is written again after an unknown write outcome | SURVIVED | killed | H19: the link itself never writes again after an unknown out | +| 17 | an ack followed by no run marks the item finished | killed | killed | N15: the fence lands in preflight, then an input handler tak; N5: an input handler takes the prompt: ack, no run, delivery | +| 18 | admission reopens without the post-settle empty clear | killed | killed | N25: ordinary Interrupt reconciles; a non-empty queue_update; N4: the ack arrives after the first abort and a run starts: | +| 19 | abort is sent after a clear_queue timeout | SURVIVED | killed | N7: clear_queue answers an error: no abort, nativeQueue unkn | +| 20 | the incarnation token check is skipped | killed | killed | H21: a retry of the exact request with the old token after a; H22: after H21 and a valid recovery, a new request with the | +| 21 | a missing cgroup path counts as empty | killed | killed | K15: the shim gone or engine/cgroup.events unreadable: evide | +| 22 | a unit with a different invocation ID is signalled | killed | killed | K14: a unit with the recorded name but another invocation ID | +| 23 | a restart during force stop records the kill phase as done | killed | killed | K10: controller killed between the TERM and kill phases: res | +| 24 | an eligibility record can be used twice | killed | killed | K17: two launcher calls with one eligibility record: one lau | +| 26 | a non-empty clear_queue reaches reconciled | killed | killed | N1: an extension's follow-up queued after the fence is clear | +| 27 | the pending slot's in-flight preflight is abandoned at the fence | killed | killed | N15: the fence lands in preflight, then an input handler tak; N3: the fence lands in preflight, preflight errors, no run: ; N4: the ack arrives after the first abort and a run starts: | +| 28 | the fake engine queues a Mosaic prompt while streaming instead of throwing | killed | killed | N10: fake conformance | +| 29 | a no-unit observation frees a pair that has a spawn marker | killed | killed | W20: crash after the spawn marker, scope collected; uncertai | +| 30 | a settled run with empty clears is taken as interruption evidence without aborted | killed | killed | N14: the run completes while clear_queue is in flight: finis; N17: the run fails on its own during the exchange: failed, F; N18: no final assistant message_end, or a lost line: working | +| 31 | a receipt whose run ended stop is relabelled failed interrupted during a stop | killed | killed | N14: the run completes while clear_queue is in flight: finis | +| 32 | turnState input-reconciled is used to reconcile an Interrupt | killed | killed | N4: the ack arrives after the first abort and a run starts: ; N9: a run that started before the fence and ends aborted: fa | +| 33 | Interrupt with no slot and no run creates a stop | killed | killed | H10: a no-turn Interrupt lifts only its own fence; admission; H9: Interrupt racing a prompt's dispatch: before the write, ; N16: Interrupt with no slot and no run refuses no-turn: no s | +| 34 | a run is attributed to the slot with the overlap checks skipped | killed | killed | N18: no final assistant message_end, or a lost line: working; N8: an extension prompt starts a run during Mosaic preflight | +| 34b | an overlap signal leaves the binding active | killed | killed | N12: input that starts a run after the final empty clear is ; N13: agent_start with no slot held is O1; a later prompt ref; N19: a losing extension prompt settles inside the Mosaic run … | +| 35 | a settle that doesn't close the slot's own run is the slot's settle (open agent_start) | killed | killed | N19: a losing extension prompt settles inside the Mosaic run; N8: an extension prompt starts a run during Mosaic preflight | +| 35b | a settle with no agent_start since the ack is the slot's settle | killed | killed | N19: a losing extension prompt settles inside the Mosaic run | +| 36 | agent_start or agent_settled with no slot held is ignored | killed | killed | N12: input that starts a run after the final empty clear is ; N13: agent_start with no slot held is O1; a later prompt ref; N21: a losing settle after the receipt settled finished is O | +| 37 | the launch seal check accepts --extension and missing --no-* flags | killed | killed | N24: any --extension argument, or a missing --no-* flag, ref | +| 37b | the binding launches without the --no-* flags | killed | killed | E1: send, ack, user, toolCall, toolResult, final answer: sho; E2: U+2028, U+2029 inside JSON strings and CRLF line ends ea; E3: a multipart final, two blocks, null request correlation … | +| 38 | an ack-without-start run settles failed | killed | killed | N11: the run fails before any user message_start: delivery-u | +| 39 | an empty clear is recorded as proof no external input was removed | killed | killed | N22: an agent-level custom message is dropped by the clear w | + +For mutant 14, "First pass" is the equivalent variant described above. + +Mutants 13 and 14 also hit the 900-second run timeout. Each fails its +tests first. With two live controllers (13) or a guard that admits the +symlinked path (14), later fixtures in the same files hang. + +`tests/fake-pi.mjs` gained `failResponse(type)` for the N7 case. It +answers `success: false`, as Pi's RPC loop does when a handler throws. + +## Defects found and fixed during the build + +All are in my files. They were found by tests while I wrote them, and none +reached a commit. + +1. A refused contender's controller unlinked the live controller's socket. + The socket directory is now prepared only after the claim classifies. +2. `#evaluate` read `res.outcome` from `recover`'s Promise, so `recover` + over the wire threw `internal`. W8 missed it because it resumes through + a restart. +3. `#admission` and `#uncertain` skipped the binding push when only the + state changed, so clients kept showing `stopping` after a failed force + stop. +4. The client kept non-final receipts live across a new incarnation. It + now marks them outcome unknown. +5. The `unknown` push's `type` field overwrote the envelope's + `type: "push"`, so clients dropped it. The field is now `nativeType`. +6. A `Transcript` attached to a connected client didn't read the page until + the next welcome. It reads at once. +7. The controller's new refusal codes broke control-board's + `REFUSAL_STATUS` completeness test, which scans every `new Refusal("…")` + in `conversation/src`. Control-layer refusals now throw `ControlRefusal`, + a subclass in `safe-fs.mjs`. The README says why. `packages/control-board` + is untouched and green. + +## Findings against pinned Pi 0.85.1 + +Reported, not built around: + +- **Startup append.** A session whose branch has no + `thinking_level_change` entry gains one at every start (sdk.js + 240–252). The leaf moves after launch, so the K8 load check fails closed: + the binding goes `uncertain` and prompts refuse `preflight`. Sessions Pi + created carry the entry, so this affects only sessions written by + something else. A pre-spawn check would refuse them before launch. That + is a later increment's decision. +- **One inline extension command under the seal.** `get_commands` still + lists `/llama` (Pi's bundled llama.cpp router). Slash text is refused at + admission, so it can't be invoked. The smoke test pins the command list + so a change shows. + +## Limits + +- The mutation pass proves each listed mutant fails a test. It doesn't + prove the tests catch every other fault. +- K13 is refused on this host by cgroup namespace delegation; the test + records the host behaviour, not a controller check. +- `cohort.test.mjs` needs a systemd user manager; without one the scope + tests skip and say so. diff --git a/agents/dewey/work/chat-03/I1-manifest.sha256 b/agents/dewey/work/chat-03/I1-manifest.sha256 new file mode 100644 index 00000000..985e9ef3 --- /dev/null +++ b/agents/dewey/work/chat-03/I1-manifest.sha256 @@ -0,0 +1,27 @@ +2ed3790eb877414b8e985ca16031d8963adfb5c9555d415efb76288b76a5e1a6 packages/conversation/package.json +974f75fc5e7770d0be84448e2d7ff34acdd3855397bf6b5dd59e9072b22dedcb packages/conversation/README.md +6c3e112e13e69f5a9cfd6b217d3f9ba439000c8f353e556207b2e5063bcdee91 packages/conversation/src/claim.mjs +a45beb82438bb42d97d1e322010e175e785767664e548b90c4125e98b7660003 packages/conversation/src/client.mjs +6a5ef01d36111af8f5d872e30e0dd03b079f4650dd2b977c0ade5a1cf22ba3fe packages/conversation/src/cohort.mjs +e2140d9cec046c58b09a91b40a83cb7c93b640840933ef0e68889a9c8178de5d packages/conversation/src/controller.mjs +a833f130f0086e0942de2f46d3d4acfeebf898659f0d7e5d6f375a2780ac5e70 packages/conversation/src/engine.mjs +2fe414b4d2382e0ebfaa63e63dbd17ff4bb6ffc6bd7c5d0bbcc806797d6b7129 packages/conversation/src/events.mjs +eaf0772b60bb17fbf9548267c9cfeb0104f92f5cbafdfcb60c1633883eec6432 packages/conversation/src/framing.mjs +a96fd4304cfb7a72d36ebf7f9c1fad81881df4621ba17719558428be60559a8a packages/conversation/src/guard.mjs +6496b1aa72c130f44999a7389708535321e10f5c0ae16cb9f7bb2f6dd80f4703 packages/conversation/src/pi-pin.mjs +96f7b64c2e2c3493e270224542818fd14f042e3cf0ac14788b3f881010bc25b1 packages/conversation/src/records.mjs +62b0f7e9f798f584fcc0e4ead0522e44fd113bd0803f2bad92ffa480cadb7527 packages/conversation/src/safe-fs.mjs +83dd41c5a6b40a2f5e4b65a0e75c9cf9517b6e89fdfe8361368e993583a97a1d packages/conversation/src/shim.mjs +94f2b114c3ad2b7f217de8fe0ee0babf145ef7441534c24e9fab0528676ed475 packages/conversation/src/terminal.mjs +071e14db0bc6933a438d0fbf5a2f0f1a9daefbef913908fc1cd5e1171cc9177a packages/conversation/src/text-policy.mjs +f834ee740e7149e7d2ed976112d0719ce0660270e3b61cf93e1774d112c992a2 packages/conversation/src/transcript.mjs +e778f2836f7062a414d7542954b54b83e1d39459529d6b03f5306eb4091793f0 packages/conversation/src/turns.mjs +4fddb664719d25d8c0d4871a621be68190392aacb2e3212f431660d3511d63e7 packages/conversation/tests/claim.test.mjs +10a24b347b07426b2b7f65ce6284c9fc38d6ff3046b91d55ea5baba54d8d9774 packages/conversation/tests/cohort.test.mjs +492ed353b2b755cc5fbe0fb4e645fab60bea3ccd68fbbe48c18bfef3a51cd5c2 packages/conversation/tests/ctrl-child.mjs +855879fd9a4830cd23453814c2c36418e006bde8457e46173af8968cea5a45cb packages/conversation/tests/fake-pi.mjs +a867751872d1a47383a21b71772b26b0a8c8d48253d03ca6003a2496af413836 packages/conversation/tests/flows.test.mjs +c59af12539f272c5f8ce6ca7ea026cc230c54d76cde35d5cf85962a596d681d4 packages/conversation/tests/harness.mjs +269ab45c7bd50c31068caa1d2b34329da928317bd5871e290fe57fbacf8a3028 packages/conversation/tests/races.test.mjs +8535ad8eee7384b29557dfa6cf8517d22fbc5ff9693f513ffc5e35ff627d71a8 packages/conversation/tests/smoke.test.mjs +18d5b4b0eecf4ca37d04b46b9103e704a133eee4973b6e51719c93ac9a1e1255 packages/conversation/tests/turns.test.mjs diff --git a/agents/dewey/work/chat-03/I1-r2-manifest.sha256 b/agents/dewey/work/chat-03/I1-r2-manifest.sha256 new file mode 100644 index 00000000..220c71b9 --- /dev/null +++ b/agents/dewey/work/chat-03/I1-r2-manifest.sha256 @@ -0,0 +1,27 @@ +2ed3790eb877414b8e985ca16031d8963adfb5c9555d415efb76288b76a5e1a6 packages/conversation/package.json +3d32a202743e99696bf3ab58d03eab86a34f59006978549f846eb3c52a793eef packages/conversation/README.md +6c3e112e13e69f5a9cfd6b217d3f9ba439000c8f353e556207b2e5063bcdee91 packages/conversation/src/claim.mjs +a45beb82438bb42d97d1e322010e175e785767664e548b90c4125e98b7660003 packages/conversation/src/client.mjs +6a5ef01d36111af8f5d872e30e0dd03b079f4650dd2b977c0ade5a1cf22ba3fe packages/conversation/src/cohort.mjs +06afe06161775b03d2f7def129bcaae04167c7484e3e9fa112652f5e4a8e6c06 packages/conversation/src/controller.mjs +a833f130f0086e0942de2f46d3d4acfeebf898659f0d7e5d6f375a2780ac5e70 packages/conversation/src/engine.mjs +2fe414b4d2382e0ebfaa63e63dbd17ff4bb6ffc6bd7c5d0bbcc806797d6b7129 packages/conversation/src/events.mjs +eaf0772b60bb17fbf9548267c9cfeb0104f92f5cbafdfcb60c1633883eec6432 packages/conversation/src/framing.mjs +a96fd4304cfb7a72d36ebf7f9c1fad81881df4621ba17719558428be60559a8a packages/conversation/src/guard.mjs +f813184cbef5151449b2e6e7b3f1b375fb20f26600d2b8101aa72f13181b60fe packages/conversation/src/pi-pin.mjs +96f7b64c2e2c3493e270224542818fd14f042e3cf0ac14788b3f881010bc25b1 packages/conversation/src/records.mjs +62b0f7e9f798f584fcc0e4ead0522e44fd113bd0803f2bad92ffa480cadb7527 packages/conversation/src/safe-fs.mjs +83dd41c5a6b40a2f5e4b65a0e75c9cf9517b6e89fdfe8361368e993583a97a1d packages/conversation/src/shim.mjs +5dc53b8617db15df6e4103fb40407013f0e4a96da1e9b1cb722aef0de1d24f37 packages/conversation/src/terminal.mjs +071e14db0bc6933a438d0fbf5a2f0f1a9daefbef913908fc1cd5e1171cc9177a packages/conversation/src/text-policy.mjs +f834ee740e7149e7d2ed976112d0719ce0660270e3b61cf93e1774d112c992a2 packages/conversation/src/transcript.mjs +e778f2836f7062a414d7542954b54b83e1d39459529d6b03f5306eb4091793f0 packages/conversation/src/turns.mjs +64ca6e7e2a65ddcaf0b5dfd54abe2af904e991d3d9e7ff8ffef3bb7e82e5a2eb packages/conversation/tests/claim.test.mjs +41d4a2f3a0a7f3ba1b1c03059c055b15866ff571d106ecd4948d857c9f8da259 packages/conversation/tests/cohort.test.mjs +492ed353b2b755cc5fbe0fb4e645fab60bea3ccd68fbbe48c18bfef3a51cd5c2 packages/conversation/tests/ctrl-child.mjs +d0476d48bd0d4015c0f17c8facf52a5c409dbdef211d5c43340ac68e56b91cdc packages/conversation/tests/fake-pi.mjs +abdafbe8f8e8fdde559b99ad3b81f00d463abbc8663d202be0b14f1b9f5ad890 packages/conversation/tests/flows.test.mjs +ac664988c350f4437137fc50e2ab90122ab2ec7368caf790ab94e195ed225257 packages/conversation/tests/harness.mjs +3dfb2e6afbe41b53de2b4d4a52caa7a3a08663d5b0f3494cf28aa9d311e3bf6c packages/conversation/tests/races.test.mjs +8535ad8eee7384b29557dfa6cf8517d22fbc5ff9693f513ffc5e35ff627d71a8 packages/conversation/tests/smoke.test.mjs +52425185e4393f019a06879db02eac9cde2b1f90e60c29a40cd361e70fb8c0cb packages/conversation/tests/turns.test.mjs diff --git a/agents/filbert/work/chat-03-i1-review-r2-2026-10-04.md b/agents/filbert/work/chat-03-i1-review-r2-2026-10-04.md new file mode 100644 index 00000000..8a19bdaa --- /dev/null +++ b/agents/filbert/work/chat-03-i1-review-r2-2026-10-04.md @@ -0,0 +1,140 @@ +# CHAT-03 I1, row 5, round 2 review (Filbert) + +Issue #1507. Candidate: manifest `agents/dewey/work/chat-03/I1-r2-manifest.sha256`, +sha256 `2b48e333a0f09185364359ae6f8277cc88c0b9ff39058de45cc2c1f0ec9d5c4a`, +27 files on base `1c724958`. Packet: `agents/dewey/work/chat-03/BUILD-I1-r2.md`. +Round 1 review: `agents/filbert/work/chat-03-i1-review-r1-2026-10-04.md` (comment 26683). + +Verdict: **approve.** All six of my round 1 blockers are fixed. Three +follow-ups below; none of them blocks the commit. + +## Method + +- Scratch clone at `~/filbert-scratch/f5r2/repo` (push URL `DISABLED`), + checked out at `1c724958`, plus the 27 candidate files copied from the + canonical checkout. `sha256sum -c` on the manifest: 27 OK. + `node_modules` is a symlink to the canonical checkout's, as in round 1. +- Round 1 to round 2 diff (`r1-r2.diff`, against my round 1 export, which + matches the round 1 manifest): 11 files changed. `cohort.mjs` and + `shim.mjs` are unchanged since round 1. +- Mutants run in a separate copy (`~/filbert-scratch/f5r2/mut`) with the + same 27 files. Baseline there: 152/152. + +## Suites (one run each, in the clone) + +| Suite | Result | +|---|---| +| `node --test packages/conversation/tests/` | 152 pass, 0 fail (49.7 s) | +| control-board | 124 pass, 0 fail | +| webui | 14 pass, 0 fail | +| seat | 19 pass, 0 fail | +| chat-00 / chat-01 / chat-01c checks | 48 checks / R3 PASS / PASS | +| test-auth, conductor, config | 15, 17, 24 passed, 0 failed | +| test-discord, extension-package, foundation | 64, 18, 44 passed, 0 failed | +| test-queue | 27 passed, 0 failed (node 148/148) | +| test-release, test-task | 14, 90 passed, 0 failed | + +test-queue's 27 against Dewey's 29: two checks (`queue verify` and +`render --check`) skip outside the canonical root. The node part matches. +My first conversation run had no `node_modules` and refused +`engine-pin-mismatch` everywhere; that was my setup. The log is kept as +`conv-0-no-node_modules.txt`. + +## Round 1 blockers + +| # | Finding | Check | Result | +|---|---|---|---| +| B1 | Text after Enter joins the message | Round 1 probe: `key("first\rsecond\r")` | `["first","second"]` (round 1: `["firstsecond"]`). Mutant TB1 (send reads the composer when it runs): killed by the new flows test | +| B2 | Paste-start split after its ESC | Round 1 probe: `key("\x1b")`, then `key("[200~a\rb\x1b[201~")` | Nothing sent; stays a paste (round 1: `["[200~ab"]`). Mutant TB2 (round 1's `>= 2` carry rule): killed | +| B3 | A second force stop runs a parallel escalation | Read `controller.mjs` 680–690 and 1451–1460; new H10 test | Fixed. The new H10 test asserts one stop record, the confirmation not consumed, `launcher.stops` 1, every claim revision on the first stop and no engine bytes. Mutant (drop `\|\| this.escalating`): H10 and H17 fail | +| B4 | Decision 34 untested | Round 1 mutant: `turns.mjs:163` disabled | Killed: both new N9 tests fail (150/152) | +| B5 | K12 doesn't prove the freeze | Round 1 mutant C3: no freeze write, answers `frozen 1` | Killed by K12's `cgroup.freeze` read. The code waits for `frozen 1` before enumerating (below). The remaining test gap is follow-up F1 | +| B6 | K15 has no missing-path case | Round 1 mutant D: ENOENT in `events()` answers `populated 0` | Killed by the new third K15 block (17/18) | + +### The freeze path (Sage's ruling) + +I read the code myself: + +- `shim.mjs` `freeze` op: it writes `engine/cgroup.freeze`, then + `waitFor(e => e.frozen === 1 || e.populated === 0, timeoutMs)`. That + polls `engine/cgroup.events` every 10 ms. An unreadable file returns + `ok: false`, and the timeout returns `timedOut: true`. +- `cohort.mjs` 181–185: `freeze` is sent with `timeoutMs: 3000` and a + 6000 ms client limit. `!frozen.ok` or `frozen.timedOut` returns + `unavailable` at phase `kill` before `members` is called. `members` runs + only after a `frozen 1` (or `populated 0`) was read. + +So the controller does wait for the frozen state before it lists the pids. +Under the ruling, the missing test is a follow-up and doesn't block. + +## Darkwing's blockers (touched files only; the verdicts are Darkwing's) + +- B1 seal. `checkSeal` is an allow-list. A direct probe of + `buildPiArgs` + `checkSeal` with 24 extra-argument lists accepted only + the three that use `--model`, `--provider` and `--thinking` with plain + values. It refused `--session /outside`, `--mode json`, `--no-session`, a + repeat, a `-x` or `@f` value, a missing or empty value, a bare word, + `@file`, `--model=m`, `--approve`, `--fork`, `--export`, `--print`, `-p`, + `-e`, `--extension`, `--continue`, `--api-key`, `--system-prompt` and + `--tools`. A relative session path refuses too. Note for Darkwing: a + caller can keep the default `engine.command` and pass its own `preArgs`, + for example the Pi `cli.js` path plus `--no-session`. Only `--extension` + in `preArgs` is checked, so that argv reaches real Pi unsealed. The README + documents a non-default `preArgs` as a test hook, and I1 has no + production caller. I list it as a follow-up (F3), not a blocker. +- B2 session key. The key is the header ID, read at construction. Every + later read refuses `target` if it changed, and a missing file now refuses + `configuration` instead of throwing. `claim.mjs` `sessionKey` is + unchanged and takes any value. The three new W4 tests pass. + +## Round 1 notes + +n1, n2 and n3 are code changes. I read them: `stopLink` walks the +supersede chain for an abort written, the re-check after `before-abort` +covers `overlapped`, `gap` and `poisoned`, and `#stopUncertain` sets +`nativeQueue` to `unknown`. Dewey's r2-n1, r2-n2 and r2-n3 mutants are in +the table; I didn't rerun them. Dewey's dispositions of n4–n20 are +acceptable as stated. + +## Follow-ups (none blocks the commit) + +- **F1. No test proves enumeration happens under the freeze.** The packet + says both K12 checks "don't depend on timing". That's true of the + `cgroup.freeze` read, but the read only proves the file was written at + some point. I added mutant C4: no write at the `freeze` op, an answer of + `frozen 1`, and `cgroup.freeze` written just before `cgroup.kill`. It + survives the cohort suite 3 out of 3 runs (18/18 each). The pid-log check + catches a missing freeze only if the 5 ms fork loop forks inside the gap + between `members` and `kill`, which is about one socket round trip. + Dewey's r2-B5b (written, not waited on) also survives here (18/18). + Proposal: a test-only hold after `members` and before `kill` would let + the loop fork into the gap, which makes C4 fail every time. r2-B5b still + needs the FUSE or privileged fixture already proposed. +- **F2. Takeover and Enter in one input chunk.** `key("\x14hi\r")` from an + observer: round 1 took over and sent `hi`. Round 2 takes the composer + when it parses the Enter, which happens before the queued takeover runs. + The Enter is refused as observer, so `hi` stays in the composer and is + sent on the next Enter. Nothing is sent that shouldn't be; it costs an + extra keypress. Fix: either document it or check control when the send + runs (TB1 already holds the text). +- **F3. Overriding `preArgs` with the default command** (see Darkwing B1 + above). Refuse a non-default `engine.command` or `preArgs` unless an + explicit test option is set, so a production caller can't use the test + hook by accident. + +## Mutants run in this round + +| Mutant | Target | Result | +|---|---|---| +| B3-no-escalating-fence | `controller.mjs` force-stop guard | killed (H10, H17) | +| D34-line163 | `turns.mjs:163` | killed (two N9 tests) | +| TB1-send-at-run | `terminal.mjs` Enter | killed (flows) | +| TB2-lone-esc | `terminal.mjs` ESC carry | killed (flows) | +| C3-no-freeze-answer-frozen | `shim.mjs` freeze | killed (K12) | +| C4-freeze-at-kill ×3 | `shim.mjs` freeze moved to the kill | survived 3/3 (F1) | +| C2-no-wait-frozen | `shim.mjs` freeze not awaited (= r2-B5b) | survived (F1) | +| D-enoent-populated0 | `shim.mjs` `events()` | killed (K15) | + +Logs: `~/filbert-scratch/f5r2/logs/` (`mut-*.txt`, `mut-summary.txt`, +`conv-1.txt`, `summary.txt` and one log per suite). Probes: +`b1probe.mjs`, `sealprobe.mjs` and `tb-edge.mjs` in `~/filbert-scratch/f5r2/`. diff --git a/docs/SESSIONS.md b/docs/SESSIONS.md index bfc3ef23..ee43fc38 100644 --- a/docs/SESSIONS.md +++ b/docs/SESSIONS.md @@ -475,3 +475,5 @@ are never rewritten or removed; corrections are new entries. 2026-10-04T19:56:14Z | Sage (T3 Claude Code, thread 1ef1e4f8) | slice 1 addendum A | Darkwing's addendum (0b36acb0) committed as a record; lead decision 49; PRD draft 0.3; assigned Researcher Vikunja probes P1-P7 on a scratch container and Darkwing the task_snapshots prototype extension 2026-10-04T19:59:24Z | Sage (T3 Claude Code, thread 1ef1e4f8) | slice 1 prototype v2 | Darkwing's schema-v2, proto-v2 and outputs committed as records after a Sage rerun on Node 26; lead decision 50 accepts the five choices beyond addendum A; Researcher probes P1-P7 still running 2026-10-04T20:25:29Z | Dewey (T3 Claude Code) | row 5 CHAT-03 I1 round-1 rework | Darkwing B1-B2 and Filbert B1-B6 fixed; conversation 152/152, other suites green; 60 of 61 mutants killed (r2-B5b survives, recorded); manifest 2b48e333; round 2 requested, comment 26689 on #1507 (revs 56-58); nothing committed +2026-10-04T20:42:10Z | Filbert (claude, T3 9cb9731e) | row 5 CHAT-03 I1 round 2 review (#1507) | approve: B1-B6 fixed (probes rerun, round 1 mutants killed; freeze path read, code waits for frozen 1 before members); follow-ups F1 (enumeration-under-freeze test, C4 and r2-B5b survive), F2 (takeover+Enter in one chunk), F3 (preArgs override reaches Pi unsealed); comment 26694; queue rev 60 committed ddd9cf36 (not pushed); review file agents/filbert/work/chat-03-i1-review-r2-2026-10-04.md sha256 2f88ccd9 (uncommitted) +2026-10-04T20:47:45Z | Sage (T3 Claude Code, thread 1ef1e4f8) | row 5 CHAT-03 I1 commit | both round 2 approvals (26690, 26694); manifest 2b48e333 checked; all suites green on an export; candidate, packets and Filbert's review record committed; follow-ups in DEFERRED; row 5 to waiting-on-jason for Gate E diff --git a/docs/plans/DEFERRED.md b/docs/plans/DEFERRED.md index 32f2a793..787119a0 100644 --- a/docs/plans/DEFERRED.md +++ b/docs/plans/DEFERRED.md @@ -185,6 +185,26 @@ at every gate. Started 2026-09-12 during the control board MVP. Slice 1 role tokens stay in the broker (lead decision 47). The workers' own exposure needs a separate piece. Filbert, survey section 8.6. (2026-10-04) +- **CHAT-03 I1 round 2 follow-ups, for the I3 brief.** Neither reviewer + held any of these as blocking (Darkwing comment 26690, Filbert comment + 26694 on #1507). + - The engine command and `preArgs` sit outside the seal. They're + documented as a test hook, but `preArgs` with the default command + reaches real Pi unsealed. The I3 entry point must not take them from + config. Both reviewers found this. + - `engine.env` defaults to `process.env`. I3 builds the engine's + environment from an explicit list. Darkwing F3. + - `escalating` is set in `controller.mjs` before `after()` runs. If + admission or poison throws, the flag stays set, and later force stops + are refused as `fenced` until restart. Darkwing F2. + - No test proves that the processes are listed only after the cgroup + is frozen. The code waits for `frozen 1` in `cgroup.events`, and + both Filbert and Dewey confirmed it by reading it. Mutants r2-B5b and + C4 survive. Proposed: a test-only hold after `members`, or a FUSE or + privileged fixture. Filbert F1. + - A takeover followed by Enter in the same chunk leaves the text + unsent. Filbert F2. + (2026-10-04, #1507) ## Queue diff --git a/packages/conversation/README.md b/packages/conversation/README.md index 48975c0e..6a27df27 100644 --- a/packages/conversation/README.md +++ b/packages/conversation/README.md @@ -1,13 +1,20 @@ # conversation -Read-only Pi conversation 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 nothing -here writes a file. +Two layers over Pi conversations, issue #1507 (row 5). Plain ESM, no +dependencies, Node 24 or newer. -Issue #1507 (CHAT-02, row 5). Brief: `agents/dewey/work/chat-02/BRIEF.md` R4. -Plain ESM, no dependencies, Node 24 or newer. A library with no server: the -control board serves it on two GET routes (D3). +- **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 @@ -195,8 +202,10 @@ at most 680 ms per conversation. ## Tests -`node --test packages/conversation/tests/` covers fixtures F1–F15 and F17 of -the brief (F16 is in the control-board suite, which serves the routes). +`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 @@ -205,3 +214,292 @@ the brief (F16 is in the control-board suite, which serves the routes). `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 [--grant ]` | +| `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:", 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 +`/.pi/state//sessions/.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 `, 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 /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 (`^[`, ``), 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:", refusal: "" }`. 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. diff --git a/packages/conversation/package.json b/packages/conversation/package.json index 2e4bf4b9..1849c6a1 100644 --- a/packages/conversation/package.json +++ b/packages/conversation/package.json @@ -2,10 +2,16 @@ "name": "@mosaic/conversation", "version": "0.1.0", "private": true, - "description": "Read-only Pi conversation histories for the Console: approved roots, safe opens, branch pages and cursors. No server; the control board serves it.", + "description": "Pi conversations for the Console: read-only histories (CHAT-02) and mediated control of a sealed headless Pi through a local controller, client library and terminal (CHAT-03 I1, fixtures only).", "license": "UNLICENSED", "type": "module", "engines": { "node": ">=24" }, - "exports": { ".": "./src/reader.mjs" }, + "exports": { + ".": "./src/reader.mjs", + "./controller": "./src/controller.mjs", + "./client": "./src/client.mjs", + "./transcript": "./src/transcript.mjs", + "./terminal": "./src/terminal.mjs" + }, "scripts": { "test": "node --test tests/" } } diff --git a/packages/conversation/src/claim.mjs b/packages/conversation/src/claim.mjs new file mode 100644 index 00000000..941c2be1 --- /dev/null +++ b/packages/conversation/src/claim.mjs @@ -0,0 +1,354 @@ +// The writer-claim record (#1507, CHAT-03 §2, D1). +// +// A claim holds two keys, the seat tuple (seat, project, workspace) and the +// native session identity. Each key is a directory under the claim root and +// each revision a numbered file in it (r0000000001.json, ...). A revision is +// published by writing a temporary file in the same directory, fsyncing it, +// link()ing it to the next revision name and fsyncing the directory. link() +// fails when the name exists, so publication is exclusive and a revision name +// only ever points at complete contents. Revisions are never rewritten. +// +// Keys are taken seat first, then session. Every step of the lifecycle +// publishes on the seat key and then on the session key. The pair's state is +// the more conservative of the two: uncertain > stopping > active > reserved +// > stopped. A key is held unless its highest revision is `stopped` with a +// proof reference. A highest revision that does not parse holds the key as +// `uncertain`; an older revision is never reused. +// +// The store is internal. It never crosses the wire, and it stores only the +// CHAT-01 binding fields it needs. + +import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, unlinkSync, writeSync } from "node:fs"; +import { join } from "node:path"; +import { createHash, randomBytes } from "node:crypto"; +import { bootId as readBootId, identityOf, ownerState } from "../../discord/src/journal.mjs"; +import { ControlRefusal } from "./safe-fs.mjs"; +import { ID } from "./parts.mjs"; + +export const CLAIM_VERSION = 1; +export const STATES = Object.freeze(["uncertain", "stopping", "active", "reserved", "stopped"]); +export const PROOF_KINDS = Object.freeze(["cohortProof", "boot", "no-unit"]); +export const ALREADY_ACTIVE = "already-active"; +export const UNSAFE_REPLACEMENT = "unsafe-replacement"; +export const FOREIGN_HOST = "foreign-host"; + +const REVISION = /^r(\d{10})\.json$/; +const revName = (n) => `r${String(n).padStart(10, "0")}.json`; +const rank = (state) => STATES.indexOf(state); + +export function machineId() { + try { + const id = readFileSync("/etc/machine-id", "utf8").trim(); + return /^[0-9a-f]{32}$/.test(id) ? id : null; + } catch { + return null; + } +} + +export const defaultHost = Object.freeze({ machineId, bootId: readBootId }); + +export function newClaimId() { + return "c" + randomBytes(12).toString("hex"); +} + +export function unitNameFor(claimId) { + return `mosaic-chat-${claimId}`; +} + +// The more conservative of two states. +export function conservative(a, b) { + return rank(a) <= rank(b) ? a : b; +} + +const keyHash = (key) => createHash("sha256").update(JSON.stringify(key)).digest("hex").slice(0, 32); + +export function seatKey({ seat, project, workspace }) { + return { kind: "seat", seat, project, workspace }; +} + +export function sessionKey(session) { + return { kind: "session", session }; +} + +function valid(rec, key, n) { + return rec && typeof rec === "object" && rec.version === CLAIM_VERSION && rec.kind === "writer-claim" && + rec.revision === n && JSON.stringify(rec.key) === JSON.stringify(key) && typeof rec.claimId === "string" && + ID.test(rec.claimId) && STATES.includes(rec.state) && rec.host && typeof rec.host === "object" && + (rec.proof === null || (rec.proof && PROOF_KINDS.includes(rec.proof.kind))); +} + +// A key's highest revision is free only when it is `stopped` with a proof. +export function held(head) { + if (head.n === 0) return false; + if (head.damaged) return true; + return !(head.record.state === "stopped" && head.record.proof); +} + +export function headState(head) { + if (head.n === 0) return "stopped"; + if (head.damaged) return "uncertain"; + if (head.record.state === "stopped" && !head.record.proof) return "uncertain"; + return head.record.state; +} + +export class ClaimStore { + constructor({ root, host = defaultHost, identity = identityOf, barrier = null, now = () => new Date() }) { + if (typeof root !== "string" || !root) throw new ControlRefusal("configuration", "a claim root is required; CHAT-03 has no default"); + this.root = root; + this.host = host; + this.identity = identity; + this.barrier = barrier; + this.now = now; + } + + dir(key) { + return join(this.root, key.kind, keyHash(key)); + } + + async pause(name, detail) { + if (this.barrier) await this.barrier(name, detail); + } + + head(key) { + const dir = this.dir(key); + let names; + try { + names = readdirSync(dir); + } catch (err) { + if (err.code === "ENOENT") return { key, n: 0, record: null, damaged: false }; + throw err; + } + let n = 0; + for (const name of names) { + const m = REVISION.exec(name); + if (m) n = Math.max(n, Number(m[1])); + } + if (n === 0) return { key, n: 0, record: null, damaged: false }; + let record = null; + try { + record = JSON.parse(readFileSync(join(dir, revName(n)), "utf8")); + } catch { + record = null; + } + if (!valid(record, key, n)) return { key, n, record: null, damaged: true }; + return { key, n, record, damaged: false }; + } + + revisions(key) { + const dir = this.dir(key); + let names; + try { + names = readdirSync(dir); + } catch { + return []; + } + return names.filter((x) => REVISION.test(x)).sort().map((name) => ({ name, text: readFileSync(join(dir, name), "utf8") })); + } + + // Publishes revision `n` on `key`. Returns null when another writer + // published that revision first. + async publish(key, n, body) { + const dir = this.dir(key); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const record = { ...body, key, revision: n, writtenAt: this.now().toISOString() }; + const tmp = join(dir, `.tmp-${randomBytes(8).toString("hex")}`); + const fd = openSync(tmp, "wx", 0o400); + try { + writeSync(fd, JSON.stringify(record) + "\n"); + await this.pause("temp-written", { key, n }); + fsyncSync(fd); + } finally { + closeSync(fd); + } + await this.pause("temp-synced", { key, n }); + let won = true; + try { + linkSync(tmp, join(dir, revName(n))); + } catch (err) { + if (err.code !== "EEXIST") throw err; + won = false; + } + unlinkSync(tmp); + if (!won) return null; + await this.pause("linked", { key, n }); + const dfd = openSync(dir, "r"); + try { + fsyncSync(dfd); + } finally { + closeSync(dfd); + } + await this.pause("dir-synced", { key, n }); + return record; + } + + owner(incarnation) { + const id = this.identity(process.pid); + return { pid: process.pid, start: id.start, boot: id.boot, incarnation }; + } + + // Why a held pair refuses a new claim. + refusal(seat, session) { + const heads = [seat, session].filter(held); + const mine = this.host.machineId(); + if (heads.some((h) => !h.damaged && h.record.host.machineId !== mine)) return FOREIGN_HOST; + if (heads.some((h) => h.damaged)) return UNSAFE_REPLACEMENT; + if (heads.length === 2 && heads[0].record.claimId !== heads[1].record.claimId) return UNSAFE_REPLACEMENT; + const state = heads.map(headState).reduce(conservative, "stopped"); + return state === "reserved" || state === "active" ? ALREADY_ACTIVE : UNSAFE_REPLACEMENT; + } + + refuse(seat, session) { + const code = this.refusal(seat, session); + return new ControlRefusal(code, `the writer claim for this seat or session is held (${code})`); + } + + // Reserves both keys under a new claim ID. `fields` holds the binding + // fields the record stores. Refuses when either key is held. + async acquire(seatK, sessionK, fields) { + for (let attempt = 0; attempt < 4; attempt++) { + const seat = this.head(seatK), session = this.head(sessionK); + if (held(seat) || held(session)) throw this.refuse(seat, session); + const claimId = fields.claimId ?? newClaimId(); + const body = { + version: CLAIM_VERSION, kind: "writer-claim", claimId, + bindingId: fields.bindingId, harness: fields.harness, conversation: fields.conversation, + branch: fields.branch, leaf: fields.leaf, pins: fields.pins, + host: { machineId: this.host.machineId(), bootId: this.host.bootId() }, + owner: fields.owner, unitName: unitNameFor(claimId), spawnMarker: false, + invocationId: null, engine: null, shim: null, generation: fields.generation, + state: "reserved", proof: null, stop: null, prior: fields.prior ?? null, + execution: fields.execution ?? null, cohortRef: fields.cohortRef ?? null, + }; + const first = await this.publish(seatK, seat.n + 1, body); + if (!first) continue; // lost the seat key: re-read and re-decide + await this.pause("between-keys", { claimId }); + const now = this.head(sessionK); + const second = held(now) ? null : await this.publish(sessionK, now.n + 1, body); + if (!second) { + // The session key is held by someone else: close our seat revision + // with a no-unit proof. Nothing was spawned. + await this.publish(seatK, seat.n + 2, { ...body, state: "stopped", proof: { kind: "no-unit", ref: null } }); + throw this.refuse(this.head(seatK), this.head(sessionK)); + } + return { claimId, seatKey: seatK, sessionKey: sessionK, n: { seat: first.revision, session: second.revision }, record: body }; + } + throw new ControlRefusal(UNSAFE_REPLACEMENT, "the writer claim changed during every attempt"); + } + + // Publishes `patch` on both keys, seat first. Both heads must still be this + // claim's own last revisions. + async advance(claim, patch) { + const next = { ...claim.record, ...patch }; + const n = { ...claim.n }; + for (const kind of ["seat", "session"]) { + const key = kind === "seat" ? claim.seatKey : claim.sessionKey; + const head = this.head(key); + if (head.damaged || head.n !== n[kind] || head.record.claimId !== claim.claimId) { + throw new ControlRefusal(UNSAFE_REPLACEMENT, `the ${kind} key changed under claim ${claim.claimId}`); + } + const rec = await this.publish(key, head.n + 1, next); + if (!rec) throw new ControlRefusal(UNSAFE_REPLACEMENT, `another writer published on the ${kind} key of claim ${claim.claimId}`); + n[kind] = rec.revision; + if (kind === "seat") await this.pause("between-keys", { claimId: claim.claimId, patch }); + } + claim.record = next; + claim.n = n; + return claim; + } + + // Restart classification for a pair whose recorded owner may be gone. Acts + // only when the owner is proven gone: a different boot, or no process with + // the recorded pid and start time. `units.lookup(record)` reports the + // intended unit as { state: "absent" | "alive" | "unknown" }. `bootProof` + // issues the boot proof: it returns { proof, effects } once the verifier + // accepted both, or null, which leaves the pair `uncertain`. `adopt` is the + // owner record a recovery controller publishes when it takes over an + // orphan; without it nothing is adopted. `stoppedPatch` adds fields (the + // leaf at proof) to a `stopped` revision this classification publishes. + async classify(seatK, sessionK, { units, bootProof = null, adopt = null, stoppedPatch = {} } = {}) { + const seat = this.head(seatK), session = this.head(sessionK); + if (!held(seat) && !held(session)) return { state: "free" }; + const code = this.refusal(seat, session); + if (code === FOREIGN_HOST) return { state: "uncertain", refusal: FOREIGN_HOST }; + if (seat.damaged || session.damaged) return { state: "uncertain", refusal: UNSAFE_REPLACEMENT, damaged: true }; + const heads = [seat, session].filter(held); + if (heads.length === 2 && heads[0].record.claimId !== heads[1].record.claimId) return { state: "uncertain", refusal: UNSAFE_REPLACEMENT }; + const claimId = heads[0].record.claimId; + const ours = [seat, session].filter((h) => h.n > 0 && !h.damaged && h.record.claimId === claimId); + const latest = ours.reduce((a, b) => (a.record.writtenAt >= b.record.writtenAt ? a : b)).record; + const owner = latest.owner; + if (adopt && owner?.incarnation === adopt.incarnation && owner?.pid === adopt.pid) return { state: "owned", claimId, record: latest }; + const status = ownerState(owner ? { pid: owner.pid, start: owner.start ?? null, boot: owner.boot ?? null } : { invalid: true }, { identity: this.identity }); + if (status === "live" || status === "unknown" || status === "invalid") return { state: headState(heads[0]), refusal: ALREADY_ACTIVE, claimId, record: latest }; + + const pair = heads.map(headState).reduce(conservative, "stopped"); + const marker = ours.some((h) => h.record.spawnMarker); + const claim = this.claimFrom(seatK, sessionK, claimId, seat, session, latest); + + // A different boot on the same host: every process of that boot is gone. + if (latest.host.bootId !== this.host.bootId()) { + if (!bootProof) return { state: "uncertain", refusal: UNSAFE_REPLACEMENT, claimId, record: latest, needs: "boot-proof" }; + const issued = await bootProof(latest); + if (!issued) return { state: "uncertain", refusal: UNSAFE_REPLACEMENT, claimId, record: latest, needs: "boot-proof" }; + const { proof, effects = null } = issued; + await this.finish(claim, { ...stoppedPatch, state: "stopped", proof: { kind: "boot", ref: proof.id, effects: effects?.id ?? null }, spawnMarker: marker }); + return { state: "stopped", proofKind: "boot", proof, effects, claimId, record: claim.record }; + } + + // Keys disagree because a release stopped between them: finish it under + // the same claim ID and proof. + const stoppedHead = ours.find((h) => h.record.state === "stopped" && h.record.proof); + if (stoppedHead) { + await this.finish(claim, { state: "stopped", proof: stoppedHead.record.proof, spawnMarker: marker || latest.spawnMarker, leafAtProof: stoppedHead.record.leafAtProof ?? null, branchAtProof: stoppedHead.record.branchAtProof ?? null }); + return { state: "stopped", proofKind: stoppedHead.record.proof.kind, claimId, record: claim.record, completed: true }; + } + + const unit = await units.lookup(latest); + if (pair === "reserved" && !marker && unit.state === "absent") { + await this.finish(claim, { ...stoppedPatch, state: "stopped", proof: { kind: "no-unit", ref: null } }); + return { state: "stopped", proofKind: "no-unit", claimId, record: claim.record }; + } + + // A marker with no unit, a unit that exists, or an engine that may run: + // the pair is uncertain. A collected scope is an absent observation, not + // proof that every process ended. The marker is copied, never dropped. + const patch = { state: latest.state === "stopping" && latest.stop ? "stopping" : "uncertain", spawnMarker: marker }; + if (adopt) patch.owner = adopt; + await this.finish(claim, patch); + return { state: patch.state, claimId, record: claim.record, unit, adopted: Boolean(adopt), claim, resumeStop: latest.stop && latest.state === "stopping" ? latest.stop : null }; + } + + claimFrom(seatK, sessionK, claimId, seat, session, latest) { + return { + claimId, seatKey: seatK, sessionKey: sessionK, + n: { seat: seat.n, session: session.n }, + heads: { seat, session }, + record: { ...latest }, + }; + } + + // Brings both keys of a claim to `patch`, seat first. A key whose head + // belongs to an older claim (a half-done acquire) gains the revision too. + async finish(claim, patch) { + const next = { ...claim.record, ...patch }; + for (const kind of ["seat", "session"]) { + const key = kind === "seat" ? claim.seatKey : claim.sessionKey; + const head = this.head(key); + if (head.damaged) throw new ControlRefusal(UNSAFE_REPLACEMENT, `the ${kind} key is unreadable`); + if (head.n > 0 && head.record.claimId !== claim.claimId && held(head)) throw new ControlRefusal(UNSAFE_REPLACEMENT, `the ${kind} key belongs to another claim`); + const same = head.n > 0 && head.record.claimId === claim.claimId && head.record.state === next.state && + JSON.stringify(head.record.proof) === JSON.stringify(next.proof) && head.record.spawnMarker === next.spawnMarker && + JSON.stringify(head.record.owner) === JSON.stringify(next.owner); + if (same) { + claim.n[kind] = head.n; + continue; + } + const rec = await this.publish(key, head.n + 1, next); + if (!rec) throw new ControlRefusal(UNSAFE_REPLACEMENT, `another writer published on the ${kind} key of claim ${claim.claimId}`); + claim.n[kind] = rec.revision; + if (kind === "seat") await this.pause("between-keys", { claimId: claim.claimId, patch }); + } + claim.record = next; + return claim; + } +} diff --git a/packages/conversation/src/client.mjs b/packages/conversation/src/client.mjs new file mode 100644 index 00000000..13037075 --- /dev/null +++ b/packages/conversation/src/client.mjs @@ -0,0 +1,234 @@ +// The CHAT-03 client library (#1507, CHAT-03 §1, deviation V-1). +// +// It speaks the controller's line protocol over the Unix socket: `hello` +// with a grant, then CHAT-01 v2 client requests, each carrying the +// controller's incarnation token. The library never resends. A request still +// pending when the connection drops, or one refused `stale-incarnation`, is +// shown as "outcome unknown, check the transcript" (lead decision 25, H21– +// H23). An actor may copy its text into a new request; the library doesn't +// present that as a safe retry. +// +// Drafts are client-local in CHAT-03: the draft ID and revision name the +// client's own buffer, and the text travels in the request envelope. CHAT-04 +// owns server-side drafts. + +import { connect as netConnect } from "node:net"; +import { LineSplitter, encodeLine, parseLine } from "./framing.mjs"; +import { newId, targetOf } from "./records.mjs"; + +export const OUTCOME_UNKNOWN = "outcome unknown, check the transcript"; +const STALE = "stale-incarnation"; +const FINAL = new Set(["finished", "failed", "dispatch-refused", "delivery-unknown"]); + +export class ConversationClient { + constructor({ socketPath, grant = "grant-local", connect = netConnect }) { + this.socketPath = socketPath; + this.grant = grant; + this.netConnect = connect; + this.sock = null; + this.incarnation = null; + this.connection = null; + this.binding = null; + this.streamEpoch = null; + this.pending = new Map(); + this.unknown = []; + this.receipts = new Map(); + this.events = []; + this.pushes = []; + this.listeners = new Set(); + this.waiters = new Set(); + this.closed = true; + } + + get target() { + return this.binding ? targetOf(this.binding) : null; + } + + get isController() { + return !!this.connection && this.binding?.controllerConnection === this.connection.id; + } + + // `fn(message)` for every push, reply and status change. + on(fn) { + this.listeners.add(fn); + return () => this.listeners.delete(fn); + } + + #notify(msg) { + for (const fn of this.listeners) fn(msg); + for (const w of [...this.waiters]) w(); + } + + // Resolves with the welcome. A new incarnation turns every request still + // pending under the old one into outcome-unknown; none is resent (H23). + async connect() { + this.#dropPending("disconnected"); + const sock = this.netConnect(this.socketPath); + this.sock = sock; + await new Promise((resolve, reject) => { + sock.once("connect", resolve); + sock.once("error", reject); + }); + this.closed = false; + const welcome = new Promise((resolve, reject) => { + this.welcomeWaiter = { resolve, reject }; + }); + const splitter = new LineSplitter((line) => this.#onLine(line)); + sock.on("data", (c) => splitter.push(c)); + sock.on("error", () => {}); + sock.on("close", () => { + if (this.sock !== sock) return; + this.closed = true; + this.welcomeWaiter?.reject(new Error("the controller closed the connection")); + this.welcomeWaiter = null; + this.#dropPending("disconnected"); + this.#notify({ type: "status", status: "disconnected" }); + }); + sock.write(encodeLine({ type: "hello", grant: this.grant })); + return welcome; + } + + close() { + this.sock?.destroy(); + } + + #dropPending(reason) { + for (const [id, p] of this.pending) { + const entry = { request: id, operation: p.envelope.command.operation, incarnation: p.incarnation, reason, display: OUTCOME_UNKNOWN }; + this.unknown.push(entry); + p.resolve({ outcome: "outcome-unknown", refusal: null, display: OUTCOME_UNKNOWN, reason, request: null, receipt: null }); + this.#notify({ type: "outcome-unknown", ...entry }); + } + this.pending.clear(); + } + + // A receipt last seen short of a final state belongs to the old controller; + // the new one doesn't know it. Its outcome is unknown (H20, H23). + #unsettled() { + for (const r of this.receipts.values()) { + if (FINAL.has(r.state) || this.unknown.some((u) => u.receipt === r.id)) continue; + const entry = { request: null, receipt: r.id, operation: "prompt", incarnation: this.incarnation, reason: STALE, display: OUTCOME_UNKNOWN }; + this.unknown.push(entry); + this.#notify({ type: "outcome-unknown", ...entry }); + } + } + + #onLine(line) { + const parsed = parseLine(line); + if (parsed.error) return; + const msg = parsed.value; + if (msg.type === "welcome") { + if (this.incarnation !== null && this.incarnation !== msg.incarnation) { + this.#dropPending(STALE); + this.#unsettled(); + } + this.incarnation = msg.incarnation; + this.connection = msg.connection; + this.binding = msg.binding; + this.streamEpoch = msg.streamEpoch; + this.welcomeWaiter?.resolve(msg); + this.welcomeWaiter = null; + this.#notify(msg); + return; + } + if (msg.type === "refused" && this.welcomeWaiter) { + this.welcomeWaiter.reject(Object.assign(new Error(`refused: ${msg.refusal}`), { refusal: msg.refusal })); + this.welcomeWaiter = null; + return; + } + if (msg.type === "reply") { + const p = this.pending.get(msg.id); + if (!p) return; + this.pending.delete(msg.id); + const reply = { ...msg }; + if (msg.refusal === STALE) { + reply.display = OUTCOME_UNKNOWN; + this.unknown.push({ request: msg.id, operation: p.envelope.command.operation, incarnation: p.incarnation, reason: STALE, display: OUTCOME_UNKNOWN }); + } + if (msg.receipt) this.receipts.set(msg.receipt.id, msg.receipt); + p.resolve(reply); + this.#notify(reply); + return; + } + if (msg.type === "push") { + this.pushes.push(msg); + if (msg.kind === "binding") this.binding = msg.binding; + else if (msg.kind === "connection" && msg.connection.id === this.connection?.id) this.connection = msg.connection; + else if (msg.kind === "receipt") this.receipts.set(msg.receipt.id, msg.receipt); + else if (msg.kind === "event") this.events.push(msg.event); + this.#notify(msg); + } + } + + // The envelope for one operation on the current target. + envelope(operation, fields = {}) { + if (!this.connection || !this.binding) throw new Error("not connected"); + return { version: 2, kind: "clientRequest", id: newId("req"), connection: this.connection.id, target: this.target, command: { operation, ...fields } }; + } + + // Sends one request and resolves with the controller's reply. + request(operation, fields = {}, text) { + return this.send(this.envelope(operation, fields), text); + } + + // Sends an envelope under the token this client holds now. An envelope is + // sent once; the library has no retry path. + send(envelope, text, { incarnation = this.incarnation } = {}) { + if (this.closed) return Promise.resolve({ outcome: "refused:channel", refusal: "channel", display: "not connected" }); + return new Promise((resolve) => { + this.pending.set(envelope.id, { envelope, incarnation, resolve }); + const msg = { type: "request", incarnation, request: envelope }; + if (text !== undefined) msg.text = text; + this.sock.write(encodeLine(msg)); + }); + } + + prompt(text, draft = { id: newId("draft"), revision: 1 }) { + return this.request("prompt", { draft: draft.id, draftRevision: draft.revision }, text); + } + + observe({ cursor = null, limit = 50 } = {}) { + return this.request("observe", { cursor, limit }); + } + + takeover() { + return this.request("takeover"); + } + + interrupt() { + return this.request("interrupt"); + } + + // Issue, confirm and use a confirmation for a force stop, a recovery or + // recovery control. + async confirmed(operation, fields = {}) { + const issued = await this.request("issue-confirmation", { operationToConfirm: operation }); + if (issued.outcome !== "confirmation-issued") return issued; + const id = issued.data.confirmation.id; + const answered = await this.request("answer-confirmation", { confirmation: id, answer: "confirm" }); + if (answered.outcome !== "confirmation-confirmed") return answered; + return this.request(operation, { ...fields, confirmation: id }); + } + + receipt(id) { + return this.receipts.get(id) ?? null; + } + + // Resolves when `pred()` holds, or false after `ms`. + waitFor(pred, ms = 5000) { + if (pred()) return Promise.resolve(true); + return new Promise((resolve) => { + const w = () => { + if (!pred()) return; + this.waiters.delete(w); + clearTimeout(t); + resolve(true); + }; + const t = setTimeout(() => { + this.waiters.delete(w); + resolve(false); + }, ms); + this.waiters.add(w); + }); + } +} diff --git a/packages/conversation/src/cohort.mjs b/packages/conversation/src/cohort.mjs new file mode 100644 index 00000000..50907aea --- /dev/null +++ b/packages/conversation/src/cohort.mjs @@ -0,0 +1,235 @@ +// Engine launch, cohort observation and force stop (#1507, CHAT-03 §6). +// +// ScopeLauncher starts the supervisor shim (shim.mjs) in a delegated +// systemd user scope named after the claim. The cohort is the scope's +// `engine` cgroup, and the scope's invocation ID is the membership epoch. A +// unit with the right name but another invocation ID is a different cohort: +// it gets no signal, and its evidence is unavailable. +// +// PgroupLauncher is the fallback with no scope: the engine leads its own +// process group. A process group can't be enumerated completely, so a force +// stop on it always ends `uncertain`. +// +// Force stop: check the invocation ID against systemd and the shim; TERM +// every member and wait a bounded grace; freeze `engine` and wait for +// `frozen 1`; enumerate every member with pid and start time; write +// `cgroup.kill`; wait for `populated 0`. Only when all of that succeeded is +// membership complete. Anything unavailable ends the stop `uncertain`. + +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { connect } from "node:net"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { processStart } from "../../discord/src/journal.mjs"; +import { LineSplitter, encodeLine, parseLine } from "./framing.mjs"; +import { hash, newId, record, sealProof } from "./records.mjs"; + +export const AUTHORITY = "mosaic-conversation-shim-fixture"; +export const SHIM_PATH = join(dirname(fileURLToPath(import.meta.url)), "shim.mjs"); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +export function cohortRefOf({ machineId, bootId, unitName, invocationId }) { + return "cohort-" + hash({ machineId, bootId, unitName, invocationId }).slice(0, 40); +} + +// One request per connection keeps a dead shim from wedging a caller. +export function shimRequest(socketPath, op, extra = {}, timeoutMs = 3000) { + return new Promise((resolve) => { + if (!socketPath || !existsSync(socketPath)) return resolve({ ok: false, unavailable: "the shim socket is gone" }); + const sock = connect(socketPath); + let done = false; + const finish = (v) => { + if (done) return; + done = true; + clearTimeout(timer); + sock.destroy(); + resolve(v); + }; + const timer = setTimeout(() => finish({ ok: false, unavailable: `the shim did not answer ${op}` }), timeoutMs); + const splitter = new LineSplitter((line) => { + const p = parseLine(line); + finish(p.error ? { ok: false, unavailable: `shim answer ${p.error}` } : p.value); + }); + sock.on("data", (c) => splitter.push(c)); + sock.on("error", () => finish({ ok: false, unavailable: "the shim is unreachable" })); + sock.on("close", () => finish({ ok: false, unavailable: "the shim closed the connection" })); + sock.on("connect", () => sock.write(encodeLine({ id: 1, op, ...extra }))); + }); +} + +export function systemctlShow(unitName) { + const r = spawnSync("systemctl", ["--user", "show", "-p", "LoadState,ActiveState,InvocationID,ControlGroup", `${unitName}.scope`], { encoding: "utf8", timeout: 5000 }); + if (r.status !== 0) return null; + const out = {}; + for (const line of r.stdout.split("\n")) { + const i = line.indexOf("="); + if (i > 0) out[line.slice(0, i)] = line.slice(i + 1); + } + return { loadState: out.LoadState ?? null, activeState: out.ActiveState ?? null, invocationId: out.InvocationID || null, controlGroup: out.ControlGroup || null }; +} + +// claim.classify's unit lookup. A unit systemd has collected is absent; one +// that is loaded and active is alive; anything unreadable is unknown. +export const systemdUnits = Object.freeze({ + lookup(rec) { + if (!rec?.unitName) return { state: "unknown" }; + const s = systemctlShow(rec.unitName); + if (!s) return { state: "unknown" }; + if (s.loadState === "not-found" || (s.activeState === "inactive" && !s.controlGroup)) return { state: "absent" }; + if (["active", "activating", "deactivating", "reloading"].includes(s.activeState)) return { state: "alive", invocationId: s.invocationId }; + return { state: "unknown", detail: s }; + }, +}); + +export function scopeAvailable() { + const r = spawnSync("systemd-run", ["--user", "--scope", "--quiet", "-p", "Delegate=yes", "--", "true"], { timeout: 10000 }); + return r.status === 0; +} + +export class ScopeLauncher { + constructor({ shimPath = SHIM_PATH, startTimeoutMs = 10000 } = {}) { + this.kind = "scope"; + this.shimPath = shimPath; + this.startTimeoutMs = startTimeoutMs; + } + + async launch({ unitName, socketPath, command, args, cwd, env }) { + const proc = spawn("systemd-run", ["--user", "--scope", "-p", "Delegate=yes", `--unit=${unitName}`, "--quiet", "--", process.execPath, this.shimPath, "--socket", socketPath, "--", command, ...args], { + cwd, env, stdio: ["pipe", "pipe", "pipe"], + }); + const exited = new Promise((r) => proc.on("exit", (code, signal) => r({ code, signal }))); + const end = Date.now() + this.startTimeoutMs; + let hello = null; + while (Date.now() < end) { + const race = await Promise.race([exited.then((e) => ({ exited: e })), sleep(20).then(() => null)]); + if (race?.exited) throw Object.assign(new Error(`the scope exited before the shim answered (${JSON.stringify(race.exited)})`), { code: "launch-failed" }); + if (existsSync(socketPath)) { + hello = await shimRequest(socketPath, "hello"); + if (hello.ok) break; + } + } + if (!hello?.ok) throw Object.assign(new Error("the shim never answered"), { code: "launch-failed", proc }); + const show = systemctlShow(unitName); + return { + kind: "scope", proc, stdin: proc.stdin, stdout: proc.stdout, stderr: proc.stderr, + pid: hello.enginePid, start: hello.engineStart === null ? null : String(hello.engineStart), + invocationId: hello.invocationId, scope: hello.scope, shimSocket: socketPath, + systemd: show, exited, + }; + } +} + +export class PgroupLauncher { + constructor() { + this.kind = "pgroup"; + } + + async launch({ command, args, cwd, env }) { + const proc = spawn(command, args, { cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true }); + const exited = new Promise((r) => proc.on("exit", (code, signal) => r({ code, signal }))); + await new Promise((resolve, reject) => { + proc.once("spawn", resolve); + proc.once("error", reject); + }); + return { kind: "pgroup", proc, stdin: proc.stdin, stdout: proc.stdout, stderr: proc.stderr, pid: proc.pid, start: processStart(proc.pid), invocationId: null, scope: null, shimSocket: null, exited }; + } +} + +// Runs the force-stop escalation from the TERM phase. `onPhase(name)` is +// awaited before each phase begins, so the caller records the phase before +// any signal. Returns { outcome: "proven" | "unavailable", ... }. Nothing is +// recorded as done unless it was observed. +export async function forceStopCohort({ kind, unitName, invocationId, shimSocket, pid, graceMs = 1000, onPhase = async () => {} }) { + if (kind === "pgroup") { + await onPhase("term"); + try { + process.kill(-pid, "SIGTERM"); + } catch { + // the group is gone + } + await sleep(graceMs); + await onPhase("kill"); + try { + process.kill(-pid, "SIGKILL"); + } catch { + // the group is gone + } + return { outcome: "unavailable", reason: "process-group fallback: membership can't be enumerated completely" }; + } + const show = systemctlShow(unitName); + if (!show || !invocationId || show.invocationId !== invocationId) { + return { outcome: "unavailable", reason: `invocation ID mismatch or unreadable (recorded ${invocationId}, found ${show?.invocationId ?? "none"}); no signal sent` }; + } + const hello = await shimRequest(shimSocket, "hello"); + if (!hello.ok) return { outcome: "unavailable", reason: hello.unavailable }; + if (hello.invocationId !== invocationId || hello.scope !== show.controlGroup) { + return { outcome: "unavailable", reason: "the shim does not answer for the recorded scope; no signal sent" }; + } + await onPhase("term"); + const term = await shimRequest(shimSocket, "term"); + if (!term.ok) return { outcome: "unavailable", reason: term.unavailable, phase: "term" }; + const end = Date.now() + graceMs; + for (;;) { + const e = await shimRequest(shimSocket, "events"); + if (!e.ok) return { outcome: "unavailable", reason: e.unavailable, phase: "term" }; + if (e.populated === 0 || Date.now() >= end) break; + await sleep(20); + } + await onPhase("kill"); + const frozen = await shimRequest(shimSocket, "freeze", { timeoutMs: 3000 }, 6000); + if (!frozen.ok) return { outcome: "unavailable", reason: frozen.unavailable, phase: "kill" }; + if (frozen.timedOut) return { outcome: "unavailable", reason: "engine never reported frozen 1", phase: "kill" }; + const listed = await shimRequest(shimSocket, "members"); + if (!listed.ok) return { outcome: "unavailable", reason: listed.unavailable, phase: "kill" }; + const killed = await shimRequest(shimSocket, "kill", { timeoutMs: 5000 }, 8000); + if (!killed.ok) return { outcome: "unavailable", reason: killed.unavailable, phase: "kill" }; + if (killed.timedOut || killed.populated !== 0) return { outcome: "unavailable", reason: "engine never reported populated 0", phase: "kill" }; + const observedAt = new Date().toISOString(); + if (listed.members.some((m) => !Number.isInteger(m.startTicks) || m.startTicks < 1)) { + return { outcome: "unavailable", reason: "a member's start time was unreadable at enumeration", phase: "kill" }; + } + return { + outcome: "proven", membershipComplete: true, epoch: invocationId, observedAt, boot: listed.boot, + members: listed.members.map((m) => ({ pid: m.pid, boot: listed.boot, startTicks: m.startTicks, terminatedAt: observedAt })), + }; +} + +export function cohortProof({ binding, stop, result }) { + return sealProof(record("cohortProof", { + id: newId("cohort-proof"), authority: AUTHORITY, conversation: binding.scope.conversation, execution: binding.execution, + cohortRef: binding.cohortRef, membershipEpoch: result.epoch, membershipComplete: result.membershipComplete === true, + members: result.members, observedAt: result.observedAt, verificationDigest: "", stop, + })); +} + +// A tool-start with no tool-end at stop time is an `uncertain` effect. +// Killing never counts as rollback. +export function effectReport({ binding, stop, tools, observedAt }) { + return sealProof(record("effectReport", { + id: newId("effects"), authority: AUTHORITY, conversation: binding.scope.conversation, execution: binding.execution, + cohortRef: binding.cohortRef, + invocations: [...tools.values()].map((t) => ({ id: t.call, disposition: t.end ? "completed" : "uncertain", evidence: t.end ?? t.start })), + observedAt, verificationDigest: "", stop, + })); +} + +// The current boot's start time, from /proc/stat btime. Every process of an +// earlier boot ended before it. +export function bootTime() { + const line = readFileSync("/proc/stat", "utf8").split("\n").find((l) => l.startsWith("btime ")); + return new Date(Number(line.slice(6)) * 1000).toISOString(); +} + +// A boot proof for a claim recorded under an earlier boot of this host. Its +// evidence is the boot change; it goes through the same verifier. +export function bootProof({ claim, conversation, execution, cohortRef, stop, now = () => new Date() }) { + const terminatedAt = bootTime(); + const members = claim.engine?.pid && claim.engine?.start ? [{ pid: claim.engine.pid, boot: claim.host.bootId, startTicks: Number(claim.engine.start), terminatedAt }] : []; + return sealProof(record("cohortProof", { + id: newId("boot-proof"), authority: AUTHORITY, conversation, execution, cohortRef, + membershipEpoch: claim.invocationId ?? `boot-${claim.host.bootId}`, membershipComplete: true, members, + observedAt: now().toISOString(), verificationDigest: "", stop, + })); +} diff --git a/packages/conversation/src/controller.mjs b/packages/conversation/src/controller.mjs new file mode 100644 index 00000000..3ce8b0cf --- /dev/null +++ b/packages/conversation/src/controller.mjs @@ -0,0 +1,1655 @@ +// The CHAT-03 I1 controller (#1507, CHAT-03 §§1–3, §6). +// +// One controller process per execution owns the engine's stdin. It serves a +// Unix socket in a 0700 directory; every connection starts as an observer. +// Commands are CHAT-01 v2 client requests and are evaluated in check.mjs's +// order, with CHAT-03's narrowings: no broker queue (`busy`), one pending +// dispatch slot, the incarnation token (`stale-incarnation`), and Interrupt's +// `no-turn`. The controller never writes a session file and never opens one +// through Pi. +// +// Fixture-only in code: the claim root, socket directory and session file +// are constructor arguments checked by the live-session guard at +// construction and again at bind (§2). +// +// Proofs go to the injected verifier, the fixture's trusted digest registry +// as in CHAT-01. With no verifier nothing verifies, and every stop ends +// `uncertain`. + +import { lstatSync, mkdirSync, readFileSync, unlinkSync } from "node:fs"; +import { createServer } from "node:net"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { randomBytes } from "node:crypto"; +import { processStart } from "../../discord/src/journal.mjs"; +import { ALREADY_ACTIVE, ClaimStore, FOREIGN_HOST, UNSAFE_REPLACEMENT, machineId, seatKey, sessionKey } from "./claim.mjs"; +import { AUTHORITY, PgroupLauncher, bootProof, cohortProof, cohortRefOf, effectReport, forceStopCohort, systemdUnits } from "./cohort.mjs"; +import { EngineLink } from "./engine.mjs"; +import { DIALOG_METHODS, KNOWN_UNSHOWN, NOTIFY_METHODS, deltaBlocks, isFoldedUpdate, messageBlocks, partsOf, roleOf, toolResultText } from "./events.mjs"; +import { LineSplitter, encodeLine, parseLine } from "./framing.mjs"; +import { LiveSessionGuard, realPath } from "./guard.mjs"; +import { ID, fragments, safeId } from "./parts.mjs"; +import { parseSnapshot } from "./pi.mjs"; +import { ENGINE_PIN_MISMATCH, PI_BIN, UNSEALED_ENGINE, argvDigest, buildPiArgs, checkEnginePin, checkSeal } from "./pi-pin.mjs"; +import { ACTOR, conversationId, createReader, rootsFromSpecs } from "./reader.mjs"; +import { clone, equal, hash, newId, receiptAllows, record, scopeMatch, sealProof, sha256, targetOf } from "./records.mjs"; +import { ControlRefusal, Refusal } from "./safe-fs.mjs"; +import { TEXT_POLICY, textPolicy } from "./text-policy.mjs"; +import { DONE_STOPS, Tracker } from "./turns.mjs"; + +export const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + +// CHAT-03 refusal names (§"New names"), plus the two Sage approved for this +// build (malformed, eligibility). +export const BUSY = "busy"; +export const STALE_INCARNATION = "stale-incarnation"; +export const TRANSPORT_UNKNOWN = "transport-unknown"; +export const HANDLED_WITHOUT_RUN = "handled-without-run"; +export const ACK_WITHOUT_START = "ack-without-start"; +export const INTERRUPTED = "interrupted"; +export const NO_TURN = "no-turn"; +export const RUN_OVERLAP = "run-overlap"; +export const MALFORMED = "malformed"; +export const ELIGIBILITY = "eligibility"; + +// check.mjs `caps`. +export const CAPS = Object.freeze({ + observe: "observe", prompt: "send", takeover: "take-control", "edit-queued": "send", "cancel-queued": "send", approval: "approve", + interrupt: "interrupt", "force-stop": "force-stop", recover: "recover", "acquire-recovery-control": "recover-control", + "list-drafts": "draft", "create-draft": "draft", "update-draft": "draft", "discard-draft": "draft", + "begin-upload": "upload", "append-upload": "upload", "complete-upload": "upload", "discard-upload": "upload", + "issue-confirmation": "confirm", "answer-confirmation": "confirm", +}); +export const ALL_CAPABILITIES = Object.freeze(["observe", "send", "take-control", "approve", "interrupt", "force-stop", "recover", "recover-control", "draft", "upload", "confirm"]); + +// Operations I1 implements. Drafts, uploads, queue edits and approvals are +// CHAT-04 or I4 and refuse `unsupported-capability`. +export const VERIFIED_OPERATIONS = Object.freeze(["observe", "prompt", "takeover", "acquire-recovery-control", "interrupt", "force-stop", "recover", "issue-confirmation", "answer-confirmation"]); + +export const TIMEOUTS = Object.freeze({ ack: 5000, state: 5000, start: 5000, clear: 5000, abort: 10000, settle: 10000, grace: 1000, write: 5000, maxRounds: 3 }); + +const FINAL = new Set(["finished", "failed", "dispatch-refused", "delivery-unknown"]); +const STOP_IN_PROGRESS = new Set(["fenced", "cancelling", "stopping"]); +const STOP_NEXT = { fenced: ["cancelling", "stopping", "uncertain"], cancelling: ["stopping", "uncertain"], stopping: ["uncertain"], uncertain: [] }; +const SEAL_BASIS = "seal: --no-extensions and no --extension argument (pi-pin.mjs SEAL_FLAGS)"; +const DIALOG_REASON = "Pi dialogs are not answered in CHAT-03 (lead decision 30); shown disabled"; + +const refused = (code, extra = {}) => ({ outcome: `refused:${code}`, refusal: code, ...extra }); +const isGen = (v) => Number.isInteger(v) && v >= 1 && v <= Number.MAX_SAFE_INTEGER; +const isId = (v) => typeof v === "string" && ID.test(v); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// The client request envelope, per operation (CHAT-01 schema `command`). +// Unknown operations pass the shape check and are refused by evaluate. +const SHAPES = { + observe: { cursor: (v) => v === null || isId(v), limit: (v) => Number.isInteger(v) && v >= 1 && v <= 100 }, + prompt: { draft: isId, draftRevision: isGen }, + takeover: {}, + interrupt: {}, + "force-stop": { confirmation: isId }, + recover: { stop: isId, confirmation: isId }, + "acquire-recovery-control": { confirmation: isId }, + "issue-confirmation": { operationToConfirm: (v) => ["force-stop", "recover", "acquire-recovery-control"].includes(v) }, + "answer-confirmation": { confirmation: isId, answer: (v) => v === "confirm" || v === "cancel" }, +}; + +export function malformedRequest(r) { + if (!r || typeof r !== "object" || Array.isArray(r)) return "the request is not an object"; + const keys = Object.keys(r).sort().join(","); + if (keys !== "command,connection,id,kind,target,version") return "the request has missing or extra fields"; + if (r.version !== 2 || r.kind !== "clientRequest" || !isId(r.id) || !isId(r.connection)) return "bad version, kind, id or connection"; + const t = r.target; + if (!t || typeof t !== "object" || Object.keys(t).sort().join(",") !== "branch,controllerGeneration,conversation,execution") return "bad target"; + if (!isId(t.conversation) || !isId(t.branch) || !isId(t.execution) || !isGen(t.controllerGeneration)) return "bad target"; + const cmd = r.command; + if (!cmd || typeof cmd !== "object" || Array.isArray(cmd) || typeof cmd.operation !== "string" || !CAPS[cmd.operation]) return "bad command"; + const shape = SHAPES[cmd.operation]; + if (!shape) return null; + const fields = Object.keys(cmd).filter((k) => k !== "operation"); + if (fields.length !== Object.keys(shape).length || fields.some((k) => !shape[k] || !shape[k](cmd[k]))) return `bad ${cmd.operation} fields`; + return null; +} + +class Lock { + #tail = Promise.resolve(); + run(fn) { + const p = this.#tail.then(() => fn()); + this.#tail = p.then(() => {}, () => {}); + return p; + } +} + +// Tool calls in the session file, for effects when no stream was observed +// (an orphan, a boot proof). A call with no result entry is open. +export function sessionTools(parsed) { + const tools = new Map(); + for (const { entry } of parsed.entries) { + if (entry.type !== "message") continue; + const m = entry.message; + if (m?.role === "assistant" && Array.isArray(m.content)) { + for (const c of m.content) if (c?.type === "toolCall" && typeof c.id === "string") tools.set(c.id, { call: safeId(c.id), start: safeId(`s.${entry.id}`), end: null }); + } + if (m?.role === "toolResult" && tools.has(m.toolCallId)) tools.get(m.toolCallId).end = safeId(`s.${entry.id}`); + } + return tools; +} + +export class Controller { + constructor(opts = {}) { + const { + fixtureRoot, claimRoot, socketDir, sessionFile, seat, workspace = "default", + engine = {}, launcher = new PgroupLauncher(), verifier = null, host, identity, barrier = null, units = systemdUnits, + grants = null, guardOptions = {}, timeouts = {}, now = () => new Date(), + policyRevision = "policy-fixture-1", sourceRootRef = "source-fixture-1", approvedMappings = null, pinRoot = REPO_ROOT, auditWritable = true, + hostId = null, + } = opts; + for (const [k, v] of Object.entries({ claimRoot, socketDir, sessionFile, seat })) { + if (typeof v !== "string" || !v) throw new ControlRefusal("configuration", `${k} is required; CHAT-03 has no default`); + } + this.guard = new LiveSessionGuard({ fixtureRoot, ...guardOptions }); + this.paths = { claimRoot: resolve(claimRoot), socketDir: resolve(socketDir), sessionFile: resolve(sessionFile) }; + this.guard.check(this.paths, "construction"); + const dir = dirname(this.paths.sessionFile); + const projectRoot = resolve(dir, "..", "..", "..", ".."); + const project = basename(projectRoot); + const roots = rootsFromSpecs([{ sessionsDir: dir, agent: seat, project }]); + if (roots.length !== 1) throw new ControlRefusal("configuration", "the session file must be /.pi/state//sessions/.jsonl"); + this.root = roots[0]; + this.reader = createReader({ roots, now: () => now().getTime() }); + this.seat = seat; + this.project = project; + this.workspace = workspace; + this.conversation = conversationId(this.root, basename(this.paths.sessionFile)); + this.engine = { + command: engine.command ?? process.execPath, + preArgs: engine.preArgs ?? [join(pinRoot, PI_BIN)], + extraArgs: engine.extraArgs ?? [], + cwd: engine.cwd ?? projectRoot, + env: engine.env ?? process.env, + }; + for (const k of ["preArgs", "extraArgs"]) { + if (!Array.isArray(this.engine[k])) throw new ControlRefusal(UNSEALED_ENGINE, `engine.${k} is not a list`); + } + this.piArgs = buildPiArgs({ sessionFile: this.paths.sessionFile, extraArgs: this.engine.extraArgs }); + this.#checkSeal(); + this.launcher = launcher; + this.verifier = verifier; + this.units = units; + this.barrier = barrier; + this.now = now; + this.T = { ...TIMEOUTS, ...timeouts }; + this.pinRoot = pinRoot; + this.policyRevision = policyRevision; + this.sourceRootRef = sourceRootRef; + this.approvedMappings = approvedMappings ?? [sourceRootRef]; + this.auditWritable = auditWritable; + this.hostId = hostId ?? `host-${machineId() ?? "unknown"}`; + this.grantSpecs = grants; + this.store = new ClaimStore({ root: this.paths.claimRoot, host, identity, barrier, now }); + this.seatK = seatKey({ seat, project, workspace }); + // The session key is the Pi header ID (BRIEF §2 D1), not the + // conversation ID: two paths to one session file, a hard link or a copy, + // share it. The conversation itself needs no key of its own: it names one + // file under one seat, which the seat key already covers. + this.nativeSession = this.#readSession().nativeSession; + this.sessionK = sessionKey({ harness: "pi", nativeSession: this.nativeSession }); + + this.incarnation = randomBytes(16).toString("hex"); + this.lock = new Lock(); + this.b = null; + this.exec = null; + this.claim = null; + this.claimChain = Promise.resolve(); + this.closers = new Set(["starting"]); + this.stops = new Map(); + this.receipts = new Map(); + this.requests = new Map(); + this.connections = new Map(); + this.channels = new Set(); + this.confirmations = new Map(); + this.grants = new Map(); + this.dedup = new Map(); + this.eligibility = new Map(); + this.outcomeUnknown = new Set(); + this.escalating = null; + this.abortWritten = new Set(); + this.preflightOk = false; + this.server = null; + this.sockets = new Set(); + this.events = []; + this.evidence = { dropped: { lines: 0, bytes: 0 }, unknownEvents: {}, unshown: {}, folded: 0, dialogs: [], notices: 0, gaps: [], overlaps: [], uncertain: [], stops: [], refusedRevisions: [], internal: [], stderrTail: "" }; + } + + get binding() { + return this.b; + } + + get socketPath() { + return join(this.paths.socketDir, "controller.sock"); + } + + async #pause(name, detail = {}) { + if (this.barrier) await this.barrier(name, detail); + } + + #checkSeal() { + const bad = this.engine.preArgs.find((a) => typeof a !== "string" || a === "-e" || a === "--extension" || a.startsWith("--extension=")); + if (bad !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `engine pre-arguments carry ${bad}`); + checkSeal(this.piArgs); + } + + #pins() { + const pin = checkEnginePin(this.pinRoot); + return { engineVersion: pin.version, enginePin: pin.pin, argvDigest: argvDigest(this.engine.command, [...this.engine.preArgs, ...this.piArgs]) }; + } + + // Every read after construction refuses a header ID other than the one the + // claim is keyed on. + #readSession() { + let text; + try { + text = readFileSync(this.paths.sessionFile, "utf8"); + } catch (err) { + throw new ControlRefusal("configuration", `the session file is unreadable (${err.code ?? "error"})`); + } + const parsed = parseSnapshot(text); + if (!isId(parsed.header.id)) throw new ControlRefusal("configuration", "the session header id is not a CHAT-01 id"); + if (this.nativeSession !== undefined && parsed.header.id !== this.nativeSession) throw new ControlRefusal("target", "the session header id changed since construction"); + return { nativeSession: parsed.header.id, branch: parsed.defaultBranch, leaf: parsed.defaultLeaf?.entry.id ?? null, snapshotDigest: sha256(text), parsed }; + } + + #guardCheck(when) { + this.guard.check(this.paths, when); + } + + #socketDir() { + const dir = this.paths.socketDir; + mkdirSync(dir, { recursive: true, mode: 0o700 }); + const st = lstatSync(dir); + if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077) !== 0) { + throw new ControlRefusal("channel", "the socket directory must be a real 0700 directory owned by this user"); + } + let sock = null; + try { + sock = lstatSync(this.socketPath); + } catch { + sock = null; + } + if (sock) { + if (!sock.isSocket()) throw new ControlRefusal("channel", "controller.sock exists and is not a socket"); + unlinkSync(this.socketPath); + } + } + + #grantRecords(scope) { + const specs = this.grantSpecs ?? [{ id: "grant-local", revision: "rev-1", actor: ACTOR, capabilities: ALL_CAPABILITIES }]; + for (const g of specs) { + this.grants.set(g.id, record("grant", { + id: g.id, revision: g.revision ?? "rev-1", actor: g.actor ?? ACTOR, scope: clone(g.scope ?? scope), + capabilities: [...(g.capabilities ?? ALL_CAPABILITIES)], state: g.state ?? "active", + expiresAt: g.expiresAt ?? new Date(this.now().getTime() + 24 * 3600 * 1000).toISOString(), + })); + } + } + + #newBinding({ execution, generation, session, cohortRef, state, pins }) { + const scope = { host: this.hostId, seat: this.seat, project: this.project, workspace: this.workspace, conversation: this.conversation }; + if (this.grants.size === 0) this.#grantRecords(scope); + return record("binding", { + id: newId("binding"), scope, harness: "pi", nativeSession: session.nativeSession, branch: session.branch, execution, + engineDigest: hash({ engineVersion: pins.engineVersion, enginePin: pins.enginePin }), configDigest: hash(pins), + policyRevision: this.policyRevision, sourceRootRef: this.sourceRootRef, snapshotDigest: session.snapshotDigest, + cohortRef, controllerGeneration: generation, controllerConnection: null, state, admission: "closed", + createdAt: this.now().toISOString(), stop: null, + }); + } + + // ---- start ------------------------------------------------------------- + + // Bind: guard, seal and pins, then classify the claim pair. Returns + // { launched, classified }. + async start() { + this.#guardCheck("bind"); + this.#checkSeal(); + const pins = this.#pins(); + const session = this.#readSession(); + const classified = await this.store.classify(this.seatK, this.sessionK, { + units: this.units, + adopt: this.store.owner(this.incarnation), + stoppedPatch: { leafAtProof: session.leaf, branchAtProof: session.branch }, + bootProof: (latest) => this.#bootProof(latest, session), + }); + if (classified.refusal === FOREIGN_HOST || classified.damaged) throw new ControlRefusal(classified.refusal, `the claim pair is held (${classified.refusal})`); + if (classified.refusal === ALREADY_ACTIVE || classified.state === "owned") throw new ControlRefusal(ALREADY_ACTIVE, "the writer claim for this seat or session is held by a live controller"); + if (classified.state === "stopped") return { launched: false, classified: { state: "stopped", proofKind: classified.proofKind } }; + if (classified.state === "uncertain" || classified.state === "stopping") { + if (!classified.claim) throw new ControlRefusal(classified.refusal ?? UNSAFE_REPLACEMENT, `the claim pair is uncertain (${classified.needs ?? "held"})`); + await this.#serveOrphan(classified, session, pins); + return { launched: false, classified: { state: classified.state, unit: classified.unit?.state ?? null } }; + } + // Free. A resume of this session checks the last proven stop. + const prior = this.store.head(this.sessionK); + let generation = 1; + let priorRef = null; + if (prior.n > 0 && !prior.damaged && prior.record.state === "stopped") { + const p = prior.record; + if (!equal(p.pins, pins)) throw new ControlRefusal(ENGINE_PIN_MISMATCH, "the engine pin or launch argv changed since the proven stop"); + const leaf = p.leafAtProof !== undefined ? p.leafAtProof : p.leaf; + const branch = p.branchAtProof ?? p.branch; + if (leaf !== session.leaf || branch !== session.branch) throw new ControlRefusal("target", "the session branch or leaf changed since the proven stop"); + generation = p.generation + 1; + priorRef = { claimId: p.claimId, stop: p.stop?.id ?? null }; + } + const execution = newId("exec"); + this.b = this.#newBinding({ execution, generation, session, cohortRef: `pending-${execution}`, state: "reserved", pins }); + const claim = await this.store.acquire(this.seatK, this.sessionK, { + bindingId: this.b.id, harness: "pi", conversation: this.conversation, branch: session.branch, leaf: session.leaf, pins, + owner: this.store.owner(this.incarnation), generation, prior: priorRef, execution, cohortRef: null, + }); + // The socket directory is prepared only once this controller holds the + // pair: a refused contender never touches a live controller's socket. + this.#socketDir(); + await this.#listen(); + await this.#launchInto(claim, session); + return { launched: true, classified: { state: "free" } }; + } + + async #bootProof(latest, session) { + if (!this.verifier) return null; + const binding = { scope: { conversation: latest.conversation }, execution: latest.execution ?? `boot-${latest.claimId}`, cohortRef: latest.cohortRef ?? `cohort-boot-${latest.claimId}` }; + const stop = latest.stop?.id ?? null; + const proof = bootProof({ claim: latest, conversation: binding.scope.conversation, execution: binding.execution, cohortRef: binding.cohortRef, stop, now: this.now }); + const effects = effectReport({ binding, stop, tools: sessionTools(session.parsed), observedAt: proof.observedAt }); + this.verifier.post(proof); + this.verifier.post(effects); + const ok = this.verifier.cohort(proof, effects, { binding, stop, now: this.now(), epoch: proof.membershipEpoch }); + return ok ? { proof, effects } : null; + } + + // An orphan or an interrupted force stop: no engine pipe, the binding is + // `uncertain` (or `stopping`), and only a confirmed force stop moves it on. + async #serveOrphan(classified, session, pins) { + const rec = classified.claim.record; + this.claim = classified.claim; + const execution = rec.execution ?? newId("exec"); + this.b = this.#newBinding({ execution, generation: rec.generation, session, cohortRef: rec.cohortRef ?? `pending-${execution}`, state: rec.state === "stopping" ? "stopping" : "uncertain", pins }); + this.b.id = rec.bindingId ?? this.b.id; + this.closers.add("uncertain"); + this.closers.delete("starting"); + if (rec.stop?.record) { + this.stops.set(rec.stop.record.id, clone(rec.stop.record)); + this.b.stop = rec.stop.record.id; + if (rec.stop.record.mode === "force-stop") this.closers.add("force-stop"); + } + this.orphanTools = sessionTools(session.parsed); + this.#admission(); + this.#socketDir(); + await this.#listen(); + this.#evidenceAdd("uncertain", { reason: "orphan", unit: classified.unit?.state ?? null, adopted: classified.adopted }); + if (classified.resumeStop) { + const stop = this.stops.get(classified.resumeStop.id) ?? null; + if (stop) void this.#forceStop(stop, { confirmation: classified.resumeStop.confirmation ?? null, resumed: true }).catch((e) => this.#internal("force-stop", e)); + } + } + + // ---- launch ------------------------------------------------------------ + + #newExec(execution) { + const exec = { + execution, link: null, proc: null, tools: new Map(), msgSerial: 0, toolSerial: 0, message: null, seq: 0, + streamEpoch: `${execution}.${this.incarnation.slice(0, 12)}`, responseIdx: new Map(), clears: new Map(), + waiters: new Set(), dialogs: [], closing: false, promptSlots: new Map(), + }; + exec.tracker = new Tracker({ + onOverlap: (signal, entry) => this.#onOverlap(exec, signal, entry), + onWorking: (slot) => this.#revise(slot.receipt, "working", null), + onSlotSettled: (slot, result) => this.#onSlotSettled(exec, slot, result), + // Lead decision 34: only the controller's abort produces `aborted`, so + // it links to the stop in progress only once an abort was written in + // that stop's chain (the stop or one it superseded). + stopLink: () => { + const s = this.stops.get(this.b?.stop); + if (!s || !STOP_IN_PROGRESS.has(s.state)) return null; + for (let p = s; p; p = this.stops.get(p.supersedes)) if (this.abortWritten.has(p.id)) return s.id; + return null; + }, + }); + return exec; + } + + async #launchInto(claim, session) { + this.claim = claim; + const b = this.b; + await this.#pause("reserved", { claimId: claim.claimId }); + await this.#claimAdvance({ spawnMarker: true }); + await this.#pause("spawn-marker", { claimId: claim.claimId }); + const exec = this.#newExec(b.execution); + this.exec = exec; + let proc; + try { + proc = await this.launcher.launch({ + unitName: claim.record.unitName, socketPath: join(this.paths.socketDir, `shim-${claim.claimId.slice(0, 12)}.sock`), + command: this.engine.command, args: [...this.engine.preArgs, ...this.piArgs], cwd: this.engine.cwd, env: this.engine.env, + }); + } catch (err) { + this.#uncertain("launch-failed", { error: String(err.code ?? err.message).slice(0, 200) }); + return; + } + exec.proc = proc; + const cohortRef = cohortRefOf({ machineId: claim.record.host.machineId, bootId: claim.record.host.bootId, unitName: claim.record.unitName, invocationId: proc.invocationId ?? `pgroup-${proc.pid}-${proc.start}` }); + b.cohortRef = cohortRef; + proc.stderr?.on("data", (c) => { + this.evidence.stderrTail = (this.evidence.stderrTail + c.toString("utf8")).slice(-65536); + }); + exec.link = new EngineLink({ + execution: b.execution, stdin: proc.stdin, stdout: proc.stdout, writeTimeoutMs: this.T.write, + onLine: (link, value, bytes) => this.#onLine(exec, link, value, bytes), + onResponse: (link, value, entry) => this.#onResponse(exec, link, value, entry), + onGap: (link, kind, detail) => this.#onGap(exec, link, kind, detail), + onEnd: (link) => this.#onEnd(exec, link), + }); + await this.#claimAdvance({ engine: { kind: proc.kind, pid: proc.pid, start: proc.start }, invocationId: proc.invocationId, shim: proc.shimSocket, execution: b.execution, cohortRef }); + await this.#pause("spawned", { claimId: claim.claimId, pid: proc.pid }); + this.#push({ kind: "binding", binding: b }); + // K8: the engine must have loaded this session file at this leaf. + const why = await this.#checkLoaded(exec, session); + if (this.exec !== exec) return; + if (why) { + this.#evidenceAdd("uncertain", { reason: "loaded-session", detail: why }); + this.#uncertain("target", { detail: why }); + return; + } + if (exec.link.poisoned || exec.tracker.overlapped || this.closers.has("uncertain")) return; + try { + await this.#claimAdvance({ state: "active" }); + } catch (err) { + this.#uncertain("claim", { error: String(err.code ?? err.message) }); + return; + } + this.preflightOk = true; + b.state = "active"; + this.closers.delete("starting"); + this.#admission(); + await this.#pause("active", { claimId: claim.claimId }); + } + + async #checkLoaded(exec, session) { + const st = await this.#ask(exec, "get_state", {}, this.T.state); + if (!st.response) return `get_state: ${st.why}`; + const d = st.response.data ?? {}; + if (!st.response.success || typeof d.sessionFile !== "string" || realPath(d.sessionFile) !== realPath(this.paths.sessionFile)) return "the engine loaded another session file"; + if (d.sessionId !== session.nativeSession) return "the engine reports another session id"; + const tree = await this.#ask(exec, "get_tree", {}, this.T.state); + if (!tree.response) return `get_tree: ${tree.why}`; + if (!tree.response.success || (tree.response.data?.leafId ?? null) !== session.leaf) return "the engine loaded another leaf"; + return null; + } + + // One request with a bounded response. Transport failures poison the link. + async #ask(exec, type, fields, timeoutMs, beforeWrite = null) { + if (!exec.link || exec.link.poisoned) return { why: "poisoned" }; + const req = exec.link.request(type, fields, { timeoutMs, beforeWrite }); + const res = await req.response; + if (res.unsent) { + this.#transport(exec, `${type}-write-${res.unsent.reason ?? res.unsent.outcome}`); + return { why: "unsent", id: req.id }; + } + if (res.timeout) { + this.#transport(exec, `${type}-timeout`); + return { why: "timeout", id: req.id }; + } + return { response: res.response, idx: exec.responseIdx.get(req.id), id: req.id }; + } + + // ---- socket ------------------------------------------------------------ + + async #listen() { + if (this.server) return; + this.server = createServer((sock) => this.#accept(sock)); + await new Promise((res, rej) => { + this.server.once("error", rej); + this.server.listen(this.socketPath, () => { + this.server.off("error", rej); + res(); + }); + }); + } + + #accept(sock) { + this.sockets.add(sock); + const state = { conn: null }; + const send = (v) => { + if (!sock.destroyed) sock.write(encodeLine(v)); + }; + state.send = send; + const splitter = new LineSplitter((line) => this.#onClientLine(state, line), { maxBytes: 4 * 1024 * 1024, onOverflow: () => sock.destroy() }); + sock.on("data", (c) => splitter.push(c)); + sock.on("error", () => {}); + sock.on("close", () => { + this.sockets.delete(sock); + const c = state.conn ? this.connections.get(state.conn) : null; + if (c && c.rec.state === "connected") { + // Disconnect never transfers control and never changes a claim (H11). + c.rec.state = "disconnected"; + c.send = null; + this.#push({ kind: "connection", connection: c.rec }); + } + }); + } + + #onClientLine(state, line) { + const parsed = parseLine(line); + if (parsed.error) return state.send({ type: "refused", refusal: MALFORMED, detail: parsed.error }); + const msg = parsed.value; + if (!state.conn) { + if (msg?.type !== "hello") return state.send({ type: "refused", refusal: MALFORMED, detail: "hello first" }); + const g = typeof msg.grant === "string" ? this.grants.get(msg.grant) : undefined; + if (!g || !this.b) return state.send({ type: "refused", refusal: "grant" }); + const rec = record("connection", { + id: newId("conn"), actor: g.actor, grant: g.id, grantRevision: g.revision, conversation: this.b.scope.conversation, + mode: "observer", transport: "local-terminal", state: "connected", authenticatedChannelRef: newId("channel"), + createdAt: this.now().toISOString(), generation: 1, + }); + this.channels.add(rec.authenticatedChannelRef); + this.connections.set(rec.id, { rec, send: state.send }); + state.conn = rec.id; + return state.send({ type: "welcome", incarnation: this.incarnation, connection: rec, binding: this.b, target: targetOf(this.b), streamEpoch: this.exec?.streamEpoch ?? null }); + } + if (msg?.type !== "request") return state.send({ type: "refused", refusal: MALFORMED, detail: "unknown message type" }); + const id = isId(msg.request?.id) ? msg.request.id : null; + const reply = (res) => state.send({ type: "reply", id, ...res }); + if (msg.incarnation !== this.incarnation) return reply(refused(STALE_INCARNATION)); + const bad = malformedRequest(msg.request); + if (bad) return reply(refused(MALFORMED, { detail: bad })); + if (msg.request.connection !== state.conn) return reply(refused("channel")); + if (msg.text !== undefined && typeof msg.text !== "string") return reply(refused(MALFORMED, { detail: "text must be a string" })); + void this.#handle(state, msg.request, msg.text, reply); + return undefined; + } + + async #handle(state, r, text, reply) { + const c = this.connections.get(state.conn)?.rec ?? null; + let res; + try { + if (r.command.operation === "interrupt") { + // Interrupt sets its fence before it takes the dispatch lock (§3). + const pre = this.#evaluate(c, r, text); + if (!pre.fence) res = pre; + else { + await this.#pause("interrupt-fenced", { request: r.id }); + res = await this.lock.run(async () => { + const out = await this.#interruptLocked(c, r, pre); + if (!out.outcome.startsWith("refused:")) this.dedup.set(pre.dedupKey, { actor: c.actor, digest: pre.digest, outcome: out.outcome, receipt: null, request: null }); + return out; + }); + } + } else { + res = await this.lock.run(() => this.#evaluate(c, r, text)); + } + } catch (err) { + if (err instanceof Refusal) res = refused(err.code, { detail: err.message }); + else { + this.#internal("evaluate", err); + res = { outcome: "error", detail: "internal error; recorded in controller evidence" }; + } + } + const { after, fence, closer, fenceIdx, dedupKey, digest, ...wire } = res; + reply(wire); + if (after) void after().catch((e) => this.#internal("after", e)); + } + + // ---- evaluate (check.mjs order) ---------------------------------------- + + #grantOk(c) { + const g = this.grants.get(c.grant); + if (!g || g.actor !== c.actor || g.revision !== c.grantRevision || g.state !== "active" || Date.parse(g.expiresAt) <= this.now().getTime()) return null; + return g; + } + + #evaluate(c, r, text) { + const b = this.b, t = r.target, cmd = r.command, op = cmd.operation; + if (!c || c.state !== "connected" || !this.channels.has(c.authenticatedChannelRef)) return refused("channel"); + if (c.transport === "private-host") return refused("unsupported-capability"); + const g = this.#grantOk(c); + if (!g) return refused("grant"); + if (!equal(g.scope, b.scope) || c.conversation !== b.scope.conversation) return refused("scope"); + if (!this.approvedMappings.includes(b.sourceRootRef)) return refused("mapping"); + if (!this.auditWritable) return refused("audit"); + if (!g.capabilities.includes(CAPS[op])) return refused("capability"); + if (!VERIFIED_OPERATIONS.includes(op)) return refused("unsupported-capability"); + if (t.conversation !== b.scope.conversation || t.execution !== b.execution) return refused("target"); + if (op === "observe") return this.#observe(c, r); + if (t.branch !== b.branch) return refused("target"); + const key = `${c.actor}\0${t.conversation}\0${r.id}`; + const digest = hash({ target: t, command: cmd, payloadDigest: sha256(text ?? "") }); + const prior = this.dedup.get(key); + if (prior) { + if (prior.actor !== c.actor || prior.digest !== digest) return refused("conflicting-request"); + const receipt = prior.receipt ? this.receipts.get(prior.receipt) : null; + return { outcome: `existing:${receipt?.state ?? prior.outcome}`, receipt: receipt ?? null, request: prior.request ? this.requests.get(prior.request) : null }; + } + const finish = (res) => { + if (res?.fence) return { ...res, dedupKey: key, digest }; + if (res && !res.outcome.startsWith("refused:")) { + this.dedup.set(key, { actor: c.actor, digest, outcome: res.outcome, receipt: res.receipt?.id ?? null, request: res.request?.id ?? null }); + } + return res; + }; + // `recover` is asynchronous (it acquires a claim); everything else, + // including Interrupt's fence, returns synchronously. + const res = this.#evaluateOp(c, g, r, text); + return typeof res?.then === "function" ? res.then(finish) : finish(res); + } + + #evaluateOp(c, g, r, text) { + const b = this.b, t = r.target, cmd = r.command, op = cmd.operation; + if (t.controllerGeneration !== b.controllerGeneration) return refused("generation"); + if (op === "issue-confirmation") { + if (!g.capabilities.includes(CAPS[cmd.operationToConfirm])) return refused("capability"); + const x = record("confirmation", { + id: newId("confirmation"), actor: c.actor, connection: c.id, target: clone(t), operation: cmd.operationToConfirm, + intentDigest: hash({ target: t, operation: cmd.operationToConfirm, stop: b.stop }), + expiresAt: new Date(this.now().getTime() + 60000).toISOString(), state: "pending", connectionGeneration: c.generation, stop: b.stop, + }); + this.confirmations.set(x.id, x); + this.#pushTo(c.id, { kind: "confirmation", confirmation: x }); + return { outcome: "confirmation-issued", data: { confirmation: x } }; + } + if (op === "answer-confirmation") { + const x = this.confirmations.get(cmd.confirmation); + if (!x || x.state !== "pending" || x.actor !== c.actor || x.connection !== c.id || x.connectionGeneration !== c.generation || !equal(x.target, t) || Date.parse(x.expiresAt) <= this.now().getTime()) return refused("confirmation"); + x.state = cmd.answer === "confirm" ? "confirmed" : "cancelled"; + this.#pushTo(c.id, { kind: "confirmation", confirmation: x }); + return { outcome: `confirmation-${x.state}`, data: { confirmation: x } }; + } + if (op === "takeover") { + if (c.id === b.controllerConnection) return refused("already-controller"); + if (!g.capabilities.includes("observe")) return refused("capability"); + if (b.state !== "active" || b.admission !== "open") return refused("fenced"); + return { outcome: this.#transfer(c, false) }; + } + if (op === "acquire-recovery-control") { + if (!g.capabilities.includes("observe")) return refused("capability"); + if (b.admission !== "closed") return refused("fenced"); + const old = this.connections.get(b.controllerConnection)?.rec; + if (old?.state === "connected") return refused("controller-present"); + if (!this.#checkConfirmation(r, c, op)) return refused("confirmation"); + return { outcome: this.#transfer(c, true) }; + } + if (c.id !== b.controllerConnection || c.mode !== "controller") return refused("controller"); + if (op === "force-stop") { + // One escalation at a time: a second force stop waits until the first + // ends, and can be retried once it ends `uncertain`. + if (!["active", "stopping", "uncertain"].includes(b.state) || this.escalating) return refused("fenced"); + if (!this.#checkConfirmation(r, c, op)) return refused("confirmation"); + const s = this.#startStop("force-stop", { requestId: r.id, connection: c.id, target: t }); + this.escalating = s.id; + this.closers.add("force-stop"); + this.#admission(); + this.exec?.link?.poison("force-stop"); + return { outcome: "force-stop-fenced", stop: s, after: () => this.#forceStop(s, { confirmation: cmd.confirmation }) }; + } + if (op === "recover") return this.#recover(c, r); + if (!this.preflightOk) return refused("preflight"); + if (b.state !== "active" || b.admission !== "open") return refused("fenced"); + if (op === "interrupt") { + // The fence, set synchronously; the rest runs under the lock. + const closer = `interrupt:${r.id}`; + this.closers.add(closer); + this.#admission(); + return { fence: true, closer, fenceIdx: this.exec?.tracker.idx ?? 0 }; + } + if (op === "prompt") return this.#admitPrompt(c, g, r, text); + return refused("unsupported-capability"); + } + + #checkConfirmation(r, c, op) { + const b = this.b, x = this.confirmations.get(r.command.confirmation); + if (!x || x.state !== "confirmed" || x.actor !== c.actor || x.connection !== c.id || x.connectionGeneration !== c.generation || x.operation !== op || !equal(x.target, r.target) || + x.stop !== b.stop || (op === "recover" && x.stop !== r.command.stop) || x.intentDigest !== hash({ target: r.target, operation: op, stop: x.stop }) || + Date.parse(x.expiresAt) <= this.now().getTime()) return false; + x.state = "consumed"; + this.#pushTo(c.id, { kind: "confirmation", confirmation: x }); + return true; + } + + #transfer(c, recovery) { + const b = this.b; + const old = this.connections.get(b.controllerConnection)?.rec; + if (old) old.mode = "observer"; + b.controllerGeneration += 1; + b.controllerConnection = c.id; + c.mode = "controller"; + this.#emit("control-transferred", { stop: b.stop }); + this.#push({ kind: "binding", binding: b }); + for (const x of [old, c].filter(Boolean)) this.#push({ kind: "connection", connection: x }); + this.#claimGeneration(); + return recovery ? "recovery-control-acquired" : "transferred"; + } + + #claimGeneration() { + if (!this.claim || this.claim.record.state === "stopped") return; + const generation = this.b.controllerGeneration; + this.#claimAdvance({ generation }).catch((err) => this.#uncertain("claim", { error: String(err.code ?? err.message) })); + } + + #observe(c, r) { + const t = r.target, cmd = r.command; + const res = cmd.cursor + ? this.reader.next({ cursor: cmd.cursor, conversation: t.conversation, branch: t.branch, actor: c.actor }) + : this.reader.open({ conversation: t.conversation, branch: t.branch, actor: c.actor }); + if (!res.ok) { + const code = res.refusal.code; + if (code === "unknown-branch" || code === "unknown-conversation") return refused("target"); + if (code.startsWith("cursor-") || code === "source-replaced") return refused("cursor", { detail: code }); + return refused(code, { detail: res.refusal.message }); + } + const exec = this.exec; + return { + outcome: "observing", + data: { + page: res.page, cursor: res.cursor, follow: res.follow, view: res.view, incarnation: this.incarnation, + // The seam: no replay in CHAT-03. Events from fromSequence on are + // live; the page and the stream are not deduplicated by ID (E4). + // `quiet` says no run was visible and no prompt held the slot when + // the page was read, so every earlier message had settled and Pi had + // persisted it (agent-session.js 386–398 persists on message_end, + // before agent_settled). Otherwise the cut is not atomic, and the + // client marks the seam and re-reads after the run settles. + seam: { replay: "unavailable", streamEpoch: exec?.streamEpoch ?? null, fromSequence: (exec?.seq ?? 0) + 1, reconcile: true, quiet: !exec?.tracker.current && !exec?.tracker.slot }, + limitAdvisory: true, + }, + }; + } + + // ---- prompt ------------------------------------------------------------ + + #admitPrompt(c, g, r, text) { + const b = this.b, t = r.target, cmd = r.command; + if (typeof text !== "string" || text.length > 262144) return refused("draft"); + if (textPolicy(text)) return refused(TEXT_POLICY); + const exec = this.exec; + if (exec?.tracker.slot) return refused(BUSY); + if (!exec?.link || exec.link.poisoned) return refused("fenced"); + const now = this.now().toISOString(); + const request = record("request", { + id: newId("request"), clientRequest: r.id, connection: c.id, actor: c.actor, grant: g.id, grantRevision: g.revision, target: clone(t), + operationDigest: hash({ target: t, command: cmd, payloadDigest: sha256(text) }), command: clone(cmd), admittedAt: now, frozenPayload: null, + }); + const receipt = record("receipt", { id: newId("receipt"), request: request.id, target: clone(t), state: "admitted", revision: 1, reasonCode: null, event: null, createdAt: now }); + this.requests.set(request.id, request); + this.receipts.set(receipt.id, receipt); + const slot = { request: request.id, receipt, text, connection: c.id, actor: c.actor, generation: t.controllerGeneration, grant: g.id, incarnation: this.incarnation, exec, writeStarted: false, written: false, refused: false, released: false, nativeError: null }; + exec.tracker.attach(slot); + this.#push({ kind: "receipt", receipt }); + return { outcome: "admitted", receipt, request, after: () => this.#dispatch(slot) }; + } + + #recheck(slot) { + const b = this.b, exec = slot.exec; + if (b.state !== "active" || b.admission !== "open") return "fenced"; + const c = this.connections.get(slot.connection)?.rec; + if (!c || c.state === "revoked") return "channel"; + if (b.controllerConnection !== c.id || c.mode !== "controller") return "controller"; + if (b.controllerGeneration !== slot.generation) return "generation"; + if (!this.#grantOk(c) || c.grant !== slot.grant) return "grant"; + if (exec !== this.exec || exec.execution !== b.execution || !exec.link || exec.link.poisoned) return "fenced"; + if (slot.incarnation !== this.incarnation) return STALE_INCARNATION; + if (textPolicy(slot.text)) return TEXT_POLICY; + return null; + } + + async #dispatch(slot) { + const exec = slot.exec; + await this.#pause("admitted", { request: slot.request }); + let req = null; + await this.lock.run(async () => { + if (slot.refused || slot.released) return; + await this.#pause("before-recheck", { request: slot.request }); + const why = this.#recheck(slot); + if (why) return this.#dispatchRefused(slot, why); + slot.writeStarted = true; + req = exec.link.request("prompt", { message: slot.text }, { timeoutMs: this.T.ack }); + exec.promptSlots.set(req.id, slot); + const w = await req.written; + if (w.outcome === "written") this.#markWritten(slot); + else this.#transport(exec, `prompt-write-${w.reason ?? w.outcome}`); + return undefined; + }); + if (!slot.written) return; + await this.#pause("written", { request: slot.request }); + const res = await req.response; + if (res.timeout && slot.ackIdx === null && !this.#final(slot.receipt)) this.#transport(exec, "no-prompt-response"); + } + + // The write callback can fire after the engine has read the line and + // answered it; a response for the prompt shows the write completed first. + #markWritten(slot) { + if (slot.written) return; + slot.written = true; + this.#revise(slot.receipt, "dispatched", null); + } + + #dispatchRefused(slot, code) { + slot.refused = true; + this.#revise(slot.receipt, "dispatch-refused", code); + this.#releaseSlot(slot); + } + + #releaseSlot(slot) { + const tr = slot.exec.tracker; + if (tr.slot === slot) tr.release(); + slot.released = true; + this.#wake(slot.exec); + } + + #final(receipt) { + return FINAL.has(receipt.state) || this.outcomeUnknown.has(receipt.id); + } + + #revise(receipt, state, reasonCode, extra = {}) { + if (!receiptAllows(receipt.state, state)) { + this.evidence.refusedRevisions.push({ receipt: receipt.id, from: receipt.state, to: state, reasonCode }); + return false; + } + receipt.state = state; + receipt.revision += 1; + receipt.reasonCode = reasonCode; + receipt.createdAt = this.now().toISOString(); + this.#push({ kind: "receipt", receipt, ...extra }); + return true; + } + + // A `working` receipt never moves back; it is shown as outcome unknown. + #markOutcomeUnknown(slot, reason) { + if (this.outcomeUnknown.has(slot.receipt.id)) return; + this.outcomeUnknown.add(slot.receipt.id); + this.#push({ kind: "receipt", receipt: slot.receipt, outcomeUnknown: true, reason }); + } + + #settleFailure(slot, kind) { + if (!slot || !slot.writeStarted || this.#final(slot.receipt)) return; + if (slot.receipt.state === "working") this.#markOutcomeUnknown(slot, kind); + else this.#revise(slot.receipt, "delivery-unknown", kind); + } + + // ---- engine output ----------------------------------------------------- + + #stale(exec, link) { + return exec !== this.exec || link !== exec.link; + } + + #onResponse(exec, link, value, entry) { + if (this.#stale(exec, link)) { + this.evidence.dropped.lines += 1; + return; + } + const tr = exec.tracker; + const idx = tr.read(); + exec.responseIdx.set(value.id, idx); + if (entry.type === "clear_queue") this.#closeClearWindow(exec, value.id); + if (entry.type === "get_state" && value.success && Number(value.data?.pendingMessageCount) > 0) { + tr.overlap("O5", idx, { cause: "pending-messages", count: Number(value.data.pendingMessageCount) }); + } + if (entry.type === "prompt") { + const slot = exec.promptSlots.get(value.id); + if (slot) { + this.#markWritten(slot); + if (value.success) { + if (tr.slot === slot) tr.acked(idx); + slot.ackIdx = idx; + this.#revise(slot.receipt, "acknowledged", null); + if (!slot.released && tr.slot === slot) void this.#postAck(slot).catch((e) => this.#internal("post-ack", e)); + } else { + slot.nativeError = typeof value.error === "string" ? value.error.slice(0, 2000) : null; + if (!this.#final(slot.receipt) && this.#revise(slot.receipt, "failed", null, { nativeError: slot.nativeError })) this.#releaseSlot(slot); + } + } + } + this.#wake(exec); + } + + async #postAck(slot) { + const exec = slot.exec, tr = exec.tracker; + const ackIdx = slot.ackIdx; + const st = await this.#ask(exec, "get_state", {}, this.T.state); + if (slot.released || this.#final(slot.receipt)) return; + if (!st.response) return; + const replyIdx = st.idx; + const between = (i) => i !== null && i > ackIdx && i < replyIdx; + const quiet = !tr.runs.some((r) => between(r.startIdx) || between(r.settleIdx)) && !tr.looseSettles.some(between); + if (st.response.data?.isStreaming === false && quiet && slot.runId === null) { + if (tr.overlapped || tr.gap) return; + this.#revise(slot.receipt, "delivery-unknown", HANDLED_WITHOUT_RUN); + this.#releaseSlot(slot); + return; + } + if (slot.runId === null) { + const ok = await this.#waitFor(exec, () => slot.runId !== null || this.#final(slot.receipt) || slot.released, this.T.start); + if (!ok) this.#transport(exec, "no-agent-start"); + } + } + + #onSlotSettled(exec, slot, result) { + if (result.outcomeUnknown) { + this.#markOutcomeUnknown(slot, result.outcomeUnknown); + this.#uncertain(result.outcomeUnknown, {}); + return; + } + const extra = {}; + if (result.nativeError !== undefined) extra.nativeError = result.nativeError; + if (result.stop) extra.stop = result.stop; + this.#revise(slot.receipt, result.state, result.reason ?? null, extra); + this.#releaseSlot(slot); + } + + #onOverlap(exec, signal, entry) { + this.closers.add("overlap"); + this.evidence.overlaps.push(entry); + this.#settleFailure(exec.tracker.slot, RUN_OVERLAP); + this.#uncertain(RUN_OVERLAP, { signal }); + this.#push({ kind: "evidence", evidence: { overlap: entry } }); + } + + #onGap(exec, link, kind, detail) { + if (this.#stale(exec, link)) { + this.evidence.dropped.lines += 1; + return; + } + if (exec.closing) return; + this.evidence.gaps.push({ kind, detail, idx: exec.tracker.idx }); + this.#transport(exec, kind); + } + + #onEnd(exec, link) { + if (this.#stale(exec, link) || exec.closing) return; + this.#transport(exec, "eof"); + } + + #transport(exec, reason) { + if (exec !== this.exec) return; + exec.link?.poison(reason); + const tr = exec.tracker; + if (!tr.gap) tr.gap = { reason, idx: tr.idx }; + this.#settleFailure(tr.slot, TRANSPORT_UNKNOWN); + this.#uncertain(TRANSPORT_UNKNOWN, { reason }); + this.#wake(exec); + } + + #uncertain(reason, detail = {}) { + const b = this.b; + this.evidence.uncertain.push({ reason, ...detail, at: this.now().toISOString() }); + const first = !this.closers.has("uncertain"); + this.closers.add("uncertain"); + if (b.state !== "stopping" && b.state !== "stopped") b.state = "uncertain"; + this.#admission(); + if (first) this.#emit("uncertain", { stop: b.stop }); + if (this.claim && ["active", "reserved"].includes(this.claim.record.state)) { + this.#claimAdvance({ state: "uncertain" }).catch((err) => this.evidence.uncertain.push({ reason: "claim", error: String(err.code ?? err.message) })); + } + } + + #onLine(exec, link, value, bytes) { + if (this.#stale(exec, link)) { + // H14: late output of a replaced engine is dropped and counted. + this.evidence.dropped.lines += 1; + this.evidence.dropped.bytes += bytes; + return; + } + const tr = exec.tracker; + const idx = tr.read(); + this.#map(exec, value, bytes); + tr.event(value, idx); + this.#wake(exec); + } + + #slotRequest(exec) { + const tr = exec.tracker, s = tr.slot; + return s && s.runId && tr.current?.id === s.runId && !tr.overlapped ? s.request : null; + } + + #map(exec, ev, bytes) { + const type = typeof ev?.type === "string" ? ev.type : null; + switch (type) { + case "message_start": { + exec.message = { id: `${exec.execution}.m${++exec.msgSerial}`, role: roleOf(ev.message) }; + return this.#emit("message-start", { message: exec.message.id, role: exec.message.role, request: this.#slotRequest(exec) }); + } + case "message_update": { + const u = ev.assistantMessageEvent; + const kind = u?.type === "text_delta" ? "text" : u?.type === "thinking_delta" ? "thinking" : null; + if (!kind) { + if (isFoldedUpdate(u?.type)) this.evidence.folded += 1; + else this.#unknown(`message_update:${u?.type}`, bytes); + return undefined; + } + const m = exec.message ?? (exec.message = { id: `${exec.execution}.m${++exec.msgSerial}`, role: "assistant" }); + const ci = Number.isInteger(u.contentIndex) && u.contentIndex >= 0 && u.contentIndex <= 63 ? u.contentIndex : null; + return this.#emit(kind === "text" ? "text-delta" : "thinking-delta", { message: m.id, role: m.role, contentIndex: ci, updateMode: "append", content: deltaBlocks(kind, u.delta, u.contentIndex), request: this.#slotRequest(exec) }); + } + case "message_end": { + const m = exec.message ?? { id: `${exec.execution}.m${++exec.msgSerial}`, role: roleOf(ev.message) }; + exec.message = null; + const parts = partsOf(messageBlocks(ev.message)); + parts.forEach((content, i) => this.#emit("message-end", { message: m.id, role: roleOf(ev.message), entry: `${m.id}.e`, part: i, lastPart: i === parts.length - 1, updateMode: "replace", content, request: this.#slotRequest(exec) })); + return undefined; + } + case "tool_execution_start": + case "tool_execution_update": + case "tool_execution_end": + return this.#mapTool(exec, type, ev); + case "agent_settled": + return this.#emit("run-settled", { request: this.#slotRequest(exec) }); + case "extension_ui_request": { + if (DIALOG_METHODS.has(ev.method)) { + // P3: shown disabled with a reason; never answered. + const dialog = { id: safeId(ev.id), method: ev.method, disabled: true, reason: DIALOG_REASON }; + exec.dialogs.push(dialog); + this.evidence.dialogs.push(dialog); + this.#push({ kind: "dialog", dialog }); + } else if (NOTIFY_METHODS.has(ev.method)) { + this.evidence.notices += 1; + } else this.#unknown(`extension_ui_request:${ev.method}`, bytes); + return undefined; + } + default: + if (type && KNOWN_UNSHOWN.has(type)) { + this.evidence.unshown[type] = (this.evidence.unshown[type] ?? 0) + 1; + return undefined; + } + return this.#unknown(type, bytes); + } + } + + #mapTool(exec, type, ev) { + const call = safeId(ev.toolCallId); + let t = exec.tools.get(call); + if (!t) { + t = { call, start: null, end: null, message: `${exec.execution}.t${++exec.toolSerial}` }; + exec.tools.set(call, t); + } + const request = this.#slotRequest(exec); + if (type === "tool_execution_start") { + const parts = fragments(JSON.stringify(ev.args ?? {})); + const content = parts.map((a, i) => ({ type: "tool-call", call, name: safeId(ev.toolName), argumentsText: a, block: 0, fragment: i, lastFragment: i === parts.length - 1 })).slice(0, 64); + t.start = this.#emit("tool-start", { message: t.message, role: "tool", content, updateMode: "none", request }).id; + return undefined; + } + const text = toolResultText(type === "tool_execution_end" ? ev.result : ev.partialResult); + const parts = fragments(text); + const content = parts.map((x, i) => ({ type: "tool-result", call, text: x, isError: ev.isError === true, block: 0, fragment: i, lastFragment: i === parts.length - 1 })).slice(0, 64); + const e = this.#emit(type === "tool_execution_end" ? "tool-end" : "tool-update", { message: t.message, role: "tool", content, updateMode: "replace", request }); + if (type === "tool_execution_end") t.end = e.id; + return undefined; + } + + #unknown(type, bytes) { + const name = typeof type === "string" && /^[A-Za-z0-9_:.-]{1,80}$/.test(type) ? type : ""; + const u = (this.evidence.unknownEvents[name] ??= { count: 0, bytes: 0 }); + u.count += 1; + u.bytes += bytes; + const total = Object.values(this.evidence.unknownEvents).reduce((n, x) => n + x.count, 0); + this.#push({ kind: "unknown", count: total, nativeType: name }); + } + + #emit(type, fields = {}) { + const exec = this.exec; + const seq = exec ? ++exec.seq : this.events.length + 1; + const e = record("event", { + id: exec ? `${exec.execution}.${seq}` : `${this.b.execution}.c${seq}`, target: targetOf(this.b), sequence: seq, + streamEpoch: exec?.streamEpoch ?? `${this.b.execution}.${this.incarnation.slice(0, 12)}`, + request: fields.request ?? null, entry: fields.entry ?? null, type, contentIndex: fields.contentIndex ?? null, + updateMode: fields.updateMode ?? "none", content: fields.content ?? [], visibility: "permitted-visible", + createdAt: this.now().toISOString(), stop: fields.stop ?? null, message: fields.message ?? null, + part: fields.part ?? null, lastPart: fields.lastPart ?? null, role: fields.role ?? null, + }); + this.events.push(e); + this.#push({ kind: "event", event: e }); + return e; + } + + // ---- admission, pushes, waits ----------------------------------------- + + // Recomputes admission and pushes the binding when its admission or state + // differs from what clients last saw. + #admission() { + const b = this.b; + if (!b) return; + const next = b.state === "active" && this.closers.size === 0 ? "open" : "closed"; + if (b.admission !== next || this.pushed?.id !== b.id || this.pushed.state !== b.state) { + b.admission = next; + this.#push({ kind: "binding", binding: b }); + } + } + + #push(msg) { + if (msg.kind === "binding") this.pushed = { id: msg.binding.id, state: msg.binding.state, admission: msg.binding.admission }; + for (const c of this.connections.values()) if (c.send && c.rec.state === "connected") c.send({ ...msg, type: "push" }); + } + + #pushTo(id, msg) { + const c = this.connections.get(id); + if (c?.send && c.rec.state === "connected") c.send({ ...msg, type: "push" }); + } + + #wake(exec) { + for (const w of [...exec.waiters]) w(); + } + + #waitFor(exec, pred, ms) { + if (pred()) return Promise.resolve(true); + return new Promise((resolve) => { + const w = () => { + if (!pred()) return; + exec.waiters.delete(w); + clearTimeout(timer); + resolve(true); + }; + const timer = setTimeout(() => { + exec.waiters.delete(w); + resolve(pred()); + }, ms); + exec.waiters.add(w); + }); + } + + #evidenceAdd(kind, detail) { + const entry = { kind, ...detail, at: this.now().toISOString() }; + if (kind === "uncertain") this.evidence.uncertain.push(entry); + else this.evidence.stops.push(entry); + this.#push({ kind: "evidence", evidence: entry }); + } + + #internal(where, err) { + this.evidence.internal.push({ where, error: String(err?.stack ?? err).slice(0, 2000) }); + } + + #claimAdvance(patch) { + const run = this.claimChain.then(() => { + const p = typeof patch === "function" ? patch(this.claim.record) : patch; + return this.store.advance(this.claim, p); + }); + this.claimChain = run.catch(() => {}); + return run; + } + + #claimFinish(patch) { + const run = this.claimChain.then(() => this.store.finish(this.claim, patch)); + this.claimChain = run.catch(() => {}); + return run; + } + + // ---- stops ------------------------------------------------------------- + + #startStop(mode, { requestId, connection, target }) { + const b = this.b; + const pred = this.stops.get(b.stop); + if (pred && !["stopped", "turn-interrupted", "superseded"].includes(pred.state)) { + pred.state = "superseded"; + if (pred.mode === "interrupt") this.closers.delete(`interrupt:${pred.id}`); + this.#push({ kind: "stop", stop: pred }); + } + const s = record("stop", { + id: newId("stop"), request: requestId, target: clone(target), mode, state: "fenced", queueDrafts: [], cohortRef: b.cohortRef, + supervisorEvidence: null, effectsEvidence: null, externalEffects: "uncertain", createdAt: this.now().toISOString(), + nativeQueue: "pending", approvalDisposition: "pending", turnEvidence: null, supersedes: b.stop, queueFailures: [], + revokedConnection: mode === "revocation" ? connection : null, + }); + this.stops.set(s.id, s); + b.stop = s.id; + if (mode === "force-stop") b.state = "stopping"; + this.#admission(); + if (mode !== "revocation") this.#emit("stopping", { stop: s.id }); + this.#push({ kind: "stop", stop: s }); + this.#push({ kind: "binding", binding: b }); + return s; + } + + #advanceStop(stop, state) { + if (!STOP_NEXT[stop.state]?.includes(state)) return false; + stop.state = state; + if (stop.mode === "force-stop") this.b.state = state === "uncertain" ? "uncertain" : "stopping"; + this.#push({ kind: "stop", stop }); + this.#admission(); + return true; + } + + // Interrupt, under the dispatch lock, after its fence was set. + async #interruptLocked(c, r, pre) { + const exec = this.exec, tr = exec?.tracker; + const dropFence = () => { + this.closers.delete(pre.closer); + this.#admission(); + }; + let dispatchRefused = null; + const slot = tr?.slot ?? null; + if (slot && !slot.writeStarted) { + this.#dispatchRefused(slot, "fenced"); + dispatchRefused = slot.receipt.id; + } + const written = tr?.slot && tr.slot.writeStarted ? tr.slot : null; + if (!written && !tr?.current) { + // H10: lifts only its own fence. + dropFence(); + return refused(NO_TURN, { effect: { dispatchRefused } }); + } + const b = this.b; + const others = [...this.closers].filter((k) => k !== pre.closer); + if (b.state !== "active" || others.length > 0 || exec.link?.poisoned) { + dropFence(); + return refused("fenced", { effect: { dispatchRefused } }); + } + if (b.controllerConnection !== c.id || c.mode !== "controller") { + dropFence(); + return refused("controller", { effect: { dispatchRefused } }); + } + if (b.controllerGeneration !== r.target.controllerGeneration) { + dropFence(); + return refused("generation", { effect: { dispatchRefused } }); + } + const s = this.#startStop("interrupt", { requestId: r.id, connection: c.id, target: r.target }); + this.closers.delete(pre.closer); + this.closers.add(`interrupt:${s.id}`); + this.#admission(); + const ctx = { exec, stop: s, fenceIdx: pre.fenceIdx, slot: written, abortIdxs: [], lastAbortIdx: null, clears: [], reason: null }; + return { outcome: "interrupt-fenced", stop: s, effect: { dispatchRefused }, after: () => this.#interruptLoop(ctx) }; + } + + async #clear(exec, ctx) { + const tr = exec.tracker; + let id = null; + const res = await this.#ask(exec, "clear_queue", {}, this.T.clear, (rid) => { + id = rid; + tr.clearWindow += 1; + exec.clears.set(rid, { closed: false }); + }); + if (id) this.#closeClearWindow(exec, id); + if (!res.response) return { ok: false, why: res.why }; + if (!res.response.success) return { ok: false, why: "error-response" }; + const d = res.response.data ?? {}; + const items = [...(Array.isArray(d.steering) ? d.steering : []), ...(Array.isArray(d.followUp) ? d.followUp : [])]; + const at = this.now().toISOString(); + const entry = { at, idx: res.idx, empty: items.length === 0, removed: items.map((x) => ({ digest: sha256(typeof x === "string" ? x : JSON.stringify(x)), bytes: Buffer.byteLength(typeof x === "string" ? x : JSON.stringify(x)) })) }; + ctx.clears.push(entry); + return { ok: true, ...entry }; + } + + #closeClearWindow(exec, id) { + const w = exec.clears.get(id); + if (!w || w.closed) return; + w.closed = true; + exec.tracker.clearWindow = Math.max(0, exec.tracker.clearWindow - 1); + } + + async #interruptLoop(ctx) { + const { exec, stop } = ctx, tr = exec.tracker; + const gone = () => stop.state === "superseded" || this.exec !== exec; + let unknown = false; + for (let round = 1; ; round++) { + if (gone()) return; + if (tr.overlapped || tr.gap || exec.link.poisoned) { + unknown = true; + break; + } + const clear = await this.#clear(exec, ctx); + if (gone()) return; + if (round === 1) this.#advanceStop(stop, "cancelling"); + if (!clear.ok) { + // Rule 2: no abort, which would run whatever is queued. + stop.nativeQueue = "unknown"; + ctx.reason = `clear-${clear.why}`; + unknown = true; + break; + } + if (!clear.empty) { + tr.overlap("O5", clear.idx, { cause: "non-empty-clear", removed: clear.removed }); + unknown = true; + break; + } + await this.#pause("before-abort", { stop: stop.id, round }); + // An overlap read with the clear's response (O5 from a queue_update in + // the same chunk) is recorded by now: no abort, which would run it. + if (tr.overlapped || tr.gap || exec.link.poisoned) { + unknown = true; + break; + } + const ab = await this.#ask(exec, "abort", {}, this.T.abort, () => { + this.abortWritten.add(stop.id); + ctx.lastAbortIdx = tr.idx; + ctx.abortIdxs.push(tr.idx); + }); + if (gone()) return; + if (!ab.response) { + ctx.reason = `abort-${ab.why}`; + unknown = true; + break; + } + const slot = ctx.slot; + if (slot && !this.#final(slot.receipt)) { + if (!(await this.#waitFor(exec, () => slot.ackIdx != null || this.#final(slot.receipt), this.T.ack))) { + this.#transport(exec, "no-prompt-response"); + unknown = true; + break; + } + if (!(await this.#waitFor(exec, () => slot.runId !== null || this.#final(slot.receipt), this.T.start))) { + this.#transport(exec, "no-agent-start"); + unknown = true; + break; + } + } + const cur = tr.current; + if (cur && cur.startIdx <= ctx.lastAbortIdx && !(await tr.waitSettle(cur, this.T.settle))) { + // K9: an interrupt that never settles stays uncertain. + ctx.reason = "no-settle"; + unknown = true; + break; + } + if (gone()) return; + if (tr.startedAfter(ctx.lastAbortIdx).length === 0 && !tr.current) break; + if (round >= this.T.maxRounds) { + stop.nativeQueue = "unknown"; + ctx.reason = "rounds-exhausted"; + unknown = true; + break; + } + } + if (gone()) return; + const outcome = unknown ? "unknown" : this.#classifyStop(ctx); + if (outcome === "interrupted") return this.#reconcile(ctx); + return this.#stopUncertain(ctx, outcome); + } + + // §3 rule 5. Unknown's conditions first. + #classifyStop(ctx) { + const { exec } = ctx, tr = exec.tracker; + if (ctx.lastAbortIdx === null || tr.overlapped || tr.gap || exec.link.poisoned) return "unknown"; + if (tr.runs.some((r) => r.startIdx > ctx.lastAbortIdx)) return "unknown"; + const active = (r, i) => r.startIdx <= i && (r.settleIdx === null || r.settleIdx > i); + const inScope = tr.runs.filter((r) => active(r, ctx.fenceIdx) || (r.startIdx > ctx.fenceIdx && r.startIdx <= ctx.lastAbortIdx)); + if (inScope.length > 1) return "unknown"; + if (inScope.length === 1) { + const run = inScope[0]; + if (run.settleIdx === null) return "unknown"; + if (run.lastStop === "aborted" && ctx.abortIdxs.some((i) => active(run, i))) return "interrupted"; + if (DONE_STOPS.has(run.lastStop)) return "completed-first"; + if (run.lastStop === "error") return "failed-on-its-own"; + return "unknown"; + } + const r = ctx.slot?.receipt; + if (r && ((r.state === "failed" && r.reasonCode === null) || (r.state === "delivery-unknown" && r.reasonCode === HANDLED_WITHOUT_RUN))) return "no-run"; + return "unknown"; + } + + #stopUncertain(ctx, outcome, why = null) { + const { stop } = ctx; + if (stop.state === "superseded") return; + // Rule 2: nothing proved the native queue clear. + if (stop.nativeQueue === "pending") stop.nativeQueue = "unknown"; + this.#advanceStop(stop, "uncertain"); + const entry = { + stop: stop.id, mode: stop.mode, outcome, reason: why ?? ctx.reason, queueBasis: SEAL_BASIS, agentLevelQueues: "unobservable", + clears: ctx.clears, lastAbortIdx: ctx.lastAbortIdx, fenceIdx: ctx.fenceIdx, + }; + this.#evidenceAdd("stop", entry); + } + + // §3 rule 6. + async #reconcile(ctx) { + const { exec, stop } = ctx, tr = exec.tracker; + const slot = ctx.slot; + if (slot) { + const { state, reasonCode } = slot.receipt; + const settled = state === "finished" || state === "failed" || (state === "delivery-unknown" && [HANDLED_WITHOUT_RUN, ACK_WITHOUT_START].includes(reasonCode)); + if (!settled || this.outcomeUnknown.has(slot.receipt.id)) return this.#stopUncertain(ctx, "interrupted", "receipt-unsettled"); + } + const st = await this.#ask(exec, "get_state", {}, this.T.state); + if (stop.state === "superseded") return undefined; + if (!st.response || !st.response.success || st.response.data?.isStreaming !== false || st.response.data?.pendingMessageCount !== 0) return this.#stopUncertain(ctx, "interrupted", "not-idle-after-abort"); + const clear = await this.#clear(exec, ctx); + if (stop.state === "superseded") return undefined; + if (!clear.ok) { + stop.nativeQueue = "unknown"; + return this.#stopUncertain(ctx, "interrupted", `post-settle-clear-${clear.why}`); + } + if (!clear.empty) { + tr.overlap("O5", clear.idx, { cause: "non-empty-post-settle-clear", removed: clear.removed }); + return this.#stopUncertain(ctx, "interrupted", "post-settle-clear-not-empty"); + } + if (tr.overlapped || tr.gap || exec.link.poisoned) return this.#stopUncertain(ctx, "interrupted", "overlap-or-gap"); + if (!this.verifier) return this.#stopUncertain(ctx, "interrupted", "no-verifier"); + const b = this.b; + const effects = effectReport({ binding: b, stop: stop.id, tools: exec.tools, observedAt: clear.at }); + const turn = sealProof(record("turnProof", { + id: newId("turn-proof"), authority: AUTHORITY, conversation: b.scope.conversation, execution: b.execution, stop: stop.id, cohortRef: b.cohortRef, + nativeQueue: "cleared", nativePending: [], turnState: "interrupted", approvalDisposition: exec.dialogs.length ? "uncertain" : "resolved", + effectsEvidence: effects.id, observedAt: clear.at, verificationDigest: "", decisionOutcomes: [], + })); + this.verifier.post(effects); + this.verifier.post(turn); + const vctx = { binding: b, stop: stop.id, now: this.now() }; + if (!this.verifier.verify(turn, "turnProof", vctx) || !this.verifier.effects(effects, vctx) || turn.approvalDisposition === "uncertain") { + return this.#stopUncertain(ctx, "interrupted", "proof-not-verified"); + } + const c = this.connections.get(b.controllerConnection)?.rec; + const g = c ? this.#grantOk(c) : null; + if (!c || c.state !== "connected" || !g || !equal(g.scope, b.scope) || !this.channels.has(c.authenticatedChannelRef) || !this.approvedMappings.includes(b.sourceRootRef) || !this.auditWritable) { + return this.#stopUncertain(ctx, "interrupted", "controller-not-current"); + } + stop.state = "turn-interrupted"; + stop.nativeQueue = "cleared"; + stop.approvalDisposition = turn.approvalDisposition; + stop.turnEvidence = turn.id; + stop.effectsEvidence = effects.id; + this.closers.delete(`interrupt:${stop.id}`); + this.#push({ kind: "stop", stop }); + this.#emit("reconciled", { stop: stop.id }); + this.#evidenceAdd("stop", { stop: stop.id, mode: "interrupt", outcome: "interrupted", queueBasis: SEAL_BASIS, agentLevelQueues: "unobservable", clears: ctx.clears, proofs: { turn: turn.id, effects: effects.id } }); + this.#admission(); + return undefined; + } + + // Holds `escalating` for the whole escalation, so no second one runs beside + // it and writes its phases onto this stop's claim revision. + async #forceStop(stop, opts) { + this.escalating = stop.id; + try { + return await this.#escalate(stop, opts); + } finally { + if (this.escalating === stop.id) this.escalating = null; + } + } + + async #escalate(stop, { confirmation = null, resumed = false } = {}) { + const b = this.b; + const rec = () => this.claim.record; + const fail = async (reason) => { + if (stop.state !== "superseded") this.#advanceStop(stop, "uncertain"); + b.state = "uncertain"; + this.closers.add("uncertain"); + this.#admission(); + this.#emit("uncertain", { stop: stop.id }); + this.#evidenceAdd("stop", { stop: stop.id, mode: "force-stop", outcome: "uncertain", reason, resumed }); + try { + await this.#claimAdvance({ state: "uncertain" }); + } catch (err) { + this.#evidenceAdd("uncertain", { reason: "claim", error: String(err.code ?? err.message) }); + } + }; + try { + await this.#claimAdvance((r) => ({ state: "stopping", stop: { id: stop.id, confirmation, phaseStarted: resumed ? r.stop?.phaseStarted ?? null : null, record: stop } })); + } catch (err) { + return fail(`claim: ${err.code ?? err.message}`); + } + await this.#pause("force-stop-recorded", { stop: stop.id }); + const engine = rec().engine; + if (!engine?.pid) return fail("no engine identity recorded"); + if (engine.kind === "pgroup" && (!engine.start || processStart(engine.pid) !== engine.start)) { + return fail("process identity can't be checked; no signal sent"); + } + if (this.exec) this.exec.closing = true; + // A launcher may own how its cohort stops (the fixture launcher does); + // the result still goes through the verifier. + const stopCohort = typeof this.launcher.forceStop === "function" ? (a) => this.launcher.forceStop(a) : forceStopCohort; + const result = await stopCohort({ + kind: engine.kind, unitName: rec().unitName, invocationId: rec().invocationId, shimSocket: rec().shim, pid: engine.pid, graceMs: this.T.grace, + onPhase: async (name) => { + await this.#claimAdvance((r) => ({ stop: { ...r.stop, phaseStarted: name } })); + await this.#pause(`phase-${name}`, { stop: stop.id }); + }, + }); + if (stop.state === "superseded") return undefined; + this.#advanceStop(stop, "stopping"); + if (result.outcome !== "proven") return fail(result.reason ?? "cohort evidence unavailable"); + if (!this.verifier) return fail("no verifier"); + const tools = this.exec && this.exec.execution === b.execution ? this.exec.tools : this.orphanTools ?? new Map(); + const proof = cohortProof({ binding: b, stop: stop.id, result }); + const effects = effectReport({ binding: b, stop: stop.id, tools, observedAt: result.observedAt }); + this.verifier.post(proof); + this.verifier.post(effects); + const ok = b.stop === stop.id && scopeMatch(stop.target, targetOf(b)) && this.verifier.cohort(proof, effects, { binding: b, stop: stop.id, now: this.now(), epoch: rec().invocationId }); + if (!ok) return fail("proof not verified"); + let session = null; + try { + session = this.#readSession(); + } catch (err) { + return fail(`session unreadable at proof: ${err.code ?? err.message}`); + } + try { + await this.#claimFinish({ state: "stopped", proof: { kind: "cohortProof", ref: proof.id, effects: effects.id }, leafAtProof: session.leaf, branchAtProof: session.branch, stop: { ...rec().stop, record: stop } }); + } catch (err) { + return fail(`claim: ${err.code ?? err.message}`); + } + stop.state = "stopped"; + stop.supervisorEvidence = proof.id; + stop.effectsEvidence = effects.id; + stop.externalEffects = effects.invocations.some((i) => i.disposition === "uncertain") ? "uncertain" : effects.invocations.length ? "completed" : "none"; + b.state = "stopped"; + this.stoppedProof = { proof, effects, epoch: rec().invocationId }; + this.#admission(); + this.#push({ kind: "stop", stop }); + this.#push({ kind: "binding", binding: b }); + this.#emit("stopped", { stop: stop.id }); + this.#evidenceAdd("stop", { stop: stop.id, mode: "force-stop", outcome: "stopped", proofs: { cohort: proof.id, effects: effects.id }, resumed }); + return undefined; + } + + // check.mjs `stopped`. + #stopped(s) { + const b = this.b, p = this.stoppedProof; + if (!s || s.state !== "stopped" || b.stop !== s.id || !scopeMatch(s.target, targetOf(b)) || !p || !this.verifier) return false; + if (p.proof.id !== s.supervisorEvidence || p.effects.id !== s.effectsEvidence) return false; + return this.verifier.cohort(p.proof, p.effects, { binding: b, stop: s.id, now: this.now(), epoch: p.epoch }); + } + + async #recover(c, r) { + const b = this.b, cmd = r.command; + const s = this.stops.get(cmd.stop); + if (b.state !== "stopped" || b.admission !== "closed" || !this.#stopped(s)) return refused("stop-proof"); + let pins; + try { + pins = this.#pins(); + } catch (err) { + return refused(err.code ?? ENGINE_PIN_MISMATCH); + } + if (!equal(pins, this.claim.record.pins)) return refused(ENGINE_PIN_MISMATCH); + const session = this.#readSession(); + if (session.leaf !== this.claim.record.leafAtProof || session.branch !== this.claim.record.branchAtProof) return refused("target"); + if (!this.#checkConfirmation(r, c, "recover")) return refused("confirmation"); + const execution = newId("exec"); + const generation = this.claim.record.generation + 1; + const bindingId = newId("binding"); + const claim = await this.store.acquire(this.seatK, this.sessionK, { + bindingId, harness: "pi", conversation: this.conversation, branch: session.branch, leaf: session.leaf, pins, + owner: this.store.owner(this.incarnation), generation, prior: { claimId: this.claim.claimId, stop: s.id }, execution, cohortRef: null, + }); + const e = { id: newId("eligibility"), claim, bindingId, execution, generation, stop: s.id, pins, leaf: session.leaf, branch: session.branch, incarnation: this.incarnation, used: false, launched: false }; + this.eligibility.set(e.id, e); + return { outcome: "recovery-eligible", data: { eligibility: e.id, claim: claim.claimId, generation } }; + } + + // A launcher's call, never a client command (Q15). Single-use: the record + // is consumed before any check, so a second call refuses (K17). + async launch(id) { + const e = this.eligibility.get(id); + if (!e || e.used) throw new ControlRefusal(ELIGIBILITY, "no unused eligibility record with that id"); + e.used = true; + if (e.incarnation !== this.incarnation) throw new ControlRefusal(ELIGIBILITY, "the eligibility record belongs to another controller incarnation"); + this.#guardCheck("launch"); + for (const key of [this.seatK, this.sessionK]) { + const h = this.store.head(key); + if (h.damaged || h.n === 0 || h.record.claimId !== e.claim.claimId || h.record.state !== "reserved" || h.record.spawnMarker) { + throw new ControlRefusal(ELIGIBILITY, "the reserved claim changed since eligibility"); + } + } + const pins = this.#pins(); + if (!equal(pins, e.pins)) throw new ControlRefusal(ENGINE_PIN_MISMATCH, "the engine pin or launch argv changed since eligibility"); + const session = this.#readSession(); + if (session.leaf !== e.leaf || session.branch !== e.branch) throw new ControlRefusal("target", "the session branch or leaf changed since eligibility (K18)"); + e.launched = true; + const old = this.exec; + this.exec = null; + if (old) old.closing = true; + this.b = this.#newBinding({ execution: e.execution, generation: e.generation, session, cohortRef: `pending-${e.execution}`, state: "reserved", pins }); + this.b.id = e.bindingId; + this.closers = new Set(["starting"]); + this.preflightOk = false; + this.stoppedProof = null; + this.orphanTools = null; + for (const c of this.connections.values()) if (c.rec.mode === "controller") c.rec.mode = "observer"; + this.#push({ kind: "binding", binding: this.b }); + await this.#launchInto(e.claim, session); + return { binding: this.b, claim: e.claim.claimId }; + } + + // Releases an unlaunched reservation with a no-unit observation: nothing + // was spawned under it. + async release(id) { + const e = this.eligibility.get(id); + if (!e || e.launched) throw new ControlRefusal(ELIGIBILITY, "no unlaunched eligibility record with that id"); + e.used = true; + e.launched = true; + await this.store.finish(e.claim, { state: "stopped", proof: { kind: "no-unit", ref: null }, leafAtProof: this.claim.record.leafAtProof ?? null, branchAtProof: this.claim.record.branchAtProof ?? null }); + return { released: e.claim.claimId }; + } + + // Server-origin (check.mjs `revoke-connection`). The revocation fence is + // never lifted in CHAT-03: reconcile-revocation needs evidence I1 lacks. + revokeConnection(id) { + const entry = this.connections.get(id); + if (!entry) throw new ControlRefusal("channel", "no such connection"); + const c = entry.rec, b = this.b; + c.state = "revoked"; + c.mode = "observer"; + if (b.controllerConnection === c.id) { + if (b.state === "active" && b.admission === "open") this.#startStop("revocation", { requestId: null, connection: c.id, target: targetOf(b) }); + b.controllerConnection = null; + b.controllerGeneration += 1; + this.closers.add("revocation"); + this.#admission(); + this.#emit("control-transferred", { stop: b.stop }); + this.#claimGeneration(); + } + this.#push({ kind: "connection", connection: c }); + this.#push({ kind: "binding", binding: b }); + return "revoked"; + } + + // Test and shutdown hook. Never a release: the claim is unchanged. + async close({ killEngine = false } = {}) { + const exec = this.exec; + if (exec) exec.closing = true; + if (killEngine && typeof exec?.proc?.kill === "function") exec.proc.kill(); + else if (killEngine && exec?.proc?.pid) { + try { + process.kill(exec.proc.kind === "pgroup" ? -exec.proc.pid : exec.proc.pid, "SIGKILL"); + } catch { + // already gone + } + } + for (const s of this.sockets) s.destroy(); + if (this.server) await new Promise((r) => this.server.close(() => r())); + this.server = null; + await this.claimChain; + if (exec?.proc && killEngine) await Promise.race([exec.proc.exited, sleep(2000)]); + } +} diff --git a/packages/conversation/src/engine.mjs b/packages/conversation/src/engine.mjs new file mode 100644 index 00000000..ca56f386 --- /dev/null +++ b/packages/conversation/src/engine.mjs @@ -0,0 +1,126 @@ +// The engine pipe (#1507, CHAT-03 §1). +// +// One EngineLink per execution owns the engine's stdin and reads its stdout. +// Output is split on LF only (framing.mjs); every line reaches the controller +// in order, tagged with the link's execution, so a replaced engine's late +// output can be dropped by incarnation (H14). +// +// Write outcomes (§1): +// written the whole line was accepted by the pipe (the write callback ran +// without an error). That is not native consumption. +// unknown the write returned an error (EPIPE), or its callback did not run +// within the bound. A partial line may be in the pipe, so the link +// is poisoned and never written again. +// `acknowledged` is the response to a request, which `request()` reports +// separately. A request whose response does not come within its bound is +// reported as a timeout; the controller decides what that means. + +import { LineSplitter, encodeLine, parseLine } from "./framing.mjs"; + +export const WRITE_TIMEOUT_MS = 5000; + +export class EngineLink { + // `onLine(link, value, bytes)` gets every parsed non-response line; + // `onResponse(link, value, entry)` every response, before its promise + // resolves; `onGap(link, kind, detail)` an unparseable line, an unmatched + // response, an overlong line or EOF with a partial line. + constructor({ execution, stdin, stdout, onLine, onResponse = () => {}, onGap, onEnd = () => {}, writeTimeoutMs = WRITE_TIMEOUT_MS }) { + this.execution = execution; + this.stdin = stdin; + this.onLine = onLine; + this.onResponse = onResponse; + this.onGap = onGap; + this.onEnd = onEnd; + this.writeTimeoutMs = writeTimeoutMs; + this.poisoned = null; + this.pending = new Map(); + this.serial = 0; + this.bytesWritten = 0; + this.ended = false; + this.writes = []; + stdin.on("error", (err) => { + this.stdinError = err.code ?? err.message; + }); + this.splitter = new LineSplitter((line) => this.#line(line), { onOverflow: () => this.onGap(this, "overlong-line", null) }); + stdout.on("data", (chunk) => this.splitter.push(chunk)); + stdout.on("end", () => { + this.ended = true; + if (this.splitter.pending() > 0) this.onGap(this, "partial-line-at-eof", { bytes: this.splitter.pending() }); + this.onEnd(this); + }); + stdout.on("error", () => {}); + } + + #line(line) { + const bytes = Buffer.byteLength(line, "utf8"); + const parsed = parseLine(line); + if (parsed.error) return this.onGap(this, "unparseable-line", { bytes, error: parsed.error }); + const value = parsed.value; + if (value.type === "response") { + const entry = typeof value.id === "string" ? this.pending.get(value.id) : undefined; + if (!entry) return this.onGap(this, "unmatched-response", { bytes, command: typeof value.command === "string" ? value.command.slice(0, 40) : null }); + this.pending.delete(value.id); + entry.late = entry.timedOut; + this.onResponse(this, value, entry); + entry.resolve({ response: value, late: entry.late }); + return undefined; + } + return this.onLine(this, value, bytes); + } + + poison(reason) { + if (!this.poisoned) this.poisoned = reason; + } + + // Writes one record. Never writes to a poisoned link. + write(value) { + if (this.poisoned) return Promise.resolve({ outcome: "refused", reason: "poisoned" }); + const line = encodeLine(value); + const bytes = Buffer.byteLength(line, "utf8"); + return new Promise((resolve) => { + let done = false; + const finish = (o) => { + if (done) return; + done = true; + clearTimeout(timer); + if (o.outcome === "unknown") this.poison(o.reason); + else this.bytesWritten += bytes; + this.writes.push({ type: value.type, id: value.id, outcome: o.outcome }); + resolve(o); + }; + const timer = setTimeout(() => finish({ outcome: "unknown", reason: "write-timeout" }), this.writeTimeoutMs); + try { + this.stdin.write(line, (err) => finish(err ? { outcome: "unknown", reason: err.code ?? "write-error" } : { outcome: "written" })); + } catch (err) { + finish({ outcome: "unknown", reason: err.code ?? "write-error" }); + } + }); + } + + // Sends a command. Returns { id, written, response }: `written` settles + // with the write outcome, `response` with { response } or { timeout: true } + // or { unsent: outcome }. The response is registered before the write, so + // it can never be unmatched. + request(type, fields = {}, { timeoutMs = 5000, beforeWrite = null } = {}) { + const id = `${type}-${++this.serial}`; + let resolve; + const response = new Promise((r) => (resolve = r)); + const entry = { id, type, resolve, timedOut: false, late: false }; + this.pending.set(id, entry); + beforeWrite?.(id); + const written = this.write({ id, type, ...fields }); + written.then((w) => { + if (w.outcome !== "written") { + this.pending.delete(id); + resolve({ unsent: w }); + return; + } + setTimeout(() => { + if (!this.pending.has(id)) return; + entry.timedOut = true; // kept, so a late answer is recognised and not a gap + resolve({ timeout: true }); + }, timeoutMs); + }); + return { id, written, response }; + } +} diff --git a/packages/conversation/src/events.mjs b/packages/conversation/src/events.mjs new file mode 100644 index 00000000..aeb54f49 --- /dev/null +++ b/packages/conversation/src/events.mjs @@ -0,0 +1,121 @@ +// Pi RPC events to CHAT-01 events (#1507, CHAT-03 §3 "Events"). +// +// Pinned Pi 0.85.1 (docs/rpc.md, rpc-types.d.ts) emits JSON lines. Mapped: +// +// message_start -> message-start +// message_update text_delta -> text-delta (append) +// message_update thinking_delta -> thinking-delta (append) +// tool_execution_start -> tool-start +// tool_execution_update -> tool-update (replace) +// tool_execution_end -> tool-end (replace) +// message_end -> message-end (replace, one or more parts) +// agent_settled -> run-settled (never cohort termination) +// +// Known and deliberately not shown: turn_start, turn_end, agent_start, +// agent_end, queue_update, compaction_*, auto_retry_*, summarization_retry_*, +// bash_execution_update, extension_error, extension_ui_request (P3 has its own notice), and the +// message_update subtypes that message-end supersedes (text_start, text_end, +// thinking_start, thinking_end, toolcall_*, start, done, error). The +// controller counts them in its evidence. Anything else is an unknown event: +// no client event, counted with its type and byte size, and never passed +// through raw. +// +// Pi's stream carries no entry ID. `entry` on message-end is a stream-local +// ID, not a session entry ID, so the seam between a history page and the +// stream can't be deduplicated by ID (E4). + +import { fragments, safeId, LIMITS } from "./parts.mjs"; + +export const KNOWN_UNSHOWN = Object.freeze(new Set([ + "turn_start", "turn_end", "agent_start", "agent_end", "queue_update", "compaction_start", "compaction_end", + "auto_retry_start", "auto_retry_end", "summarization_retry_scheduled", "summarization_retry_attempt_start", + "summarization_retry_finished", "bash_execution_update", "extension_error", "extension_ui_request", "response", +])); +export const MAPPED = Object.freeze(new Set(["message_start", "message_update", "message_end", "tool_execution_start", "tool_execution_update", "tool_execution_end", "agent_settled"])); +const FOLDED_UPDATES = new Set(["start", "text_start", "text_end", "thinking_start", "thinking_end", "toolcall_start", "toolcall_delta", "toolcall_end", "done", "error"]); + +export const DIALOG_METHODS = Object.freeze(new Set(["select", "confirm", "input", "editor"])); +export const NOTIFY_METHODS = Object.freeze(new Set(["notify", "setStatus", "setWidget", "setTitle", "set_editor_text"])); + +export function roleOf(message) { + switch (message?.role) { + case "user": + return "user"; + case "assistant": + return "assistant"; + case "toolResult": + return "tool"; + case "compactionSummary": + return "compaction"; + default: + return "notice"; + } +} + +const textOf = (content) => { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content.filter((c) => c && c.type === "text" && typeof c.text === "string").map((c) => c.text).join(""); +}; + +function pushText(out, type, text, block, extra = {}) { + const parts = fragments(text); + parts.forEach((t, i) => out.push({ type, ...extra, text: t, block, fragment: i, lastFragment: i === parts.length - 1 })); +} + +// Every content block of a finished native message, in CHAT-01 form. +export function messageBlocks(message) { + const out = []; + const role = roleOf(message); + if (role === "tool") { + pushText(out, "tool-result", textOf(message.content), 0, { call: safeId(message.toolCallId), isError: message.isError === true }); + return out; + } + if (role === "compaction") { + const parts = fragments(typeof message.summary === "string" ? message.summary : ""); + parts.forEach((t, i) => out.push({ type: "compaction", summary: t, nativeEntry: safeId(message.firstKeptEntryId ?? "compaction"), block: 0, fragment: i, lastFragment: i === parts.length - 1 })); + return out; + } + const content = typeof message?.content === "string" ? [{ type: "text", text: message.content }] : Array.isArray(message?.content) ? message.content : []; + content.forEach((c, block) => { + if (!c || typeof c !== "object") return; + if (c.type === "text") pushText(out, "text", String(c.text ?? ""), block); + else if (c.type === "thinking") { + if (c.redacted) out.push({ type: "thinking", text: "", visibility: "unavailable", block, fragment: 0, lastFragment: true }); + else pushText(out, "thinking", String(c.thinking ?? ""), block, { visibility: "permitted-visible" }); + } else if (c.type === "toolCall") { + const parts = fragments(JSON.stringify(c.arguments ?? {})); + parts.forEach((t, i) => out.push({ type: "tool-call", call: safeId(c.id), name: safeId(c.name), argumentsText: t, block, fragment: i, lastFragment: i === parts.length - 1 })); + } else if (c.type === "image") { + out.push({ type: "attachment", attachment: safeId(`image-${block}`), block, fragment: 0, lastFragment: true }); + } else { + pushText(out, "text", `[${String(c.type).slice(0, 40)} block not shown]`, block); + } + }); + return out; +} + +// Splits blocks into message-end parts of at most LIMITS.blocks blocks. +export function partsOf(blocks) { + if (blocks.length === 0) return [[]]; + const out = []; + for (let i = 0; i < blocks.length; i += LIMITS.blocks) out.push(blocks.slice(i, i + LIMITS.blocks)); + return out; +} + +export function deltaBlocks(kind, delta, contentIndex) { + const out = []; + const block = Number.isInteger(contentIndex) && contentIndex >= 0 ? contentIndex : 0; + if (kind === "thinking") pushText(out, "thinking", String(delta ?? ""), block, { visibility: "permitted-visible" }); + else pushText(out, "text", String(delta ?? ""), block); + return out; +} + +export function isFoldedUpdate(type) { + return FOLDED_UPDATES.has(type); +} + +export function toolResultText(result) { + if (result && typeof result === "object") return textOf(result.content); + return typeof result === "string" ? result : ""; +} diff --git a/packages/conversation/src/framing.mjs b/packages/conversation/src/framing.mjs new file mode 100644 index 00000000..0d00ae52 --- /dev/null +++ b/packages/conversation/src/framing.mjs @@ -0,0 +1,68 @@ +// JSONL framing for the engine pipe and the control socket (#1507, CHAT-03 §1). +// +// Records are split on LF (0x0A) only, and a trailing CR is stripped (rpc.md +// lines 30–38). Node's readline is not used, because it also splits on U+2028 +// and U+2029, which JSON strings may carry raw (E2). Splitting happens on +// bytes, before UTF-8 decoding, so a multibyte character split across two +// chunks is never damaged. + +export const MAX_LINE_BYTES = 64 * 1024 * 1024; + +export class LineSplitter { + constructor(onLine, { maxBytes = MAX_LINE_BYTES, onOverflow = null } = {}) { + this.onLine = onLine; + this.maxBytes = maxBytes; + this.onOverflow = onOverflow; + this.parts = []; + this.size = 0; + this.overflowed = false; + } + + push(chunk) { + if (this.overflowed) return; + let start = 0; + for (;;) { + const lf = chunk.indexOf(0x0a, start); + if (lf === -1) break; + this.parts.push(chunk.subarray(start, lf)); + const line = Buffer.concat(this.parts); + this.parts = []; + this.size = 0; + const end = line.length > 0 && line[line.length - 1] === 0x0d ? line.length - 1 : line.length; + this.onLine(line.subarray(0, end).toString("utf8")); + start = lf + 1; + } + if (start < chunk.length) { + const rest = chunk.subarray(start); + this.size += rest.length; + if (this.size > this.maxBytes) { + this.overflowed = true; + this.parts = []; + this.onOverflow?.(); + return; + } + this.parts.push(Buffer.from(rest)); + } + } + + // Bytes left without a terminating LF when the stream ends. They are never + // parsed as a record: a partial line is a transport gap, not data. + pending() { + return this.size; + } +} + +export function encodeLine(value) { + return JSON.stringify(value) + "\n"; +} + +// Parses one line. Returns {value} or {error}; never throws. +export function parseLine(line) { + try { + const value = JSON.parse(line); + if (value === null || typeof value !== "object" || Array.isArray(value)) return { error: "not-an-object" }; + return { value }; + } catch { + return { error: "unparseable" }; + } +} diff --git a/packages/conversation/src/guard.mjs b/packages/conversation/src/guard.mjs new file mode 100644 index 00000000..ad8caa63 --- /dev/null +++ b/packages/conversation/src/guard.mjs @@ -0,0 +1,143 @@ +// The live-session guard (#1507, CHAT-03 §2). +// +// CHAT-03 is fixture-only in code. The claim root, the socket directory and +// every session path are constructor arguments, with no default. At +// construction and again at bind, their real paths must lie inside the +// explicit fixture root and outside every protected location: the +// repository's .pi/state/, ~/.pi, ~/.claude, the configured data root and any +// path a seat registration names. A path is refused when it is inside a +// protected location or contains one. The real protections always apply; +// options only add to them, so a test can't switch them off. +// +// The guard comes out only at cutover (CHAT-07), as a reviewed data-map +// change. + +import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"; +import { homedir } from "node:os"; +import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { ControlRefusal } from "./safe-fs.mjs"; + +export const LIVE_SESSION_REFUSED = "live-session-refused"; + +const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); + +// The real path of `p`, or of its nearest existing ancestor with the rest +// appended when `p` doesn't exist yet. +export function realPath(p) { + const abs = resolve(p); + const tail = []; + let cur = abs; + for (;;) { + try { + return join(realpathSync(cur), ...tail.reverse()); + } catch (err) { + if (err.code !== "ENOENT" && err.code !== "ENOTDIR") throw err; + const up = dirname(cur); + if (up === cur) return abs; + tail.push(basename(cur)); + cur = up; + } + } +} + +const inside = (child, parent) => child === parent || child.startsWith(parent.endsWith(sep) ? parent : parent + sep); +const overlaps = (a, b) => inside(a, b) || inside(b, a); + +function configuredDataRoot(home) { + try { + const cfg = JSON.parse(readFileSync(join(home, ".config", "mosaic-dev", "config.json"), "utf8")); + if (typeof cfg?.dataRoot === "string" && cfg.dataRoot) return cfg.dataRoot.replace(/^~(?=$|\/)/, home); + } catch { + // An unreadable config adds no location; the default data root still applies. + } + return null; +} + +// Registrations under /seats///registration.json. An +// unreadable one adds nothing; the data root itself is protected anyway. +function registrationsUnder(dataRoot) { + const out = []; + const seats = join(dataRoot, "seats"); + let layouts = []; + try { + layouts = readdirSync(seats); + } catch { + return out; + } + for (const layout of layouts) { + let names = []; + try { + names = readdirSync(join(seats, layout)); + } catch { + continue; + } + for (const seat of names) { + try { + out.push(JSON.parse(readFileSync(join(seats, layout, seat, "registration.json"), "utf8"))); + } catch { + // skipped + } + } + } + return out; +} + +function pathsIn(value, out) { + if (typeof value === "string") { + if (isAbsolute(value)) out.push(value); + } else if (Array.isArray(value)) { + for (const v of value) pathsIn(v, out); + } else if (value && typeof value === "object") { + for (const v of Object.values(value)) pathsIn(v, out); + } + return out; +} + +export class LiveSessionGuard { + // `fixtureRoot` is required. `repoRoots`, `homes`, `dataRoots` and + // `registrations` add protected locations to the real ones. + constructor({ fixtureRoot, repoRoots = [], homes = [], dataRoots = [], registrations = [] } = {}) { + if (typeof fixtureRoot !== "string" || !isAbsolute(fixtureRoot)) throw new ControlRefusal(LIVE_SESSION_REFUSED, "an absolute fixture root is required"); + if (!existsSync(fixtureRoot)) throw new ControlRefusal(LIVE_SESSION_REFUSED, "the fixture root does not exist"); + this.fixtureRoot = fixtureRoot; + const home = homedir(); + const allHomes = [home, ...homes]; + const protectedPaths = []; + for (const repo of [REPO_ROOT, ...repoRoots]) protectedPaths.push({ path: join(repo, ".pi", "state"), why: "a repository .pi/state" }); + for (const h of allHomes) { + protectedPaths.push({ path: join(h, ".pi"), why: "~/.pi" }); + protectedPaths.push({ path: join(h, ".claude"), why: "~/.claude" }); + protectedPaths.push({ path: join(h, ".mosaic-dev"), why: "the default data root" }); + const configured = configuredDataRoot(h); + if (configured) protectedPaths.push({ path: configured, why: "the configured data root" }); + } + for (const d of dataRoots) protectedPaths.push({ path: d, why: "the data root" }); + const roots = protectedPaths.filter((p) => p.why.includes("data root")).map((p) => p.path); + const found = roots.flatMap(registrationsUnder); + for (const reg of [...found, ...registrations]) for (const p of pathsIn(reg, [])) protectedPaths.push({ path: p, why: "a seat registration" }); + this.protected = protectedPaths; + } + + // Refuses unless every path is inside the fixture root and clear of every + // protected location, both as written and as real paths. + check(paths, when) { + const root = realPath(this.fixtureRoot); + const guarded = this.protected.flatMap((p) => [{ ...p, path: resolve(p.path) }, { ...p, path: realPath(p.path) }]); + for (const [name, p] of Object.entries(paths)) { + if (typeof p !== "string" || !isAbsolute(p)) throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} must be an absolute path (${when})`); + const written = resolve(p), real = realPath(p); + if (!inside(written, root) && !inside(written, resolve(this.fixtureRoot))) { + throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} is outside the fixture root (${when})`); + } + // The real path must be inside the real fixture root: a symlink out of + // it is refused even when the link itself sits inside. + if (!inside(real, root)) throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} resolves outside the fixture root (${when})`); + for (const candidate of [written, real]) { + const hit = guarded.find((g) => overlaps(candidate, g.path)); + if (hit) throw new ControlRefusal(LIVE_SESSION_REFUSED, `${name} overlaps ${hit.why} (${when})`); + } + } + return true; + } +} diff --git a/packages/conversation/src/pi-pin.mjs b/packages/conversation/src/pi-pin.mjs new file mode 100644 index 00000000..0ddf1a31 --- /dev/null +++ b/packages/conversation/src/pi-pin.mjs @@ -0,0 +1,92 @@ +// The Pi pin and the engine seal (#1507, CHAT-03 §3, lead decisions 31–32). +// +// Pin: package-lock.json and npm's installed record +// (node_modules/.package-lock.json) must both name the pinned version with the +// pinned integrity. That ties the install to the package through npm's record; +// it is not a hash of the files on disk. `pi` runs dist/bundle/cli.js, the +// package's bin, and the built-in llama.cpp extension ships inside it. +// +// Seal: the controller builds the launch argv. It always carries +// --no-extensions, --no-prompt-templates and --no-themes, and never an +// --extension argument (cli/args.js; usage.md 224 and 233–236). With +// --no-extensions Pi loads only command-line extension paths +// (resource-loader.js 316–318), so no explicit extension loads. Under the seal +// the Mosaic prompt in the slot is the only thing that can start a run, which +// is the basis for attributing a run to it by order. +// +// The seal is an allow-list. Pi's parser (cli/args.js) keeps the last --mode +// and the last --session, reads a bare word as a prompt and an `@` word as a +// file, so the argv must be exactly the controller's prefix followed by +// ENGINE_OPTIONS pairs, each at most once with one plain value. + +import { readFileSync } from "node:fs"; +import { isAbsolute, join } from "node:path"; +import { createHash } from "node:crypto"; +import { ControlRefusal } from "./safe-fs.mjs"; + +export const PI_PACKAGE = "@earendil-works/pi-coding-agent"; +export const PI_VERSION = "0.85.1"; +export const PI_INTEGRITY = "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ=="; +export const PI_BIN = join("node_modules", PI_PACKAGE, "dist", "bundle", "cli.js"); +export const SEAL_FLAGS = Object.freeze(["--no-extensions", "--no-prompt-templates", "--no-themes"]); +export const ENGINE_OPTIONS = Object.freeze(["--model", "--provider", "--thinking"]); + +export const ENGINE_PIN_MISMATCH = "engine-pin-mismatch"; +export const UNSEALED_ENGINE = "unsealed-engine"; + +function lockEntry(path) { + let lock; + try { + lock = JSON.parse(readFileSync(path, "utf8")); + } catch { + return null; + } + const entry = lock?.packages?.[`node_modules/${PI_PACKAGE}`]; + return entry && typeof entry === "object" ? entry : null; +} + +// `root` holds package-lock.json and node_modules/.package-lock.json. +export function checkEnginePin(root) { + for (const path of [join(root, "package-lock.json"), join(root, "node_modules", ".package-lock.json")]) { + const entry = lockEntry(path); + if (!entry || entry.version !== PI_VERSION || entry.integrity !== PI_INTEGRITY) { + throw new ControlRefusal(ENGINE_PIN_MISMATCH, `${path} does not pin ${PI_PACKAGE} ${PI_VERSION} with the pinned integrity`); + } + } + return { version: PI_VERSION, pin: PI_INTEGRITY }; +} + +export function buildPiArgs({ sessionFile, extraArgs = [] }) { + return ["--mode", "rpc", ...SEAL_FLAGS, "--session", sessionFile, ...extraArgs]; +} + +// Refuses any argv that is not `--mode rpc`, the three --no-* flags and +// `--session `, in that order, followed by ENGINE_OPTIONS +// pairs. That covers --extension in either spelling, a second --mode or +// --session, session and output flags (--no-session, --fork, --export, ...) +// and stray prompt words. +export function checkSeal(args) { + if (!Array.isArray(args) || args.some((a) => typeof a !== "string")) throw new ControlRefusal(UNSEALED_ENGINE, "launch argv is not a list of strings"); + const extension = args.find((a) => a === "-e" || a === "--extension" || a.startsWith("--extension=")); + if (extension !== undefined) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv carries ${extension}`); + for (const flag of SEAL_FLAGS) { + if (!args.includes(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv lacks ${flag}`); + } + const prefix = ["--mode", "rpc", ...SEAL_FLAGS, "--session"]; + if (prefix.some((a, i) => args[i] !== a)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv does not start with ${prefix.join(" ")}`); + const file = args[prefix.length]; + if (typeof file !== "string" || !isAbsolute(file)) throw new ControlRefusal(UNSEALED_ENGINE, "the --session value is not an absolute path"); + const seen = new Set(); + for (let i = prefix.length + 1; i < args.length; i += 2) { + const flag = args[i], value = args[i + 1]; + if (!ENGINE_OPTIONS.includes(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv carries ${flag}, which is not one of ${ENGINE_OPTIONS.join(", ")}`); + if (seen.has(flag)) throw new ControlRefusal(UNSEALED_ENGINE, `launch argv repeats ${flag}`); + if (typeof value !== "string" || !value || value.startsWith("-") || value.startsWith("@")) throw new ControlRefusal(UNSEALED_ENGINE, `${flag} needs one plain value`); + seen.add(flag); + } + return true; +} + +export function argvDigest(command, args) { + return createHash("sha256").update(JSON.stringify([command, ...args])).digest("hex"); +} diff --git a/packages/conversation/src/records.mjs b/packages/conversation/src/records.mjs new file mode 100644 index 00000000..8a7bc160 --- /dev/null +++ b/packages/conversation/src/records.mjs @@ -0,0 +1,101 @@ +// CHAT-01 records for the live controller (#1507, CHAT-03). +// +// Every record the controller emits is a CHAT-01 v2 record (schema +// docs/plans/chat-01/contracts.schema.json). The digest rule is CHAT-01's: +// sha256 of JSON with sorted keys (check.mjs `hash`). +// +// The verifier is the fixture's trusted digest registry, as in CHAT-01 +// (check.mjs `proof` and `stopped`). A proof the producer posts to it is +// self-posted, so no CHAT-03 proof is live authority (CHAT-01 lines 330–333). +// Without a verifier no proof verifies, and every stop ends `uncertain`. + +import { createHash, randomBytes } from "node:crypto"; +import { ID } from "./parts.mjs"; + +export const VERSION = 2; + +const sortKeys = (v) => + Array.isArray(v) ? v.map(sortKeys) : v && typeof v === "object" ? Object.fromEntries(Object.keys(v).sort().map((k) => [k, sortKeys(v[k])])) : v; +export const canonical = (v) => JSON.stringify(sortKeys(v)); +export const hash = (v) => createHash("sha256").update(canonical(v)).digest("hex"); +export const sha256 = (data) => createHash("sha256").update(data).digest("hex"); +export const without = (v, key) => Object.fromEntries(Object.entries(v).filter(([k]) => k !== key)); +export const equal = (a, b) => canonical(a) === canonical(b); +export const clone = (v) => structuredClone(v); + +export function newId(prefix) { + const id = `${prefix}-${randomBytes(8).toString("hex")}`; + if (!ID.test(id)) throw new Error(`bad id prefix ${prefix}`); + return id; +} + +export const record = (kind, fields) => ({ version: VERSION, kind, ...fields }); + +export function targetOf(binding) { + return { + conversation: binding.scope.conversation, + branch: binding.branch, + execution: binding.execution, + controllerGeneration: binding.controllerGeneration, + }; +} + +export const scopeMatch = (a, b) => a.conversation === b.conversation && a.branch === b.branch && a.execution === b.execution; + +// Seals a proof: its verification digest covers every other field. +export function sealProof(p) { + const body = without(p, "verificationDigest"); + return { ...body, verificationDigest: hash(body) }; +} + +export class FixtureVerifier { + constructor({ authorities = [] } = {}) { + this.authorities = new Set(authorities); + this.trusted = new Map(); + } + + // The fixture registry records a posted proof's digest (CHAT-01 fixtures' + // `trustedProofs`). Only proofs from a trusted authority are recorded. + post(p) { + if (!p || !this.authorities.has(p.authority)) return false; + const digest = hash(without(p, "verificationDigest")); + if (digest !== p.verificationDigest) return false; + this.trusted.set(p.id, digest); + return true; + } + + // check.mjs `proof`: authority, scope, stop, time and digest. + verify(p, kind, { binding, stop, now }) { + if (!p || p.kind !== kind || !this.authorities.has(p.authority)) return null; + if (p.conversation !== binding.scope.conversation || p.execution !== binding.execution || p.cohortRef !== binding.cohortRef || p.stop !== stop) return null; + if (Date.parse(p.observedAt) > now.getTime()) return null; + const digest = hash(without(p, "verificationDigest")); + return digest === p.verificationDigest && this.trusted.get(p.id) === digest ? p : null; + } + + // check.mjs `effects`. + effects(report, ctx) { + const p = this.verify(report, "effectReport", ctx); + return Boolean(p && p.invocations.every((i) => ["completed", "uncertain", "not-started"].includes(i.disposition) && (i.disposition === "not-started" || i.evidence))); + } + + // check.mjs `stopped`, less the stop-record checks the controller makes. + cohort(proof, report, ctx) { + const p = this.verify(proof, "cohortProof", ctx); + if (!p || !p.membershipComplete || p.membershipEpoch !== ctx.epoch) return false; + const unique = new Set(p.members.map((m) => `${m.boot}:${m.pid}:${m.startTicks}`)).size === p.members.length; + const dead = p.members.every((m) => m.terminatedAt && Date.parse(m.terminatedAt) <= Date.parse(p.observedAt)); + return unique && dead && this.effects(report, ctx); + } +} + +// Receipt order (§3 rule 8): admitted < dispatched < acknowledged < working < +// finished | failed. dispatch-refused only before dispatched, +// delivery-unknown only before working. +const ORDER = { admitted: 0, dispatched: 1, acknowledged: 2, working: 3, finished: 4, failed: 4 }; +export function receiptAllows(from, to) { + if (["finished", "failed", "dispatch-refused", "delivery-unknown"].includes(from)) return false; + if (to === "dispatch-refused") return from === "admitted"; + if (to === "delivery-unknown") return ORDER[from] < ORDER.working; + return ORDER[to] > ORDER[from]; +} diff --git a/packages/conversation/src/safe-fs.mjs b/packages/conversation/src/safe-fs.mjs index 582e964f..f0c85f93 100644 --- a/packages/conversation/src/safe-fs.mjs +++ b/packages/conversation/src/safe-fs.mjs @@ -26,6 +26,11 @@ export class Refusal extends Error { } } +// Refusals of the CHAT-03 control layer (controller, claims, guard, engine +// pin). They reach a socket client, never the reader's HTTP routes, so the +// control board's status map covers only the reader's own codes. +export class ControlRefusal extends Refusal {} + const SESSION_NAME = /^[A-Za-z0-9][A-Za-z0-9._:-]*\.jsonl$/; // Permission errors inside a root are one conversation's problem, not the diff --git a/packages/conversation/src/shim.mjs b/packages/conversation/src/shim.mjs new file mode 100644 index 00000000..2adbe48b --- /dev/null +++ b/packages/conversation/src/shim.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +// The supervisor shim (#1507, CHAT-03 §6). Not a library: `cohort.mjs` starts +// it as +// +// systemd-run --user --scope -p Delegate=yes --unit= --quiet -- \ +// node shim.mjs --socket -- +// +// Inside the delegated scope it moves itself into a `supervisor` child cgroup +// and starts the engine in an `engine` child cgroup. A small shell writes its +// own pid into engine/cgroup.procs before it execs `unshare -U +// --map-current-user --cgroup`, which execs the engine, so the engine is +// contained from its first instruction and its cgroup namespace is rooted at +// `engine`. The engine inherits the controller's stdin and stdout directly; +// the shim closes its own copies. The shim is not a cohort member. It holds +// the scope open, so systemd can't collect the cgroup before emptiness is +// read, and it outlives its controller so a restarted controller can reach +// the cohort again. +// +// Control is JSON lines over a Unix socket in the controller's 0700 socket +// directory. Every request names an op; every answer is {ok, ...} or +// {ok:false, unavailable}. Emptiness is a readable engine/cgroup.events with +// `populated 0`. A missing or unreadable file is unavailable, never empty. + +import { spawn } from "node:child_process"; +import { closeSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { createServer } from "node:net"; +import { join } from "node:path"; +import { LineSplitter, encodeLine, parseLine } from "./framing.mjs"; + +const args = process.argv.slice(2); +const sep = args.indexOf("--"); +const opt = (name) => { + const i = args.indexOf(name); + return i >= 0 && i < sep ? args[i + 1] : null; +}; +const socketPath = opt("--socket"); +const engineArgv = sep >= 0 ? args.slice(sep + 1) : []; +if (!socketPath || engineArgv.length === 0) { + process.stderr.write("shim: usage: shim.mjs --socket -- \n"); + process.exit(2); +} + +const own = readFileSync("/proc/self/cgroup", "utf8").split("\n").find((l) => l.startsWith("0::")); +const scope = join("/sys/fs/cgroup", own.slice(3).trim()); +const supervisor = join(scope, "supervisor"); +const engine = join(scope, "engine"); +const invocationId = process.env.INVOCATION_ID ?? null; +const bootId = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim(); + +mkdirSync(supervisor, { recursive: true }); +mkdirSync(engine, { recursive: true }); +writeFileSync(join(supervisor, "cgroup.procs"), String(process.pid)); + +const startOf = (pid) => { + try { + const stat = readFileSync(`/proc/${pid}/stat`, "utf8"); + const fields = stat.slice(stat.lastIndexOf(")") + 2).split(" "); + return Number(fields[19]); + } catch { + return null; + } +}; + +const child = spawn( + "/bin/sh", + ["-c", 'echo $$ > "$1/cgroup.procs" || exit 97; shift; exec unshare -U --map-current-user --cgroup -- "$@"', "mosaic-engine", engine, ...engineArgv], + { stdio: [0, 1, 2] }, +); +const enginePid = child.pid; +let engineExit = null; +child.on("exit", (code, signal) => { + engineExit = { code, signal, at: new Date().toISOString() }; +}); +// The engine holds the controller's pipes; the shim's copies would hide EOF. +closeSync(0); +closeSync(1); + +function events() { + try { + const text = readFileSync(join(engine, "cgroup.events"), "utf8"); + const out = {}; + for (const line of text.split("\n")) { + const [k, v] = line.split(" "); + if (k) out[k] = Number(v); + } + if (out.populated !== 0 && out.populated !== 1) return { ok: false, unavailable: "cgroup.events has no populated field" }; + return { ok: true, populated: out.populated, frozen: out.frozen ?? null }; + } catch (err) { + return { ok: false, unavailable: `engine/cgroup.events unreadable (${err.code ?? err.message})` }; + } +} + +// Every member of `engine` and its descendants: the engine's namespace can +// create child cgroups, so enumeration is recursive. +function members() { + const out = []; + const walk = (dir) => { + const procs = readFileSync(join(dir, "cgroup.procs"), "utf8").split("\n").filter(Boolean).map(Number); + for (const pid of procs) out.push({ pid, startTicks: startOf(pid), cgroup: dir.slice(scope.length) || "/" }); + for (const name of readdirSync(dir, { withFileTypes: true })) if (name.isDirectory()) walk(join(dir, name.name)); + }; + try { + walk(engine); + return { ok: true, members: out, boot: bootId }; + } catch (err) { + return { ok: false, unavailable: `engine enumeration failed (${err.code ?? err.message})` }; + } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function waitFor(pred, ms) { + const end = Date.now() + ms; + for (;;) { + const e = events(); + if (!e.ok) return e; + if (pred(e)) return e; + if (Date.now() >= end) return { ...e, timedOut: true }; + await sleep(10); + } +} + +async function handle(req) { + switch (req.op) { + case "hello": + return { ok: true, invocationId, scope: scope.slice("/sys/fs/cgroup".length), enginePid, engineStart: startOf(enginePid), shimPid: process.pid, shimStart: startOf(process.pid), boot: bootId, engineExit }; + case "events": + return events(); + case "members": + return members(); + case "term": { + const m = members(); + if (!m.ok) return m; + const signalled = []; + for (const { pid, startTicks } of m.members) { + if (startOf(pid) !== startTicks) continue; + try { + process.kill(pid, "SIGTERM"); + signalled.push(pid); + } catch { + // gone already + } + } + return { ok: true, signalled }; + } + case "freeze": + try { + writeFileSync(join(engine, "cgroup.freeze"), "1"); + } catch (err) { + return { ok: false, unavailable: `cgroup.freeze unwritable (${err.code ?? err.message})` }; + } + return waitFor((e) => e.frozen === 1 || e.populated === 0, Number(req.timeoutMs) || 2000); + case "kill": + try { + writeFileSync(join(engine, "cgroup.kill"), "1"); + } catch (err) { + return { ok: false, unavailable: `cgroup.kill unwritable (${err.code ?? err.message})` }; + } + return waitFor((e) => e.populated === 0, Number(req.timeoutMs) || 5000); + case "release": { + const e = events(); + if (!e.ok || e.populated !== 0) return { ok: false, unavailable: "the engine cgroup is not empty" }; + setTimeout(() => { + try { + unlinkSync(socketPath); + } catch { + // already gone + } + process.exit(0); + }, 10); + return { ok: true }; + } + default: + return { ok: false, unavailable: `unknown op ${String(req.op)}` }; + } +} + +const server = createServer((sock) => { + const splitter = new LineSplitter(async (line) => { + const parsed = parseLine(line); + const answer = parsed.error ? { ok: false, unavailable: parsed.error } : await handle(parsed.value); + if (!sock.destroyed) sock.write(encodeLine({ id: parsed.value?.id ?? null, ...answer })); + }, { maxBytes: 65536 }); + sock.on("data", (chunk) => splitter.push(chunk)); + sock.on("error", () => {}); +}); +server.listen(socketPath); +process.on("SIGTERM", () => {}); // a stray TERM never drops the scope's anchor +process.on("SIGHUP", () => {}); diff --git a/packages/conversation/src/terminal.mjs b/packages/conversation/src/terminal.mjs new file mode 100644 index 00000000..4aa512ae --- /dev/null +++ b/packages/conversation/src/terminal.mjs @@ -0,0 +1,280 @@ +// The mediated terminal (#1507, CHAT-03 §1, §4, §7, §9). +// +// node packages/conversation/src/terminal.mjs --socket [--grant ] +// +// A thin view over the client library. It renders the same Transcript the +// library offers (E7), so it shows what any other client shows. It holds no +// engine-side state. +// +// The composer is a local buffer (§4). It is empty at start, cleared after +// each submit and whenever the controller changes, and it submits only while +// this connection is the controller. An observer's submit is refused here, +// "not admitted: controller", and sends nothing (S5). A bracketed paste is +// inserted literally, newlines included; it never submits by itself. +// +// Keys: 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. +// +// Engine text is shown with control characters made visible, so transcript +// content can't drive the operator's terminal. + +import { pathToFileURL } from "node:url"; +import { ConversationClient, OUTCOME_UNKNOWN } from "./client.mjs"; +import { Transcript } from "./transcript.mjs"; + +export const NOT_CONTROLLER = "not admitted: controller"; +const PASTE_START = "\x1b[200~"; +const PASTE_END = "\x1b[201~"; +const KEYS = Object.freeze({ "\r": "submit", "\n": "newline", "\x7f": "backspace", "\b": "backspace", "\x14": "takeover", "\x07": "interrupt", "\x0f": "reload", "\x03": "quit", "\x04": "quit" }); + +// Control characters, line and paragraph separators, bidi controls, invisible +// characters that can hide or spoof text (zero-width space, word joiner and +// invisible operators, BOM, tag characters) shown as text. ZWJ and ZWNJ pass: +// emoji sequences and joining scripts need them. +export function visible(s) { + return String(s).replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u061c\u200b\u200e\u200f\u2028\u2029\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff\u{e0000}-\u{e007f}]|\t/gu, (c) => { + if (c === "\t") return " "; + const n = c.codePointAt(0); + if (n < 0x20) return "^" + String.fromCharCode(n + 64); + if (n === 0x7f) return "^?"; + return ``; + }); +} + +// For lines that must stay one line (head, status, notices): LF shown too. +const oneLine = (s) => visible(s).replace(/\n/g, "^J"); + +export class Terminal { + constructor({ client, write = () => {}, rows = 24, onQuit = () => {} }) { + this.client = client; + this.write = write; + this.rows = rows; + this.onQuit = onQuit; + this.transcript = new Transcript({ client }); + this.composer = ""; + this.inPaste = false; + this.carry = ""; + this.scroll = 0; + this.status = ""; + this.unknownCount = 0; + this.dialogs = []; + this.notices = []; + this.lastReceipt = null; + this.controller = client.binding?.controllerConnection ?? null; + this.frame = []; + this.sent = 0; + this.queue = Promise.resolve(); + client.on((m) => this.#onClient(m)); + this.transcript.on(() => this.render()); + } + + #onClient(m) { + if (m.type === "welcome" || (m.type === "push" && m.kind === "binding")) { + const next = this.client.binding?.controllerConnection ?? null; + // §4: the composer clears on every control transfer (mutant 10). + if (next !== this.controller) this.composer = ""; + this.controller = next; + } else if (m.type === "push" && m.kind === "unknown") { + this.unknownCount = m.count; + } else if (m.type === "push" && m.kind === "dialog") { + this.dialogs.push(m.dialog); + } else if (m.type === "push" && m.kind === "receipt") { + if (m.outcomeUnknown) this.notices.push(`prompt ${m.receipt.id}: ${OUTCOME_UNKNOWN}`); + if (m.receipt.id === this.lastReceipt?.id) this.lastReceipt = m.receipt; + } else if (m.type === "outcome-unknown") { + this.notices.push(`${m.operation}: ${OUTCOME_UNKNOWN}`); + } else if (m.type === "status" && m.status === "disconnected") { + this.status = "disconnected; Ctrl-O reconnects"; + } + this.render(); + } + + // Feeds raw terminal input. Resolves when the actions it started finish. + key(data) { + const actions = []; + let s = this.carry + data; + this.carry = ""; + let i = 0; + while (i < s.length) { + if (this.inPaste) { + const end = s.indexOf(PASTE_END, i); + if (end === -1) { + const keep = partialSuffix(s.slice(i), PASTE_END); + this.composer += s.slice(i, s.length - keep); + this.carry = s.slice(s.length - keep); + break; + } + this.composer += s.slice(i, end); + this.inPaste = false; + i = end + PASTE_END.length; + continue; + } + if (s.startsWith(PASTE_START, i)) { + this.inPaste = true; + i += PASTE_START.length; + continue; + } + if (s[i] === "\x1b") { + // A trailing ESC, alone or with more of the paste-start marker, waits + // for the next chunk. A lone Escape has no action here, so holding it + // costs nothing. + if (s.length - i < PASTE_START.length && PASTE_START.startsWith(s.slice(i))) { + this.carry = s.slice(i); + break; + } + const seq = /^\x1b(?:\[[0-9;?]*[ -/]*[@-~]|O.|[\s\S])?/.exec(s.slice(i))[0]; + if (seq === "\x1b[5~") this.scroll += Math.max(1, this.rows - 4); + else if (seq === "\x1b[6~") this.scroll = Math.max(0, this.scroll - Math.max(1, this.rows - 4)); + else if (seq === "\x1b\r") this.composer += "\n"; + i += seq.length; + continue; + } + const action = KEYS[s[i]]; + if (action === "newline") this.composer += "\n"; + else if (action === "backspace") this.composer = Array.from(this.composer).slice(0, -1).join(""); + else if (action === "submit") { + // The composer is taken at the Enter, so text after it in the same + // chunk starts the next message instead of joining this one. + const text = this.#take(); + if (text !== null) actions.push(() => this.#send(text)); + } else if (action) actions.push(() => this.#act(action)); + else if (s[i] >= " ") this.composer += s[i]; + i += 1; + } + this.render(); + for (const run of actions) this.queue = this.queue.then(run); + return this.queue; + } + + async #act(action) { + if (action === "takeover") return this.#show("takeover", await this.client.takeover()); + if (action === "interrupt") return this.#show("interrupt", await this.client.interrupt()); + if (action === "reload") { + if (this.client.closed) { + try { + await this.client.connect(); + } catch (err) { + this.status = `connect: ${err.refusal ?? err.message}`; + } + } else await this.transcript.reload("manual"); + return this.render(); + } + if (action === "quit") return this.onQuit(); + } + + // Sends the composer as one prompt, only as the controller. A submit clears + // the composer whatever its outcome; an observer's Enter is refused before + // that and leaves the buffer for the operator. + async submit() { + const text = this.#take(); + if (text === null) { + this.render(); + return { sent: false, refusal: "controller" }; + } + return this.#send(text); + } + + // Empties the composer and returns its text, or refuses an observer and + // returns null, leaving the buffer. + #take() { + if (!this.client.isController) { + this.status = NOT_CONTROLLER; + return null; + } + const text = this.composer; + this.composer = ""; + return text; + } + + async #send(text) { + if (!text) return { sent: false, refusal: null }; + this.sent += 1; + const r = await this.client.prompt(text); + if (r.receipt) this.lastReceipt = r.receipt; + this.#show("prompt", r); + return { sent: true, reply: r }; + } + + #show(op, r) { + if (r.outcome === "outcome-unknown") this.status = `${op}: ${OUTCOME_UNKNOWN}`; + else if (r.refusal) this.status = `not admitted: ${r.refusal}`; + else this.status = `${op}: ${r.outcome}`; + this.render(); + } + + // The frame: a header, the transcript window, dialogs, notices, the status + // line and the composer. + render() { + const b = this.client.binding; + const head = `${oneLine(b?.state ?? "disconnected")} | ${this.client.isController ? "controller" : "observer"} | unknown events: ${this.unknownCount}`; + const body = []; + for (const line of this.transcript.lines()) body.push(...visible(line.replace(/\r\n/g, "\n")).split("\n")); + for (const d of this.dialogs) body.push(oneLine(`[dialog ${d.method} disabled: ${d.reason}]`)); + for (const n of this.notices) body.push(oneLine(n)); + const receipt = this.lastReceipt ? `last prompt: ${this.lastReceipt.state}${this.lastReceipt.reasonCode ? ` (${this.lastReceipt.reasonCode})` : ""}` : ""; + const status = oneLine([this.status, receipt].filter(Boolean).join(" | ")); + const composer = (this.composer ? this.composer.split("\n") : [""]).map((l, i) => (i ? " " : "> ") + visible(l)); + const room = Math.max(1, this.rows - 2 - composer.length); + const end = Math.max(0, body.length - this.scroll); + this.frame = [head, ...body.slice(Math.max(0, end - room), end), status, ...composer]; + this.write("\x1b[H\x1b[2J" + this.frame.join("\r\n")); + return this.frame; + } +} + +// How many trailing characters of `s` begin `marker`. +function partialSuffix(s, marker) { + for (let n = Math.min(s.length, marker.length - 1); n > 0; n--) if (marker.startsWith(s.slice(-n))) return n; + return 0; +} + +function parseArgs(argv) { + const out = { socket: null, grant: "grant-local" }; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === "--socket") out.socket = argv[++i]; + else if (argv[i] === "--grant") out.grant = argv[++i]; + else throw new Error(`unknown argument: ${argv[i]}`); + } + if (!out.socket) throw new Error("usage: terminal.mjs --socket [--grant ]"); + return out; +} + +async function main() { + let args; + try { + args = parseArgs(process.argv.slice(2)); + } catch (err) { + process.stderr.write(err.message + "\n"); + process.exit(2); + } + const { stdin, stdout } = process; + const client = new ConversationClient({ socketPath: args.socket, grant: args.grant }); + const restore = () => { + stdout.write("\x1b[?2004l\r\n"); + if (stdin.isTTY) stdin.setRawMode(false); + }; + const quit = () => { + restore(); + client.close(); + process.exit(0); + }; + const term = new Terminal({ client, write: (s) => stdout.write(s), rows: stdout.rows || 24, onQuit: quit }); + try { + await client.connect(); + } catch (err) { + process.stderr.write(`could not connect: ${err.refusal ?? err.message}\n`); + process.exit(1); + } + if (stdin.isTTY) stdin.setRawMode(true); + stdout.write("\x1b[?2004h"); + stdout.on("resize", () => { + term.rows = stdout.rows || 24; + term.render(); + }); + stdin.on("data", (c) => void term.key(c.toString("utf8"))); + stdin.on("end", quit); + term.render(); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) main(); diff --git a/packages/conversation/src/text-policy.mjs b/packages/conversation/src/text-policy.mjs new file mode 100644 index 00000000..f64d8344 --- /dev/null +++ b/packages/conversation/src/text-policy.mjs @@ -0,0 +1,41 @@ +// The text policy for mediated prompts (#1507, CHAT-03 §4). +// +// CHAT-01 lines 181–183 refuse `text-policy` when the first non-whitespace +// character is `/`. CHAT-01 lines 368–370 leave `!`, `@` and slashes on later +// lines open; CHAT-03 settles them from the pinned Pi 0.85.1 source. The RPC +// `prompt` command calls AgentSession.prompt(message) (rpc-mode.js 298–318), +// and that path interprets text only through three checks, all on the exact +// first character: +// +// - extension commands: `text.startsWith("/")` (agent-session.js 828); +// - skills: `text.startsWith("/skill:")` (agent-session.js 984); +// - prompt templates: `text.startsWith("/")` (prompt-templates.js 222). +// +// The bundle `pi` runs (dist/bundle/chunks/chunk-JVUZSMYM.js) carries the same +// three checks. `!` and `!!` are shell shortcuts of the interactive editor only +// (interactive-mode.js 2502), and `@` is a file argument of the command line +// only (cli/args.js 214); neither is on the RPC prompt path. Pi never trims +// before its checks, so a slash after leading whitespace or on a later line is +// plain text to Pi. CHAT-01's rule still refuses the leading-whitespace case. +// +// Every prefix is therefore classified: a first non-whitespace `/` is refused, +// and everything else is text. PREFIXES is the list the tests read (S2). + +export const TEXT_POLICY = "text-policy"; + +export const PREFIXES = Object.freeze([ + Object.freeze({ prefix: "/", interpreted: true, example: "/goal x", basis: "agent-session.js 828: extension commands run immediately" }), + Object.freeze({ prefix: "/skill:", interpreted: true, example: "/skill:ms-unslop", basis: "agent-session.js 984: skills expand" }), + Object.freeze({ prefix: "/