@junghanacs/entwurf 0.12.10 → 0.13.1

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 (86) hide show
  1. package/AGENTS.md +6 -3
  2. package/BASELINE.md +56 -163
  3. package/CHANGELOG.md +35 -0
  4. package/CONTRIBUTING.md +4 -2
  5. package/DELIVERY.md +117 -261
  6. package/README.md +74 -399
  7. package/VERIFY.md +59 -85
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +231 -0
  10. package/docs/external-mcp-host.md +132 -0
  11. package/docs/fresh-cut-policy.md +99 -0
  12. package/docs/setup-clean-host.md +123 -328
  13. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +21 -48
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +155 -11
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +16 -4
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +66 -7
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +190 -5
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/tool-surface.js +12 -4
  21. package/mcp/entwurf-bridge/src/index.ts +21 -48
  22. package/package.json +14 -9
  23. package/pi/settings.reference.json +1 -1
  24. package/pi-extensions/acp-provider.ts +20 -10
  25. package/pi-extensions/entwurf-control.ts +20 -49
  26. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  27. package/pi-extensions/lib/acp/augment.ts +99 -2
  28. package/pi-extensions/lib/acp/backend-adapter.ts +190 -14
  29. package/pi-extensions/lib/acp/backend.ts +310 -37
  30. package/pi-extensions/lib/acp/config.ts +19 -5
  31. package/pi-extensions/lib/acp/engraving.ts +46 -2
  32. package/pi-extensions/lib/acp/event-mapper.ts +26 -16
  33. package/pi-extensions/lib/acp/models.ts +69 -7
  34. package/pi-extensions/lib/acp/overlay.ts +234 -5
  35. package/pi-extensions/lib/acp/tool-surface.ts +12 -4
  36. package/prompts/engraving.md +13 -5
  37. package/run.sh +386 -119
  38. package/scripts/check-acp-carrier-augment.ts +246 -10
  39. package/scripts/check-acp-cortex.ts +668 -0
  40. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  41. package/scripts/check-acp-provider-surface.ts +50 -6
  42. package/scripts/check-acp-sdk-surface.ts +60 -21
  43. package/scripts/check-acp-session-reuse.ts +64 -1
  44. package/scripts/check-acp-stop-reason.ts +342 -0
  45. package/scripts/check-entwurf-v2-surface.ts +41 -0
  46. package/scripts/check-gate-qualification.ts +7 -2
  47. package/scripts/check-probe-cli-shim.ts +879 -0
  48. package/scripts/check-probe-ordering.ts +2482 -0
  49. package/scripts/check-release-gate-outcomes.ts +332 -0
  50. package/scripts/check-shell-quote.ts +4 -4
  51. package/scripts/fixtures/probe-cli-shim +20 -0
  52. package/scripts/fixtures/probe-mcp-server.ts +168 -12
  53. package/scripts/lib/live-skip.ts +33 -0
  54. package/scripts/lib/probe-acp-turn.ts +228 -0
  55. package/scripts/lib/probe-cli-shim.ts +464 -0
  56. package/scripts/lib/probe-cli-target.ts +165 -0
  57. package/scripts/lib/probe-event-log.ts +383 -0
  58. package/scripts/lib/probe-verdict.ts +1213 -0
  59. package/scripts/lib/step-outcome.sh +88 -0
  60. package/scripts/mutants/acp-augment.json +106 -0
  61. package/scripts/mutants/acp-cortex.json +196 -0
  62. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  63. package/scripts/mutants/acp-stop-reason.json +80 -0
  64. package/scripts/mutants/probe-ordering.json +1054 -0
  65. package/scripts/mutants/release-gate.json +105 -0
  66. package/scripts/mutants/v2-surface.json +26 -15
  67. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  68. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  69. package/scripts/smoke-acp-cortex-live.ts +386 -0
  70. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  71. package/scripts/smoke-acp-mcp-live.ts +2 -2
  72. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  73. package/scripts/smoke-acp-ordering-probe-live.ts +848 -0
  74. package/scripts/smoke-acp-overlay-live.ts +2 -2
  75. package/scripts/smoke-acp-provider-live.ts +2 -2
  76. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  77. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  78. package/scripts/smoke-acp-skill-live.ts +2 -2
  79. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  80. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  81. package/scripts/smoke-agy-native-push-live.ts +4 -3
  82. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  83. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  84. package/scripts/smoke-entwurf-v2-matrix-live.ts +2 -2
  85. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +2 -4
  86. package/scripts/smoke-meta-install-state.sh +4 -0
@@ -55,7 +55,7 @@ import {
55
55
  type ResolvedAcpConfig,
56
56
  resolveProviderConfig,
57
57
  } from "./config.js";
