@junghanacs/entwurf 0.39.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 (137) hide show
  1. package/AGENTS.md +4 -2
  2. package/BASELINE.md +60 -5
  3. package/CHANGELOG.md +557 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +52 -2
  6. package/FAQ.md +8 -4
  7. package/README.md +37 -17
  8. package/VERIFY.md +23 -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 +64 -9
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +100 -24
  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-mailbox-body.js +10 -4
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +96 -16
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +5 -29
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +28 -0
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +26 -11
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/pi-retire-request.js +76 -0
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-receive.js +123 -0
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-send.js +259 -0
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/remote-pair-transport.js +80 -0
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +67 -17
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/retirement-event.js +211 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook.js +124 -10
  36. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-pi-durable.js +53 -2
  37. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +1 -1
  38. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +1 -0
  39. package/mcp/entwurf-bridge/dist/scripts/remote-pair-receive.js +65 -0
  40. package/mcp/entwurf-bridge/src/index.ts +118 -24
  41. package/mcp/entwurf-bridge/test.sh +2 -2
  42. package/mcp/entwurf-bridge/tsconfig.build.json +17 -1
  43. package/package.json +5 -5
  44. package/pi/meta-bridge/entwurf-meta-receive/scripts/hook-launch.sh +3 -3
  45. package/pi-extensions/entwurf-control.ts +55 -20
  46. package/pi-extensions/lib/cc-retire-pidfd.py +236 -0
  47. package/pi-extensions/lib/cc-retire-request.ts +553 -0
  48. package/pi-extensions/lib/control-retire.ts +195 -0
  49. package/pi-extensions/lib/entwurf-control-rpc.ts +17 -1
  50. package/pi-extensions/lib/entwurf-v2-retire-production.ts +227 -0
  51. package/pi-extensions/lib/entwurf-v2-retire.ts +269 -0
  52. package/pi-extensions/lib/entwurf-v2-surface.ts +2 -1
  53. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +390 -36
  54. package/pi-extensions/lib/fresh-call-composition.ts +15 -0
  55. package/pi-extensions/lib/fresh-call-dispatch.ts +45 -4
  56. package/pi-extensions/lib/herdr-fresh-call.ts +5 -0
  57. package/pi-extensions/lib/meta-mailbox-body.ts +13 -4
  58. package/pi-extensions/lib/meta-session.ts +140 -16
  59. package/pi-extensions/lib/mux-fresh-call.ts +7 -27
  60. package/pi-extensions/lib/mux-launch.ts +29 -0
  61. package/pi-extensions/lib/mux-resume-call.ts +33 -12
  62. package/pi-extensions/lib/pi-retire-request.ts +111 -0
  63. package/pi-extensions/lib/remote-pair-receive.ts +184 -0
  64. package/pi-extensions/lib/remote-pair-send.ts +325 -0
  65. package/pi-extensions/lib/remote-pair-transport.ts +81 -0
  66. package/pi-extensions/lib/resume-launch-identity.ts +104 -18
  67. package/pi-extensions/lib/retirement-event.ts +253 -0
  68. package/pi-extensions/meta-bridge-hook.ts +142 -13
  69. package/pi-extensions/meta-bridge-pi-durable.ts +56 -2
  70. package/run.sh +122 -7
  71. package/scripts/agy-bridge-config.py +4 -1
  72. package/scripts/build-bridge.sh +14 -0
  73. package/scripts/check-capability-bundle-reach.ts +135 -2
  74. package/scripts/check-entwurf-bridge-boot.ts +2 -1
  75. package/scripts/check-entwurf-fact-provider.ts +1 -0
  76. package/scripts/check-entwurf-v2-production.ts +2 -0
  77. package/scripts/check-entwurf-v2-surface.ts +7 -0
  78. package/scripts/check-gate-qualification.ts +20 -8
  79. package/scripts/check-herdr-activation.ts +2 -2
  80. package/scripts/check-herdr-placement.ts +1 -0
  81. package/scripts/check-hook-launch-topology.ts +6 -15
  82. package/scripts/check-install-container.sh +2 -2
  83. package/scripts/check-meta-doctor-oracle.sh +27 -1
  84. package/scripts/check-meta-facts.ts +8 -3
  85. package/scripts/check-meta-hook-session-switch.ts +230 -13
  86. package/scripts/check-meta-identity-consumers.ts +1 -0
  87. package/scripts/check-meta-listing.ts +1 -0
  88. package/scripts/check-meta-manifest-schema.py +1 -1
  89. package/scripts/check-meta-session.ts +31 -0
  90. package/scripts/check-meta-v3-record.ts +55 -1
  91. package/scripts/check-mux-resume-call.ts +35 -9
  92. package/scripts/check-peer-facts.ts +1 -0
  93. package/scripts/check-pi-durable-contact.ts +3 -1
  94. package/scripts/check-pi-durable-send.ts +4 -2
  95. package/scripts/check-pi-mcp-bridge.ts +1 -0
  96. package/scripts/check-remote-pair-installed.ts +242 -0
  97. package/scripts/check-resume-launch-identity.ts +62 -23
  98. package/scripts/lib/meta-hook-bundle.ts +72 -0
  99. package/scripts/lib/remote-pair-installed-audit.ts +25 -0
  100. package/scripts/meta-bridge-claude-floor.sh +13 -9
  101. package/scripts/meta-bridge-doctor.sh +17 -0
  102. package/scripts/meta-bridge-install.sh +6 -0
  103. package/scripts/meta-facts.ts +1 -1
  104. package/scripts/mutants/bridge-boot-resume.json +5 -2
  105. package/scripts/mutants/cc-retire-pidfd.json +129 -0
  106. package/scripts/mutants/cc-retire-request.json +171 -0
  107. package/scripts/mutants/control-retire.json +105 -0
  108. package/scripts/mutants/control-send-receipt.json +2 -2
  109. package/scripts/mutants/copilot-receive.json +2 -6
  110. package/scripts/mutants/entwurf-v2-retire.json +476 -0
  111. package/scripts/mutants/fresh-call-dispatch.json +37 -0
  112. package/scripts/mutants/meta-hook-session-switch.json +88 -0
  113. package/scripts/mutants/meta-identity.json +57 -0
  114. package/scripts/mutants/mux-fresh-call.json +1 -1
  115. package/scripts/mutants/mux-resume-call.json +40 -4
  116. package/scripts/mutants/pi-durable.json +79 -0
  117. package/scripts/mutants/pi-retire-request.json +72 -0
  118. package/scripts/mutants/remote-pair-display.json +53 -0
  119. package/scripts/mutants/remote-pair-installed-audit.json +28 -0
  120. package/scripts/mutants/remote-pair-receive.json +126 -0
  121. package/scripts/mutants/remote-pair-roundtrip.json +83 -0
  122. package/scripts/mutants/remote-pair-send.json +194 -0
  123. package/scripts/mutants/remote-pair-transport.json +66 -0
  124. package/scripts/mutants/resume-launch-identity.json +35 -8
  125. package/scripts/mutants/retirement-event.json +195 -0
  126. package/scripts/mutants/v2-surface.json +16 -0
  127. package/scripts/mutants/v2-visible-resume.json +283 -13
  128. package/scripts/probe-bridge-command.ts +1 -0
  129. package/scripts/remote-pair-receive.ts +73 -0
  130. package/scripts/smoke-agy-install-state.sh +2 -2
  131. package/scripts/smoke-copilot-mcp-state.sh +1 -1
  132. package/scripts/smoke-herdr-raw-install-live.sh +5 -4
  133. package/scripts/smoke-meta-install-state.sh +4 -4
  134. package/scripts/smoke-mux-lifecycle-live.ts +10 -10
  135. package/scripts/smoke-omp-mcp-state.sh +1 -1
  136. package/scripts/smoke-pi-durable-fresh-live.ts +5 -3
  137. package/scripts/smoke-pi-provider-state.sh +3 -3
