@junghanacs/entwurf 0.14.1 → 0.15.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 (95) hide show
  1. package/AGENTS.md +8 -5
  2. package/CHANGELOG.md +113 -0
  3. package/DELIVERY.md +73 -3
  4. package/README.md +55 -4
  5. package/VERIFY.md +6 -6
  6. package/demo/README.md +3 -1
  7. package/demo/demo-baseline.sh +12 -1
  8. package/demo/demo.sh +9 -1
  9. package/docs/acp-backend-rail.md +103 -4
  10. package/docs/external-mcp-host.md +26 -4
  11. package/docs/setup-clean-host.md +3 -3
  12. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +8 -6
  13. package/mcp/entwurf-bridge/dist/pi-extensions/entwurf-capabilities.json +1 -0
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/copilot-fresh-preflight.js +253 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-sender-identity.js +10 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +102 -28
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +77 -11
  18. package/mcp/entwurf-bridge/dist/pi-extensions/meta-bridge-hook-copilot.js +288 -0
  19. package/mcp/entwurf-bridge/dist/scripts/doctor-pi-provider.js +139 -47
  20. package/mcp/entwurf-bridge/dist/scripts/probe-bridge-command.js +294 -0
  21. package/mcp/entwurf-bridge/src/index.ts +10 -6
  22. package/mcp/entwurf-bridge/tsconfig.build.json +17 -6
  23. package/package.json +23 -12
  24. package/pi/copilot-receive/entwurf-receive/extension.mjs +323 -0
  25. package/pi/entwurf-capabilities.json +1 -0
  26. package/pi/meta-bridge-copilot/.claude-plugin/marketplace.json +11 -0
  27. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/.claude-plugin/plugin.json +5 -0
  28. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/hooks/hooks.json +7 -0
  29. package/pi/meta-bridge-copilot/entwurf-meta-receive-copilot/scripts/copilot-hook-launch.sh +85 -0
  30. package/pi-extensions/entwurf-control.ts +17 -8
  31. package/pi-extensions/lib/acp/backend.ts +229 -9
  32. package/pi-extensions/lib/copilot-fresh-preflight.ts +251 -0
  33. package/pi-extensions/lib/meta-sender-identity.ts +10 -2
  34. package/pi-extensions/lib/meta-session.ts +102 -29
  35. package/pi-extensions/lib/mux-fresh-call.ts +82 -11
  36. package/pi-extensions/meta-bridge-hook-copilot.ts +323 -0
  37. package/run.sh +389 -26
  38. package/scripts/agy-bridge-config.py +47 -13
  39. package/scripts/agy-bridge.sh +73 -23
  40. package/scripts/check-acp-prompt-lifecycle.ts +221 -9
  41. package/scripts/check-copilot-birth-hook.ts +497 -0
  42. package/scripts/check-copilot-launch.ts +395 -0
  43. package/scripts/check-copilot-receive-arm.ts +734 -0
  44. package/scripts/check-copilot-statusline.ts +122 -0
  45. package/scripts/check-entwurf-bridge-boot.ts +28 -0
  46. package/scripts/check-entwurf-capabilities.ts +25 -6
  47. package/scripts/check-fresh-cut-gate.sh +92 -23
  48. package/scripts/check-gate-qualification.ts +10 -3
  49. package/scripts/check-install-container.sh +2 -2
  50. package/scripts/check-install-surface.ts +1 -1
  51. package/scripts/check-meta-capability-source.ts +89 -5
  52. package/scripts/check-meta-doctor-oracle.sh +26 -0
  53. package/scripts/check-meta-manifest-schema.py +38 -0
  54. package/scripts/check-meta-receiver-marker.ts +25 -2
  55. package/scripts/check-meta-session.ts +32 -0
  56. package/scripts/check-probe-bridge-command.ts +201 -0
  57. package/scripts/check-release-gate-outcomes.ts +54 -1
  58. package/scripts/copilot-bridge-doctor.sh +209 -0
  59. package/scripts/copilot-bridge-install.sh +224 -0
  60. package/scripts/copilot-launch.sh +236 -0
  61. package/scripts/copilot-mcp-bridge.sh +177 -0
  62. package/scripts/copilot-mcp-config.py +221 -0
  63. package/scripts/copilot-receive-bridge.sh +483 -0
  64. package/scripts/copilot-statusline-bridge.sh +149 -0
  65. package/scripts/copilot-statusline-config.py +201 -0
  66. package/scripts/copilot-statusline.sh +68 -0
  67. package/scripts/dev-bin.sh +4 -1
  68. package/scripts/doctor-pi-provider.ts +155 -51
  69. package/scripts/meta-bridge-hook-log.sh +17 -5
  70. package/scripts/mutants/acp-prompt-lifecycle.json +25 -3
  71. package/scripts/mutants/bridge-command-boot.json +107 -0
  72. package/scripts/mutants/capability-cache.json +64 -0
  73. package/scripts/mutants/copilot-birth.json +155 -0
  74. package/scripts/mutants/copilot-launch.json +187 -0
  75. package/scripts/mutants/copilot-receive.json +263 -0
  76. package/scripts/mutants/fresh-cut.json +17 -0
  77. package/scripts/mutants/mux-fresh-call.json +216 -2
  78. package/scripts/mutants/pack-install.json +17 -0
  79. package/scripts/mutants/release-gate.json +13 -0
  80. package/scripts/probe-bridge-command.ts +330 -0
  81. package/scripts/raw-async-delivery/README.md +203 -1
  82. package/scripts/raw-async-delivery/copilot-enqueue-addressed.sh +35 -0
  83. package/scripts/raw-async-delivery/copilot-extension-receive/extension.mjs +123 -0
  84. package/scripts/raw-async-delivery/copilot-ui-server-probe.mjs +337 -0
  85. package/scripts/smoke-acp-raw-turn-live.ts +1 -1
  86. package/scripts/smoke-agy-install-state.sh +76 -2
  87. package/scripts/smoke-copilot-mcp-state.sh +153 -0
  88. package/scripts/smoke-copilot-statusline-state.sh +131 -0
  89. package/scripts/smoke-entwurf-chain-live.ts +1 -1
  90. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  91. package/scripts/smoke-mux-fresh-call-live.ts +3 -1
  92. package/scripts/smoke-mux-lifecycle-live.ts +4 -2
  93. package/scripts/smoke-pi-provider-state.sh +135 -6
  94. package/scripts/smoke-resident-garden-guard.sh +2 -2
  95. package/scripts/tsconfig.json +1 -0