58
- import { buildAcpPrompt } from "./context.js";
58
+ import { type AcpTextBlock, buildAcpPrompt } from "./context.js";
59
59
  import {
60
60
  type AcpPiStreamState,
61
61
  applyAcpSessionUpdate,
@@ -78,10 +78,31 @@ import {
78
78
  } from "./session-store.js";
79
79
  import { assertExcludeToolsHonored, PI_BUILTIN_BACKED_TOOLS } from "./tool-surface.js";
80
80
 
81
+ // Bootstrap boundaries ONLY. initialize / newSession / set-model are handshake
82
+ // steps that make no model progress, so a stuck one is a dead session and a cold
83
+ // retry costs nothing — a wall-clock bound is honest there.
84
+ //
85
+ // There is deliberately NO prompt boundary. A running turn is not a failure for
86
+ // having taken long: tool use, reasoning, and provider queueing all legitimately
87
+ // outlive any number we could pick, and the previous 600s absolute cutoff killed
88
+ // turns that were still actively producing tool calls. Worse, the cutoff's own
89
+ // message ("prompt timed out after 600000ms") lands inside pi's transient-error
90
+ // dictionary (`RETRYABLE_PROVIDER_ERROR_PATTERN` in @earendil-works/pi-ai
91
+ // `utils/retry.ts` matches `timed? out` / `timeout`), so pi replayed the SAME
92
+ // full prompt from a cold ACP session up to `retry.maxRetries` times — paying the
93
+ // whole turn again to arrive at the same wall. Elapsed time is not evidence.
94
+ // A prompt now ends only on lifecycle events: it resolves, the operator aborts,
95
+ // or the child dies / its stdio ends (see awaitAcpPromptTurn).
81
96
  const INITIALIZE_TIMEOUT_MS = 30_000;
82
97
  const NEW_SESSION_TIMEOUT_MS = 30_000;
83
98
  const SET_MODEL_TIMEOUT_MS = 30_000;
84
- const PROMPT_TIMEOUT_MS = 600_000;
99
+
100
+ // Bounded CLEANUP window after a user abort — not a turn deadline. On abort we
101
+ // send the ACP `session/cancel` notification and give the agent this long to
102
+ // answer the pending prompt with `cancelled` (the protocol's own ending, which
103
+ // maps to aborted). Only if it does not do so within the window do we escalate
104
+ // to process-group teardown, so an abort always returns promptly.
105
+ const ABORT_CANCEL_GRACE_MS = 5_000;
85
106
 
86
107
  type StdioChild = ChildProcessByStdio<Writable, Readable, Readable>;
87
108
 
@@ -121,6 +142,12 @@ export interface AcpTurnDeps {
121
142
  now(): string;
122
143
  /** Record dir override (tests). Defaults to the real session cache dir. */
123
144
  sessionDir?: string;
145
+ /**
146
+ * Post-abort cleanup grace (gates). Defaults to ABORT_CANCEL_GRACE_MS. This is
147
+ * the ONLY injectable clock left on the turn path and it bounds cleanup after
148
+ * an abort — never a running prompt.
149
+ */
150
+ abortGraceMs?: number;
124
151
  }
125
152
 
126
153
  // ---------------------------------------------------------------------------
@@ -147,6 +174,24 @@ interface BridgeSession {
147
174
  busy: boolean;
148
175
  /** Mutable per-turn router — see the CRITICAL note in the file header. */
149
176
  activePromptHandler?: (event: AcpBridgeEvent) => void;
177
+ /**
178
+ * The child's own dying words, SESSION-scoped on purpose. The stderr drain is
179
+ * installed once at spawn; keeping the buffer on the turn that spawned would
180
+ * leave every later reuse turn reporting a bare "ACP connection closed" with
181
+ * nothing to diagnose it by (observed 2026-07-30 on a live sonnet reuse turn).
182
+ */
183
+ stderrTail: string[];
184
+ /** How the child ended, once it has — folded into the prompt-phase error. */
185
+ exit?: { code: number | null; signal: NodeJS.Signals | null };
186
+ /** Set while a prompt is in flight so a child death can close it (awaitAcpPromptTurn). */
187
+ notifyChildGone?: (err: Error) => void;
188
+ /**
189
+ * We are tearing this child down ON PURPOSE (turn-scoped teardown, config
190
+ * drift, error/abort cleanup). Its exit is then expected, not news: without
191
+ * this flag every ordinary turn-scoped turn would announce its own routine
192
+ * teardown to the NEXT turn as if the session had died.
193
+ */
194
+ retiring?: boolean;
150
195
  }
151
196
 
152
197
  const bridgeSessions = new Map<string, BridgeSession>();
@@ -173,21 +218,78 @@ function registerGlobalCleanup(): void {
173
218
  });
174
219
  }
175
220
 