@@ -0,0 +1,259 @@
1
+ /**
2
+ * remote-pair-send — the SENDING half of #107's one declared remote pair (rail 2a).
3
+ *
4
+ * It sits in front of the local v2 decider, at the surface. With no route declaration, or a target
5
+ * the declaration does not name, the local path runs unchanged. When the declaration names the
6
+ * target, any local trace of that gid (a record entry in any state, or anything at its control
7
+ * socket path) fails closed as an ambiguity before transport. Otherwise the call runs over one
8
+ * transport call: no synthetic record, no new backend, no new rail plan, and no retry.
9
+ *
10
+ * Sender: only the caller's authoritative envelope becomes the claim. It must be the declared local
11
+ * citizen; an anonymous envelope or another gid is refused before transport. The model chooses
12
+ * neither host nor command: both come from the operator's declaration.
13
+ *
14
+ * Remote command: ssh joins the remote words into one shell string. So the alias and the remote
15
+ * declaration path are held to a narrow POSIX-safe charset that needs no quoting, and the command
16
+ * is the fixed `entwurf remote-pair-receive --pair <path>`. There is no quoting engine here.
17
+ *
18
+ * Receipt: a result is decided only when the exit code and the one stdout JSON line agree
19
+ * (see {@link classifyRemoteReceipt}). Everything else is `remote-indeterminate`: the other host
20
+ * may or may not have enqueued. Read and answer stay unknown until a reply arrives.
21
+ */
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { controlSocketPathIn } from "./control-socket-path.js";
25
+ import { defaultMetaSessionsDir, metaRecordExistsByGardenId, requireGardenId } from "./meta-session.js";
26
+ import { inspectControlSocketPath } from "./socket-discovery.js";
27
+ export class RemoteRouteDeclarationError extends Error {
28
+ }
29
+ const SSH_ALIAS = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
30
+ const REMOTE_PATH = /^\/[A-Za-z0-9._/-]+$/;
31
+ const CONNECT_TIMEOUT_SECONDS = 10;
32
+ export const REMOTE_SEND_TIMEOUT_MS = 30_000;
33
+ const PAIR_REJECT_REASONS = ["remote-pair-from-mismatch", "remote-pair-target-mismatch"];
34
+ export function parseRemoteRouteDeclaration(text) {
35
+ const what = "remote route declaration";
36
+ let raw;
37
+ try {
38
+ raw = JSON.parse(text);
39
+ }
40
+ catch (err) {
41
+ throw new RemoteRouteDeclarationError(`${what}: not JSON (${err instanceof Error ? err.message : String(err)})`);
42
+ }
43
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
44
+ throw new RemoteRouteDeclarationError(`${what}: must be a JSON object`);
45
+ }
46
+ const obj = raw;
47
+ const want = ["localGardenId", "remoteGardenId", "remotePairFile", "ssh"];
48
+ const got = Object.keys(obj).sort();
49
+ if (got.length !== want.length || got.some((k, i) => k !== want[i])) {
50
+ throw new RemoteRouteDeclarationError(`${what}: keys must be exactly ${want.join(", ")} (got ${got.join(", ")})`);
51
+ }
52
+ const { ssh, remotePairFile } = obj;
53
+ if (typeof ssh !== "string" || !SSH_ALIAS.test(ssh)) {
54
+ throw new RemoteRouteDeclarationError(`${what}: "ssh" must be an alias matching ${SSH_ALIAS}`);
55
+ }
56
+ if (typeof remotePairFile !== "string" ||
57
+ !REMOTE_PATH.test(remotePairFile) ||
58
+ remotePairFile.split("/").includes("..")) {
59
+ throw new RemoteRouteDeclarationError(`${what}: "remotePairFile" must be an absolute path matching ${REMOTE_PATH} with no ".." segment`);
60
+ }
61
+ return {
62
+ remoteGardenId: requireGardenId(obj.remoteGardenId),
63
+ ssh,
64
+ localGardenId: requireGardenId(obj.localGardenId),
65
+ remotePairFile,
66
+ };
67
+ }
68
+ /** The one argv the transport runs. Every word is inside the no-quoting charset above. */
69
+ export function remoteReceiveArgv(route) {
70
+ return [
71
+ "ssh",
72
+ "-o",
73
+ "BatchMode=yes",
74
+ "-o",
75
+ `ConnectTimeout=${CONNECT_TIMEOUT_SECONDS}`,
76
+ route.ssh,
77
+ "entwurf",
78
+ "remote-pair-receive",
79
+ "--pair",
80
+ route.remotePairFile,
81
+ ];
82
+ }
83
+ function tail(s) {
84
+ return s.length > 400 ? s.slice(-400) : s;
85
+ }
86
+ /**
87
+ * Decide only on agreement. 0 + dispatched/!isError = accepted; 3 + rejected(pair reason) = pair
88
+ * refused; 4 + dispatched/isError = the other host reported an error while processing (its own text
89
+ * says what is known, e.g. `retry-safe: false` after a possible write; this line infers neither
90
+ * delivery nor non-delivery); 2 + invalid = it refused our
91
+ * input. Any other exit, a signal, a timeout, no line, more than one line, non-JSON, or a JSON
92
+ * kind/field that disagrees with the exit is indeterminate.
93
+ */
94
+ export function classifyRemoteReceipt(r) {
95
+ const indeterminate = (why) => ({
96
+ kind: "remote-indeterminate",
97
+ why,
98
+ exitCode: r.exitCode,
99
+ stderrTail: tail(r.stderr),
100
+ });
101
+ if (r.timedOut)
102
+ return indeterminate("timed out");
103
+ if (r.signal !== null)
104
+ return indeterminate(`ended by signal ${r.signal}`);
105
+ if (r.exitCode === null) {
106
+ return indeterminate(r.stderr.startsWith("spawn failed")
107
+ ? "the transport did not start (spawn failed)"
108
+ : "did not exit (no exit code)");
109
+ }
110
+ if (![0, 2, 3, 4].includes(r.exitCode))
111
+ return indeterminate(`exit ${r.exitCode}`);
112
+ const nl = r.stdout.indexOf("\n");
113
+ if (nl === -1 || nl !== r.stdout.length - 1)
114
+ return indeterminate("stdout is not exactly one line");
115
+ let j;
116
+ try {
117
+ j = JSON.parse(r.stdout);
118
+ }
119
+ catch {
120
+ return indeterminate("stdout is not JSON");
121
+ }
122
+ if (j === null || typeof j !== "object" || Array.isArray(j))
123
+ return indeterminate("stdout is not a JSON object");
124
+ const o = j;
125
+ const keys = Object.keys(o).sort().join(",");
126
+ if (r.exitCode === 0 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === false) {
127
+ if (typeof o.text === "string")
128
+ return { kind: "remote-accepted", text: o.text };
129
+ }
130
+ if (r.exitCode === 4 && keys === "isError,kind,text" && o.kind === "dispatched" && o.isError === true) {
131
+ if (typeof o.text === "string")
132
+ return { kind: "remote-reported-error", text: o.text };
133
+ }
134
+ if (r.exitCode === 3 && keys === "detail,kind,reason" && o.kind === "rejected") {
135
+ if (typeof o.reason === "string" && PAIR_REJECT_REASONS.includes(o.reason) && typeof o.detail === "string") {
136
+ return { kind: "remote-pair-refused", reason: o.reason, detail: o.detail };
137
+ }
138
+ }
139
+ if (r.exitCode === 2 && keys === "detail,kind" && o.kind === "invalid" && typeof o.detail === "string") {
140
+ return { kind: "remote-invalid", detail: o.detail };
141
+ }
142
+ return indeterminate(`exit ${r.exitCode} disagrees with its receipt`);
143
+ }
144
+ function render(route, o) {
145
+ const via = `via ${route.ssh}`;
146
+ switch (o.kind) {
147
+ case "remote-accepted":
148
+ return {
149
+ isError: false,
150
+ text: `entwurf_v2 remote ${via} → accepted by the other host (its fact; read/answer unknown until a reply arrives; paths below are on the other host)\n${o.text}`,
151
+ };
152
+ case "remote-reported-error":
153
+ // The other host's dispatch returned isError. That can follow a write (an enqueue whose
154
+ // stamp or signal then threw is `retry-safe: false`), so this header claims neither
155
+ // non-delivery nor safety to resend: the other host's own text below is the fact.
156
+ return {
157
+ isError: true,
158
+ text: `entwurf_v2 remote ${via} → the other host reported an error while processing; read its receipt below, delivery is not inferred from this line; not retried (paths below are on the other host)\n${o.text}`,
159
+ };
160
+ case "remote-pair-refused":
161
+ return {
162
+ isError: true,
163
+ text: `entwurf_v2 remote ${via} → refused by the other host's pair: ${o.reason} (${o.detail})`,
164
+ };
165
+ case "remote-invalid":
166
+ return { isError: true, text: `entwurf_v2 remote ${via} → the other host refused the input: ${o.detail}` };
167
+ case "remote-indeterminate":
168
+ return {
169
+ isError: true,
170
+ text: `entwurf_v2 remote ${via} → indeterminate: ${o.why}. The other host may or may not have enqueued; ` +
171
+ `not retried.${o.stderrTail ? `\n${o.stderrTail}` : ""}`,
172
+ };
173
+ }
174
+ }
175
+ function refuse(text) {
176
+ return { isError: true, text: `entwurf_v2 rejected: ${text}` };
177
+ }
178
+ export async function runEntwurfV2WithRemoteRoute(params, opts) {
179
+ const route = opts.route;
180
+ if (route === null || params.target !== route.remoteGardenId) {
181
+ return opts.runLocal(params, { senderProvider: opts.senderProvider });
182
+ }
183
+ if (await opts.localTrace(route.remoteGardenId)) {
184
+ return refuse(`remote-route-local-conflict: ${route.remoteGardenId} is declared remote ${`via ${route.ssh}`} but this host also has a local trace of it; not choosing`);
185
+ }
186
+ const sender = opts.senderProvider();
187
+ if (!sender || sender.origin === "external-mcp" || sender.sessionId !== route.localGardenId) {
188
+ return refuse(`remote-sender-not-declared: only ${route.localGardenId} may send over this route (sender: ${sender ? sender.sessionId : "none"})`);
189
+ }
190
+ const claim = {
191
+ from: sender.sessionId,
192
+ fromAgentId: sender.agentId,
193
+ fromCwd: sender.cwd,
194
+ target: route.remoteGardenId,
195
+ message: params.message,
196
+ wantsReply: params.wants_reply ?? false,
197
+ };
198
+ const result = await opts.transport.run(remoteReceiveArgv(route), JSON.stringify(claim), opts.timeoutMs ?? REMOTE_SEND_TIMEOUT_MS);
199
+ return render(route, classifyRemoteReceipt(result));
200
+ }
201
+ /**
202
+ * The production local-trace fact: the store's own record-entry inspection (absent vs anything
203
+ * else — readable, unreadable, symlinked or drifted all count) and the control-socket path
204
+ * inspection, in EVERY socket dir the caller names. The bridge's `ENTWURF_DIR` can differ from the
205
+ * decider's canonical dir, so both are passed and a trace in either one means this host already
206
+ * answers to the gid.
207
+ */
208
+ export function makeLocalTraceFact(dirs) {
209
+ const socketDirs = [...new Set(dirs.controlSocketDirs.map((d) => path.resolve(d)))];
210
+ return async (gardenId) => {
211
+ if (metaRecordExistsByGardenId(gardenId, dirs.sessionsDir))
212
+ return true;
213
+ for (const dir of socketDirs) {
214
+ const inspection = await inspectControlSocketPath(controlSocketPathIn(dir, gardenId));
215
+ if (inspection.kind !== "absent")
216
+ return true;
217
+ }
218
+ return false;
219
+ };
220
+ }
221
+ /** The operator's opt-in file name. One per host; it declares that host's one remote pair. */
222
+ export const REMOTE_PAIR_OPT_IN_FILE = "entwurf-remote-pair.json";
223
+ /**
224
+ * Where the opt-in file lives: beside the meta-record store, i.e. the pi agent dir unless an
225
+ * `ENTWURF_META_SESSIONS_DIR` override relocates the store (then the file follows the store it
226
+ * pairs). Read-only reuse of the existing root resolution; nothing here writes.
227
+ */
228
+ export function remotePairOptInPath() {
229
+ return path.join(path.dirname(defaultMetaSessionsDir()), REMOTE_PAIR_OPT_IN_FILE);
230
+ }
231
+ /**
232
+ * Read the opt-in file once, at the moment of use (no cache). Absent is the ONLY "off". A
233
+ * symlink, a non-regular entry, an unreadable file or an invalid declaration throws: the operator
234
+ * opted in, so a broken opt-in is refused rather than read as off.
235
+ *
236
+ * The bridge this declares is the operator's to build and secure (for example a private network
237
+ * such as Tailscale, plus SSH authentication and permissions). Entwurf only walks the declared
238
+ * route: it installs, configures, discovers and verifies none of it, and never retries.
239
+ */
240
+ export function readRemotePairOptIn(file) {
241
+ let st;
242
+ try {
243
+ st = fs.lstatSync(file);
244
+ }
245
+ catch (err) {
246
+ if (err.code === "ENOENT")
247
+ return null;
248
+ throw err;
249
+ }
250
+ if (!st.isFile()) {
251
+ throw new RemoteRouteDeclarationError(`remote pair opt-in ${file} must be a regular non-symlink file`);
252
+ }
253
+ try {
254
+ return parseRemoteRouteDeclaration(fs.readFileSync(file, "utf8"));
255
+ }
256
+ catch (err) {
257
+ throw new RemoteRouteDeclarationError(`${file}: ${err instanceof Error ? err.message : String(err)}`);
258
+ }
259
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * remote-pair-transport — the production process adapter behind #107's `RemoteTransport` (rail 2c).
3
+ *
4
+ * One child per send: `spawn(argv[0], argv.slice(1))` with no shell, stdin written once and closed,
5
+ * stdout and stderr captured separately. On the bound it signals ITS OWN child only (TERM, then KILL
6
+ * after a short grace); if the pipes are still held after one more grace, it closes its own read ends
7
+ * and settles as a timeout rather than waiting for a close that may never come. It is not a process manager: it tracks no
8
+ * other process, scans nothing, and never retries. Classification belongs to
9
+ * `classifyRemoteReceipt`; this module only reports what the child did.
10
+ */
11
+ import { spawn } from "node:child_process";
12
+ const CAPTURE_CAP = 64 * 1024;
13
+ const KILL_GRACE_MS = 2000;
14
+ const FINAL_GRACE_MS = 2000;
15
+ function capped(chunks, size, chunk) {
16
+ if (size.n >= CAPTURE_CAP)
17
+ return;
18
+ const room = CAPTURE_CAP - size.n;
19
+ const piece = chunk.length > room ? chunk.subarray(0, room) : chunk;
20
+ chunks.push(piece);
21
+ size.n += piece.length;
22
+ }
23
+ export const processTransport = {
24
+ run(argv, stdin, timeoutMs) {
25
+ return new Promise((resolve) => {
26
+ const out = [];
27
+ const err = [];
28
+ const outN = { n: 0 };
29
+ const errN = { n: 0 };
30
+ let timedOut = false;
31
+ let spawnError = null;
32
+ let settled = false;
33
+ const child = spawn(argv[0], argv.slice(1), { shell: false, stdio: ["pipe", "pipe", "pipe"] });
34
+ let killTimer;
35
+ let finalTimer;
36
+ const settle = (code, signal) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ clearTimeout(bound);
41
+ if (killTimer)
42
+ clearTimeout(killTimer);
43
+ if (finalTimer)
44
+ clearTimeout(finalTimer);
45
+ const stderr = Buffer.concat(err).toString("utf8");
46
+ resolve({
47
+ exitCode: spawnError ? null : code,
48
+ signal: spawnError ? null : signal,
49
+ timedOut,
50
+ stdout: Buffer.concat(out).toString("utf8"),
51
+ stderr: spawnError ? `spawn failed: ${spawnError}${stderr ? `\n${stderr}` : ""}` : stderr,
52
+ });
53
+ };
54
+ const bound = setTimeout(() => {
55
+ timedOut = true;
56
+ child.kill("SIGTERM");
57
+ killTimer = setTimeout(() => {
58
+ child.kill("SIGKILL");
59
+ // Something else may still hold our stdout/stderr pipes (an inherited descendant,
60
+ // an ssh ControlMaster). We do not chase it: after one more grace we close OUR read
61
+ // ends and settle as a timeout, which the receipt reads as indeterminate.
62
+ finalTimer = setTimeout(() => {
63
+ child.stdout.destroy();
64
+ child.stderr.destroy();
65
+ settle(child.exitCode, child.signalCode);
66
+ }, FINAL_GRACE_MS);
67
+ }, KILL_GRACE_MS);
68
+ }, timeoutMs);
69
+ child.stdout.on("data", (c) => capped(out, outN, c));
70
+ child.stderr.on("data", (c) => capped(err, errN, c));
71
+ child.on("error", (e) => {
72
+ spawnError = e.message;
73
+ });
74
+ // A child that exits before reading stdin closes the pipe; that is its outcome, not ours.
75
+ child.stdin.on("error", () => { });
76
+ child.on("close", (code, signal) => settle(code, signal));
77
+ child.stdin.end(stdin);
78
+ });
79
+ },
80
+ };
@@ -1,27 +1,33 @@
1
1
  /**
2
- * resume-launch-identity — the RECORD-AUTHORITATIVE half of resuming a pi citizen,
3
- * kept after the visible-first cut removed everything that used to call it.
2
+ * resume-launch-identity — the RECORD-AUTHORITATIVE half of a visible same-id resume: garden id →
3
+ * the meta-record → which native session is this citizen's, and where it lives.
4
4
  *
5
- * It was the preamble of the `spawn-bg` watcher's `spawnChild` (entwurf-v2-spawn-production.ts,
6
- * deleted with that transport): garden id → the meta-record → the transcript that record
7
- * claims → the provider/model/cwd that transcript's own header carries. None of that is
8
- * about being hidden or visible. It is the answer to "which being is this, and which
9
- * conversation is theirs", and it is the same answer a VISIBLE resume will need.
5
+ * It began as the preamble of the retired `spawn-bg` watcher (garden id → the meta-record → the
6
+ * transcript that record claims → the provider/model/cwd that transcript's own header carries).
7
+ * None of that is about being hidden or visible; it is the answer to "which being is this, and
8
+ * which conversation is theirs". `entwurf-v2-visible-resume` is its consumer, and
9
+ * `check-resume-launch-identity` holds the record-integrity rules below — the transcript header id
10
+ * must equal `record.nativeSessionId`, an addressable read (#52) rather than a plain targeted one,
11
+ * a recorded ACP provider must resolve its bridge.
10
12
  *
11
- * So this leaf is preserved deliberately, with no consumer in the shipped tree today.
12
- * `check-resume-launch-identity` holds its contract so the record-integrity rules below —
13
- * the transcript header id must equal `record.nativeSessionId`, an addressable read
14
- * (#52) rather than a plain targeted one, a recorded ACP provider must resolve its
15
- * bridge — cannot rot while they wait.
13
+ * The domain is pi and claude-code (#147 L2). Both own their transcript and re-attach to the same
14
+ * record when their native session reopens; this leaf only names that session. For pi it reads
15
+ * the transcript header (the launch needs its provider/model/cwd). For claude-code it reads no
16
+ * transcript at all — the vendor owns it, and `claude --resume <id>` needs nothing from it — and
17
+ * checks only that the recorded path is an absolute file that still exists. Every other backend
18
+ * is a named refusal: native-push citizens have no resume authority (AGENTS Hard Rule 10), and a
19
+ * durable host is not reopened through Entwurf.
16
20
  *
17
21
  * It takes a bare garden id, not a plan: the plan type it used to destructure belonged to
18
22
  * the removed transport, and the identity question never needed the rest of it.
19
23
  */
