@junghanacs/entwurf 0.13.1 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/AGENTS.md +48 -15
  2. package/BASELINE.md +3 -3
  3. package/CHANGELOG.md +45 -0
  4. package/CONTRIBUTING.md +13 -9
  5. package/DELIVERY.md +7 -6
  6. package/README.md +27 -27
  7. package/VERIFY.md +22 -14
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +9 -4
  12. package/docs/external-mcp-host.md +4 -5
  13. package/docs/setup-clean-host.md +8 -7
  14. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +148 -28
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +8 -6
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  37. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  38. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  39. package/mcp/entwurf-bridge/src/index.ts +164 -28
  40. package/mcp/entwurf-bridge/start.sh +2 -2
  41. package/mcp/entwurf-bridge/test.sh +23 -9
  42. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  43. package/package.json +22 -11
  44. package/pi-extensions/entwurf-control.ts +218 -40
  45. package/pi-extensions/lib/acp/backend.ts +71 -12
  46. package/pi-extensions/lib/acp/overlay.ts +8 -6
  47. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  48. package/pi-extensions/lib/entwurf-core.ts +15 -15
  49. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  50. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  51. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  52. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  53. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  54. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  55. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  56. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  57. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  58. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  59. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  60. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  61. package/pi-extensions/lib/meta-session.ts +93 -5
  62. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  63. package/pi-extensions/lib/mux-launch.ts +267 -0
  64. package/pi-extensions/lib/mux-placement.ts +387 -0
  65. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  66. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  67. package/pi-extensions/lib/session-id.js +8 -5
  68. package/pi-extensions/lib/socket-discovery.ts +3 -3
  69. package/run.sh +471 -201
  70. package/scripts/agy-bridge-config.py +5 -1
  71. package/scripts/check-acp-backend-preflight.ts +1 -1
  72. package/scripts/check-acp-overlay.ts +13 -3
  73. package/scripts/check-acp-stream-hooks.ts +504 -0
  74. package/scripts/check-elapsed.sh +25 -0
  75. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  76. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  77. package/scripts/check-entwurf-control-rpc.ts +4 -3
  78. package/scripts/check-entwurf-resume-args.ts +72 -70
  79. package/scripts/check-entwurf-session-identity.ts +14 -10
  80. package/scripts/check-entwurf-v2-contract.ts +34 -59
  81. package/scripts/check-entwurf-v2-decider.ts +17 -177
  82. package/scripts/check-entwurf-v2-lock.ts +5 -2
  83. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  84. package/scripts/check-entwurf-v2-production.ts +2 -91
  85. package/scripts/check-entwurf-v2-release.ts +10 -105
  86. package/scripts/check-entwurf-v2-runner.ts +4 -85
  87. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  88. package/scripts/check-entwurf-v2-send.ts +0 -28
  89. package/scripts/check-entwurf-v2-surface.ts +157 -128
  90. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  91. package/scripts/check-fresh-cut-gate.sh +1 -1
  92. package/scripts/check-gate-qualification.ts +98 -7
  93. package/scripts/check-install-container.sh +10 -2
  94. package/scripts/check-install-surface.ts +1 -1
  95. package/scripts/check-keyset-overlap.py +1 -1
  96. package/scripts/check-meta-facts.ts +249 -0
  97. package/scripts/check-meta-identity-consumers.ts +1 -1
  98. package/scripts/check-meta-session.ts +169 -0
  99. package/scripts/check-mux-launch-tmux.ts +316 -0
  100. package/scripts/check-mux-launch.ts +288 -0
  101. package/scripts/check-mux-launcher-fence.ts +264 -0
  102. package/scripts/check-mux-parent-artifact.ts +195 -0
  103. package/scripts/check-mux-placement-tmux.ts +322 -0
  104. package/scripts/check-mux-placement.ts +323 -0
  105. package/scripts/check-mux-resume-call.ts +282 -0
  106. package/scripts/check-probe-cli-shim.ts +25 -22
  107. package/scripts/check-probe-ordering.ts +84 -76
  108. package/scripts/check-release-gate-outcomes.ts +127 -7
  109. package/scripts/check-resume-launch-identity.ts +244 -0
  110. package/scripts/check-socket-discovery.ts +1 -1
  111. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  112. package/scripts/inventory-verification-surface.ts +349 -0
  113. package/scripts/lib/claude-launcher-fence.ts +322 -0
  114. package/scripts/lib/mutation-qualify.ts +109 -3
  115. package/scripts/meta-bridge-doctor.sh +6 -8
  116. package/scripts/meta-facts.ts +60 -0
  117. package/scripts/mutants/acp-overlay.json +17 -0
  118. package/scripts/mutants/acp-stream-hooks.json +158 -0
  119. package/scripts/mutants/bridge-boot-resume.json +45 -0
  120. package/scripts/mutants/meta-facts.json +50 -0
  121. package/scripts/mutants/meta-identity.json +36 -0
  122. package/scripts/mutants/mux-boundary.json +196 -0
  123. package/scripts/mutants/mux-fresh-call.json +185 -0
  124. package/scripts/mutants/mux-launcher-fence.json +123 -0
  125. package/scripts/mutants/mux-parent-artifact.json +39 -0
  126. package/scripts/mutants/mux-resume-call.json +148 -0
  127. package/scripts/mutants/probe-ordering.json +0 -1037
  128. package/scripts/mutants/release-gate.json +35 -0
  129. package/scripts/mutants/resume-args.json +76 -0
  130. package/scripts/mutants/resume-launch-identity.json +96 -0
  131. package/scripts/mutants/v2-surface.json +58 -18
  132. package/scripts/mutants/v2-visible-resume.json +215 -0
  133. package/scripts/new-session-id.ts +9 -4
  134. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  135. package/scripts/smoke-agy-native-push-live.ts +6 -17
  136. package/scripts/smoke-entwurf-v2-matrix-live.ts +1 -1
  137. package/scripts/smoke-meta-honesty.sh +1 -1
  138. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  139. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  140. package/scripts/smoke-pi-attach.ts +1 -1
  141. package/scripts/smoke-user-scope-citizen.sh +1 -1
  142. package/scripts/tsconfig.json +1 -0
  143. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  144. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  145. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  146. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  147. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  148. package/scripts/check-acp-sdk-surface.ts +0 -275
  149. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  150. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  151. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  152. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -467
