@junghanacs/entwurf 0.40.0 → 0.41.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 (118) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +55 -2
  3. package/CHANGELOG.md +321 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +18 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +26 -8
  8. package/VERIFY.md +13 -10
  9. package/docs/acp-backend-rail.md +5 -1
  10. package/docs/external-mcp-host.md +8 -6
  11. package/docs/fresh-cut-policy.md +9 -1
  12. package/docs/setup-clean-host.md +24 -8
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +81 -19
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/cc-retire-request.js +473 -0
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/control-retire.js +125 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire-production.js +149 -0
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-retire.js +158 -0
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +2 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +283 -20
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +7 -0
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-dispatch.js +47 -3
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/herdr-fresh-call.js +3 -1
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  32. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  33. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  34. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  35. package/mcp/entwurf-bridge/src/index.ts +93 -19
  36. package/mcp/entwurf-bridge/test.sh +2 -2
  37. package/mcp/entwurf-bridge/tsconfig.build.json +10 -1
  38. package/package.json +4 -4
  39. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  40. package/pi-extensions/entwurf-control.ts +55 -20
  41. package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  42. package/pi-extensions/lib/cc-retire-request.ts +553 -0
  43. package/pi-extensions/lib/control-retire.ts +195 -0
  44. package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
  45. package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
  46. package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
  47. package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
  48. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
  49. package/pi-extensions/lib/fresh-call-composition.ts +15 -0
  50. package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
  51. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  52. package/pi-extensions/lib/meta-session.ts +140 -16
  53. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  54. package/pi-extensions/lib/mux-launch.ts +29 -0
  55. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  56. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  57. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  58. package/pi-extensions/lib/retirement-event.ts +253 -0
  59. package/pi-extensions/meta-bridge-hook.ts +142 -13
  60. package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
  61. package/run.sh +78 -7
  62. package/scripts/agy-bridge-config.py +4 -1
  63. package/scripts/build-bridge.sh +14 -0
  64. package/scripts/check-capability-bundle-reach.ts +135 -2
  65. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  66. package/scripts/check-entwurf-fact-provider.ts +1 -0
  67. package/scripts/check-entwurf-v2-production.ts +2 -0
  68. package/scripts/check-entwurf-v2-surface.ts +7 -0
  69. package/scripts/check-gate-qualification.ts +14 -8
  70. package/scripts/check-herdr-activation.ts +2 -2
  71. package/scripts/check-herdr-placement.ts +1 -0
  72. package/scripts/check-hook-launch-topology.ts +6 -15
  73. package/scripts/check-install-container.sh +2 -2
  74. package/scripts/check-meta-doctor-oracle.sh +27 -1
  75. package/scripts/check-meta-facts.ts +8 -3
  76. package/scripts/check-meta-hook-session-switch.ts +230 -13
  77. package/scripts/check-meta-identity-consumers.ts +1 -0
  78. package/scripts/check-meta-listing.ts +1 -0
  79. package/scripts/check-meta-manifest-schema.py +1 -1
  80. package/scripts/check-meta-session.ts +31 -0
  81. package/scripts/check-meta-v3-record.ts +55 -1
  82. package/scripts/check-mux-resume-call.ts +35 -9
  83. package/scripts/check-peer-facts.ts +1 -0
  84. package/scripts/check-pi-durable-contact.ts +3 -1
  85. package/scripts/check-pi-durable-send.ts +4 -2
  86. package/scripts/check-pi-mcp-bridge.ts +1 -0
  87. package/scripts/check-resume-launch-identity.ts +62 -23
  88. package/scripts/lib/meta-hook-bundle.ts +72 -0
  89. package/scripts/meta-bridge-claude-floor.sh +13 -9
  90. package/scripts/meta-bridge-doctor.sh +17 -0
  91. package/scripts/meta-bridge-install.sh +6 -0
  92. package/scripts/meta-facts.ts +1 -1
  93. package/scripts/mutants/bridge-boot-resume.json +5 -2
  94. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  95. package/scripts/mutants/cc-retire-request.json +171 -0
  96. package/scripts/mutants/control-retire.json +105 -0
  97. package/scripts/mutants/copilot-receive.json +2 -6
  98. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  99. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  100. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  101. package/scripts/mutants/meta-identity.json +57 -0
  102. package/scripts/mutants/mux-fresh-call.json +1 -1
  103. package/scripts/mutants/mux-resume-call.json +40 -4
  104. package/scripts/mutants/pi-durable.json +79 -0
  105. package/scripts/mutants/pi-retire-request.json +72 -0
  106. package/scripts/mutants/resume-launch-identity.json +35 -8
  107. package/scripts/mutants/retirement-event.json +195 -0
  108. package/scripts/mutants/v2-surface.json +16 -0
  109. package/scripts/mutants/v2-visible-resume.json +283 -13
  110. package/scripts/probe-bridge-command.ts +1 -0
  111. package/scripts/smoke-agy-install-state.sh +2 -2
  112. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  113. package/scripts/smoke-herdr-raw-install-live.sh +5 -4
  114. package/scripts/smoke-meta-install-state.sh +4 -4
  115. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  116. package/scripts/smoke-omp-mcp-state.sh +1 -1
  117. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  118. package/scripts/smoke-pi-provider-state.sh +3 -3