20
- import { existsSync } from "node:fs";
24
+ import { existsSync, statSync } from "node:fs";
21
25
  import path from "node:path";
22
26
  import { AcpModelNotCuratedError, isCuratedAcpModelId, PROVIDER_ID } from "./acp/model-ids.js";
23
27
  import { getEntwurfExplicitExtensions, readSessionIdentity } from "./entwurf-core.js";
24
28
  import { readAddressableMetaIdentity } from "./meta-session.js";
29
+ /** The backends with a visible same-id resume. A closed set: nothing outside it is reopened. */
30
+ export const RESUME_BACKENDS = ["pi", "claude-code"];
25
31
  /**
26
32
  * One of the TWO expected refusals on this leaf: the target is a citizen of a backend that has no
27
33
  * same-id resume at all. The other is `AcpModelNotCuratedError` (#128, `acp/model-ids.js`).
@@ -31,23 +37,64 @@ import { readAddressableMetaIdentity } from "./meta-session.js";
31
37
  * bridge — and those keep their bare cause-rich `Error`, because a caller cannot act on them
32
38
  * except by looking. A Claude Code or agy garden id is different in kind: nothing is broken,
33
39
  * the operator simply asked a capability boundary to do something it does not cover, and the
34
- * honest answer is a named refusal rather than an error report about a store that is fine.
40
+ * honest answer is a named refusal rather than an error report about a store that is fine. Since
41
+ * #147 L2 the boundary is `RESUME_BACKENDS`; a Codex, agy, Copilot, OMP or pi-durable garden id is
42
+ * this refusal.
35
43
  *
36
44
  * It carries a `reason` so the caller matches on a FIELD. Parsing the message string would make
37
45
  * the wording load-bearing, and re-reading the record to ask "was it pi?" would read an address
38
46
  * twice and could answer differently the second time.
39
47
  */