@@ -0,0 +1,1136 @@
1
+ /**
2
+ * smoke-mux-lifecycle-live — the PUBLIC-HARNESS lifecycle acceptance for mux.
3
+ *
4
+ * RELEASE MUST. Needs `LIVE=1`. Spends real model turns on the operator's configured runtimes:
5
+ * two pi siblings (one native-provider, one recorded-ACP-provider), each resumed once, plus one
6
+ * Claude Code sibling.
7
+ *
8
+ * ── What this proves that nothing else does ──
9
+ *
10
+ * Every other gate here enters from SOURCE: it calls `freshCall()` or `visibleResume()` directly.
11
+ * That leaves the whole operator circuit unproven — the tool a caller actually reaches, the
12
+ * identity that surface derives for itself, and everything that happens AFTER the sibling names
13
+ * itself. So this gate enters where a caller enters, through a real MCP `tools/call`, and follows
14
+ * one citizen all the way around:
15
+ *
16
+ * tools/call entwurf_fresh_call → LAUNCH receipt (tmux coordinates + nonce, nothing else)
17
+ * the sibling's own first action → CALLBACK receipt (its garden id, as the sender envelope of
18
+ * the nonce message — never a lookup, never a self-report)
19
+ * tools/call entwurf_v2 f-a-f → LIVE SEND receipt (control-socket, into a running session)
20
+ * tools/call entwurf_resume_call → REFUSED while the citizen is live, with no window opened
21
+ * closeWindow(launch handle) → dormant: pane gone, socket unconnectable, record preserved
22
+ * tools/call entwurf_v2 f-a-f → refused honestly, because dormant is unreachable by delivery
23
+ * tools/call entwurf_resume_call → RESUME LAUNCH + RESUME OBSERVATION receipts, kept apart
24
+ * tools/call entwurf_v2 f-a-f → the resumed citizen recalls the fact from BEFORE the close
25
+ *
26
+ * The receipts are kept SEPARATE and asserted separate: a launch receipt that mentioned the
27
+ * sibling's garden id, or an observation read off a launch, would be exactly the "one step
28
+ * vouching for the next" this rail refuses (docs/mux-launch-rail.md §12).
29
+ *
30
+ * The last step is the one that makes the resume mean anything. A socket answering under the same
31
+ * id proves a process stood up at that address; only a reply carrying a fact from before the
32
+ * window closed proves it is the SAME CONVERSATION. Both provider shapes are driven to that point,
33
+ * because they resolve their launch argv differently — the recorded-ACP shape is the only one that
34
+ * re-injects a bridge extension (#29), and a resume that silently lost its history would look
35
+ * identical from the socket axis.
36
+ *
37
+ * Claude Code runs the same first half and then STOPS where its capability stops: callback id,
38
+ * mailbox delivery, mailbox read receipt — and a resume that is REFUSED as `target-not-pi`, with
39
+ * no window opened and no lock residue after the per-gid coordination lock is released. Only pi
40
+ * stands a control socket up, so only pi has a same-id
41
+ * resume; the refusal is the proof, not an omission.
42
+ *
43
+ * ── Isolate the WRITES, keep the runtimes real ──
44
+ *
45
+ * The obvious fence — redirect HOME and everything under it — is wrong here, and wrongly GREEN is
46
+ * worse than red: a pi with no `PI_CODING_AGENT_DIR` and a Claude with no config dir are
47
+ * unauthenticated runtimes that fail for a reason this gate is not testing. So the split is:
48
+ *
49
+ * REAL (runtime-owned) the authenticated runtime config — the real pi agent dir for
50
+ * Pi, and canonical HOME/optional CLAUDE_CONFIG_DIR for Claude.
51
+ * Native session transcripts remain there as evidence.
52
+ * FIXTURE (entwurf-owned writes) the four meta roots, the working directory and the tmux
53
+ * servers — for every cell. For the NATIVE pi cell also the XDG
54
+ * roots, the control-socket dir and the v2 lock dir, all of
55
+ * which follow its fixture HOME.
56
+ *
57
+ * The ACP cell is the exception, and it is stated rather than hidden. Its provider spawns `claude`
58
+ * as a child that authenticates from the operator's real home — MEASURED 2026-08-06: under a
59
+ * fenced HOME that turn never reached the model at all, dying with `errorMessage: "Authentication
60
+ * required"`, and pointing CLAUDE_CONFIG_DIR at the real config dir did not change it. So that one
61
+ * cell runs under the REAL home (the same trade `smoke-acp-bundled-mcp-live` already makes), which
62
+ * means its citizen's control socket and per-gid lock appear briefly under the operator's own
63
+ * `~/.pi/entwurf-control` and `~/.pi/entwurf-v2-locks` while the cell runs. The meta store stays
64
+ * fixture, so no garden RECORD is ever minted outside it, and the safety oracle is the end state:
65
+ * all six real roots must hold the entry sets they started with, with no fixture garden id
66
+ * anywhere and no leftover lock for the target.
67
+ *
68
+ * Be honest about what that means, because VERIFY.md says it too: this gate READS the operator's
69
+ * real runtime config, and the siblings it opens write their own session transcripts into the real
70
+ * pi agent dir exactly as any session does — those are not cleaned up, and they are the evidence a
71
+ * resume was real.
72
+ *
73
+ * The invariant is therefore NOT "nothing is written outside the fixture". It is this, per axis:
74
+ *
75
+ * four meta roots (records, mailbox, receivers, senders) ALWAYS fixture, every cell
76
+ * native pi cell — control socket, v2 lock fixture (its HOME is)
77
+ * ACP pi cell — control socket, v2 lock REAL, transient (its HOME must be)
78
+ * Claude cell — runtime HOME real; mailbox + meta fixture
79
+ *
80
+ * So no garden RECORD is ever minted outside the fixture, and the closing oracle is the end state
81
+ * of SIX real roots — the four meta roots plus `~/.pi/entwurf-control` and
82
+ * `~/.pi/entwurf-v2-locks` — each holding the entry set it started with, with no fixture garden id
83
+ * anywhere. That proof now runs on the failure path too.
84
+ *
85
+ * Every bounded wait here belongs to the HARNESS. The product gets no watcher, no retry and no
86
+ * supervisor out of this file — `entwurf_resume_call`'s own bounded observation is the product's
87
+ * single startup wait, and this gate only reads its receipt.
88
+ */
89
+
90
+ import { type ChildProcess, spawn, spawnSync } from "node:child_process";
91
+ import { createHash } from "node:crypto";
92
+ import fs from "node:fs";
93
+ import fsp from "node:fs/promises";
94
+ import os from "node:os";
95
+ import path from "node:path";
96
+ import {
97
+ assessLauncherCleanup,
98
+ restoreOriginalXdg,
99
+ snapshotClaudeLauncher,
100
+ snapshotOriginalXdg,
101
+ verifyClaudeLauncher,
102
+ } from "./lib/claude-launcher-fence.ts";
103
+ import { skipLive } from "./lib/live-skip.ts";
104
+
105
+ const LABEL = "smoke-mux-lifecycle-live";
106
+ const REPO = path.join(import.meta.dirname, "..");
107
+ const BRIDGE_LAUNCHER = path.join(REPO, "mcp", "entwurf-bridge", "start.sh");
108
+
109
+ /** The two pi shapes. They differ in exactly one thing that matters here — whether the record
110
+ * carries `provider=entwurf`, which is what makes the resume argv re-inject the bridge (#29). */
111
+ const PI_NATIVE_MODEL = "openai-codex/gpt-5.6-terra";
112
+ const PI_ACP_MODEL = "entwurf/claude-sonnet-5";
113
+ const CLAUDE_MODEL = "claude-sonnet-5";
114
+
115
+ // Bounded waits — harness-side only. Each names what it is waiting FOR, so a red run says which
116
+ // real-world step never happened rather than "timeout".
117
+ const CALLBACK_WAIT_MS = 300_000; // sibling boot + its first turn (the nonce callback). A cold
118
+ // Claude Code start loads the operator's plugins before the first turn even begins, so this bound
119
+ // is generous on purpose: it must describe a sibling that never called back, not one still booting.
120
+ const TRANSCRIPT_WAIT_MS = 180_000; // pi turn_end completing record.transcriptPath
121
+ const LIVE_SEND_LANDED_MS = 180_000; // the sent message reaching the sibling's own transcript
122
+ const DORMANT_WAIT_MS = 30_000; // pane process exit + socket becoming unconnectable
123
+ const RECEIVER_ARM_MS = 120_000; // the Claude sibling's SessionStart arming its mailbox
124
+ const RECALL_MS = 240_000; // the resumed session's model actually replying
125
+ const CALL_TIMEOUT_MS = 60_000; // an ordinary tools/call round trip
126
+ const RESUME_CALL_TIMEOUT_MS = 90_000; // resume_call blocks on its own bounded observation (30s)
127
+ const POLL_MS = 500;
128
+
129
+ /** The scene fact, fixed here so `demo/` can replay the SAME messages without owning a second
130
+ * copy of the logic. The oracles stay machine facts — sender envelopes, rail receipts, records,
131
+ * sockets, transcripts — never pane text. */
132
+ const SCENE_FACT = "tempered indigo";
133
+
134
+ // Captured BEFORE any redirect: these name the operator's world and must stay untouched.
135
+ const REAL_HOME = os.homedir();
136
+ const REAL_PI_AGENT_DIR = process.env.PI_CODING_AGENT_DIR?.trim() || path.join(REAL_HOME, ".pi", "agent");
137
+ const ORIGINAL_CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR?.trim() || null;
138
+ const REAL_CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR || path.join(REAL_HOME, ".claude");
139
+ const REAL_XDG_DATA_HOME = process.env.XDG_DATA_HOME?.trim() || path.join(REAL_HOME, ".local", "share");
140
+ const REAL_XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME?.trim() || path.join(REAL_HOME, ".config");
141
+ const REAL_XDG_STATE_HOME = process.env.XDG_STATE_HOME?.trim() || path.join(REAL_HOME, ".local", "state");
142
+ const REAL_XDG_CACHE_HOME = process.env.XDG_CACHE_HOME?.trim() || path.join(REAL_HOME, ".cache");
143
+ // The four XDG roots as the OPERATOR has them — presence and value, captured before any redirect,
144
+ // so the real-HOME Claude Code cell can be given exact operator-env parity (issue #67). Distinct
145
+ // from the REAL_XDG_* constants above, which canonicalise an absent variable to its default path:
146
+ // parity restores absence AS absence.
147
+ const ORIGINAL_XDG = snapshotOriginalXdg(process.env);
148
+ const REAL_META_ROOTS: Record<string, string> = {
149
+ "meta-sessions": process.env.ENTWURF_META_SESSIONS_DIR?.trim() || path.join(REAL_PI_AGENT_DIR, "meta-sessions"),
150
+ "meta-mailbox": process.env.ENTWURF_META_MAILBOX_DIR?.trim() || path.join(REAL_PI_AGENT_DIR, "meta-mailbox"),
151
+ "meta-receivers": process.env.ENTWURF_META_RECEIVERS_DIR?.trim() || path.join(REAL_PI_AGENT_DIR, "meta-receivers"),
152
+ "meta-senders": process.env.ENTWURF_META_SENDERS_DIR?.trim() || path.join(REAL_PI_AGENT_DIR, "meta-senders"),
153
+ "entwurf-control": path.join(REAL_HOME, ".pi", "entwurf-control"),
154
+ // The per-gid lock dir is a HOMEDIR CONSTANT, not an env knob (entwurf-v2-lock.ts computes it
155
+ // from `os.homedir()` at import). It is an entwurf-owned write root like the others, so the
156
+ // operator's copy is proven untouched here too.
157
+ "entwurf-v2-locks": path.join(REAL_HOME, ".pi", "entwurf-v2-locks"),
158
+ };
159
+ const CLAUDE_PLUGIN_HOOKS = path.join(
160
+ REAL_XDG_DATA_HOME,
161
+ "entwurf",
162
+ "meta-bridge",
163
+ ".assembled",
164
+ "entwurf-meta-receive",
165
+ "hooks",
166
+ "hooks.json",
167
+ );
168
+
169
+ let passed = 0;
170
+ /** Every receipt this run collected, kept apart by name — see the header. */
171
+ const receipts: Record<string, string> = {};
172
+
173
+ function ok(label: string, cond: boolean, detail?: string): void {
174
+ if (!cond) throw new Error(`${LABEL}: FAILED — ${label}${detail ? `\n${detail}` : ""}`);
175
+ console.log(` ok ${label}`);
176
+ passed++;
177
+ }
178
+
179
+ function sleep(ms: number): Promise<void> {
180
+ return new Promise((resolve) => setTimeout(resolve, ms));
181
+ }
182
+
183
+ /**
184
+ * The entry SET of a directory we must not write to. A missing directory is a valid answer
185
+ * (ENOENT); anything else — EACCES above all — is a real failure and must not be laundered into
186
+ * "absent", which would turn an unreadable store into a passing proof.
187
+ */
188
+ function entrySet(dir: string): string {
189
+ try {
190
+ return fs.readdirSync(dir).sort().join("\n");
191
+ } catch (err) {
192
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return "<absent>";
193
+ throw err;
194
+ }
195
+ }
196
+
197
+ function tmux(socket: string, args: string[], env: NodeJS.ProcessEnv): { status: number | null; stdout: string } {
198
+ const r = spawnSync("tmux", ["-S", socket, ...args], { env, encoding: "utf8" });
199
+ return { status: r.status, stdout: (r.stdout ?? "").trim() };
200
+ }
201
+
202
+ function onPath(cmd: string): boolean {
203
+ return spawnSync("sh", ["-c", `command -v ${cmd}`], { encoding: "utf8" }).status === 0;
204
+ }
205
+
206
+ function pidIsAlive(pid: number): boolean {
207
+ try {
208
+ process.kill(pid, 0);
209
+ return true;
210
+ } catch (err) {
211
+ if ((err as NodeJS.ErrnoException).code === "ESRCH") return false;
212
+ throw err;
213
+ }
214
+ }
215
+
216
+ async function waitForPidsGone(pids: ReadonlySet<number>, timeoutMs = 10_000): Promise<boolean> {
217
+ const deadline = Date.now() + timeoutMs;
218
+ while (Date.now() < deadline) {
219
+ if ([...pids].every((pid) => !pidIsAlive(pid))) return true;
220
+ await sleep(100);
221
+ }
222
+ return [...pids].every((pid) => !pidIsAlive(pid));
223
+ }
224
+
225
+ /** Poll a predicate until it holds or the bound expires. Harness-only (see the header). */
226
+ async function waitFor(what: string, timeoutMs: number, probe: () => boolean | Promise<boolean>): Promise<boolean> {
227
+ const deadline = Date.now() + timeoutMs;
228
+ for (;;) {
229
+ if (await probe()) return true;
230
+ if (Date.now() >= deadline) {
231
+ console.log(` .. gave up waiting for ${what} after ${timeoutMs}ms`);
232
+ return false;
233
+ }
234
+ await sleep(POLL_MS);
235
+ }
236
+ }
237
+
238
+ function sha256(file: string): string {
239
+ return createHash("sha256").update(fs.readFileSync(file)).digest("hex");
240
+ }
241
+
242
+ // ── the public ingress: a real MCP client over stdio ────────────────────────
243
+ //
244
+ // Deliberately the SHIPPED launcher rather than an in-process import: a caller reaches these
245
+ // tools on a separate process whose identity it must derive for itself, and that derivation is
246
+ // half of what this gate is about.
247
+ class BridgeClient {
248
+ private readonly child: ChildProcess;
249
+ private readonly replies = new Map<number, any>();
250
+ private buf = "";
251
+ private stderr = "";
252
+ private nextId = 1;
253
+
254
+ constructor(env: NodeJS.ProcessEnv) {
255
+ this.child = spawn(BRIDGE_LAUNCHER, [], { stdio: ["pipe", "pipe", "pipe"], env });
256
+ this.child.stdout?.on("data", (d) => {
257
+ this.buf += d.toString();
258
+ const lines = this.buf.split("\n");
259
+ this.buf = lines.pop() ?? "";
260
+ for (const line of lines) {
261
+ const t = line.trim();
262
+ if (!t) continue;
263
+ try {
264
+ const msg = JSON.parse(t);
265
+ if (typeof msg?.id === "number") this.replies.set(msg.id, msg);
266
+ } catch {
267
+ /* not a JSON-RPC frame; the launcher keeps chatter on stderr */
268
+ }
269
+ }
270
+ });
271
+ this.child.stderr?.on("data", (d) => {
272
+ this.stderr += d.toString();
273
+ });
274
+ }
275
+
276
+ private async request(body: Record<string, unknown>, what: string, timeoutMs: number): Promise<any> {
277
+ const id = this.nextId++;
278
+ this.child.stdin?.write(`${JSON.stringify({ jsonrpc: "2.0", id, ...body })}\n`);
279
+ const deadline = Date.now() + timeoutMs;
280
+ for (;;) {
281
+ const got = this.replies.get(id);
282
+ if (got) return got;
283
+ if (Date.now() > deadline) {
284
+ throw new Error(`${LABEL}: timeout waiting for ${what}\n--- bridge stderr ---\n${this.stderr.slice(-2000)}`);
285
+ }
286
+ await sleep(50);
287
+ }
288
+ }
289
+
290
+ async toolNames(): Promise<string[]> {
291
+ const listed = await this.request({ method: "tools/list" }, "tools/list", CALL_TIMEOUT_MS);
292
+ return ((listed?.result?.tools ?? []) as { name?: string }[]).map((t) => t?.name ?? "");
293
+ }
294
+
295
+ /** One `tools/call`, returned as the operator sees it: rendered text plus the error flag. */
296
+ async call(
297
+ name: string,
298
+ args: Record<string, unknown>,
299
+ timeoutMs = CALL_TIMEOUT_MS,
300
+ ): Promise<{ text: string; isError: boolean }> {
301
+ const res = await this.request(
302
+ { method: "tools/call", params: { name, arguments: args } },
303
+ `tools/call ${name}`,
304
+ timeoutMs,
305
+ );
306
+ const text: string = res?.result?.content?.[0]?.text ?? JSON.stringify(res);
307
+ return { text, isError: res?.result?.isError === true };
308
+ }
309
+
310
+ stderrTail(): string {
311
+ return this.stderr.slice(-2000);
312
+ }
313
+
314
+ close(): void {
315
+ try {
316
+ this.child.kill("SIGTERM");
317
+ } catch {
318
+ /* already gone */
319
+ }
320
+ }
321
+ }
322
+
323
+ // ── parsing the rendered receipts ───────────────────────────────────────────
324
+ // Parsed, never assumed: these are the operator-visible texts, so reading them the way an
325
+ // operator would is part of what is being accepted.
326
+
327
+ interface Coordinates {
328
+ windowId: string;
329
+ windowIndex: string;
330
+ sessionId: string;
331
+ paneId: string;
332
+ panePid: string;
333
+ }
334
+
335
+ function parseCoordinates(text: string, what: string): Coordinates {
336
+ const win = /^\s*window:\s+(@\d+) \(index (\d+)\) in session (\$\d+)/m.exec(text);
337
+ const pane = /^\s*pane:\s+(%\d+) pid (\d+)/m.exec(text);
338
+ if (!win || !pane) throw new Error(`${LABEL}: could not read ${what} tmux coordinates\n--- receipt ---\n${text}`);
339
+ return {
340
+ windowId: win[1] as string,
341
+ windowIndex: win[2] as string,
342
+ sessionId: win[3] as string,
343
+ paneId: pane[1] as string,
344
+ panePid: pane[2] as string,
345
+ };
346
+ }
347
+
348
+ function parseNonce(text: string): string {
349
+ const nonce = /^\s*nonce:\s+(\S+)/m.exec(text);
350
+ if (!nonce) throw new Error(`${LABEL}: could not read the launch nonce\n--- receipt ---\n${text}`);
351
+ return nonce[1] as string;
352
+ }
353
+
354
+ const GARDEN_ID = /^\d{8}T\d{6}-[0-9a-f]{6}$/;
355
+
356
+ async function main(): Promise<void> {
357
+ // ── prerequisites: every one is a SKIP (97), never a red for a host that was never
358
+ // provisioned for this axis. `--cut` is what turns a skip into a blocked release.
359
+ if (process.env.LIVE !== "1") skipLive(LABEL, "LIVE=1 not set — this gate opens real windows and spends model turns");
360
+ if (spawnSync("tmux", ["-V"], { encoding: "utf8" }).status !== 0) skipLive(LABEL, "tmux is not installed on PATH");
361
+ if (!onPath("pi")) skipLive(LABEL, "the pi runtime is not on PATH — the fixed pi backend cannot be opened");
362
+ if (!onPath("claude"))
363
+ skipLive(LABEL, "the claude runtime is not on PATH — the fixed claude-code backend cannot be opened");
364
+ if (!fs.existsSync(REAL_PI_AGENT_DIR))
365
+ skipLive(LABEL, `no pi agent dir at ${REAL_PI_AGENT_DIR} — an unauthenticated pi would fail for the wrong reason`);
366
+ if (!fs.existsSync(REAL_CLAUDE_CONFIG_DIR))
367
+ skipLive(
368
+ LABEL,
369
+ `no Claude config dir at ${REAL_CLAUDE_CONFIG_DIR} — an unauthenticated claude would fail likewise`,
370
+ );
371
+ {
372
+ // The pi siblings are launched with the fixed argv `pi <prompt> --entwurf-control --model …`,
373
+ // so the extension must come from the operator's own pi install. Without it the window
374
+ // opens, no record is born, and the callback never arrives — a timeout that says nothing
375
+ // about the rail under test.
376
+ const settingsPath = path.join(REAL_PI_AGENT_DIR, "settings.json");
377
+ let registered = false;
378
+ try {
379
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, "utf8")) as { packages?: unknown };
380
+ registered =
381
+ Array.isArray(parsed.packages) && parsed.packages.some((p) => typeof p === "string" && p.includes("entwurf"));
382
+ } catch {
383
+ registered = false;
384
+ }
385
+ if (!registered)
386
+ skipLive(
387
+ LABEL,
388
+ `${settingsPath} does not register the entwurf pi package — a fresh 'pi --entwurf-control' would open ` +
389
+ "without the extension and never become a citizen. Run './run.sh setup <project>' first.",
390
+ );
391
+ }
392
+ if (!fs.existsSync(CLAUDE_PLUGIN_HOOKS))
393
+ skipLive(
394
+ LABEL,
395
+ `the Claude meta-bridge plugin is not installed (${CLAUDE_PLUGIN_HOOKS} missing) — a fresh Claude sibling ` +
396
+ "would never arm its mailbox, so the mailbox axis would fail for an install reason. Run './run.sh setup <project>' first.",
397
+ );
398
+ if (!fs.existsSync(BRIDGE_LAUNCHER)) skipLive(LABEL, `the bridge launcher is missing at ${BRIDGE_LAUNCHER}`);
399
+
400
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "mux-lifecycle-live-"));
401
+ const scratch = path.join(root, "cwd");
402
+ const beforeRealRoots: Record<string, string> = {};
403
+ for (const [name, dir] of Object.entries(REAL_META_ROOTS)) beforeRealRoots[name] = entrySet(dir);
404
+ const originalCwd = process.cwd();
405
+ // FAIL-CLOSED launcher preflight (issue #67): pin the real claude launcher — path, kind, link,
406
+ // resolved target and content — before ANY Claude-capable child starts. Both the Claude Code
407
+ // cell and the ACP pi cell (whose provider spawns `claude`) are downstream of this pin.
408
+ const launcherSnapshot = snapshotClaudeLauncher({ env: process.env, fixtureRoot: root });
409
+
410
+ // ── Every WRITE axis into the fixture; the two auth roots stay real ──────
411
+ const fenced: Record<string, string> = {
412
+ HOME: path.join(root, "home"),
413
+ XDG_CONFIG_HOME: path.join(root, "xdg-config"),
414
+ XDG_DATA_HOME: path.join(root, "xdg-data"),
415
+ XDG_STATE_HOME: path.join(root, "xdg-state"),
416
+ XDG_CACHE_HOME: path.join(root, "xdg-cache"),
417
+ XDG_RUNTIME_DIR: path.join(root, "xdg-runtime"),
418
+ ENTWURF_META_SESSIONS_DIR: path.join(root, "meta-sessions"),
419
+ ENTWURF_META_RECEIVERS_DIR: path.join(root, "meta-receivers"),
420
+ ENTWURF_META_SENDERS_DIR: path.join(root, "meta-senders"),
421
+ ENTWURF_META_MAILBOX_DIR: path.join(root, "meta-mailbox"),
422
+ };
423
+ // NOT an env axis: `ENTWURF_V2_LOCK_DIR` is a constant the lock module derives from
424
+ // `os.homedir()` when it is imported. Setting an env var of that name would look like a fence
425
+ // and fence nothing — the earlier version of this file did exactly that and then checked the
426
+ // directory the product never writes to, so "no lock residue" was a claim about an empty
427
+ // directory nobody used. The fixture HOME above is what actually moves it.
428
+
429
+ const referenced: Record<string, string> = { PI_CODING_AGENT_DIR: REAL_PI_AGENT_DIR };
430
+ for (const dir of Object.values(fenced)) fs.mkdirSync(dir, { recursive: true });
431
+ fs.mkdirSync(scratch, { recursive: true });
432
+ fs.chmodSync(fenced.XDG_RUNTIME_DIR as string, 0o700);
433
+ for (const [k, v] of Object.entries(fenced)) process.env[k] = v;
434
+ for (const [k, v] of Object.entries(referenced)) process.env[k] = v;
435
+ delete process.env.CLAUDE_CONFIG_DIR;
436
+ // A pi carrier inherited from whatever opened THIS process would outrank the sender marker
437
+ // below and send the bridge children out under someone else's identity.
438
+ delete process.env.PI_SESSION_ID;
439
+ delete process.env.PI_AGENT_ID;
440
+ delete process.env.ENTWURF_META_SENDER_MARKER;
441
+ process.chdir(scratch);
442
+
443
+ // Import the product layers only AFTER the redirects — a module that resolved its roots at
444
+ // import time (meta roots, the control-socket dir) would have captured the operator's.
445
+ const meta = await import("../pi-extensions/lib/meta-session.ts");
446
+ const { closeWindow } = await import("../pi-extensions/lib/mux-placement.ts");
447
+ const { inspectControlSocketPath, mapInspectionToLiveness, controlSocketPath } = await import(
448
+ "../pi-extensions/lib/socket-discovery.ts"
449
+ );
450
+ const { probeSocketLiveness } = await import("../pi-extensions/lib/socket-probe.ts");
451
+ const { ENTWURF_V2_LOCK_DIR: lockDirUnderTest } = await import("../pi-extensions/lib/entwurf-v2-lock.ts");
452
+
453
+ /** Where a CELL's citizen keeps its control socket and its per-gid lock. These are HOME-derived
454
+ * constants with no env override, so they follow whichever HOME that cell's tmux server runs
455
+ * under — and the two cells deliberately run under different ones (see `CELL_ENV`). A single
456
+ * imported constant would describe only this harness process and would silently claim the ACP
457
+ * cell's roots as well. */
458
+ interface CellRoots {
459
+ home: string;
460
+ controlDir: string;
461
+ lockDir: string;
462
+ }
463
+ const cellRoots = (home: string): CellRoots => ({
464
+ home,
465
+ controlDir: path.join(home, ".pi", "entwurf-control"),
466
+ lockDir: path.join(home, ".pi", "entwurf-v2-locks"),
467
+ });
468
+ /** The native pi cell is fully fenced: fixture HOME isolates its socket and its lock.
469
+ *
470
+ * The ACP cell cannot be. Its provider spawns `claude` as a child, and that child authenticates
471
+ * from the operator's real home — MEASURED 2026-08-06: under a fenced HOME the ACP turn never
472
+ * reached the model at all, dying with `errorMessage: "Authentication required"`, and pointing
473
+ * `CLAUDE_CONFIG_DIR` at the real config dir did not change it. So this cell runs under the
474
+ * REAL home, the same trade `smoke-acp-bundled-mcp-live` already makes, and its control socket
475
+ * and per-gid lock live briefly under the operator's own `~/.pi/` while it runs. The meta store,
476
+ * the working directory and the tmux server stay fixture. The safety oracle is the end state:
477
+ * all six real roots must have the entry sets they started with, with no fixture garden id and
478
+ * no leftover lock. */
479
+ const NATIVE_ROOTS = cellRoots(fenced.HOME as string);
480
+ const ACP_ROOTS = cellRoots(REAL_HOME);
481
+
482
+ /** The decider's OWN liveness verdict for a garden id, asked the same way it asks — against the
483
+ * control directory THAT CELL's citizen actually uses. */
484
+ async function socketState(gid: string, controlDir: string): Promise<string> {
485
+ const p = controlSocketPath(gid, controlDir);
486
+ const mapped = await mapInspectionToLiveness(await inspectControlSocketPath(p), probeSocketLiveness);
487
+ return "addressConflict" in mapped ? "address-conflict" : mapped.liveness;
488
+ }
489
+
490
+ const siblingGids = new Set<string>();
491
+ const panePids = new Set<number>();
492
+ const privateSockets = new Set<string>();
493
+ const bridges: BridgeClient[] = [];
494
+ let runError: unknown = null;
495
+ try {
496
+ ok(
497
+ "fence: every entwurf-owned WRITE root is inside the fixture and none is the operator's home",
498
+ Object.values(fenced).every((d) => d.startsWith(root)) && !Object.values(fenced).includes(REAL_HOME),
499
+ );
500
+ ok(
501
+ "fence: runtime auth roots are real — this gate accepts configured runtimes, not empty ones",
502
+ referenced.PI_CODING_AGENT_DIR === REAL_PI_AGENT_DIR &&
503
+ !(referenced.PI_CODING_AGENT_DIR as string).startsWith(root) &&
504
+ !REAL_CLAUDE_CONFIG_DIR.startsWith(root),
505
+ );
506
+ ok(
507
+ "fence: the product layers resolved their roots to the fixture, and cwd is the scratch dir",
508
+ meta.defaultMetaSessionsDir().startsWith(root) &&
509
+ meta.defaultMetaMailboxDir().startsWith(root) &&
510
+ controlSocketPath("probe").startsWith(root) &&
511
+ lockDirUnderTest.startsWith(root) &&
512
+ process.cwd().startsWith(root),
513
+ `--- lock dir the product resolved ---\n${lockDirUnderTest}`,
514
+ );
515
+ ok(
516
+ "fence: for THIS process and the fixture-HOME cell, the lock directory the product resolved is the fixture one — asserted against the module's own constant, because an env var of that name is read by nothing. It says nothing about the ACP cell, whose child runs under the real home in its own process and is judged by the real-root entry sets at the end instead",
517
+ lockDirUnderTest === NATIVE_ROOTS.lockDir,
518
+ );
519
+ ok("fence: XDG_RUNTIME_DIR is 0700", (fs.statSync(fenced.XDG_RUNTIME_DIR as string).mode & 0o777) === 0o700);
520
+
521
+ // ── The caller: a self-fetch citizen the bridge can recognise as its own owner ──
522
+ const nativeSessionId = `mux-lifecycle-live-${process.pid}`;
523
+ const caller = meta.upsertMetaSession({ input: { backend: "claude-code", nativeSessionId, cwd: scratch } });
524
+ const callerGid = caller.record.gardenId;
525
+ meta.writeMetaReceiverMarker({
526
+ gardenId: callerGid,
527
+ backend: "claude-code",
528
+ nativeSessionId,
529
+ ownerPid: process.pid,
530
+ armProvenance: "session-start",
531
+ });
532
+ const senderMarkerPath = meta.writeMetaSenderMarker({
533
+ backend: "claude-code",
534
+ gardenId: callerGid,
535
+ nativeSessionId,
536
+ cwd: scratch,
537
+ ownerPid: process.pid,
538
+ });
539
+ ok(
540
+ "fixture: the caller citizen was minted INSIDE the fixture store, with an armed mailbox and a sender marker",
541
+ Boolean(callerGid) && caller.path.startsWith(root) && senderMarkerPath.startsWith(root),
542
+ );
543
+
544
+ /** Drain the caller's inbox until the nonce comes back, and return the SENDER ENVELOPE
545
+ * garden id that rode with it. That line is the entire correlation contract, so it is
546
+ * parsed rather than assumed. `readMetaInbox` drains, so every arrival must be recorded
547
+ * on the pass that saw it. */
548
+ async function awaitCallbackSender(nonce: string, timeoutMs: number): Promise<string | null> {
549
+ let sender: string | null = null;
550
+ await waitFor(`the ${nonce} callback`, timeoutMs, () => {
551
+ for (const msg of meta.readMetaInbox({ gardenId: callerGid }).messages) {
552
+ const from = /^\s*session:\s+(\S+)/m.exec(msg.body)?.[1] ?? "";
553
+ if (msg.body.includes(nonce) && from) sender = from;
554
+ }
555
+ return sender !== null;
556
+ });
557
+ return sender;
558
+ }
559
+
560
+ /** One private tmux server per CELL, plus the client env that anchors into it. */
561
+ function startPrivateServer(
562
+ cell: string,
563
+ backend: "pi" | "claude-code",
564
+ roots: CellRoots = NATIVE_ROOTS,
565
+ ): {
566
+ socket: string;
567
+ serverPid: string;
568
+ env: NodeJS.ProcessEnv;
569
+ } {
570
+ const socket = path.join(root, `${cell}.sock`);
571
+ privateSockets.add(socket);
572
+ const serverEnv = { ...process.env } as NodeJS.ProcessEnv;
573
+ delete serverEnv.TMUX;
574
+ delete serverEnv.TMUX_PANE;
575
+ // Pi keeps fixture HOME so its control socket is isolated. Claude needs canonical
576
+ // operator HOME (and the operator's optional CLAUDE_CONFIG_DIR) or it enters first-run
577
+ // onboarding instead of exercising the configured runtime. A tmux window inherits the
578
+ // SERVER's environment, so separate servers preserve those backend-native environments
579
+ // without adding an env carrier to the product.
580
+ // A cell whose runtime authenticates from the operator's home gets it — Claude Code
581
+ // directly, and the ACP-backed pi cell because its provider spawns `claude`. Everything
582
+ // else about those cells stays fixture: meta store, cwd, tmux server.
583
+ if (roots.home === REAL_HOME) {
584
+ serverEnv.HOME = REAL_HOME;
585
+ // EXACT parity with the shipped focused smoke, which is green on this axis: when the
586
+ // operator has no CLAUDE_CONFIG_DIR, the variable is DELETED rather than set to the
587
+ // canonical path. "Same directory either way" is a guess about how the runtime reads
588
+ // its config; the working control does not set it, so neither does this.
589
+ if (ORIGINAL_CLAUDE_CONFIG_DIR) serverEnv.CLAUDE_CONFIG_DIR = ORIGINAL_CLAUDE_CONFIG_DIR;
590
+ else delete serverEnv.CLAUDE_CONFIG_DIR;
591
+ // The ACP cell additionally needs the real XDG roots: its provider resolves the
592
+ // bridged runtime through them — a MEASURED requirement, canonicalised paths and
593
+ // all, preserved as-is. The Claude Code cell instead gets EXACT operator-env parity
594
+ // on those four variables (issue #67): real HOME plus a fixture XDG_DATA_HOME was
595
+ // the state in which Claude's self-update rewrote the operator's real launcher into
596
+ // the fixture tree and teardown dangled it. Parity restores each original value and
597
+ // DELETES an originally absent variable — not the canonical-path widening that once
598
+ // turned this working cell into a failing one.
599
+ if (backend === "pi") {
600
+ serverEnv.XDG_CONFIG_HOME = REAL_XDG_CONFIG_HOME;
601
+ serverEnv.XDG_DATA_HOME = REAL_XDG_DATA_HOME;
602
+ serverEnv.XDG_STATE_HOME = REAL_XDG_STATE_HOME;
603
+ serverEnv.XDG_CACHE_HOME = REAL_XDG_CACHE_HOME;
604
+ } else {
605
+ restoreOriginalXdg(serverEnv, ORIGINAL_XDG);
606
+ }
607
+ }
608
+ if (tmux(socket, ["new-session", "-d", "-s", "fixture", "-c", scratch, "-n", "anchor"], serverEnv).status !== 0) {
609
+ throw new Error(`${LABEL}: could not start the private ${cell} tmux server at ${socket}`);
610
+ }
611
+ const anchorPane = tmux(
612
+ socket,
613
+ ["display-message", "-p", "-t", "fixture:anchor", "#{pane_id}"],
614
+ serverEnv,
615
+ ).stdout;
616
+ const serverPid = tmux(socket, ["display-message", "-p", "-t", "fixture:anchor", "#{pid}"], serverEnv).stdout;
617
+ const clientEnv: NodeJS.ProcessEnv = {
618
+ ...process.env,
619
+ // The BRIDGE CHILD only needs the real home when it must resolve a citizen's control
620
+ // SOCKET there — that is the ACP pi cell, whose socket follows its real-home runtime.
621
+ // Claude Code is reached through the meta-mailbox, which is env-addressed and fixture,
622
+ // so that cell's bridge child stays fenced exactly as it was before this split.
623
+ ...(roots.home === REAL_HOME && backend === "pi" ? { HOME: REAL_HOME } : {}),
624
+ TMUX: `${socket},0,0`,
625
+ TMUX_PANE: anchorPane,
626
+ ENTWURF_META_SENDER_MARKER: senderMarkerPath,
627
+ };
628
+ ok(
629
+ `${cell}: the tmux anchor is a private socket, never the operator's`,
630
+ String(clientEnv.TMUX).startsWith(root) && serverPid.length > 0,
631
+ );
632
+ return { socket, serverPid, env: clientEnv };
633
+ }
634
+
635
+ /** How many windows this server holds. Used to prove a REFUSED resume mutated nothing —
636
+ * "no window was opened" is a claim about the server, not about the response text. */
637
+ function windowCount(socket: string, env: NodeJS.ProcessEnv): number {
638
+ const listed = tmux(socket, ["list-windows", "-a", "-F", "#{window_id}"], env);
639
+ return listed.stdout.split("\n").filter((l) => l.trim()).length;
640
+ }
641
+
642
+ /** The pane's ACTUAL start directory, asked by stable pane id in a SEPARATE query.
643
+ * `pane_start_path` rather than `pane_current_path`: measured 2026-08-06, the row a
644
+ * `new-window -P -F` prints is emitted BEFORE the child chdirs, so current-path there
645
+ * still reports the caller's cwd. Pane TEXT is never an oracle anywhere in this gate. */
646
+ function paneStartPath(socket: string, paneId: string, env: NodeJS.ProcessEnv): string {
647
+ return tmux(socket, ["display-message", "-p", "-t", paneId, "#{pane_start_path}"], env).stdout;
648
+ }
649
+
650
+ /** Locks left behind in the root THAT CELL uses. A lock is per-gid, so a shared operator
651
+ * root is checked for THIS target rather than emptiness — other citizens may legitimately
652
+ * hold their own. */
653
+ function lockResidue(roots: CellRoots, targetGid?: string): string[] {
654
+ const all = fs.existsSync(roots.lockDir) ? fs.readdirSync(roots.lockDir) : [];
655
+ return roots.home === REAL_HOME && targetGid ? all.filter((f) => f.includes(targetGid)) : all;
656
+ }
657
+
658
+ // ════════════════════════════════════════════════════════════════════
659
+ // The pi lifecycle, run once per PROVIDER SHAPE
660
+ // ════════════════════════════════════════════════════════════════════
661
+ async function runPiCell(cell: string, model: string, full: boolean, roots: CellRoots): Promise<void> {
662
+ const srv = startPrivateServer(cell, "pi", roots);
663
+ const bridge = new BridgeClient(srv.env);
664
+ bridges.push(bridge);
665
+
666
+ if (full) {
667
+ const tools = await bridge.toolNames();
668
+ ok(
669
+ "public ingress: the real bridge boots and registers entwurf_fresh_call, entwurf_v2, entwurf_inbox_read and entwurf_resume_call",
670
+ ["entwurf_fresh_call", "entwurf_v2", "entwurf_inbox_read", "entwurf_resume_call"].every((t) =>
671
+ tools.includes(t),
672
+ ),
673
+ `--- tools ---\n${tools.join(",") || "(none)"}\n--- bridge stderr ---\n${bridge.stderrTail()}`,
674
+ );
675
+ }
676
+
677
+ // ── receipt 1/4: LAUNCH ─────────────────────────────────────────
678
+ const launch = await bridge.call("entwurf_fresh_call", {
679
+ backend: "pi",
680
+ model,
681
+ task:
682
+ `Remember this fact for the rest of this session: the color is ${SCENE_FACT}. ` +
683
+ "Then reply with the single word ACK and stop. Do not read files.",
684
+ });
685
+ ok(
686
+ `${cell}: tools/call entwurf_fresh_call returned a launch receipt through the public surface`,
687
+ !launch.isError,
688
+ `--- response ---\n${launch.text}\n--- bridge stderr ---\n${bridge.stderrTail()}`,
689
+ );
690
+ receipts[`${cell}/1-launch`] = launch.text;
691
+ const coords = parseCoordinates(launch.text, "the launch receipt's");
692
+ const nonce = parseNonce(launch.text);
693
+ panePids.add(Number(coords.panePid));
694
+ ok(
695
+ `${cell}: the launch receipt carries tmux coordinates, the requested model and the nonce, and says out loud that it is not delivery`,
696
+ launch.text.includes(model) && launch.text.includes("LAUNCH receipt") && launch.text.includes("does NOT mean"),
697
+ `--- receipt ---\n${launch.text}`,
698
+ );
699
+
700
+ // ── receipt 2/4: CALLBACK IDENTITY ──────────────────────────────
701
+ const gid = await awaitCallbackSender(nonce, CALLBACK_WAIT_MS);
702
+ ok(
703
+ `${cell}: the nonce came back and its SENDER ENVELOPE carries a garden id — correlation without asking the sibling`,
704
+ gid !== null && GARDEN_ID.test(gid),
705
+ `--- launch receipt ---\n${launch.text}`,
706
+ );
707
+ const citizen = gid as string;
708
+ siblingGids.add(citizen);
709
+ receipts[`${cell}/2-callback-identity`] = `${citizen} (sender envelope of ${nonce})`;
710
+ ok(
711
+ `${cell}: the launch receipt never named that garden id — the address came from the callback, not from the launch`,
712
+ !launch.text.includes(citizen) && citizen !== callerGid,
713
+ );
714
+ ok(
715
+ `${cell}: the sibling's record is in the FIXTURE store and its control socket is ALIVE`,
716
+ meta.metaRecordExistsByGardenId(citizen) && (await socketState(citizen, roots.controlDir)) === "alive",
717
+ );
718
+
719
+ // The record's transcriptPath is completed at turn_end, and it is the resume target —
720
+ // so this wait is also the proof the sibling's first turn really finished.
721
+ let transcript = "";
722
+ let recordCwd = "";
723
+ const gotTranscript = await waitFor(`the ${cell} record to carry a transcriptPath`, TRANSCRIPT_WAIT_MS, () => {
724
+ const record = meta.readMetaIdentityByGardenId(citizen);
725
+ if (record.transcriptPath && fs.existsSync(record.transcriptPath)) {
726
+ transcript = record.transcriptPath;
727
+ recordCwd = record.cwd ?? "";
728
+ return true;
729
+ }
730
+ return false;
731
+ });
732
+ ok(
733
+ `${cell}: the sibling completed a turn and its record now names a transcript (the resume target)`,
734
+ gotTranscript && transcript.length > 0,
735
+ );
736
+
737
+ // ── receipt 3/4: LIVE SEND ──────────────────────────────────────
738
+ const liveNeedle = `MUX_LIVE_SEND_${nonce.slice(-10)}`;
739
+ const send = await bridge.call("entwurf_v2", {
740
+ target: citizen,
741
+ intent: "fire-and-forget",
742
+ mode: "follow_up",
743
+ message: `Scene 2 (${liveNeedle}): you were told a color. Reply with that color only, then stop.`,
744
+ });
745
+ ok(
746
+ `${cell}: tools/call entwurf_v2 fire-and-forget delivered on the CONTROL-SOCKET rail to the fresh citizen`,
747
+ !send.isError && send.text.includes("control-socket") && send.text.includes("sent"),
748
+ `--- response ---\n${send.text}\n--- bridge stderr ---\n${bridge.stderrTail()}`,
749
+ );
750
+ receipts[`${cell}/3-live-send`] = send.text;
751
+ ok(
752
+ `${cell}: the live send physically landed in the sibling's OWN transcript — not just an ack from the rail`,
753
+ await waitFor("the sent message in the transcript", LIVE_SEND_LANDED_MS, () =>
754
+ fs.readFileSync(transcript, "utf8").includes(liveNeedle),
755
+ ),
756
+ `--- transcript ---\n${transcript}`,
757
+ );
758
+
759
+ // ── a resume aimed at a LIVE citizen is refused, and mutates nothing ──
760
+ const windowsBefore = windowCount(srv.socket, srv.env);
761
+ const refusedLive = await bridge.call("entwurf_resume_call", { target: citizen }, RESUME_CALL_TIMEOUT_MS);
762
+ ok(
763
+ `${cell}: entwurf_resume_call against a LIVE citizen is REFUSED as target-live and points at entwurf_v2`,
764
+ refusedLive.isError && refusedLive.text.includes("target-live") && refusedLive.text.includes("entwurf_v2"),
765
+ `--- response ---\n${refusedLive.text}`,
766
+ );
767
+ ok(
768
+ `${cell}: that refusal opened NO window — the server holds the same window count, so "no window was opened" is a fact about tmux, not a sentence in the response`,
769
+ windowCount(srv.socket, srv.env) === windowsBefore,
770
+ );
771
+ receipts[`${cell}/4-resume-refused-live`] = refusedLive.text;
772
+
773
+ // ── close the STABLE LAUNCH HANDLE and prove dormancy ───────────
774
+ const closed = closeWindow(
775
+ {
776
+ serverPid: srv.serverPid,
777
+ sessionId: coords.sessionId,
778
+ windowId: coords.windowId,
779
+ windowIndex: coords.windowIndex,
780
+ paneId: coords.paneId,
781
+ panePid: coords.panePid,
782
+ },
783
+ srv.env,
784
+ );
785
+ ok(`${cell}: the stable launch handle closed its own window`, closed === "closed", `--- outcome ---\n${closed}`);
786
+ ok(
787
+ `${cell}: the pane process is gone`,
788
+ await waitForPidsGone(new Set([Number(coords.panePid)]), DORMANT_WAIT_MS),
789
+ );
790
+ let dormantState = "";
791
+ ok(
792
+ `${cell}: the citizen is DORMANT — its control socket no longer answers, which is the fact the decider reads`,
793
+ await waitFor("the control socket to stop answering", DORMANT_WAIT_MS, async () => {
794
+ dormantState = await socketState(citizen, roots.controlDir);
795
+ return dormantState === "dead";
796
+ }),
797
+ `--- socket ---\n${controlSocketPath(citizen, roots.controlDir)} → ${dormantState}`,
798
+ );
799
+ ok(
800
+ `${cell}: the record survived the close — a dormant citizen keeps its address`,
801
+ meta.metaRecordExistsByGardenId(citizen),
802
+ );
803
+
804
+ if (full) {
805
+ const dormantSend = await bridge.call("entwurf_v2", {
806
+ target: citizen,
807
+ intent: "fire-and-forget",
808
+ message: "this must be refused: a dormant socket citizen has no active receiver",
809
+ });
810
+ ok(
811
+ `${cell}: delivery to the dormant citizen is refused HONESTLY as dormant-fire-forget-unsupported — nothing is queued for a session nobody is draining`,
812
+ dormantSend.isError && dormantSend.text.includes("dormant-fire-forget-unsupported"),
813
+ `--- response ---\n${dormantSend.text}`,
814
+ );
815
+ receipts[`${cell}/5-dormant-delivery-refused`] = dormantSend.text;
816
+ }
817
+
818
+ // ── receipt 4/4: RESUME LAUNCH + RESUME OBSERVATION ──────────────
819
+ const beforeResumeSha = sha256(transcript);
820
+ const beforeResumeGids = fs.readdirSync(meta.defaultMetaSessionsDir()).length;
821
+ const resumed = await bridge.call("entwurf_resume_call", { target: citizen }, RESUME_CALL_TIMEOUT_MS);
822
+ ok(
823
+ `${cell}: tools/call entwurf_resume_call reopened the citizen through the public surface`,
824
+ !resumed.isError,
825
+ `--- response ---\n${resumed.text}\n--- bridge stderr ---\n${bridge.stderrTail()}`,
826
+ );
827
+ receipts[`${cell}/6-resume-launch+observation`] = resumed.text;
828
+ const resumedCoords = parseCoordinates(resumed.text, "the resume receipt's");
829
+ panePids.add(Number(resumedCoords.panePid));
830
+ ok(
831
+ `${cell}: the resume response carries BOTH receipts and keeps them apart by name — a LAUNCH that only claims tmux made a window, and an OBSERVATION that is the only fact saying the citizen is back`,
832
+ resumed.text.includes("LAUNCH receipt") &&
833
+ resumed.text.includes("OBSERVATION receipt") &&
834
+ resumed.text.includes("the control socket for") &&
835
+ resumed.text.includes(citizen),
836
+ `--- response ---\n${resumed.text}`,
837
+ );
838
+ ok(
839
+ `${cell}: the OBSERVATION says the socket answered — and the same garden id is alive again on the decider's own probe`,
840
+ resumed.text.includes("answered after") && (await socketState(citizen, roots.controlDir)) === "alive",
841
+ );
842
+ ok(
843
+ `${cell}: the resume minted NO new citizen — the store holds the same number of records, and the resumed window is a different pane than the closed one`,
844
+ fs.readdirSync(meta.defaultMetaSessionsDir()).length === beforeResumeGids &&
845
+ resumedCoords.paneId !== coords.paneId,
846
+ );
847
+ ok(
848
+ `${cell}: the resume ran NO turn — the transcript is byte-identical across the call (a resume reopens a conversation, it does not continue it)`,
849
+ sha256(transcript) === beforeResumeSha,
850
+ );
851
+ ok(
852
+ `${cell}: the resumed pane actually started in the RECORD's cwd — asked by stable pane id in a separate tmux query, never read off the pane`,
853
+ recordCwd.length > 0 && paneStartPath(srv.socket, resumedCoords.paneId, srv.env) === recordCwd,
854
+ `--- record cwd ---\n${recordCwd}\n--- pane_start_path ---\n${paneStartPath(srv.socket, resumedCoords.paneId, srv.env)}`,
855
+ );
856
+ ok(
857
+ `${cell}: the per-gid lock was released — no lock file survives the resume`,
858
+ lockResidue(roots, citizen).length === 0,
859
+ `--- lock dir ${roots.lockDir} ---\n${lockResidue(roots, citizen).join("\n") || "(empty)"}`,
860
+ );
861
+
862
+ // ── the fact that makes the resume mean something ────────────────
863
+ const recallNeedle = `MUX_RECALL_${nonce.slice(-10)}`;
864
+ const recallSend = await bridge.call("entwurf_v2", {
865
+ target: citizen,
866
+ intent: "fire-and-forget",
867
+ mode: "follow_up",
868
+ message: `Scene 3 (${recallNeedle}): reply with the color you were asked to remember at the start, and stop.`,
869
+ });
870
+ ok(
871
+ `${cell}: the resumed citizen is addressable again — entwurf_v2 reached it on the control-socket rail`,
872
+ !recallSend.isError && recallSend.text.includes("control-socket"),
873
+ `--- response ---\n${recallSend.text}`,
874
+ );
875
+ receipts[`${cell}/7-recall-send`] = recallSend.text;
876
+ ok(
877
+ `${cell}: the resumed session RECALLED the fact it was told before the window closed — this, not the socket, is what proves the same CONVERSATION came back`,
878
+ await waitFor("the recalled fact in the resumed transcript", RECALL_MS, () => {
879
+ const text = fs.readFileSync(transcript, "utf8");
880
+ const after = text.slice(text.lastIndexOf(recallNeedle));
881
+ return after.includes(recallNeedle) && after.toLowerCase().includes(SCENE_FACT);
882
+ }),
883
+ `--- transcript ---\n${transcript}`,
884
+ );
885
+
886
+ // ── tear the resumed window down through the product's own verb ──
887
+ const resumedClose = closeWindow(
888
+ {
889
+ serverPid: srv.serverPid,
890
+ sessionId: resumedCoords.sessionId,
891
+ windowId: resumedCoords.windowId,
892
+ windowIndex: resumedCoords.windowIndex,
893
+ paneId: resumedCoords.paneId,
894
+ panePid: resumedCoords.panePid,
895
+ },
896
+ srv.env,
897
+ );
898
+ ok(
899
+ `${cell}: the resumed window closed by its own stable handle`,
900
+ resumedClose === "closed" || resumedClose === "already-gone",
901
+ );
902
+ }
903
+
904
+ // ════════════════════════════════════════════════════════════════════
905
+ // CLAUDE CODE — the same first half, then the honest stop
906
+ //
907
+ // Runs FIRST on purpose. Cell order is not a product contract, and this cell carries the
908
+ // heaviest external prerequisite: a configured Claude Code whose own session hook arms its
909
+ // mailbox. Putting it first means a host that cannot satisfy it fails before two pi turns
910
+ // have been spent. No cell reads another's state — the cross-cell correlation is asserted
911
+ // once, after all three, from the collected ids.
912
+ // ════════════════════════════════════════════════════════════════════
913
+ async function runClaudeCell(): Promise<void> {
914
+ const cc = startPrivateServer("claude-code", "claude-code", cellRoots(REAL_HOME));
915
+ const ccBridge = new BridgeClient(cc.env);
916
+ bridges.push(ccBridge);
917
+
918
+ const ccLaunch = await ccBridge.call("entwurf_fresh_call", {
919
+ backend: "claude-code",
920
+ model: CLAUDE_MODEL,
921
+ task: `Reply with the single word ACK and then stop. Do not read files. (scene fact: ${SCENE_FACT})`,
922
+ });
923
+ ok(
924
+ "claude-code: tools/call entwurf_fresh_call returned a launch receipt through the public surface",
925
+ !ccLaunch.isError,
926
+ `--- response ---\n${ccLaunch.text}\n--- bridge stderr ---\n${ccBridge.stderrTail()}`,
927
+ );
928
+ receipts["claude-code/1-launch"] = ccLaunch.text;
929
+ const ccCoords = parseCoordinates(ccLaunch.text, "the launch receipt's");
930
+ const ccNonce = parseNonce(ccLaunch.text);
931
+ panePids.add(Number(ccCoords.panePid));
932
+
933
+ const ccGid = await awaitCallbackSender(ccNonce, CALLBACK_WAIT_MS);
934
+ ok(
935
+ "claude-code: the nonce came back and its SENDER ENVELOPE carries a garden id",
936
+ ccGid !== null && GARDEN_ID.test(ccGid),
937
+ `--- launch receipt ---\n${ccLaunch.text}`,
938
+ );
939
+ const ccCitizen = ccGid as string;
940
+ siblingGids.add(ccCitizen);
941
+ receipts["claude-code/2-callback-identity"] = `${ccCitizen} (sender envelope of ${ccNonce})`;
942
+ ok(
943
+ "claude-code: the launch receipt never named that garden id — the address came from the callback, not from the launch",
944
+ !ccLaunch.text.includes(ccCitizen) && ccCitizen !== callerGid,
945
+ );
946
+
947
+ ok(
948
+ "claude-code: the sibling armed its OWN mailbox from its session hook (a self-fetch citizen is deliverable)",
949
+ await waitFor("the claude sibling's receiver marker", RECEIVER_ARM_MS, () =>
950
+ Boolean(meta.readMetaReceiverMarker({ gardenId: ccCitizen })),
951
+ ),
952
+ );
953
+ const ccSend = await ccBridge.call("entwurf_v2", {
954
+ target: ccCitizen,
955
+ intent: "fire-and-forget",
956
+ message: `Scene 4: the color is ${SCENE_FACT}. No reply needed.`,
957
+ });
958
+ ok(
959
+ "claude-code: tools/call entwurf_v2 fire-and-forget enqueued on the META-MAILBOX rail",
960
+ !ccSend.isError && ccSend.text.includes("meta-mailbox"),
961
+ `--- response ---\n${ccSend.text}\n--- bridge stderr ---\n${ccBridge.stderrTail()}`,
962
+ );
963
+ receipts["claude-code/3-mailbox-send"] = ccSend.text;
964
+ const box = path.join(meta.defaultMetaMailboxDir(), ccCitizen);
965
+ const boxFiles = await fsp.readdir(box).catch(() => [] as string[]);
966
+ ok(
967
+ "claude-code: exactly one .msg physically landed in the sibling's mailbox, and the doorbell was poked",
968
+ boxFiles.filter((f) => f.endsWith(".msg")).length === 1 && boxFiles.includes("inbox.signal"),
969
+ `--- mailbox ${box} ---\n${boxFiles.join("\n") || "(empty)"}`,
970
+ );
971
+
972
+ const ccRead = await ccBridge.call("entwurf_inbox_read", { gardenId: ccCitizen });
973
+ ok(
974
+ "claude-code: tools/call entwurf_inbox_read returned the body and stamped a read receipt",
975
+ !ccRead.isError && ccRead.text.includes("lastReadAt=") && ccRead.text.includes(SCENE_FACT),
976
+ `--- response ---\n${ccRead.text}`,
977
+ );
978
+ receipts["claude-code/4-mailbox-read"] = ccRead.text;
979
+ ok(
980
+ "claude-code: the delivered body names the CALLER's identity — sender and receiver joined",
981
+ ccRead.text.includes(callerGid),
982
+ `--- response ---\n${ccRead.text}`,
983
+ );
984
+
985
+ // No resume is invented for a backend that never stands a control socket up. The refusal is
986
+ // the proof: it opens no window, and its per-gid coordination lock leaves no residue.
987
+ const ccWindowsBefore = windowCount(cc.socket, cc.env);
988
+ const ccResume = await ccBridge.call("entwurf_resume_call", { target: ccCitizen }, RESUME_CALL_TIMEOUT_MS);
989
+ ok(
990
+ "claude-code: entwurf_resume_call is REFUSED as target-not-pi — a capability boundary named as itself, not an error about a record that is perfectly fine",
991
+ ccResume.isError && ccResume.text.includes("target-not-pi"),
992
+ `--- response ---\n${ccResume.text}`,
993
+ );
994
+ ok(
995
+ "claude-code: that refusal opened no window and left no lock behind",
996
+ windowCount(cc.socket, cc.env) === ccWindowsBefore && lockResidue(cellRoots(REAL_HOME), ccCitizen).length === 0,
997
+ );
998
+ receipts["claude-code/5-resume-refused-not-pi"] = ccResume.text;
999
+ }
1000
+
1001
+ await runClaudeCell();
1002
+ await runPiCell("pi-native", PI_NATIVE_MODEL, true, NATIVE_ROOTS);
1003
+ await runPiCell("pi-acp", PI_ACP_MODEL, false, ACP_ROOTS);
1004
+
1005
+ ok(
1006
+ "correlation: all three siblings reported DIFFERENT garden ids and none is the launcher's — asserted after every cell, so it never depends on which one ran first",
1007
+ siblingGids.size === 3 && !siblingGids.has(callerGid),
1008
+ );
1009
+
1010
+ // ── the receipt axes stay apart ──────────────────────────────────────
1011
+ const launchText = receipts["pi-native/1-launch"] as string;
1012
+ const sendText = receipts["pi-native/3-live-send"] as string;
1013
+ const resumeText = receipts["pi-native/6-resume-launch+observation"] as string;
1014
+ ok(
1015
+ "receipts: fresh launch / callback identity / live send / resume launch+observation are four DISTINCT texts, each naming its own step",
1016
+ new Set([launchText, receipts["pi-native/2-callback-identity"] as string, sendText, resumeText]).size === 4,
1017
+ );
1018
+ ok(
1019
+ "receipts: no earlier step's text was reused to vouch for a later one — the fresh launch never mentions delivery, the send never mentions a window, and the resume observation is the only text claiming the citizen is back",
1020
+ !launchText.includes("control-socket") &&
1021
+ !sendText.includes("window:") &&
1022
+ !launchText.includes("OBSERVATION receipt") &&
1023
+ resumeText.includes("OBSERVATION receipt"),
1024
+ );
1025
+ ok(
1026
+ "receipts: the retired owned-outcome resume is nowhere in this lifecycle — not in a call, not in a receipt",
1027
+ !Object.values(receipts).some((t) => t.includes("owned-outcome") && !t.includes("withdrawn")) &&
1028
+ !Object.values(receipts).some((t) => t.includes("spawn-bg")),
1029
+ );
1030
+ } catch (err) {
1031
+ // CAPTURED, not rethrown here. Teardown and the self-fence proof below must run on the
1032
+ // FAILURE path too: a red cell is exactly when a fixture is most likely to have leaked a
1033
+ // record, a socket or a lock into the operator's own roots, and the earlier shape — where
1034
+ // those checks sat after a `finally` — skipped them precisely then. Only the success path
1035
+ // was ever self-fenced.
1036
+ runError = err;
1037
+ }
1038
+
1039
+ // ── teardown, on both paths ──────────────────────────────────────────────
1040
+ const cleanupProblems: string[] = [];
1041
+ for (const bridge of bridges) bridge.close();
1042
+ for (const socket of privateSockets) tmux(socket, ["kill-server"], process.env);
1043
+ const panesGone = await waitForPidsGone(panePids);
1044
+ if (!panesGone)
1045
+ cleanupProblems.push(
1046
+ `tracked fixture pane processes were not proven gone after teardown: ${[...panePids].join(", ") || "(none)"}`,
1047
+ );
1048
+ process.chdir(originalCwd);
1049
+ // Launcher integrity BEFORE fixture removal, on success and failure alike (issue #67). Removal
1050
+ // happens only when it is PROVEN non-destructive: the launcher demonstrably does not reference
1051
+ // the fixture tree AND every TRACKED launched pane process is proven gone — a live pane could
1052
+ // still rewrite the launcher after the check. (Tracked-pane quiescence only; this claims
1053
+ // nothing about untracked detached descendants.) An unproven state blocks removal loudly
1054
+ // rather than guessing.
1055
+ const launcherProblems = verifyClaudeLauncher(launcherSnapshot);
1056
+ const launcherCleanup = assessLauncherCleanup(launcherSnapshot);
1057
+ for (const p of launcherProblems) cleanupProblems.push(`launcher integrity: ${p}`);
1058
+ for (const p of launcherCleanup.problems) cleanupProblems.push(`launcher cleanup: ${p}`);
1059
+ if (!launcherCleanup.safeToRemove || !panesGone) {
1060
+ cleanupProblems.push(
1061
+ `fixture removal of ${root} is BLOCKED — ${
1062
+ launcherCleanup.safeToRemove
1063
+ ? "tracked pane processes are not proven gone"
1064
+ : "the real claude launcher references the fixture tree or could not be proven safe"
1065
+ }; resolve the named problems above, then remove the tree by hand`,
1066
+ );
1067
+ } else {
1068
+ try {
1069
+ fs.rmSync(root, { recursive: true, force: true });
1070
+ } catch (err) {
1071
+ cleanupProblems.push(
1072
+ `fixture root ${root} could not be removed: ${err instanceof Error ? err.message : String(err)}`,
1073
+ );
1074
+ }
1075
+ if (fs.existsSync(root)) cleanupProblems.push(`fixture root ${root} still exists after removal`);
1076
+ }
1077
+
1078
+ // ── the self-fence proof, on both paths ──────────────────────────────────
1079
+ // Collected rather than thrown, so a failing run reports the run error AND whatever it left
1080
+ // behind. One must never hide the other.
1081
+ const fenceProblems: string[] = [];
1082
+ const rootUnchanged: Record<string, boolean> = {};
1083
+ const readableRealEntries: string[] = [];
1084
+ for (const [name, dir] of Object.entries(REAL_META_ROOTS)) {
1085
+ try {
1086
+ const current = entrySet(dir);
1087
+ readableRealEntries.push(current);
1088
+ rootUnchanged[name] = current === beforeRealRoots[name];
1089
+ if (!rootUnchanged[name]) fenceProblems.push(`the operator's ${name} entry set changed`);
1090
+ } catch (err) {
1091
+ rootUnchanged[name] = false;
1092
+ fenceProblems.push(
1093
+ `the operator's ${name} root could not be inspected after the run: ${
1094
+ err instanceof Error ? err.message : String(err)
1095
+ }`,
1096
+ );
1097
+ }
1098
+ }
1099
+ const realNow = readableRealEntries.join("\n");
1100
+ const noGidLeaked = [...siblingGids].every((gid) => !realNow.includes(gid));
1101
+ for (const gid of siblingGids) {
1102
+ if (realNow.includes(gid)) fenceProblems.push(`fixture garden id ${gid} appears in a real root`);
1103
+ }
1104
+
1105
+ if (runError || cleanupProblems.length > 0 || fenceProblems.length > 0) {
1106
+ const parts: string[] = [];
1107
+ if (runError) parts.push(`RUN: ${runError instanceof Error ? runError.message : String(runError)}`);
1108
+ if (cleanupProblems.length > 0) parts.push(`CLEANUP:\n ${cleanupProblems.join("\n ")}`);
1109
+ if (fenceProblems.length > 0) parts.push(`SELF-FENCE:\n ${fenceProblems.join("\n ")}`);
1110
+ throw new Error(`${LABEL}: run did not complete cleanly —\n\n${parts.join("\n\n")}`);
1111
+ }
1112
+
1113
+ // ── Prove the fence held, by name and by entry set, on every real root ───
1114
+ for (const name of Object.keys(REAL_META_ROOTS)) {
1115
+ ok(`self-fence: the operator's ${name} has the same entry set as before`, rootUnchanged[name] === true);
1116
+ }
1117
+ ok(
1118
+ "self-fence: not one fixture garden id appears in any of the operator's real record / mailbox / marker / socket / lock roots",
1119
+ noGidLeaked,
1120
+ );
1121
+ ok(
1122
+ "self-fence: the real claude launcher, its link and its resolved target are exactly as pinned before launch",
1123
+ verifyClaudeLauncher(launcherSnapshot).length === 0,
1124
+ );
1125
+
1126
+ console.log("\n receipts (kept apart, never folded):");
1127
+ for (const [name, text] of Object.entries(receipts)) {
1128
+ console.log(` ${name.padEnd(38)} ${text.split("\n")[0]}`);
1129
+ }
1130
+ console.log(`\n${LABEL}: ${passed} checks passed`);
1131
+ }
1132
+
1133
+ main().catch((err) => {
1134
+ console.error(`${LABEL}: ${err instanceof Error ? err.message : String(err)}`);
1135
+ process.exit(1);
1136
+ });