@@ -161,6 +161,35 @@ interface AcpBridgeEvent {
161
161
  decision?: "approved" | "cancelled";
162
162
  }
163
163
 
164
+ /**
165
+ * A one-shot latch for "the child has ended", created at spawn and settled by
166
+ * `onChildGone`. `settled` answers the question with no waiting at all; the
167
+ * promise is only for the bounded post-mortem window (settleChildEnd).
168
+ */
169
+ interface ChildEndLatch {
170
+ settled: boolean;
171
+ promise: Promise<void>;
172
+ /** Called exactly once by onChildGone; cleared so a second end is a no-op. */
173
+ settle?: () => void;
174
+ }
175
+
176
+ function makeChildEndLatch(): ChildEndLatch {
177
+ let settle!: () => void;
178
+ const promise = new Promise<void>((resolve) => {
179
+ settle = resolve;
180
+ });
181
+ const latch: ChildEndLatch = {
182
+ settled: false,
183
+ promise,
184
+ settle: () => {
185
+ latch.settled = true;
186
+ latch.settle = undefined;
187
+ settle();
188
+ },
189
+ };
190
+ return latch;
191
+ }
192
+
164
193
  interface BridgeSession {
165
194
  key: string;
166
195
  cwd: string;
@@ -183,8 +212,36 @@ interface BridgeSession {
183
212
  stderrTail: string[];
184
213
  /** How the child ended, once it has — folded into the prompt-phase error. */
185
214
  exit?: { code: number | null; signal: NodeJS.Signals | null };
215
+ /**
216
+ * LATCH for the child's end, readable at ANY time — deliberately not a
217
+ * callback.
218
+ *
219
+ * `notifyChildGone` lives only between the two lines of
220
+ * `awaitAcpPromptTurn`'s try/finally, so it can carry the exit status only
221
+ * when the child's `exit` event wins the race against the transport — and on
222
+ * the shape this backend actually runs, it does not. With piped stdio on
223
+ * Linux the child's stdout EOF was measured landing about a millisecond BEFORE
224
+ * node emits `exit`, for a clean exit and for SIGKILL alike (issue #72). The
225
+ * SDK's generic "ACP connection closed" therefore settles the race first,
226
+ * `finally` clears the callback, and the exit status arriving one tick later
227
+ * has nowhere to go — which is how #72's field sample reached the operator
228
+ * naming neither exit code nor signal. The opposite order stays possible and
229
+ * is still handled (notifyChildGone), so both are covered by the gate.
230
+ *
231
+ * The latch outlives the race: `settled` is the durable fact and `promise`
232
+ * lets a failing turn wait a BOUNDED moment for a late end (settleChildEnd).
233
+ */
234
+ childEnd: ChildEndLatch;
186
235
  /** Set while a prompt is in flight so a child death can close it (awaitAcpPromptTurn). */
187
236
  notifyChildGone?: (err: Error) => void;
237
+ /**
238
+ * This TURN owns reporting the child's end, so the next turn must not also
239
+ * announce it. Raised at the top of a failure path — BEFORE the bounded
240
+ * settle and before any teardown — so an end observed during that window is
241
+ * still a natural one (we have not signalled anything yet) but is reported
242
+ * exactly once, by the turn that failed on it.
243
+ */
244
+ reporting?: boolean;
188
245
  /**
189
246
  * We are tearing this child down ON PURPOSE (turn-scoped teardown, config
190
247
  * drift, error/abort cleanup). Its exit is then expected, not news: without
@@ -229,10 +286,13 @@ function onChildGone(session: BridgeSession, exit?: { code: number | null; signa
229
286
  if (exit) session.exit = exit;
230
287
  if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
231
288
  retainedChildren.delete(session.child);
289
+ // Close the latch FIRST and unconditionally: it is the durable fact, and a
290
+ // failing turn may already be waiting on it inside its bounded settle window.
291
+ session.childEnd.settle?.();
232
292
  if (session.notifyChildGone) {
233
293
  // A turn was waiting on this child — it reports the death itself.
234
294
  session.notifyChildGone(childEndedError(session));
235
- } else if (!session.retiring) {
295
+ } else if (!session.retiring && !session.reporting) {
236
296
  // Died BETWEEN turns with no turn to fail and nobody tearing it down, so
237
297
  // nobody has seen it. Without this the next turn would silently open a
238
298
  // fresh child and read as an ordinary cold start, hiding that the backend
@@ -281,6 +341,139 @@ function childEndedError(session: BridgeSession): Error {
281
341
  );
282
342
  }
283
343
 
344
+ /**
345
+ * The BOUNDED post-mortem window a turn that ALREADY failed may wait for the
346
+ * child's exit status.
347
+ *
348
+ * This is NOT a prompt deadline and must never become one: nothing here can end
349
+ * a running turn. It opens only after the prompt has already settled as a
350
+ * transport closure, and it closes on the child's own `exit` — which follows the
351
+ * stdout EOF by about a millisecond, so this is headroom, not a felt wait.
352
+ */
353
+ const CHILD_END_SETTLE_MS = 500;
354
+
355
+ /**
356
+ * The SDK's exact words for "the transport ended under a pending request"
357
+ * (`@agentclientprotocol/sdk` jsonrpc.js / acp.js:
358
+ * `closeSignal.reason ?? new Error("ACP connection closed")`).
359
+ *
360
+ * Matched EXACTLY, not by substring: this text appears only when the close
361
+ * carried NO reason — a clean stdout EOF rather than an errored stream — which
362
+ * is the one failure shape that arrives with no lifecycle facts of its own. A
363
+ * close that DID carry a reason already explains itself and must not be given a
364
+ * settle delay, and our own `childEndedError` already names the exit status.
365
+ * Widening this to a substring test would put that delay on errors that do not
366
+ * need it.
367
+ */
368
+ const ACP_CONNECTION_CLOSED_TEXT = "ACP connection closed";
369
+
370
+ function isAcpConnectionClosure(err: unknown): boolean {
371
+ const message = err instanceof Error ? err.message : typeof err === "string" ? err : "";
372
+ return message.trim() === ACP_CONNECTION_CLOSED_TEXT;
373
+ }
374
+
375
+ /**
376
+ * Wait a BOUNDED moment for a child end that the transport already implied.
377
+ *
378
+ * Resolves immediately when the latch is already closed (the common case once
379
+ * the ~1ms gap has passed) and never rejects. The timer is deliberately NOT
380
+ * unref'd: it is the only thing that ends this wait, so letting the loop drain
381
+ * past it would leave a failing turn unsealed — the opposite of the honesty this
382
+ * exists for. It is cleared on both exits, so it holds nothing open.
383
+ */
384
+ async function settleChildEnd(latch: ChildEndLatch, ms: number): Promise<void> {
385
+ if (latch.settled) return;
386
+ let timer: ReturnType<typeof setTimeout> | undefined;
387
+ try {
388
+ await Promise.race([
389
+ latch.promise,
390
+ new Promise<void>((resolve) => {
391
+ timer = setTimeout(resolve, ms);
392
+ }),
393
+ ]);
394
+ } finally {
395
+ if (timer) clearTimeout(timer);
396
+ }
397
+ }
398
+
399
+ /**
400
+ * The lifecycle line appended to a transport-closure failure, so the operator
401
+ * reads WHICH phase died and HOW the child ended.
402
+ *
403
+ * Wording is load-bearing for the same reason `childEndedError`'s is: pi
404
+ * classifies a failed assistant message against `RETRYABLE_PROVIDER_ERROR_PATTERN`
405
+ * (@earendil-works/pi-ai `utils/retry`), whose terms include "timed out",
406
+ * "timeout", "terminated", "connection lost" and "ended without". None of those
407
+ * may appear here, or a dead child would put a full cold prompt replay back on
408
+ * the table — with the tool side effects this turn already produced.
409
+ *
410
+ * That pattern is a bare substring alternation (`new RegExp(terms.join("|"), "i")`
411
+ * — NO word boundaries) and its terms enumerate the HTTP statuses "429", "500",
412
+ * "502", "503", "504" and "524". So the settle bound is deliberately NOT interpolated: rendering
413
+ * CHILD_END_SETTLE_MS made this line say "within 500ms", which pi read as an
414
+ * HTTP 500 and classified as transient. Naming the window in prose keeps a later
415
+ * change of that constant from silently re-arming the replay — do not "improve"
416
+ * this by putting the number back; the bound belongs in the code and the gate.
417
+ *
418
+ * The three endings are kept DISTINCT on purpose (issue #72 Done-when): an
419
+ * exit code, a signal, and "we waited and it never said" are three different
420
+ * facts, and collapsing them is what made the original sample unreadable.
421
+ */
422
+ function childEndLifecycleLine(opts: {
423
+ /** "pre-prompt" covers a new turn's bootstrap AND a reuse turn's pre-send steps. */
424
+ phase: "pre-prompt" | "prompt";
425
+ exit?: { code: number | null; signal: NodeJS.Signals | null };
426
+ ended: boolean;
427
+ }): string {
428
+ const where =
429
+ opts.phase === "prompt"
430
+ ? "the ACP backend connection closed while the prompt was still in flight"
431
+ : "the ACP backend connection closed before this turn's prompt was sent";
432
+ const how = opts.ended
433
+ ? `the child ended (${describeChildEnd(opts.exit)})`
434
+ : "the child reported no exit status within the bounded post-mortem window";
435
+ return `[acp] lifecycle: ${where} — ${how}; this turn has no answer`;
436
+ }
437
+
438
+ /**
439
+ * The failure path's post-mortem: enrich ONLY the transport-closure shape, and
440
+ * only after waiting a bounded moment for the exit status the close implies.
441
+ *
442
+ * Returns `undefined` for every other failure — an abort, a bootstrap throw, or
443
+ * a child death our own `notifyChildGone` already diagnosed — so no other error
444
+ * pays a delay and none is double-reported.
445
+ *
446
+ * Callers MUST run this BEFORE tearing the child down: our teardown SIGTERMs the
447
+ * process group, so an exit read after it would be OUR signal recorded as the
448
+ * child's cause of death.
449
+ *
450
+ * The stderr claim is deliberately NARROW. Running before teardown means the tail
451
+ * already collected is not cut short by our own cleanup, and stderr arriving
452
+ * during this window still lands in it. It does NOT promise the child's last
453
+ * words: node's `exit` can precede the stdio drain, and nothing here waits for
454
+ * the stderr pipe to close. Draining it is a separate lever, not this one.
455
+ */
456
+ async function diagnoseTransportClosure(opts: {
457
+ err: unknown;
458
+ session: BridgeSession | undefined;
459
+ phase: "pre-prompt" | "prompt";
460
+ aborted: boolean;
461
+ }): Promise<string | undefined> {
462
+ if (opts.aborted || !opts.session) return undefined;
463
+ if (!isAcpConnectionClosure(opts.err)) return undefined;
464
+ const session = opts.session;
465
+ // This turn owns the announcement from here on, so the next turn must not
466
+ // repeat it. Raised BEFORE the wait and before any signal of ours, so an end
467
+ // observed inside the window is still a natural one.
468
+ session.reporting = true;
469
+ await settleChildEnd(session.childEnd, CHILD_END_SETTLE_MS);
470
+ return childEndLifecycleLine({
471
+ phase: opts.phase,
472
+ exit: session.exit,
473
+ ended: session.childEnd.settled,
474
+ });
475
+ }
476
+
284
477
  // ---------------------------------------------------------------------------
285
478
  // timeout / launch / permission / stopReason / teardown helpers
286
479
  // ---------------------------------------------------------------------------
@@ -728,12 +921,16 @@ export function streamAcpTurn(
728
921
  stream.end();
729
922
  }
730
923
 
731
- function finishError(err: unknown, aborted: boolean, stderrTail?: string[]): void {
924
+ function finishError(err: unknown, aborted: boolean, stderrTail?: string[], lifecycle?: string): void {
732
925
  finalizeAcpStreamState(state);
733
926
  state.output.stopReason = aborted ? "aborted" : "error";
734
927
  const base = err instanceof Error ? err.message : String(err);
928
+ // The FIRST failure stays first and verbatim (it is what the backend
929
+ // actually said); the lifecycle line is added, never substituted, so a
930
+ // reader can still match the transport's own text.
931
+ const diagnosed = lifecycle ? `${base}\n${lifecycle}` : base;
735
932
  const tail = (stderrTail ?? []).join("").trim().slice(-1_000);
736
- const full = tail ? `${base}\n--- backend stderr (tail) ---\n${tail}` : base;
933
+ const full = tail ? `${diagnosed}\n--- backend stderr (tail) ---\n${tail}` : diagnosed;
737
934
  // A-c: a real failure (not an abort) that looks like a context-window
738
935
  // overflow gets an actionable hint appended, so "API Error" stops hiding
739
936
  // the turn-scoped full-transcript-replay cause.
@@ -924,6 +1121,8 @@ export function streamAcpTurn(
924
1121
  let child: AcpChildLike | undefined;
925
1122
  let session: BridgeSession | undefined;
926
1123
  let onAbort: (() => void) | undefined;
1124
+ /** Which phase a failure belongs to — flipped once the prompt is on the wire. */
1125
+ let phase: "pre-prompt" | "prompt" = "pre-prompt";
927
1126
  const stderrTail: string[] = [];
928
1127
  const sessionKey = resolveSessionKey(opts, cwd);
929
1128
  try {
@@ -1012,6 +1211,9 @@ export function streamAcpTurn(
1012
1211
  // this turn with the session, so a later reuse turn can still report
1013
1212
  // the child's dying words.
1014
1213
  stderrTail,
1214
+ // Armed at spawn, before ANY turn can fail on this child — the latch
1215
+ // must already exist when the `exit` listener below can fire.
1216
+ childEnd: makeChildEndLatch(),
1015
1217
  };
1016
1218
  const sess = session;
1017
1219
  spawned.once("exit", (...args: unknown[]) =>
@@ -1102,6 +1304,9 @@ export function streamAcpTurn(
1102
1304
  signal.removeEventListener("abort", onAbort);
1103
1305
  onAbort = undefined;
1104
1306
  }
1307
+ // From here the prompt is on the wire: a transport closure now is a
1308
+ // PROMPT-phase failure, and the catch says so.
1309
+ phase = "prompt";
1105
1310
  const promptResult = await awaitAcpPromptTurn(session, wireParams, {
1106
1311
  signal,
1107
1312
  graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS,
@@ -1134,6 +1339,13 @@ export function streamAcpTurn(
1134
1339
  session.busy = false;
1135
1340
  if (bridgeSessions.get(sessionKey) === session) bridgeSessions.delete(sessionKey);
1136
1341
  }
1342
+ // ORDER IS THE CONTRACT (#72): natural settle → seal → teardown.
1343
+ // The bounded wait for the child's own exit status runs BEFORE we signal
1344
+ // anything, so what we report is how the child actually ended and not our
1345
+ // own SIGTERM; the tail collected by then is sealed before our cleanup
1346
+ // touches the stderr pipe.
1347
+ const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
1348
+ finishError(err, aborted, session?.stderrTail ?? stderrTail, lifecycle);
1137
1349
  // error/abort → drop the (uncertain) session and close its child; an
1138
1350
  // uncertain connection must never be reused (GPT ④).
1139
1351
  if (child) {
@@ -1142,7 +1354,6 @@ export function streamAcpTurn(
1142
1354
  session?.connection.close?.(err);
1143
1355
  teardownChild(child);
1144
1356
  }
1145
- finishError(err, aborted, session?.stderrTail ?? stderrTail);
1146
1357
  } finally {
1147
1358
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
1148
1359
  }
@@ -1150,6 +1361,8 @@ export function streamAcpTurn(
1150
1361
 
1151
1362
  // --- reuse: send only the latest user delta to the live ACP session
1152
1363
  async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
1364
+ /** Same phase discipline as a new turn — reuse just has no bootstrap to lose. */
1365
+ let phase: "pre-prompt" | "prompt" = "pre-prompt";
1153
1366
  try {
1154
1367
  if (signal?.aborted) throw new Error("aborted before prompt");
1155
1368
 
@@ -1171,6 +1384,7 @@ export function streamAcpTurn(
1171
1384
  // ahead of the wire write; the prompt driver then owns the abort surface.
1172
1385
  const wireParams = await applyProviderPayloadHook(options, { sessionId: session.acpSessionId, prompt }, model);
1173
1386
  if (signal?.aborted) throw new Error("aborted during payload hook");
1387
+ phase = "prompt";
1174
1388
  const promptResult = await awaitAcpPromptTurn(session, wireParams, {
1175
1389
  signal,
1176
1390
  graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS,
@@ -1188,16 +1402,22 @@ export function streamAcpTurn(
1188
1402
  const aborted = Boolean(signal?.aborted);
1189
1403
  session.activePromptHandler = undefined;
1190
1404
  session.busy = false;
1191
- // error/abort on a reused session → drop it and close the child (GPT ④).
1192
1405
  if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
1406
+ // The SAME diagnostics a new turn reports, in the SAME order (#72):
1407
+ // natural settle → seal → teardown. This is the path the field sample
1408
+ // took — a retained child that died after its tool phase — so the
1409
+ // ordering matters most here: teardown first would have overwritten the
1410
+ // child's own exit status with our SIGTERM.
1411
+ const lifecycle = await diagnoseTransportClosure({ err, session, phase, aborted });
1412
+ // Without the session-scoped tail a mid-turn child death on a resident
1413
+ // session surfaced as a bare "ACP connection closed" with nothing to read
1414
+ // it by.
1415
+ finishError(err, aborted, session.stderrTail, lifecycle);
1416
+ // error/abort on a reused session → drop it and close the child (GPT ④).
1193
1417
  session.retiring = true;
1194
1418
  retainedChildren.delete(session.child);
1195
1419
  session.connection.close?.(err);
1196
1420
  teardownChild(session.child);
1197
- // The SAME diagnostics a new turn reports. Without the session-scoped
1198
- // tail a mid-turn child death on a resident session surfaced as a bare
1199
- // "ACP connection closed" with nothing to read it by.
1200
- finishError(err, aborted, session.stderrTail);
1201
1421
  }
1202
1422
  }
1203
1423
 
@@ -0,0 +1,251 @@
1
+ /**
2
+ * copilot-fresh-preflight — the pre-mutation capability check a Copilot fresh call needs
3
+ * (#82 RAIL 9, `docs/adding-a-harness.md` step 9 clauses 3 and 4).
4
+ *
5
+ * ── Why this is a leaf and not a doctor ──
6
+ *
7
+ * A doctor answers "is this host correctly wired?" and is allowed to be slow, to spawn the
8
+ * vendor CLI, and to read live processes. This is not that. It answers ONE narrower question
9
+ * at ONE moment: *before* `mux-fresh-call` mutates the operator's tmux session, are the four
10
+ * things the fresh contract requires actually in place on this filesystem?
11
+ *
12
+ * birth — without the birth plugin the session mints no record, so the callback
13
+ * would carry no garden id and the sibling never becomes addressable.
14
+ * MCP hand — without the bridge server the callback tool does not exist in that
15
+ * session, and the first turn has nothing to call.
16
+ * receive — without the receiver unit the sibling can be launched and can call
17
+ * home, and then nothing can ever be delivered TO it.
18
+ * visible identity — without the custom footer the citizen has a garden id nobody can see,
19
+ * which step 4 refuses to call lifecycle parity.
20
+ *
21
+ * ── Ordering is the whole point ──
22
+ *
23
+ * `scripts/copilot-launch.sh` already fails closed on the receiver, and that check is kept as
24
+ * it is: it guards every manual `entwurf copilot`, whose accepted contract stays receive-only.
25
+ * But a launcher runs INSIDE the window tmux just made, so its refusal leaves a dead window
26
+ * behind and the caller reads a launch receipt for a sibling that never was. `mux-fresh-call`'s
27
+ * rule is that nothing above the single mutation may leave a window behind, so the fresh lane
28
+ * decides the same facts one layer earlier. The overlap on the receiver axis is deliberate and
29
+ * each side names the other; the other three axes are the FRESH lane's requirement only.
30
+ *
31
+ * ── What this deliberately does NOT claim ──
32
+ *
33
+ * Ownership/configuration truth only: entwurf's units are installed and the settings the
34
+ * vendor will read say what they must say. It does NOT prove the Copilot CLI loaded the
35
+ * plugin, connected the MCP server, scanned the extension, or rendered a garden id in its
36
+ * footer — that is runtime truth, and it belongs to `doctor-copilot-*` (which may spawn the
37
+ * vendor) and to the step 9 clause 7 LIVE receipt. A green preflight is a statement about this
38
+ * filesystem, not a prediction about the next process.
39
+ *
40
+ * Every predicate below MIRRORS the shipped adapter that owns that file, deliberately
41
+ * including its environment seams — a preflight that resolved a path its own installer never
42
+ * writes would refuse a correctly installed host and send the operator hunting.
43
+ *
44
+ * No vendor spawn, no network, no await, no mutation.
45
+ */
46
+
47
+ import { accessSync, constants, existsSync, lstatSync, readFileSync, statSync } from "node:fs";
48
+ import * as path from "node:path";
49
+
50
+ /** One reason per capability. These strings are stable contract — they cross the public
51
+ * surfaces as `entwurf_fresh_call` refusals, so a caller can act on them. */
52
+ export type CopilotPreflightRejectReason =
53
+ | "copilot-birth-unit-missing"
54
+ | "copilot-mcp-hand-missing"
55
+ | "copilot-receive-unit-missing"
56
+ | "copilot-visible-identity-missing";
57
+
58
+ const BIRTH_PLUGIN = "entwurf-meta-receive-copilot";
59
+ const RECEIVE_UNIT = "entwurf-receive";
60
+ const MCP_SERVER_KEY = "entwurf-bridge";
61
+ /** Same default and same env seam as `scripts/copilot-statusline-bridge.sh`. */
62
+ const DEFAULT_STATUSLINE_COMMAND = "entwurf-copilot-statusline";
63
+
64
+ function dataHome(env: NodeJS.ProcessEnv): string | null {
65
+ const xdg = env.XDG_DATA_HOME;
66
+ if (typeof xdg === "string" && xdg.length > 0) return xdg;
67
+ const home = env.HOME;
68
+ if (typeof home === "string" && home.length > 0) return path.join(home, ".local", "share");
69
+ return null;
70
+ }
71
+
72
+ /** A JSON object or nothing. Failure to read, parse, or find an object is the SAME answer
73
+ * here — absent — because the caller's next move is identical in all of them: run the
74
+ * installer. Telling a corrupt state file apart from a missing one is the doctor's job. */
75
+ function readJsonObject(file: string): Record<string, unknown> | null {
76
+ try {
77
+ const parsed: unknown = JSON.parse(readFileSync(file, "utf8"));
78
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
79
+ ? (parsed as Record<string, unknown>)
80
+ : null;
81
+ } catch {
82
+ // Bounded environment probing, Hard Rule 15's stated exception.
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function isDir(p: string): boolean {
88
+ try {
89
+ return statSync(p).isDirectory();
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ function isSymlink(p: string): boolean {
96
+ try {
97
+ return lstatSync(p).isSymbolicLink();
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ /** Same DEST the managed launcher will arm from (`scripts/copilot-launch.sh`).
104
+ * `COPILOT_EXTENSIONS_DIR` wins; otherwise `$HOME/.copilot/extensions/<unit>`.
105
+ * String equality, not realpath: the launcher compares with `[ = ]`. */
106
+ function receiveDest(env: NodeJS.ProcessEnv): string | null {
107
+ const override = env.COPILOT_EXTENSIONS_DIR;
108
+ if (typeof override === "string" && override.length > 0) {
109
+ return path.join(override, RECEIVE_UNIT);
110
+ }
111
+ const home = env.HOME;
112
+ if (typeof home !== "string" || home.length === 0) return null;
113
+ return path.join(home, ".copilot", "extensions", RECEIVE_UNIT);
114
+ }
115
+
116
+ function isExecutableFile(p: string): boolean {
117
+ try {
118
+ // `X_OK` for THIS user, which is what `command -v` answers and what Copilot will need.
119
+ // A mode-bit test would call a root-owned 0700 binary executable for everyone.
120
+ accessSync(p, constants.X_OK);
121
+ return statSync(p).isFile();
122
+ } catch {
123
+ return false;
124
+ }
125
+ }
126
+
127
+ /**
128
+ * The statusline command as the shipped adapter resolves it: a value containing a separator is
129
+ * a path and must be executable; a bare name is looked up on PATH. This is a small local
130
+ * search rather than `mux-launch`'s resolver because the import fence keeps `mux-launch` to
131
+ * exactly two importers — a third would be a decision, and this leaf does not need one.
132
+ */
133
+ function commandResolvable(command: string, env: NodeJS.ProcessEnv): boolean {
134
+ if (command.includes("/")) return isExecutableFile(command);
135
+ const raw = env.PATH;
136
+ if (typeof raw !== "string" || raw.length === 0) return false;
137
+ for (const dir of raw.split(path.delimiter)) {
138
+ if (dir.length === 0) continue;
139
+ if (isExecutableFile(path.join(dir, command))) return true;
140
+ }
141
+ return false;
142
+ }
143
+
144
+ /**
145
+ * The visible-identity axis, step 9 clause 4. The subject is the EFFECTIVE settings the vendor
146
+ * will read, not entwurf's ownership record: a host can carry a correct footer configuration
147
+ * with no install-state (measured on the acceptance host, 2026-08-24 — `doctor-copilot-statusline`
148
+ * reported `settings: configured (resolvable)` / `state: absent` / rc=0), and refusing that host
149
+ * would be refusing a working visible identity because of a missing receipt.
150
+ *
151
+ * The install-state, when present, is checked for the ONE thing it can still contradict: that
152
+ * it manages the very file Copilot reads. A state pointing somewhere else means two settings
153
+ * files disagree about who owns the footer, and which one wins is not decidable from here.
154
+ */
155
+ function visibleIdentityMissing(env: NodeJS.ProcessEnv, data: string): boolean {
156
+ const command = env.COPILOT_STATUSLINE_COMMAND || DEFAULT_STATUSLINE_COMMAND;
157
+ const configured = env.COPILOT_SETTINGS_CONFIG;
158
+ let settingsPath: string;
159
+ if (typeof configured === "string" && configured.length > 0) {
160
+ settingsPath = configured;
161
+ } else {
162
+ const home = env.HOME;
163
+ if (typeof home !== "string" || home.length === 0) return true;
164
+ settingsPath = path.join(home, ".copilot", "settings.json");
165
+ }
166
+ // A symlinked settings file is somebody else's SSOT; the adapter refuses to touch it and
167
+ // this refuses to certify it.
168
+ if (isSymlink(settingsPath)) return true;
169
+ const settings = readJsonObject(settingsPath);
170
+ if (settings === null) return true;
171
+ const statusLine = settings.statusLine;
172
+ if (typeof statusLine !== "object" || statusLine === null || Array.isArray(statusLine)) return true;
173
+ if ((statusLine as Record<string, unknown>).command !== command) return true;
174
+ const footer = settings.footer;
175
+ if (typeof footer !== "object" || footer === null || Array.isArray(footer)) return true;
176
+ // `showCustom` must be exactly true. A truthy string would render nothing.
177
+ if ((footer as Record<string, unknown>).showCustom !== true) return true;
178
+ if (!commandResolvable(command, env)) return true;
179
+
180
+ const state = readJsonObject(path.join(data, "entwurf", "copilot-statusline", "install-state.json"));
181
+ if (state === null) return false; // absent state is not drift — see the doc comment above
182
+ const managed = state.managedSettingsPath;
183
+ if (typeof managed !== "string" || !path.isAbsolute(managed)) return true;
184
+ return path.resolve(managed) !== path.resolve(settingsPath);
185
+ }
186
+
187
+ /**
188
+ * Answer for ONE fresh Copilot launch. `null` means every required capability is in place on
189
+ * this filesystem; anything else is the first missing one, in the order the fresh contract
190
+ * consumes them: be born, hold the hand, be reachable, be visible.
191
+ */
192
+ export function copilotFreshPreflight(env: NodeJS.ProcessEnv = process.env): CopilotPreflightRejectReason | null {
193
+ const data = dataHome(env);
194
+ // With no HOME and no XDG_DATA_HOME there is no place any of these could be installed.
195
+ // Report the FIRST capability rather than inventing a fifth reason for "no home".
196
+ if (data === null) return "copilot-birth-unit-missing";
197
+
198
+ // 1. Birth. The assembly is what the installer bakes and what the birth doctor certifies
199
+ // statically; `hooks.json` is what makes it a hook unit rather than a directory. There is
200
+ // no install-state for this unit — the artifact IS its ownership record.
201
+ const birthUnit = path.join(data, "entwurf", "meta-bridge-copilot", ".assembled", BIRTH_PLUGIN);
202
+ if (!isDir(birthUnit) || !existsSync(path.join(birthUnit, "hooks", "hooks.json"))) {
203
+ return "copilot-birth-unit-missing";
204
+ }
205
+
206
+ // 2. MCP hand. The install-state names the config it owns, so the config path is READ from
207
+ // ownership truth rather than re-derived — a preflight that guessed would refuse a
208
+ // correctly installed host whose config lives somewhere else.
209
+ const mcpState = readJsonObject(path.join(data, "entwurf", "copilot-mcp", "install-state.json"));
210
+ if (mcpState === null || mcpState.serverKey !== MCP_SERVER_KEY) return "copilot-mcp-hand-missing";
211
+ const managedConfigPath = mcpState.managedConfigPath;
212
+ if (typeof managedConfigPath !== "string" || !path.isAbsolute(managedConfigPath)) return "copilot-mcp-hand-missing";
213
+ const servers = readJsonObject(managedConfigPath)?.mcpServers;
214
+ if (typeof servers !== "object" || servers === null || Array.isArray(servers)) return "copilot-mcp-hand-missing";
215
+ // The state can be current while the config drifted (hand-edited, restored from a backup,
216
+ // replaced by another tool). The server key present in the file the CLI actually reads is
217
+ // the fact the first turn depends on.
218
+ if (!Object.hasOwn(servers as Record<string, unknown>, MCP_SERVER_KEY)) return "copilot-mcp-hand-missing";
219
+
220
+ // 3. Receive. The same facts `scripts/copilot-launch.sh` checks — unit name, recorded
221
+ // path, DEST equality against this env's extensions root, then the entry file.
222
+ const recvState = readJsonObject(path.join(data, "entwurf", "copilot-receive", "install-state.json"));
223
+ if (recvState === null || recvState.unit !== RECEIVE_UNIT) return "copilot-receive-unit-missing";
224
+ const recvPath = recvState.path;
225
+ if (typeof recvPath !== "string" || !path.isAbsolute(recvPath)) return "copilot-receive-unit-missing";
226
+ // Mirror the launcher's path-mismatch predicate PRE-MUTATION. A state that names a
227
+ // real unit in a different extensions root than this env will scan still opens a
228
+ // window today if we only check that the files exist — then `entwurf copilot`
229
+ // refuses inside it and the caller holds a launch receipt for a dead sibling.
230
+ const dest = receiveDest(env);
231
+ if (dest === null || recvPath !== dest) return "copilot-receive-unit-missing";
232
+ if (!isDir(recvPath) || !existsSync(path.join(recvPath, "extension.mjs"))) return "copilot-receive-unit-missing";
233
+
234
+ // 4. Visible identity.
235
+ if (visibleIdentityMissing(env, data)) return "copilot-visible-identity-missing";
236
+
237
+ return null;
238
+ }
239
+
240
+ /** Repair text, one line per reason. A reason a caller cannot act on is a reason they will
241
+ * guess about — each names the exact command that installs the missing capability. */
242
+ export const COPILOT_PREFLIGHT_HINT: Record<CopilotPreflightRejectReason, string> = {
243
+ "copilot-birth-unit-missing":
244
+ "the Copilot BIRTH plugin is not installed here, so the sibling would mint no record and its callback would carry no garden id — run: entwurf install-copilot-bridge",
245
+ "copilot-mcp-hand-missing":
246
+ "the entwurf-bridge MCP server is not registered in the Copilot config this host owns, so the callback tool would not exist in that session — run: entwurf install-copilot-mcp",
247
+ "copilot-receive-unit-missing":
248
+ "the Copilot RECEIVER extension is not installed here, so the sibling could call home but nothing could ever be delivered to it — run: entwurf install-copilot-receive",
249
+ "copilot-visible-identity-missing":
250
+ "Copilot's custom footer is not configured to entwurf's resolvable statusline command (or an install-state manages a different settings file), so the sibling's garden id would be visible nowhere — run: entwurf doctor-copilot-statusline, then entwurf install-copilot-statusline",
251
+ };
@@ -48,8 +48,16 @@ import {
48
48
  } from "./meta-session.ts";
49
49
  import { type NativePushAdapter, resolveNativePushAdapter } from "./native-push/adapter.ts";
50
50
 
51
- /** Every native backend that mints a garden-id from its own hook and writes a sender marker. */
52
- export const META_SENDER_BACKENDS: readonly MetaBackend[] = ["claude-code", "antigravity"];
51
+ /** Every native backend that mints a garden-id from its own hook and writes a sender marker.
52
+ *
53
+ * WRITER AND READER OPEN TOGETHER OR NOT AT ALL. A backend listed here whose hook writes no
54
+ * marker costs one wasted directory read; a backend whose hook writes a marker but is absent
55
+ * here is INVISIBLE — the bridge holds the owner pid, never looks in that directory, and the
56
+ * citizen's sends are refused as anonymous for a reason nothing in the log names. That was
57
+ * exactly the #46 defect on agy, and copilot joined the list only once its own hook wrote one
58
+ * (#82 RAIL 5b). Membership says a marker may EXIST, never that a reply can land: the reply
59
+ * rail is chosen from `nativePushSupported` at the bridge, not from this list. */
60
+ export const META_SENDER_BACKENDS: readonly MetaBackend[] = ["claude-code", "antigravity", "copilot"];
53
61
 
54
62
  /** A marker that passed BOTH guards, together with the record that vouches for it. */
55
63
  export interface TrustedMetaSender {