@@ -0,0 +1,473 @@
1
+ /**
2
+ * cc-retire-request — the Node side of retiring ONE Claude Code citizen through the packaged pidfd
3
+ * helper (#147 G3, core-side leaf).
4
+ *
5
+ * NOT an authorization boundary. The caller (a future core verb) owns every decision about WHETHER
6
+ * this citizen may be retired: who asked, self-retire refusal, and the per-target lock. This leaf
7
+ * takes no lock of its own, so a caller holding the target lock cannot deadlock against it. It
8
+ * opens no public tool, reads no role or permission, and never reads a transcript, message body or
9
+ * credential.
10
+ *
11
+ * What it adds over the helper is the binding check while the helper HOLDS the pidfd. The helper
12
+ * pins a PROCESS; a Claude Code process can switch its native session in place (`/resume`,
13
+ * `/clear`), after which the same pid is a different citizen. So the record, the receiver marker
14
+ * and the sender marker the hook moves on every event are read once before the helper starts and
15
+ * once more after it reports `ready` — the fd is held by then, so the pid the second read names
16
+ * cannot have been reused. Only when that second read still names the same garden, native
17
+ * session, binding and owner is `go` written; any other answer writes `cancel`. A switch that
18
+ * commits AFTER the second read is not covered: nothing here claims to close that window.
19
+ *
20
+ * Signals: Node sends none to the target. The only signal Node sends is to its OWN helper child,
21
+ * by ChildProcess handle, after the helper has gone silent past a fixed bound — and that child is
22
+ * not yet reaped when it is sent, so its pid cannot have been reused.
23
+ */
24
+ import { spawn } from "node:child_process";
25
+ import * as fs from "node:fs";
26
+ import * as path from "node:path";
27
+ import { inspectReceiverMarker } from "./entwurf-v2-visible-resume.js";
28
+ import { readAddressableMetaIdentity, readMetaSenderMarker, } from "./meta-session.js";
29
+ export const CC_RETIRE_HELPER_FILE = "cc-retire-pidfd.py";
30
+ /** The owner kind the Claude Code hook's receiver marker carries: `writeMetaReceiverMarker`'s
31
+ * default (meta-session.ts), which the hook does not override. A marker naming any other owner is
32
+ * another harness's process, never a Claude Code foreground owner. */
33
+ export const CC_RECEIVER_OWNER_KIND = "claude-code-cli";
34
+ /** The arm provenances the Claude Code hook writes. `extension-join` is Copilot's, not Claude's. */
35
+ export const CC_RECEIVER_ARM_PROVENANCES = [
36
+ "session-start",
37
+ "cwd-changed",
38
+ "file-changed",
39
+ ];
40
+ /** Wall-clock bound on the whole helper exchange. The helper bounds itself (10 s for the control
41
+ * frame, 5 s for the exit); this only catches a helper that stops talking. */
42
+ export const CC_RETIRE_HELPER_WALL_MS = 30_000;
43
+ /** At most this many stdout bytes are accepted from the helper; more is a protocol failure. */
44
+ const STDOUT_MAX = 4096;
45
+ const STDERR_MAX = 4096;
46
+ /**
47
+ * The packaged helper: the fixed basename BESIDE this module, nowhere else. The canonical file is
48
+ * `pi-extensions/lib/cc-retire-pidfd.py`, which is beside the source; `build-bridge.sh` byte-copies
49
+ * it beside the emitted adapter in `mcp/entwurf-bridge/dist/pi-extensions/lib/`, so the same rule
50
+ * holds at both depths (check-capability-bundle-reach asks the compiled copy from where it lives).
51
+ * `null` when that entry is not a regular file — a packaging defect, refused by name; no parent
52
+ * directory, environment path or download is ever consulted.
53
+ */
54
+ export function ccRetireHelperPath(from = import.meta.dirname) {
55
+ const candidate = path.join(from, CC_RETIRE_HELPER_FILE);
56
+ try {
57
+ return fs.lstatSync(candidate).isFile() ? candidate : null;
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** The exact argv the helper is started with: isolated interpreter, the fixed file, two facts. */
64
+ export function ccRetireHelperArgv(helperPath, pid, startKey) {
65
+ return ["-I", "-S", helperPath, String(pid), startKey];
66
+ }
67
+ export function makeCcRetireDeps(overrides = {}) {
68
+ return {
69
+ readRecord: (gid) => readAddressableMetaIdentity(gid),
70
+ inspectReceiver: (gid) => inspectReceiverMarker(gid),
71
+ readSender: (ownerPid) => readMetaSenderMarker({ backend: "claude-code", ownerPid }),
72
+ helperPath: () => ccRetireHelperPath(),
73
+ spawnHelper: (argv) => spawn("python3", [...argv], { stdio: ["pipe", "pipe", "pipe"], env: { PATH: process.env.PATH ?? "" } }),
74
+ ...overrides,
75
+ };
76
+ }
77
+ const LINUX_KEY = /^linux:[0-9]{1,20}$/;
78
+ function causeOf(err) {
79
+ return err instanceof Error ? err.message : String(err);
80
+ }
81
+ /**
82
+ * One read of the three witnesses, compared against what the caller authorised and against each
83
+ * other. `pinned` (the second read) also requires the SAME owner as the first, because the helper
84
+ * is holding that process.
85
+ */
86
+ export function checkCcRetireBinding(expected, deps, pinned) {
87
+ const gid = expected.gardenId;
88
+ let record;
89
+ try {
90
+ record = deps.readRecord(gid);
91
+ }
92
+ catch (err) {
93
+ return { ok: false, reason: "record-unreadable", detail: causeOf(err) };
94
+ }
95
+ if (record.backend !== "claude-code") {
96
+ return { ok: false, reason: "target-not-claude-code", detail: `record backend is ${record.backend}` };
97
+ }
98
+ if (record.attachIncarnation === null) {
99
+ return { ok: false, reason: "target-unbound", detail: "record carries no attachIncarnation" };
100
+ }
101
+ for (const key of ["gardenId", "backend", "nativeSessionId", "createdAt", "attachIncarnation"]) {
102
+ if (record[key] !== expected[key]) {
103
+ return { ok: false, reason: "record-drifted", detail: `record ${key} is not the one the caller authorised` };
104
+ }
105
+ }
106
+ let inspection;
107
+ try {
108
+ inspection = deps.inspectReceiver(gid);
109
+ }
110
+ catch (err) {
111
+ return { ok: false, reason: "receiver-unreadable", detail: causeOf(err) };
112
+ }
113
+ if (inspection.kind === "absent")
114
+ return { ok: false, reason: "receiver-absent", detail: "no receiver marker" };
115
+ if (inspection.kind === "corrupt")
116
+ return { ok: false, reason: "receiver-corrupt", detail: "receiver marker unreadable" };
117
+ const marker = inspection.marker;
118
+ if (marker.gardenId !== gid ||
119
+ marker.backend !== "claude-code" ||
120
+ marker.nativeSessionId !== record.nativeSessionId) {
121
+ return {
122
+ ok: false,
123
+ reason: "receiver-drifted",
124
+ detail: "receiver marker names another garden, backend or session",
125
+ };
126
+ }
127
+ if (marker.ownerKind !== CC_RECEIVER_OWNER_KIND) {
128
+ return { ok: false, reason: "receiver-foreign-owner", detail: `receiver owner kind is ${marker.ownerKind}` };
129
+ }
130
+ if (!CC_RECEIVER_ARM_PROVENANCES.includes(marker.armProvenance)) {
131
+ return {
132
+ ok: false,
133
+ reason: "receiver-foreign-provenance",
134
+ detail: `receiver arm provenance is ${marker.armProvenance}`,
135
+ };
136
+ }
137
+ if (marker.attachIncarnation === null) {
138
+ return { ok: false, reason: "receiver-unbound", detail: "receiver marker carries no attachIncarnation" };
139
+ }
140
+ if (marker.attachIncarnation !== record.attachIncarnation) {
141
+ return { ok: false, reason: "receiver-drifted", detail: "receiver binding differs from the record's" };
142
+ }
143
+ if (inspection.owner === "dead")
144
+ return { ok: false, reason: "receiver-dead", detail: `owner ${marker.ownerPid} is gone` };
145
+ if (inspection.owner !== "live") {
146
+ return { ok: false, reason: "receiver-uncertain", detail: `owner ${marker.ownerPid} cannot be proven live` };
147
+ }
148
+ if (!LINUX_KEY.test(marker.ownerStartKey)) {
149
+ return { ok: false, reason: "start-key-unsupported", detail: "owner start key is not a linux:<starttime> key" };
150
+ }
151
+ if (pinned && (marker.ownerPid !== pinned.pid || marker.ownerStartKey !== pinned.startKey)) {
152
+ return { ok: false, reason: "receiver-drifted", detail: "receiver owner is not the process the helper holds" };
153
+ }
154
+ let sender;
155
+ try {
156
+ sender = deps.readSender(marker.ownerPid);
157
+ }
158
+ catch (err) {
159
+ return { ok: false, reason: "sender-unreadable", detail: causeOf(err) };
160
+ }
161
+ if (sender === null) {
162
+ return { ok: false, reason: "sender-absent", detail: `no trusted sender marker for owner ${marker.ownerPid}` };
163
+ }
164
+ // The reader is keyed by backend directory but does not compare the body's backend with it.
165
+ if (sender.backend !== "claude-code") {
166
+ return { ok: false, reason: "sender-foreign-backend", detail: `sender marker backend is ${sender.backend}` };
167
+ }
168
+ if (sender.gardenId !== gid ||
169
+ sender.nativeSessionId !== record.nativeSessionId ||
170
+ sender.ownerPid !== marker.ownerPid ||
171
+ sender.ownerStartKey !== marker.ownerStartKey) {
172
+ return { ok: false, reason: "sender-drifted", detail: "the owner's sender marker now names another citizen" };
173
+ }
174
+ return { ok: true, owner: { pid: marker.ownerPid, startKey: marker.ownerStartKey } };
175
+ }
176
+ function exactKeys(line, keys) {
177
+ const own = Object.keys(line).sort();
178
+ const want = [...keys].sort();
179
+ return own.length === want.length && own.every((k, i) => k === want[i]);
180
+ }
181
+ /** Outcomes the helper may print and the exit code each must come with. */
182
+ const PRE_READY_OUTCOMES = { refused: 2, "already-exited": 3 };
183
+ const CANCEL_OUTCOMES = { cancelled: 3 };
184
+ const AFTER_GO_NO_SIGNAL = {
185
+ "already-exited": 3,
186
+ "observe-failed-before-signal": 3,
187
+ refused: 2,
188
+ };
189
+ const AFTER_SIGNAL = { exited: 0, "exit-unobserved": 4, "observe-failed": 4 };
190
+ /**
191
+ * The helper's terminal line, closed per outcome (its `finish(...)` calls in cc-retire-pidfd.py):
192
+ * `refused` carries a `reason` and may carry an `errno` (name or number); `exit-unobserved` carries
193
+ * `waitedS`; every other outcome carries nothing else. A missing, extra or mistyped key is not that
194
+ * outcome. The refusal `reason` text is diagnostic and stays open — reported, never branched on.
195
+ */
196
+ function terminalShapeOk(line) {
197
+ switch (line.outcome) {
198
+ case "refused": {
199
+ const hasErrno = "errno" in line;
200
+ if (hasErrno && typeof line.errno !== "string" && typeof line.errno !== "number")
201
+ return false;
202
+ return (exactKeys(line, hasErrno ? ["outcome", "reason", "errno"] : ["outcome", "reason"]) &&
203
+ typeof line.reason === "string" &&
204
+ line.reason.length > 0);
205
+ }
206
+ case "exit-unobserved":
207
+ return exactKeys(line, ["outcome", "waitedS"]) && typeof line.waitedS === "number";
208
+ default:
209
+ return exactKeys(line, ["outcome"]);
210
+ }
211
+ }
212
+ function outcomeLine(line, allowed, code) {
213
+ if (!line || typeof line.outcome !== "string")
214
+ return null;
215
+ const want = allowed[line.outcome];
216
+ if (want === undefined || want !== code)
217
+ return null;
218
+ if (!terminalShapeOk(line))
219
+ return null;
220
+ return line.outcome;
221
+ }
222
+ /**
223
+ * Retire `expected.gardenId` if, and only if, every witness still names the citizen the caller
224
+ * authorised both before the helper starts and after it holds the pidfd.
225
+ */
226
+ export async function requestCcRetire(expected, deps = makeCcRetireDeps(), opts = {}) {
227
+ if (opts.signal?.aborted)
228
+ return { outcome: "refused", reason: "aborted", detail: "aborted before any check" };
229
+ const first = checkCcRetireBinding(expected, deps);
230
+ if (!first.ok)
231
+ return { outcome: "refused", reason: first.reason, detail: first.detail };
232
+ const helperPath = deps.helperPath();
233
+ if (helperPath === null) {
234
+ return { outcome: "refused", reason: "helper-missing", detail: `packaged ${CC_RETIRE_HELPER_FILE} not found` };
235
+ }
236
+ if (opts.signal?.aborted)
237
+ return { outcome: "refused", reason: "aborted", detail: "aborted before the helper started" };
238
+ const owner = first.owner;
239
+ const argv = ccRetireHelperArgv(helperPath, owner.pid, owner.startKey);
240
+ return await new Promise((resolve) => {
241
+ let child;
242
+ try {
243
+ child = deps.spawnHelper(argv);
244
+ }
245
+ catch (err) {
246
+ resolve({ outcome: "refused", reason: "helper-spawn-failed", detail: causeOf(err) });
247
+ return;
248
+ }
249
+ const lines = [];
250
+ let buf = "";
251
+ let stdoutBytes = 0;
252
+ let stderr = "";
253
+ let protocolError = null;
254
+ /** What Node has written: nothing yet, cancel, or go. Decides how every ending is read. */
255
+ let sent = "none";
256
+ let cancelReason = null;
257
+ let settled = false;
258
+ let spawnError = null;
259
+ let wallExpired = false;
260
+ let abortedAfterGo = false;
261
+ const finish = (result) => {
262
+ if (settled)
263
+ return;
264
+ settled = true;
265
+ clearTimeout(wall);
266
+ opts.signal?.removeEventListener("abort", onAbort);
267
+ resolve(result);
268
+ };
269
+ const killOwnHelper = () => {
270
+ if (child.exitCode === null && child.signalCode === null)
271
+ child.kill("SIGKILL");
272
+ };
273
+ /** Terminal latch: once a protocol failure, the wall bound, an abort after go or the ending
274
+ * has been recorded, no frame — above all no `go` — is ever written. Killing the helper is
275
+ * cleanup; THIS is the guard. */
276
+ const terminal = () => settled || protocolError !== null || wallExpired || abortedAfterGo;
277
+ const write = (frame) => {
278
+ if (terminal() || sent !== "none")
279
+ return;
280
+ // Recorded BEFORE the write: a `go` whose write threw may still have reached the pipe.
281
+ sent = frame;
282
+ try {
283
+ child.stdin.end(`${frame}\n`);
284
+ }
285
+ catch (err) {
286
+ protocolError ??= `writing the ${frame} frame threw: ${causeOf(err)}`;
287
+ killOwnHelper();
288
+ }
289
+ };
290
+ const cancel = (reason, detail) => {
291
+ if (terminal() || sent !== "none")
292
+ return;
293
+ cancelReason = { reason, detail };
294
+ write("cancel");
295
+ };
296
+ const onAbort = () => {
297
+ if (sent === "none" && lines.length > 0)
298
+ cancel("aborted-before-go", "the requester aborted while the fd was held");
299
+ else if (sent === "none") {
300
+ cancelReason = { reason: "aborted-before-go", detail: "the requester aborted before the helper was ready" };
301
+ sent = "cancel";
302
+ try {
303
+ child.stdin.end();
304
+ }
305
+ catch (err) {
306
+ protocolError ??= `closing the control pipe threw: ${causeOf(err)}`;
307
+ killOwnHelper();
308
+ }
309
+ }
310
+ else if (sent === "go") {
311
+ abortedAfterGo = true;
312
+ killOwnHelper();
313
+ }
314
+ };
315
+ const wall = setTimeout(() => {
316
+ wallExpired = true;
317
+ killOwnHelper();
318
+ }, opts.wallMs ?? CC_RETIRE_HELPER_WALL_MS);
319
+ opts.signal?.addEventListener("abort", onAbort, { once: true });
320
+ child.stdin.on("error", () => {
321
+ // EPIPE: the helper ended first. Its exit and its lines are the evidence, not this.
322
+ });
323
+ child.on("error", (err) => {
324
+ spawnError = causeOf(err);
325
+ // No pid: the interpreter never started, so nothing can be held or signalled. Do not wait
326
+ // for a `close` that a failed spawn is not obliged to deliver.
327
+ if (child.pid === undefined)
328
+ finish({ outcome: "refused", reason: "helper-spawn-failed", detail: spawnError });
329
+ });
330
+ child.stderr.on("data", (chunk) => {
331
+ if (stderr.length < STDERR_MAX)
332
+ stderr += chunk.toString("utf8").slice(0, STDERR_MAX - stderr.length);
333
+ });
334
+ child.stdout.on("data", (chunk) => {
335
+ // After a protocol failure nothing further is read: a buffered `ready` behind a bad line
336
+ // must not be acted on.
337
+ if (protocolError !== null || settled)
338
+ return;
339
+ stdoutBytes += chunk.length;
340
+ if (stdoutBytes > STDOUT_MAX) {
341
+ protocolError ??= "helper printed more than the bounded output";
342
+ killOwnHelper();
343
+ return;
344
+ }
345
+ buf += chunk.toString("utf8");
346
+ let nl = buf.indexOf("\n");
347
+ while (nl >= 0) {
348
+ const text = buf.slice(0, nl);
349
+ buf = buf.slice(nl + 1);
350
+ let line;
351
+ try {
352
+ const parsed = JSON.parse(text);
353
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
354
+ throw new Error("not an object");
355
+ line = parsed;
356
+ }
357
+ catch {
358
+ protocolError ??= `helper printed a non-JSON-object line: ${JSON.stringify(text.slice(0, 120))}`;
359
+ killOwnHelper();
360
+ return;
361
+ }
362
+ lines.push(line);
363
+ onLine(line);
364
+ if (protocolError !== null)
365
+ return;
366
+ nl = buf.indexOf("\n");
367
+ }
368
+ });
369
+ const onLine = (line) => {
370
+ if (lines.length !== 1 || !("phase" in line))
371
+ return;
372
+ if (!exactKeys(line, ["phase", "pid"]) || line.phase !== "ready" || line.pid !== owner.pid) {
373
+ protocolError ??= `first phase line is not ready for pid ${owner.pid}`;
374
+ killOwnHelper();
375
+ return;
376
+ }
377
+ if (sent !== "none")
378
+ return; // aborted while starting: cancel already written
379
+ if (opts.signal?.aborted) {
380
+ cancel("aborted-before-go", "the requester aborted while the fd was held");
381
+ return;
382
+ }
383
+ const second = checkCcRetireBinding(expected, deps, owner);
384
+ if (!second.ok) {
385
+ cancel(second.reason, second.detail);
386
+ return;
387
+ }
388
+ write("go");
389
+ };
390
+ child.on("close", (code) => {
391
+ // Incomplete trailing output is not a protocol the helper finished: never success.
392
+ if (buf.length > 0)
393
+ protocolError ??= `helper left incomplete trailing output: ${JSON.stringify(buf.slice(0, 120))}`;
394
+ const helperDetail = (msg) => (stderr ? `${msg}; helper stderr: ${stderr.trim()}` : msg);
395
+ if (spawnError !== null && lines.length === 0) {
396
+ finish({ outcome: "refused", reason: "helper-spawn-failed", detail: spawnError });
397
+ return;
398
+ }
399
+ const ready = lines[0]?.phase === "ready";
400
+ if (sent === "go") {
401
+ const rest = lines.slice(1);
402
+ const signalled = rest[0]?.phase === "signal-requested";
403
+ if (protocolError === null && !wallExpired && !abortedAfterGo) {
404
+ if (signalled &&
405
+ rest.length === 2 &&
406
+ exactKeys(rest[0], ["phase", "signal"]) &&
407
+ rest[0].signal === "SIGTERM") {
408
+ const out = outcomeLine(rest[1], AFTER_SIGNAL, code);
409
+ if (out === "exited" && exactKeys(rest[1], ["outcome"])) {
410
+ finish({ outcome: "exited", gardenId: expected.gardenId, pid: owner.pid });
411
+ return;
412
+ }
413
+ if (out !== null && out !== "exited") {
414
+ finish({ outcome: "unknown", reason: out, detail: helperDetail(`SIGTERM requested; ${out}`) });
415
+ return;
416
+ }
417
+ }
418
+ if (!signalled && rest.length === 1) {
419
+ const out = outcomeLine(rest[0], AFTER_GO_NO_SIGNAL, code);
420
+ if (out !== null) {
421
+ const reason = out === "refused" ? `helper-refused:${String(rest[0].reason)}` : out;
422
+ finish({ outcome: "not-signalled", reason, detail: helperDetail(`after go: ${reason}`) });
423
+ return;
424
+ }
425
+ }
426
+ }
427
+ const why = abortedAfterGo
428
+ ? "aborted-after-go"
429
+ : wallExpired
430
+ ? "helper-wall-timeout"
431
+ : (protocolError ?? `helper ended with code ${code} after go without a valid outcome`);
432
+ finish({
433
+ outcome: "unknown",
434
+ reason: abortedAfterGo ? "aborted-after-go" : wallExpired ? "helper-wall-timeout" : "helper-protocol",
435
+ detail: helperDetail(`${why}${signalled ? " (signal-requested was reported)" : ""}`),
436
+ });
437
+ return;
438
+ }
439
+ // No go was written, so the helper cannot have signalled: every ending here is non-success.
440
+ if (sent === "cancel" && cancelReason !== null) {
441
+ const r = cancelReason;
442
+ const confirmed = ready && lines.length === 2 && outcomeLine(lines[1], CANCEL_OUTCOMES, code) !== null;
443
+ const eofConfirmed = !ready || (lines.length === 2 && lines[1]?.outcome === "cancelled-eof");
444
+ finish({
445
+ outcome: "not-signalled",
446
+ reason: r.reason,
447
+ detail: helperDetail(confirmed || eofConfirmed ? r.detail : `${r.detail}; helper ending not confirmed`),
448
+ });
449
+ return;
450
+ }
451
+ if (!ready && protocolError === null && lines.length === 1) {
452
+ const out = outcomeLine(lines[0], PRE_READY_OUTCOMES, code);
453
+ if (out === "refused") {
454
+ finish({
455
+ outcome: "refused",
456
+ reason: `helper-refused:${String(lines[0].reason)}`,
457
+ detail: helperDetail("helper refused before ready"),
458
+ });
459
+ return;
460
+ }
461
+ if (out === "already-exited") {
462
+ finish({ outcome: "not-signalled", reason: "already-exited", detail: helperDetail("target exited") });
463
+ return;
464
+ }
465
+ }
466
+ finish({
467
+ outcome: ready ? "not-signalled" : "refused",
468
+ reason: wallExpired ? "helper-wall-timeout" : "helper-protocol",
469
+ detail: helperDetail(protocolError ?? `helper ended with code ${code} before go`),
470
+ });
471
+ });
472
+ });
473
+ }
@@ -0,0 +1,125 @@
1
+ /** Production start/rebind seam. Same-path rebind is entirely synchronous and performs NO
2
+ * listener/path IO. Each accept snapshots the binding via acceptControlConnection below.
3
+ */
4
+ export async function startOrRebindControlServer(state, ctx, socketPath, gardenId, io) {
5
+ const binding = { gardenId, nativeSessionId: ctx.sessionManager.getSessionId(), context: ctx };
6
+ const bind = () => {
7
+ state.context = ctx;
8
+ state.binding = binding;
9
+ state.closing = false;
10
+ state.retireRequested = false;
11
+ };
12
+ if (state.socketPath === socketPath && state.server) {
13
+ bind();
14
+ return;
15
+ }
16
+ await io.prepare();
17
+ await io.stop();
18
+ await io.remove(socketPath);
19
+ bind();
20
+ state.socketPath = socketPath;
21
+ state.server = await io.create(socketPath);
22
+ }
23
+ /** Snapshot exactly once, on accept, NOT when the listener was created or a command arrives. */
24
+ export function acceptControlConnection(state, socket) {
25
+ state.connections.add(socket);
26
+ socket.once("close", () => state.connections.delete(socket));
27
+ return state.binding;
28
+ }
29
+ /** Entire final check + SDK void call is synchronous. Call only AFTER any async preflight.
30
+ * A returned request receipt proves neither process exit nor persistence/flush completion.
31
+ * After requested/unknown, unobserved exit can leave a connect-alive socket refusing commands
32
+ * as target-closing until exit or a new session_start. No same-generation unlatch or retry.
33
+ */
34
+ export function requestControlRetire(state, serverBinding, residentGardenId, expected) {
35
+ if (state.closing || state.retireRequested)
36
+ return { boundary: "refused", reason: "target-closing" };
37
+ const ctx = state.context;
38
+ if (!serverBinding ||
39
+ !ctx ||
40
+ state.binding !== serverBinding ||
41
+ ctx !== serverBinding.context ||
42
+ residentGardenId !== serverBinding.gardenId ||
43
+ expected.gardenId !== serverBinding.gardenId ||
44
+ expected.nativeSessionId !== serverBinding.nativeSessionId) {
45
+ return { boundary: "refused", reason: "target-stale" };
46
+ }
47
+ let shutdown;
48
+ try {
49
+ if (ctx.sessionManager.getSessionId() !== serverBinding.nativeSessionId) {
50
+ return { boundary: "refused", reason: "target-stale" };
51
+ }
52
+ if (ctx.mode !== "tui")
53
+ return { boundary: "refused", reason: "target-non-tui" };
54
+ if (!ctx.isIdle())
55
+ return { boundary: "refused", reason: "target-busy" };
56
+ if (ctx.hasPendingMessages())
57
+ return { boundary: "refused", reason: "target-pending" };
58
+ shutdown = ctx.shutdown;
59
+ }
60
+ catch {
61
+ // SDK invalidates captured contexts on replacement/reload. Never call through one.
62
+ return { boundary: "refused", reason: "target-context-unavailable" };
63
+ }
64
+ state.retireRequested = true;
65
+ try {
66
+ shutdown.call(ctx);
67
+ }
68
+ catch {
69
+ // The call may have started shutdown before throwing. Do not invite a replay.
70
+ return { boundary: "unknown", reason: "shutdown-call-failed" };
71
+ }
72
+ return { boundary: "shutdown-requested" };
73
+ }
74
+ /** Invalidate BEFORE waiting for server.close: accepted old connections must not act. */
75
+ export function invalidateControlBinding(state) {
76
+ state.closing = true;
77
+ state.binding = null;
78
+ state.context = null;
79
+ }
80
+ /** The production receiver's one wire envelope. The RpcResponse import is type-only:
81
+ * no runtime dependency on the RPC client is introduced. An absent id is omitted by JSON;
82
+ * error is present only for refused/unknown, matching the strict core-side parser.
83
+ */
84
+ export function retireResponseEnvelope(result, id) {
85
+ return {
86
+ type: "response",
87
+ command: "retire",
88
+ id,
89
+ success: result.boundary === "shutdown-requested",
90
+ data: result,
91
+ ...(result.boundary !== "shutdown-requested" ? { error: result.reason } : {}),
92
+ };
93
+ }
94
+ /** One response, then FIN; never wait for SDK termination to acknowledge its request.
95
+ * A failed transport leaves the caller with unknown outcome, not retry authority.
96
+ * The internal client sends one command per connection: concurrent responses on this same
97
+ * connection can be lost when retire sends FIN. It is not a multiplexed lifecycle protocol.
98
+ */
99
+ export function endRetireResponse(socket, response) {
100
+ try {
101
+ socket.end(`${JSON.stringify(response)}\n`);
102
+ }
103
+ catch {
104
+ socket.destroy();
105
+ }
106
+ }
107
+ /** Session teardown only: flush accepted writes with FIN, then bound a non-cooperating peer.
108
+ * This timer exists solely for this server.close operation, not as a resident watcher.
109
+ * Applied to all session shutdowns: other in-flight replies may become transport-unknown.
110
+ */
111
+ export async function closeControlConnections(server, sockets) {
112
+ await new Promise((resolve) => {
113
+ const timer = setTimeout(() => {
114
+ for (const socket of sockets)
115
+ socket.destroy();
116
+ }, 250);
117
+ timer.unref();
118
+ server.close(() => {
119
+ clearTimeout(timer);
120
+ resolve();
121
+ });
122
+ for (const socket of sockets)
123
+ socket.end();
124
+ });
125
+ }