176
- /** A retained child died between turns: mark dead + drop from map + retained set. */
177
- function onChildGone(session: BridgeSession): void {
221
+ /**
222
+ * The child died: mark dead + drop from map + retained set, RECORD how it ended,
223
+ * and close any prompt that was waiting on it. Without that last step a
224
+ * mid-prompt death is only observable through the SDK's generic "ACP connection
225
+ * closed" rejection, which names neither the exit status nor the stderr.
226
+ */
227
+ function onChildGone(session: BridgeSession, exit?: { code: number | null; signal: NodeJS.Signals | null }): void {
178
228
  session.alive = false;
229
+ if (exit) session.exit = exit;
179
230
  if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
180
231
  retainedChildren.delete(session.child);
232
+ if (session.notifyChildGone) {
233
+ // A turn was waiting on this child — it reports the death itself.
234
+ session.notifyChildGone(childEndedError(session));
235
+ } else if (!session.retiring) {
236
+ // Died BETWEEN turns with no turn to fail and nobody tearing it down, so
237
+ // nobody has seen it. Without this the next turn would silently open a
238
+ // fresh child and read as an ordinary cold start, hiding that the backend
239
+ // session the operator was talking to is gone. A DELIBERATE teardown is
240
+ // excluded — announcing our own routine cleanup would be noise, not news.
241
+ unreportedChildEnds.set(session.key, session.exit ?? { code: null, signal: null });
242
+ }
243
+ }
244
+
245
+ /** Deaths no turn observed, keyed by sessionKey and announced once by the next turn. */
246
+ const unreportedChildEnds = new Map<string, { code: number | null; signal: NodeJS.Signals | null }>();
247
+
248
+ /** Read-and-clear: an unreported death is announced exactly once. */
249
+ function takeUnreportedChildEnd(
250
+ sessionKey: string,
251
+ ): { code: number | null; signal: NodeJS.Signals | null } | undefined {
252
+ const end = unreportedChildEnds.get(sessionKey);
253
+ if (end) unreportedChildEnds.delete(sessionKey);
254
+ return end;
255
+ }
256
+
257
+ /** "exit code 1" / "signal SIGKILL" / "exit code 0, signal SIGTERM" / "no exit status". */
258
+ function describeChildEnd(exit?: { code: number | null; signal: NodeJS.Signals | null }): string {
259
+ if (!exit) return "no exit status";
260
+ const parts = [
261
+ exit.code !== null ? `exit code ${exit.code}` : undefined,
262
+ exit.signal ? `signal ${exit.signal}` : undefined,
263
+ ].filter(Boolean);
264
+ return parts.length > 0 ? parts.join(", ") : "no exit status";
265
+ }
266
+
267
+ /**
268
+ * The prompt-phase error for a child that died under a live turn.
269
+ *
270
+ * Wording is load-bearing: pi classifies a failed assistant message by matching
271
+ * its errorMessage against `RETRYABLE_PROVIDER_ERROR_PATTERN`, so anything we
272
+ * author here that reads like "timed out" / "timeout" / "terminated" /
273
+ * "connection lost" would put a full cold prompt replay back on the table. This
274
+ * text names the lifecycle fact and nothing that looks transient.
275
+ * (The appended backend stderr tail is the child's text, not ours.)
276
+ */
277
+ function childEndedError(session: BridgeSession): Error {
278
+ return new Error(
279
+ `entwurf: the ACP backend process ended while the prompt was still in flight (${describeChildEnd(session.exit)}) — ` +
280
+ "this turn has no answer",
281
+ );
181
282
  }
182
283
 
183
284
  // ---------------------------------------------------------------------------
184
285
  // timeout / launch / permission / stopReason / teardown helpers
185
286
  // ---------------------------------------------------------------------------
186
287
 
187
- // Race a promise against a timeout, ALWAYS clearing the timer afterwards. A
188
- // naive `Promise.race([p, sleep(ms)])` leaves the timer pending when `p` wins —
189
- // a dangling (here 10-minute) timer that keeps pi's event loop alive long after
288
+ // Race a BOOTSTRAP phase against its timeout, ALWAYS clearing the timer
289
+ // afterwards. A naive `Promise.race([p, sleep(ms)])` leaves the timer pending
290
+ // when `p` wins — a dangling timer that keeps pi's event loop alive long after
190
291
  // the turn, so pi would never exit a `-p` run. clearTimeout in finally fixes it.
292
+ // Only initialize / newSession / set-model use this; the prompt has no deadline.
191
293
  function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
192
294
  let timer: ReturnType<typeof setTimeout> | undefined;
193
295
  const timeout = new Promise<never>((_, reject) => {
@@ -199,6 +301,80 @@ function withTimeout<T>(label: string, p: Promise<T>, ms: number): Promise<T> {
199
301
  });
200
302
  }
201
303
 
304
+ /**
305
+ * Await ONE ACP prompt with NO wall-clock deadline — the turn ends on lifecycle
306
+ * events only. Shared by the new and the reuse turn so both close the same way.
307
+ *
308
+ * The three endings:
309
+ *
310
+ * resolve the agent answered `session/prompt` (any stopReason — the
311
+ * verdict mapping is the caller's job).
312
+ * child gone the child exited or its stdio ended. Both are already fatal to
313
+ * the request — the SDK rejects every pending response when the
314
+ * read loop hits EOF — but the SDK's own error says only "ACP
315
+ * connection closed". `notifyChildGone` gets there first with the
316
+ * exit status, and the caller appends the session stderr tail.
317
+ * abort the operator cancelled. We send ACP `session/cancel` and let
318
+ * the agent end its own turn (`cancelled` → aborted). Escalation
319
+ * to process-group teardown happens only after a bounded grace,
320
+ * and closing the connection rejects the pending request, so an
321
+ * abort is always answered even against a wedged child.
322
+ *
323
+ * A stalled-but-alive child is deliberately NOT an ending: a silent turn is not
324
+ * a failed turn, and nothing here may kill one for being quiet.
325
+ */
326
+ async function awaitAcpPromptTurn(
327
+ session: BridgeSession,
328
+ promptArgs: { sessionId: string; prompt: AcpTextBlock[] },
329
+ opts: { signal?: AbortSignal; graceMs: number },
330
+ ): Promise<{ stopReason?: string }> {
331
+ let rejectLifecycle: ((err: Error) => void) | undefined;
332
+ const lifecycle = new Promise<never>((_, reject) => {
333
+ rejectLifecycle = reject;
334
+ });
335
+ // The race's loser stays pending forever when the prompt wins; a rejection
336
+ // nobody observed would surface as an unhandled rejection at that point.
337
+ lifecycle.catch(() => {});
338
+
339
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
340
+ const escalateAbort = (): void => {
341
+ killChildGroup(session.child, "SIGTERM");
342
+ // Closing rejects the still-pending ACP request, so the await below settles
343
+ // even when the child ignores both the cancel notification and the signal.
344
+ session.connection.close?.(new Error("entwurf: ACP prompt cancelled by the operator"));
345
+ rejectLifecycle?.(new Error("entwurf: the ACP prompt was cancelled by the operator"));
346
+ };
347
+ const onAbort = (): void => {
348
+ try {
349
+ session.connection.cancel?.({ sessionId: promptArgs.sessionId });
350
+ } catch {
351
+ // best-effort: escalation below is what guarantees the abort returns.
352
+ }
353
+ // NOT unref'd, deliberately. This timer is the only thing that finishes an
354
+ // abort against an agent that ignores session/cancel, so letting the event
355
+ // loop drain past it would leave the turn unsettled and the child alive.
356
+ // It is bounded (graceMs) and cleared in the finally below, so the worst it
357
+ // can do is hold an exiting process for that grace — which is the cleanup
358
+ // we asked for.
359
+ graceTimer = setTimeout(escalateAbort, opts.graceMs);
360
+ };
361
+
362
+ session.notifyChildGone = (err) => rejectLifecycle?.(err);
363
+ const signal = opts.signal;
364
+ if (signal) {
365
+ if (signal.aborted) onAbort();
366
+ else signal.addEventListener("abort", onAbort, { once: true });
367
+ }
368
+
369
+ try {
370
+ return await Promise.race([session.connection.prompt(promptArgs), lifecycle]);
371
+ } finally {
372
+ if (graceTimer) clearTimeout(graceTimer);
373
+ signal?.removeEventListener("abort", onAbort);
374
+ session.notifyChildGone = undefined;
375
+ }
376
+ }
377
+
202
378
  /** Approve-all permission policy (YOLO — oracle F). options empty → cancelled. */
203
379
  function resolvePermissionResponse(params: { options?: Array<{ optionId: string; kind?: string }> }): {
204
380
  outcome: { outcome: "selected"; optionId: string } | { outcome: "cancelled" };
@@ -209,15 +385,61 @@ function resolvePermissionResponse(params: { options?: Array<{ optionId: string;
209
385
  return { outcome: { outcome: "selected", optionId: (allow ?? options[0]).optionId } };
210
386
  }
211
387
 
212
- /** ACP prompt stopReason pi stopReason. */
213
- function mapPromptStopReason(stopReason: string | undefined): AssistantMessage["stopReason"] {
388
+ /** Verdict for one ACP prompt result what pi should report, and why. */
389
+ export type AcpStopVerdict = {
390
+ stopReason: AssistantMessage["stopReason"];
391
+ /** The raw ACP reason, preserved whenever the wire carried one. */
392
+ rawStopReason?: string;
393
+ /** Set exactly when `stopReason` is "error" — carries the reason into the UI. */
394
+ errorMessage?: string;
395
+ };
396
+
397
+ /**
398
+ * ACP prompt stopReason → pi verdict.
399
+ *
400
+ * The ACP terminal set is closed (`@agentclientprotocol/sdk` 1.3.0
401
+ * `schema/types.gen`): end_turn | max_tokens | max_turn_requests | refusal |
402
+ * cancelled. Only three of those are successful or benign ends. The previous
403
+ * implementation returned a bare StopReason with `default: "stop"`, which turned
404
+ * `refusal`, `max_turn_requests`, any future member, AND a missing reason into a
405
+ * clean successful turn — pi then rendered a silently truncated answer as if the
406
+ * model had finished. pi 0.83 closed the same hole in its own providers (#7272:
407
+ * unmapped terminal reasons surface as provider errors, never successful stops)
408
+ * and added `rawStopReason` so the wire value survives the mapping. This mirrors
409
+ * that contract rather than inventing a local one.
410
+ */
411
+ export function mapPromptStopReason(stopReason: string | undefined): AcpStopVerdict {
214
412
  switch (stopReason) {
413
+ case "end_turn":
414
+ return { stopReason: "stop", rawStopReason: stopReason };
215
415
  case "max_tokens":
216
- return "length";
416
+ return { stopReason: "length", rawStopReason: stopReason };
217
417
  case "cancelled":
218
- return "aborted";
418
+ return { stopReason: "aborted", rawStopReason: stopReason };
419
+ case "refusal":
420
+ return {
421
+ stopReason: "error",
422
+ rawStopReason: stopReason,
423
+ errorMessage: "ACP backend stopped with: refusal (the model declined to answer; the turn is incomplete)",
424
+ };
425
+ case "max_turn_requests":
426
+ return {
427
+ stopReason: "error",
428
+ rawStopReason: stopReason,
429
+ errorMessage:
430
+ "ACP backend stopped with: max_turn_requests (the backend's per-turn request budget was exhausted; the turn is incomplete)",
431
+ };
432
+ case undefined:
433
+ return {
434
+ stopReason: "error",
435
+ errorMessage: "ACP backend ended the turn without a stop reason",
436
+ };
219
437
  default:
220
- return "stop";
438
+ return {
439
+ stopReason: "error",
440
+ rawStopReason: stopReason,
441
+ errorMessage: `ACP backend stopped with an unrecognized reason: ${stopReason}`,
442
+ };
221
443
  }
222
444
  }
223
445
 
@@ -426,15 +648,30 @@ export function streamAcpTurn(
426
648
  };
427
649
  }
428
650
 
651
+ /**
652
+ * Seal the turn from the ACP prompt result. "Success" here means the RPC
653
+ * returned, not that the turn ended well — a returned `refusal` /
654
+ * `max_turn_requests` / unknown / absent reason is sealed as an error event,
655
+ * never a `done`. `rawStopReason` carries the wire value out either way.
656
+ */
429
657
  function finishSuccess(promptResult: { stopReason?: string }): void {
430
658
  finalizeAcpStreamState(state);
431
- const mapped = mapPromptStopReason(promptResult?.stopReason);
432
- if (signal?.aborted || mapped === "aborted") {
659
+ const verdict = mapPromptStopReason(promptResult?.stopReason);
660
+ if (verdict.rawStopReason !== undefined) state.output.rawStopReason = verdict.rawStopReason;
661
+ if (signal?.aborted || verdict.stopReason === "aborted") {
433
662
  state.output.stopReason = "aborted";
434
663
  stream.push({ type: "error", reason: "aborted", error: state.output });
664
+ } else if (verdict.stopReason === "error") {
665
+ state.output.stopReason = "error";
666
+ state.output.errorMessage = verdict.errorMessage;
667
+ stream.push({ type: "error", reason: "error", error: state.output });
435
668
  } else {
436
- state.output.stopReason = mapped;
437
- stream.push({ type: "done", reason: mapped === "length" ? "length" : "stop", message: state.output });
669
+ state.output.stopReason = verdict.stopReason;
670
+ stream.push({
671
+ type: "done",
672
+ reason: verdict.stopReason === "length" ? "length" : "stop",
673
+ message: state.output,
674
+ });
438
675
  }
439
676
  stream.end();
440
677
  }
@@ -601,6 +838,7 @@ export function streamAcpTurn(
601
838
  // connection + child so it is not orphaned in retainedChildren (GPT blocker 2).
602
839
  if (decision.path === "new" && existing) {
603
840
  existing.alive = false;
841
+ existing.retiring = true;
604
842
  if (bridgeSessions.get(sessionKey) === existing) bridgeSessions.delete(sessionKey);
605
843
  retainedChildren.delete(existing.child);
606
844
  existing.connection.close?.();
@@ -639,12 +877,29 @@ export function streamAcpTurn(
639
877
  try {
640
878
  if (signal?.aborted) throw new Error("aborted before launch");
641
879
 
880
+ // A backend session that died BETWEEN turns is announced here, before
881
+ // the bootstrap notice — otherwise this turn looks like an ordinary
882
+ // cold start and the operator never learns that the session they were
883
+ // talking to ended. Announced once (read-and-clear); the turn continues
884
+ // normally, since opening a fresh child for a NEW user turn is not a
885
+ // replay of anything.
886
+ const priorEnd = takeUnreportedChildEnd(sessionKey);
887
+ if (priorEnd) {
888
+ pushAcpLifecycleNotice(
889
+ state,
890
+ `previous ${adapter.backend} session ended between turns (${describeChildEnd(priorEnd)}) — opening a new one`,
891
+ );
892
+ }
642
893
  // S2f visibility: surface the otherwise-silent bootstrap so a slow
643
894
  // overlay/spawn/init does not read as a hang. Display-only (marked).
644
895
  pushAcpLifecycleNotice(state, `preparing ${adapter.backend} session`);
645
- // GPT §9-5: materialize the overlay first, then spawn with launchEnvDefaults
896
+ // Overlay ordering (rail “Adapter contract”): materialize the overlay first, then spawn with launchEnvDefaults
646
897
  // + overlay.envOverrides merged over process.env (defaultDeps spawnChild).
647
- const overlay = adapter.ensureOverlay({ cwd, modelId: model.id, nativeModelId, config });
898
+ // sessionKey is the AUTHORITATIVE per-session identity (resolveSessionKey:
899
+ // opts.sessionId → PI_SESSION_ID → cwd) — a session-scoped overlay must
900
+ // scope on it, so it rides the params explicitly and the adapter never
901
+ // re-derives a weaker key from ambient env (P0-1).
902
+ const overlay = adapter.ensureOverlay({ cwd, modelId: model.id, nativeModelId, config, sessionKey });
648
903
  const launch = adapter.resolveLaunch({ cwd, modelId: model.id, nativeModelId, config });
649
904
  child = deps.spawnChild(launch, cwd, { ...adapter.launchEnvDefaults(), ...overlay.envOverrides });
650
905
  const spawned = child;
@@ -655,6 +910,11 @@ export function streamAcpTurn(
655
910
  if (stderrTail.length > 50) stderrTail.shift();
656
911
  });
657
912
 
913
+ // Abort during BOOTSTRAP (spawn → initialize → newSession → set-model):
914
+ // there is no prompt turn for the agent to cancel yet, so the child is
915
+ // simply torn down. This listener is handed off before the prompt —
916
+ // awaitAcpPromptTurn installs the protocol-cancel-first one for that
917
+ // window, and two live listeners would race SIGTERM against the cancel.
658
918
  if (signal) {
659
919
  onAbort = () => killChildGroup(spawned, "SIGTERM");
660
920
  signal.addEventListener("abort", onAbort, { once: true });
@@ -696,9 +956,15 @@ export function streamAcpTurn(
696
956
  alive: true,
697
957
  busy: true,
698
958
  activePromptHandler: undefined,
959
+ // SAME array the stderr drain above pushes into: the buffer outlives
960
+ // this turn with the session, so a later reuse turn can still report
961
+ // the child's dying words.
962
+ stderrTail,
699
963
  };
700
964
  const sess = session;
701
- spawned.once("exit", () => onChildGone(sess));
965
+ spawned.once("exit", (...args: unknown[]) =>
966
+ onChildGone(sess, { code: (args[0] as number | null) ?? null, signal: (args[1] as NodeJS.Signals) ?? null }),
967
+ );
702
968
  spawned.once("error", () => onChildGone(sess));
703
969
 
704
970
  await withTimeout(
@@ -726,7 +992,7 @@ export function streamAcpTurn(
726
992
  modelId: model.id,
727
993
  piSessionId: process.env.PI_SESSION_ID?.trim() || undefined,
728
994
  });
729
- // GPT §9-4: omit the `_meta` KEY entirely for a carrier-less backend
995
+ // Carrier-less shape (rail “Adapter contract”): omit the `_meta` KEY entirely for a carrier-less backend
730
996
  // (sessionMeta === undefined), not `_meta: undefined`.
731
997
  const newSessionArgs =
732
998
  sessionMeta === undefined
@@ -772,10 +1038,16 @@ export function streamAcpTurn(
772
1038
  // prompt could still sync-reject before the wire write; the next visible
773
1039
  // event after this is the backend's own first token / tool notice.
774
1040
  pushAcpLifecycleNotice(state, "sending prompt");
775
- const promptResult = await withTimeout(
776
- "prompt",
777
- connection.prompt({ sessionId: acpSessionId, prompt }),
778
- PROMPT_TIMEOUT_MS,
1041
+ // Hand the abort window over to the prompt driver: from here on an abort
1042
+ // is a protocol `session/cancel` first, teardown only after the grace.
1043
+ if (signal && onAbort) {
1044
+ signal.removeEventListener("abort", onAbort);
1045
+ onAbort = undefined;
1046
+ }
1047
+ const promptResult = await awaitAcpPromptTurn(
1048
+ session,
1049
+ { sessionId: acpSessionId, prompt },
1050
+ { signal, graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS },
779
1051
  );
780
1052
 
781
1053
  session.activePromptHandler = undefined;
@@ -794,6 +1066,7 @@ export function streamAcpTurn(
794
1066
  unrefRetainedChild(spawned);
795
1067
  persistRecord(session, deps);
796
1068
  } else {
1069
+ session.retiring = true;
797
1070
  connection.close?.();
798
1071
  teardownChild(spawned);
799
1072
  }
@@ -807,11 +1080,12 @@ export function streamAcpTurn(
807
1080
  // error/abort → drop the (uncertain) session and close its child; an
808
1081
  // uncertain connection must never be reused (GPT ④).
809
1082
  if (child) {
1083
+ if (session) session.retiring = true;
810
1084
  retainedChildren.delete(child);
811
1085
  session?.connection.close?.(err);
812
1086
  teardownChild(child);
813
1087
  }
814
- finishError(err, aborted, stderrTail);
1088
+ finishError(err, aborted, session?.stderrTail ?? stderrTail);
815
1089
  } finally {
816
1090
  if (signal && onAbort) signal.removeEventListener("abort", onAbort);
817
1091
  }
@@ -819,7 +1093,6 @@ export function streamAcpTurn(
819
1093
 
820
1094
  // --- reuse: send only the latest user delta to the live ACP session
821
1095
  async function runReuseTurn(session: BridgeSession, ctxSigs: string[]): Promise<void> {
822
- let onAbort: (() => void) | undefined;
823
1096
  try {
824
1097
  if (signal?.aborted) throw new Error("aborted before prompt");
825
1098
 
@@ -828,10 +1101,6 @@ export function streamAcpTurn(
828
1101
  pushAcpLifecycleNotice(state, "reusing live session");
829
1102
  session.busy = true;
830
1103
  session.activePromptHandler = makePromptHandler(session);
831
- if (signal) {
832
- onAbort = () => killChildGroup(session.child, "SIGTERM");
833
- signal.addEventListener("abort", onAbort, { once: true });
834
- }
835
1104
 
836
1105
  // The live ACP session already remembers the prior turns → send only the
837
1106
  // latest user delta (re-sending the transcript would duplicate history).
@@ -840,10 +1109,12 @@ export function streamAcpTurn(
840
1109
 
841
1110
  // S2f visibility: about to send the delta to the resident child.
842
1111
  pushAcpLifecycleNotice(state, "sending prompt");
843
- const promptResult = await withTimeout(
844
- "prompt",
845
- session.connection.prompt({ sessionId: session.acpSessionId, prompt }),
846
- PROMPT_TIMEOUT_MS,
1112
+ // A reuse turn has no bootstrap window at all — the prompt driver owns
1113
+ // the whole abort surface (protocol cancel first, teardown after grace).
1114
+ const promptResult = await awaitAcpPromptTurn(
1115
+ session,
1116
+ { sessionId: session.acpSessionId, prompt },
1117
+ { signal, graceMs: deps.abortGraceMs ?? ABORT_CANCEL_GRACE_MS },
847
1118
  );
848
1119
 
849
1120
  session.activePromptHandler = undefined;
@@ -860,12 +1131,14 @@ export function streamAcpTurn(
860
1131
  session.busy = false;
861
1132
  // error/abort on a reused session → drop it and close the child (GPT ④).
862
1133
  if (bridgeSessions.get(session.key) === session) bridgeSessions.delete(session.key);
1134
+ session.retiring = true;
863
1135
  retainedChildren.delete(session.child);
864
1136
  session.connection.close?.(err);
865
1137
  teardownChild(session.child);
866
- finishError(err, aborted);
867
- } finally {
868
- if (signal && onAbort) signal.removeEventListener("abort", onAbort);
1138
+ // The SAME diagnostics a new turn reports. Without the session-scoped
1139
+ // tail a mid-turn child death on a resident session surfaced as a bare
1140
+ // "ACP connection closed" with nothing to read it by.
1141
+ finishError(err, aborted, session.stderrTail);
869
1142
  }
870
1143
  }
871
1144
 
@@ -8,11 +8,23 @@
8
8
  // config and hands it to the backend so the documented passthrough actually
9
9
  // reaches `newSession`.
10
10
  //
11
- // Claude-only scope (NEXT §스코프). Ported from the v0.11.0 behavior oracle
12
- // (index.ts `loadProviderSettings`/`readSettingsFile` + acp-bridge.ts
11
+ // Scope. Ported from the v0.11.0 behavior oracle (index.ts
12
+ // `loadProviderSettings`/`readSettingsFile` + acp-bridge.ts
13
13
  // `normalizeMcpServers`/`enrichMcpServersWithEnvelope`) — structure new, behavior
14
- // preserved. Codex/Gemini fields (codexDisabledFeatures, …) are out of scope and
15
- // deliberately NOT carried.
14
+ // preserved. Retired Codex/Gemini-era fields (codexDisabledFeatures, …) are NOT
15
+ // carried and are silently ignored if present.
16
+ //
17
+ // The COMMON keys parsed here are Claude-SHAPED by origin (tools / permissionAllow /
18
+ // disallowedTools / settingSources / skillPlugins are claude-agent-acp's surface).
19
+ // What varies per backend is whether they are PROJECTED ONTO THE BACKEND SESSION —
20
+ // not whether they are read at all. Cortex runs native tools and reaches MCP through
21
+ // its own overlay mcp.json projection, so none of those keys shape a cortex session;
22
+ // they nonetheless stay live on the common path, where backend.ts reads `tools` for
23
+ // the backend-invariant exclude-tools preflight and folds every one of them into
24
+ // `bridgeConfigSignature` (so changing one still invalidates a reused cortex
25
+ // session). Backend-OWNED settings never land here: they ride the opaque
26
+ // `adapterSettings` seam (rail “Adapter contract”), which is what keeps a backend-named key like
27
+ // `cortexConnection` off this common shape.
16
28
  //
17
29
  // PURITY / SIGNATURE contract (NEXT oracle C / 핀1 / GPT `…2f9325` boost):
18
30
  // - `normalizeMcpServers` is pure: a SORTED, validated server list + a sha256
@@ -71,7 +83,9 @@ export interface ProviderSettings {
71
83
  disallowedTools?: string[];
72
84
  }
73
85
 
74
- /** The fully-resolved Claude provider config the backend hands to newSession. */
86
+ /** The fully-resolved common provider config the turn loop hands to newSession.
87
+ * Claude-shaped by origin; a backend reads only the fields it consumes, and its
88
+ * OWN keys ride `adapterSettings`. */
75
89
  export interface ResolvedAcpConfig {
76
90
  /** Operator-declared backend (diagnostic guard only — see ProviderSettings.backend).
77
91
  * backend.ts asserts this matches the adapter the model id routes to. */
@@ -35,6 +35,16 @@
35
35
  // incompatible and the next turn opens a fresh ACP session with the new carrier.
36
36
  // That per-turn rebuild is the accepted cost of the A/B opt-in surface, never the
37
37
  // shipped default (which stays cached precisely so a resident never rebuilds).
38
+ //
39
+ // A-JOIN (measured LIVE 2026-07-31, 0.64.0 adapter, fresh Claude ACP): the model's
40
+ // system prompt arrived as
41
+ // `You are a Claude agent, built on Anthropic's Claude Agent SDK.# Engraving Here`
42
+ // A string-form `_meta.systemPrompt` replaces the `claude_code` preset, but the
43
+ // SDK still PREFIXES its own fixed identity sentence and joins the two with
44
+ // NOTHING — so the operator's heading was swallowed into the tail of the SDK's
45
+ // sentence. The boundary therefore belongs to the CARRIER, and it cannot be
46
+ // delegated to engraving.md: the render is trimmed (below), so a leading blank
47
+ // line in the markdown is eaten before it ever reaches the wire.
38
48
 
39
49
  import { readFileSync } from "node:fs";
40
50
  import { dirname, join, resolve } from "node:path";
@@ -43,8 +53,20 @@ import { fileURLToPath } from "node:url";
43
53
  const HERE = dirname(fileURLToPath(import.meta.url));
44
54
  const DEFAULT_ENGRAVING_PATH = join(HERE, "prompts", "engraving.md");
45
55
 
56
+ /**
57
+ * The carrier's LEADING boundary — the one the Claude Agent SDK does not supply
58
+ * (see A-JOIN above). A constant, never derived from the template, so the render
59
+ * stays a pure function of (template, backend, mcpServerNames) and the operator's
60
+ * file whitespace can never drift `bridgeConfigSignature`. One blank line is the
61
+ * whole lever: it puts the carrier's first line at the start of its own block
62
+ * instead of at the end of the SDK's fixed sentence.
63
+ */
64
+ export const CARRIER_LEAD_SEPARATOR = "\n\n";
65
+
46
66
  export interface EngravingParams {
47
- /** Claude-only this cut; kept as a field so the `{{backend}}` token interpolates. */
67
+ /** Always "claude" in practice a system-prompt-carrier-less backend (cortex)
68
+ * returns null from `loadCarrier` WITHOUT calling this loader, so claudeAdapter is
69
+ * its only caller. Kept as a field so the `{{backend}}` token interpolates. */
48
70
  backend: string;
49
71
  /** MCP server names exposed to the session. SORTED before render for determinism. */
50
72
  mcpServerNames: readonly string[];
@@ -80,6 +102,22 @@ function interpolate(template: string, params: EngravingParams): string {
80
102
  return template.replace(/\{\{backend\}\}/g, params.backend).replace(/\{\{mcp_servers\}\}/g, mcpList);
81
103
  }
82
104
 
105
+ /**
106
+ * Render one template into a wire-ready carrier, or `""` when the template has no
107
+ * body (the caller turns that into the opt-out / fail-loud branches).
108
+ *
109
+ * The ORDER here is the A-join fix. Trim first: the operator template's own
110
+ * leading/trailing whitespace must not reach the wire (it would drift
111
+ * bridgeConfigSignature) and the emptiness test must see the BODY — a
112
+ * separator-only string is an opt-out, not a carrier. Then attach OUR boundary,
113
+ * which is what the SDK's fixed sentence has nothing of.
114
+ */
115
+ function renderCarrier(source: string, params: EngravingParams): string {
116
+ const body = interpolate(source, params).trim();
117
+ if (body.length === 0) return "";
118
+ return `${CARRIER_LEAD_SEPARATOR}${body}`;
119
+ }
120
+
83
121
  /**
84
122
  * The rendered engraving carrier, or null when an ENV-OVERRIDE engraving file
85
123
  * (`ENTWURF_ACP_ENGRAVING_PATH`) is empty, whitespace-only, missing, or
@@ -92,6 +130,12 @@ function interpolate(template: string, params: EngravingParams): string {
92
130
  * Callers MUST treat null as "no carrier configured" and omit `_meta.systemPrompt`
93
131
  * entirely (passing "" as the `appendSystemPrompt` signature input) so
94
132
  * subscription billing is never reclassified.
133
+ *
134
+ * A non-null carrier always LEADS with `CARRIER_LEAD_SEPARATOR`, and callers must
135
+ * pass it on BYTE-FOR-BYTE: the same string feeds `bridgeConfigSignature`
136
+ * (`appendSystemPrompt`) and the wire (`_meta.systemPrompt`), so normalizing it at
137
+ * either hop both re-opens the A-join and makes reuse key on a string that was
138
+ * never sent.
95
139
  */
96
140
  export function loadEngraving(params: EngravingParams): string | null {
97
141
  const filePath = resolveEngravingPath();
@@ -108,7 +152,7 @@ export function loadEngraving(params: EngravingParams): string | null {
108
152
  }
109
153
  return null;
110
154
  }
111
- const rendered = interpolate(source, params).trim();
155
+ const rendered = renderCarrier(source, params);
112
156
  if (rendered.length === 0) {
113
157
  if (isShippedDefault) {
114
158
  throw new Error(