40
48
  export class ResumeBackendUnsupportedError extends Error {
41
- reason = "target-not-pi";
49
+ reason = "target-backend-unsupported";
42
50
  backend;
43
51
  constructor(gardenId, backend) {
44
52
  super(`resume-launch-identity: ${gardenId} is a ${backend} citizen — same-id resume is a host-adapter relaunch ` +
45
- `capability, and its domain currently contains backend pi only (only pi stands a control socket up). ` +
46
- `This is a capability boundary, not the control-socket rail and not citizen rank.`);
53
+ `capability, and its domain is ${RESUME_BACKENDS.join(" and ")}. ` +
54
+ `This is a capability boundary, not citizen rank.`);
47
55
  this.name = "ResumeBackendUnsupportedError";
48
56
  this.backend = backend;
49
57
  }
50
58
  }
59
+ /**
60
+ * Claude Code's session id grammar: a lowercase UUID. The value becomes an argv token after
61
+ * `--resume`, so anything else — a leading `-`, whitespace, a path — is refused as a bad record
62
+ * rather than handed to the vendor CLI as something it would parse differently.
63
+ */
64
+ const CLAUDE_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
65
+ /** A claude-code record → its resume facts. Throws, with its own cause, on a record that cannot be resumed. */
66
+ function resolveClaudeCodeLaunchIdentity(gardenId, record) {
67
+ if (!CLAUDE_SESSION_ID.test(record.nativeSessionId)) {
68
+ throw new Error(`resume-launch-identity: ${gardenId} records nativeSessionId ${JSON.stringify(record.nativeSessionId)}, ` +
69
+ `which is not a Claude Code session id (lowercase UUID) — refusing to hand it to claude --resume.`);
70
+ }
71
+ const transcript = record.transcriptPath;
72
+ if (!transcript || !path.isAbsolute(transcript)) {
73
+ throw new Error(`resume-launch-identity: ${gardenId} records no absolute transcriptPath (${JSON.stringify(transcript)}) — ` +
74
+ `the birth hook writes one for every Claude Code session, so this record cannot name a session to resume.`);
75
+ }
76
+ let isFile = false;
77
+ try {
78
+ isFile = statSync(transcript).isFile();
79
+ }
80
+ catch {
81
+ isFile = false;
82
+ }
83
+ if (!isFile) {
84
+ throw new Error(`resume-launch-identity: ${gardenId} recorded transcriptPath "${transcript}" is not a file on disk — ` +
85
+ `the session's transcript is gone, so claude --resume would open nothing.`);
86
+ }
87
+ if (!path.isAbsolute(record.cwd)) {
88
+ throw new Error(`resume-launch-identity: ${gardenId} records a relative cwd ${JSON.stringify(record.cwd)}.`);
89
+ }
90
+ return {
91
+ backend: "claude-code",
92
+ nativeSessionId: record.nativeSessionId,
93
+ cwd: record.cwd,
94
+ createdAt: record.createdAt,
95
+ attachIncarnation: record.attachIncarnation,
96
+ };
97
+ }
51
98
  /**
52
99
  * Resolve launch identity for a resume. The TARGET is now resolved through the
53
100
  * meta-record (#50 C2): `gardenId → record.transcriptPath`. It used to be a global
@@ -79,6 +126,8 @@ export class ResumeBackendUnsupportedError extends Error {
79
126
  */
80
127
  export function resolveResumeLaunchIdentity(gardenId) {
81
128
  const record = readAddressableMetaIdentity(gardenId);
129
+ if (record.backend === "claude-code")
130
+ return resolveClaudeCodeLaunchIdentity(gardenId, record);
82
131
  if (record.backend !== "pi") {
83
132
  throw new ResumeBackendUnsupportedError(gardenId, record.backend);
84
133
  }
@@ -136,6 +185,7 @@ export function resolveResumeLaunchIdentity(gardenId) {
136
185
  if (provider === PROVIDER_ID && !isCuratedAcpModelId(model))
137
186
  throw new AcpModelNotCuratedError(model);
138
187
  return {
188
+ backend: "pi",
139
189
  sessionFile,
140
190
  cwd: identity.cwd,
141
191
  explicitExtensionArgs: explicitExtensions.args,