@junghanacs/entwurf 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/AGENTS.md +52 -17
  2. package/BASELINE.md +60 -206
  3. package/CHANGELOG.md +64 -0
  4. package/CONTRIBUTING.md +14 -10
  5. package/DELIVERY.md +118 -261
  6. package/README.md +91 -431
  7. package/VERIFY.md +74 -95
  8. package/demo/README.md +1 -1
  9. package/demo/demo-baseline.sh +1 -3
  10. package/demo/demo.sh +2 -5
  11. package/docs/acp-backend-rail.md +236 -0
  12. package/docs/external-mcp-host.md +131 -0
  13. package/docs/fresh-cut-policy.md +99 -0
  14. package/docs/setup-clean-host.md +124 -328
  15. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +160 -67
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/acp-client.js +12 -0
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +11 -10
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/config.js +1 -1
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/engraving.js +42 -1
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/models.js +4 -4
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/overlay.js +11 -9
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-control-rpc.js +7 -5
  23. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-core.js +13 -14
  24. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-resume-args.js +45 -40
  25. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-contract.js +117 -95
  26. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +23 -57
  27. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-lock.js +16 -7
  28. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +5 -53
  29. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-release.js +21 -36
  30. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-runner.js +3 -15
  31. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send-fallback.js +12 -11
  32. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-send.js +2 -7
  33. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +30 -67
  34. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +256 -0
  35. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-session.js +91 -5
  36. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +258 -0
  37. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-launch.js +202 -0
  38. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-placement.js +289 -0
  39. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-resume-call.js +170 -0
  40. package/mcp/entwurf-bridge/dist/pi-extensions/lib/resume-launch-identity.js +136 -0
  41. package/mcp/entwurf-bridge/dist/pi-extensions/lib/session-id.js +8 -5
  42. package/mcp/entwurf-bridge/dist/pi-extensions/lib/socket-discovery.js +3 -3
  43. package/mcp/entwurf-bridge/dist/scripts/meta-facts.js +51 -0
  44. package/mcp/entwurf-bridge/dist/scripts/new-session-id.js +9 -4
  45. package/mcp/entwurf-bridge/src/index.ts +176 -67
  46. package/mcp/entwurf-bridge/start.sh +2 -2
  47. package/mcp/entwurf-bridge/test.sh +23 -9
  48. package/mcp/entwurf-bridge/tsconfig.build.json +11 -2
  49. package/package.json +25 -11
  50. package/pi-extensions/entwurf-control.ts +220 -71
  51. package/pi-extensions/lib/acp/acp-client.ts +22 -0
  52. package/pi-extensions/lib/acp/augment.ts +42 -3
  53. package/pi-extensions/lib/acp/backend-adapter.ts +12 -11
  54. package/pi-extensions/lib/acp/backend.ts +366 -38
  55. package/pi-extensions/lib/acp/config.ts +1 -1
  56. package/pi-extensions/lib/acp/engraving.ts +43 -1
  57. package/pi-extensions/lib/acp/event-mapper.ts +16 -13
  58. package/pi-extensions/lib/acp/models.ts +4 -4
  59. package/pi-extensions/lib/acp/overlay.ts +11 -9
  60. package/pi-extensions/lib/entwurf-control-rpc.ts +7 -5
  61. package/pi-extensions/lib/entwurf-core.ts +15 -15
  62. package/pi-extensions/lib/entwurf-resume-args.ts +41 -52
  63. package/pi-extensions/lib/entwurf-v2-contract-schema.ts +1 -1
  64. package/pi-extensions/lib/entwurf-v2-contract.ts +120 -99
  65. package/pi-extensions/lib/entwurf-v2-decider.ts +30 -91
  66. package/pi-extensions/lib/entwurf-v2-lock.ts +16 -7
  67. package/pi-extensions/lib/entwurf-v2-production.ts +4 -78
  68. package/pi-extensions/lib/entwurf-v2-release.ts +25 -49
  69. package/pi-extensions/lib/entwurf-v2-runner.ts +6 -21
  70. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +12 -11
  71. package/pi-extensions/lib/entwurf-v2-send.ts +2 -7
  72. package/pi-extensions/lib/entwurf-v2-surface.ts +36 -76
  73. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +370 -0
  74. package/pi-extensions/lib/meta-session.ts +93 -5
  75. package/pi-extensions/lib/mux-fresh-call.ts +328 -0
  76. package/pi-extensions/lib/mux-launch.ts +267 -0
  77. package/pi-extensions/lib/mux-placement.ts +387 -0
  78. package/pi-extensions/lib/mux-resume-call.ts +221 -0
  79. package/pi-extensions/lib/resume-launch-identity.ts +162 -0
  80. package/pi-extensions/lib/session-id.js +8 -5
  81. package/pi-extensions/lib/socket-discovery.ts +3 -3
  82. package/prompts/engraving.md +13 -5
  83. package/run.sh +688 -282
  84. package/scripts/agy-bridge-config.py +5 -1
  85. package/scripts/check-acp-backend-preflight.ts +1 -1
  86. package/scripts/check-acp-carrier-augment.ts +246 -10
  87. package/scripts/check-acp-cortex.ts +5 -5
  88. package/scripts/check-acp-overlay.ts +13 -3
  89. package/scripts/check-acp-prompt-lifecycle.ts +565 -0
  90. package/scripts/check-acp-stop-reason.ts +342 -0
  91. package/scripts/check-acp-stream-hooks.ts +504 -0
  92. package/scripts/check-elapsed.sh +25 -0
  93. package/scripts/check-entwurf-bridge-boot.ts +51 -4
  94. package/scripts/check-entwurf-bridge-pi-free.ts +6 -5
  95. package/scripts/check-entwurf-control-rpc.ts +4 -3
  96. package/scripts/check-entwurf-resume-args.ts +72 -70
  97. package/scripts/check-entwurf-session-identity.ts +14 -10
  98. package/scripts/check-entwurf-v2-contract.ts +34 -59
  99. package/scripts/check-entwurf-v2-decider.ts +17 -177
  100. package/scripts/check-entwurf-v2-lock.ts +5 -2
  101. package/scripts/check-entwurf-v2-matrix.ts +3 -53
  102. package/scripts/check-entwurf-v2-production.ts +2 -91
  103. package/scripts/check-entwurf-v2-release.ts +10 -105
  104. package/scripts/check-entwurf-v2-runner.ts +4 -85
  105. package/scripts/check-entwurf-v2-send-fallback.ts +5 -6
  106. package/scripts/check-entwurf-v2-send.ts +0 -28
  107. package/scripts/check-entwurf-v2-surface.ts +198 -128
  108. package/scripts/check-entwurf-v2-visible-resume.ts +445 -0
  109. package/scripts/check-fresh-cut-gate.sh +1 -1
  110. package/scripts/check-gate-qualification.ts +101 -7
  111. package/scripts/check-install-container.sh +10 -2
  112. package/scripts/check-install-surface.ts +1 -1
  113. package/scripts/check-keyset-overlap.py +1 -1
  114. package/scripts/check-meta-facts.ts +249 -0
  115. package/scripts/check-meta-identity-consumers.ts +1 -1
  116. package/scripts/check-meta-session.ts +169 -0
  117. package/scripts/check-mux-launch-tmux.ts +316 -0
  118. package/scripts/check-mux-launch.ts +288 -0
  119. package/scripts/check-mux-launcher-fence.ts +264 -0
  120. package/scripts/check-mux-parent-artifact.ts +195 -0
  121. package/scripts/check-mux-placement-tmux.ts +322 -0
  122. package/scripts/check-mux-placement.ts +323 -0
  123. package/scripts/check-mux-resume-call.ts +282 -0
  124. package/scripts/check-probe-cli-shim.ts +25 -22
  125. package/scripts/check-probe-ordering.ts +121 -81
  126. package/scripts/check-release-gate-outcomes.ts +452 -0
  127. package/scripts/check-resume-launch-identity.ts +244 -0
  128. package/scripts/check-socket-discovery.ts +1 -1
  129. package/scripts/fixtures/mux-parent-transcript.scrubbed.jsonl +3 -0
  130. package/scripts/inventory-verification-surface.ts +349 -0
  131. package/scripts/lib/claude-launcher-fence.ts +322 -0
  132. package/scripts/lib/live-skip.ts +33 -0
  133. package/scripts/lib/mutation-qualify.ts +109 -3
  134. package/scripts/lib/probe-acp-turn.ts +33 -12
  135. package/scripts/lib/step-outcome.sh +88 -0
  136. package/scripts/meta-bridge-doctor.sh +6 -8
  137. package/scripts/meta-facts.ts +60 -0
  138. package/scripts/mutants/acp-augment.json +106 -0
  139. package/scripts/mutants/acp-cortex.json +2 -2
  140. package/scripts/mutants/acp-overlay.json +17 -0
  141. package/scripts/mutants/acp-prompt-lifecycle.json +100 -0
  142. package/scripts/mutants/acp-stop-reason.json +80 -0
  143. package/scripts/mutants/acp-stream-hooks.json +158 -0
  144. package/scripts/mutants/bridge-boot-resume.json +45 -0
  145. package/scripts/mutants/meta-facts.json +50 -0
  146. package/scripts/mutants/meta-identity.json +36 -0
  147. package/scripts/mutants/mux-boundary.json +196 -0
  148. package/scripts/mutants/mux-fresh-call.json +185 -0
  149. package/scripts/mutants/mux-launcher-fence.json +123 -0
  150. package/scripts/mutants/mux-parent-artifact.json +39 -0
  151. package/scripts/mutants/mux-resume-call.json +148 -0
  152. package/scripts/mutants/probe-ordering.json +6 -1021
  153. package/scripts/mutants/release-gate.json +140 -0
  154. package/scripts/mutants/resume-args.json +76 -0
  155. package/scripts/mutants/resume-launch-identity.json +96 -0
  156. package/scripts/mutants/v2-surface.json +74 -23
  157. package/scripts/mutants/v2-visible-resume.json +215 -0
  158. package/scripts/new-session-id.ts +9 -4
  159. package/scripts/smoke-acp-bundled-mcp-live.ts +4 -3
  160. package/scripts/smoke-acp-carrier-augment-live.ts +2 -2
  161. package/scripts/smoke-acp-cortex-live.ts +8 -14
  162. package/scripts/smoke-acp-long-turn-live.ts +185 -0
  163. package/scripts/smoke-acp-mcp-live.ts +2 -2
  164. package/scripts/smoke-acp-memory-containment-live.ts +2 -2
  165. package/scripts/smoke-acp-ordering-probe-live.ts +2 -2
  166. package/scripts/smoke-acp-overlay-live.ts +2 -2
  167. package/scripts/smoke-acp-provider-live.ts +2 -2
  168. package/scripts/smoke-acp-raw-turn-live.ts +3 -3
  169. package/scripts/smoke-acp-session-reuse-live.ts +2 -2
  170. package/scripts/smoke-acp-skill-live.ts +2 -2
  171. package/scripts/smoke-acp-socket-citizen-live.ts +4 -3
  172. package/scripts/smoke-acp-v2-send-live.ts +4 -3
  173. package/scripts/smoke-agy-native-push-live.ts +10 -20
  174. package/scripts/smoke-claude-native-resume-live.sh +13 -3
  175. package/scripts/smoke-entwurf-chain-live.ts +352 -0
  176. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -3
  177. package/scripts/smoke-meta-honesty.sh +1 -1
  178. package/scripts/smoke-meta-install-state.sh +4 -0
  179. package/scripts/smoke-mux-fresh-call-live.ts +365 -0
  180. package/scripts/smoke-mux-lifecycle-live.ts +1136 -0
  181. package/scripts/smoke-pi-attach.ts +1 -1
  182. package/scripts/smoke-user-scope-citizen.sh +1 -1
  183. package/scripts/tsconfig.json +1 -0
  184. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-preflight.js +0 -160
  185. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn-production.js +0 -273
  186. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-spawn.js +0 -216
  187. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +0 -373
  188. package/pi-extensions/lib/entwurf-v2-spawn.ts +0 -323
  189. package/scripts/check-acp-sdk-surface.ts +0 -236
  190. package/scripts/check-entwurf-v2-spawn-production.ts +0 -551
  191. package/scripts/check-entwurf-v2-spawn.ts +0 -399
  192. package/scripts/smoke-entwurf-v2-spawn-live.ts +0 -188
  193. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +0 -469
@@ -0,0 +1,565 @@
1
+ // Deterministic gate for the ACP PROMPT LIFECYCLE contract (backend.ts).
2
+ //
3
+ // WHAT THIS EXISTS TO STOP. The prompt used to be raced against a 600s absolute
4
+ // wall clock. Two defects rode on that single number:
5
+ //
6
+ // 1. It killed turns that were still working. A recorded host transcript
7
+ // (2026-07-30) shows the same review prompt cut three times at exactly
8
+ // 600000ms while the child was still emitting tool calls.
9
+ // 2. Its own error text ("prompt timed out after 600000ms") lands inside pi's
10
+ // transient-error dictionary — `RETRYABLE_PROVIDER_ERROR_PATTERN` in
11
+ // @earendil-works/pi-ai `utils/retry` matches `timed? out` / `timeout` — so
12
+ // pi replayed the SAME full prompt from a cold ACP session up to
13
+ // `retry.maxRetries` times. One wall-clock cutoff cost four full turns.
14
+ //
15
+ // The contract now: a prompt ends on LIFECYCLE EVENTS ONLY — it resolves, the
16
+ // operator aborts, or the child dies / its stdio ends. Elapsed time is not
17
+ // evidence, and a quiet turn is not a failed turn.
18
+ //
19
+ // The static half of that claim (no PROMPT_TIMEOUT_MS, no withTimeout("prompt"))
20
+ // is pinned by check-probe-ordering, which owns the production-boundary pins.
21
+ // THIS gate is the behavioral half: every cell drives `streamAcpTurn` against a
22
+ // fake ACP child + connection and asserts what an operator would actually see.
23
+ //
24
+ // The retry claim is checked against pi's REAL classifier, imported from
25
+ // `@earendil-works/pi-ai/compat` — an oracle independent of the subject. The
26
+ // cell also asserts the old string still classifies as retryable, so the check
27
+ // cannot pass by the classifier having quietly stopped matching anything.
28
+ //
29
+ // backend.ts imports its siblings with `.js` suffixes (the root/jiti runtime
30
+ // convention), so — like check-acp-stop-reason — we tsc-emit the project and
31
+ // import the COMPILED backend.js whose `.js` imports resolve to real siblings.
32
+
33
+ import { strict as assert } from "node:assert";
34
+ import { execFileSync } from "node:child_process";
35
+ import { copyFileSync, mkdirSync, mkdtempSync, rmdirSync, rmSync } from "node:fs";
36
+ import { tmpdir } from "node:os";
37
+ import { resolve } from "node:path";
38
+ import { pathToFileURL } from "node:url";
39
+ import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
40
+ import { isRetryableAssistantError } from "@earendil-works/pi-ai/compat";
41
+
42
+ const sonnet = { id: "claude-sonnet-5" } as unknown as Model<Api>;
43
+
44
+ type Stream = AsyncIterable<AssistantMessageEvent>;
45
+
46
+ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
47
+
48
+ /** The old cutoff's text — kept verbatim as the classifier's positive control. */
49
+ const RETIRED_TIMEOUT_TEXT = "prompt timed out after 600000ms";
50
+
51
+ /** How long a quiet in-flight prompt is observed before it must still be alive. */
52
+ const STALL_OBSERVATION_MS = 250;
53
+ /** Injected post-abort cleanup grace (production ships 5s; the contract is that it is BOUNDED). */
54
+ const TEST_ABORT_GRACE_MS = 40;
55
+
56
+ interface Deferred<T> {
57
+ promise: Promise<T>;
58
+ resolve: (value: T) => void;
59
+ reject: (err: unknown) => void;
60
+ }
61
+
62
+ function deferred<T>(): Deferred<T> {
63
+ let resolve!: (value: T) => void;
64
+ let reject!: (err: unknown) => void;
65
+ const promise = new Promise<T>((res, rej) => {
66
+ resolve = res;
67
+ reject = rej;
68
+ });
69
+ return { promise, resolve, reject };
70
+ }
71
+
72
+ /**
73
+ * A fake child that can actually DIE: the production code learns about a child's
74
+ * end through `once("exit")` and about its complaints through the stderr drain,
75
+ * so a no-op fake could not exercise either.
76
+ */
77
+ function makeFakeChild() {
78
+ const exitListeners: Array<(...args: unknown[]) => void> = [];
79
+ const stderrListeners: Array<(chunk: Buffer) => void> = [];
80
+ const kills: Array<NodeJS.Signals | number | undefined> = [];
81
+ const pipe = () => ({ destroy() {}, unref() {} });
82
+ const child = {
83
+ pid: undefined as number | undefined,
84
+ exitCode: null as number | null,
85
+ signalCode: null as NodeJS.Signals | null,
86
+ kills,
87
+ stdin: pipe(),
88
+ stdout: pipe(),
89
+ stderr: {
90
+ on(_event: "data", listener: (chunk: Buffer) => void) {
91
+ stderrListeners.push(listener);
92
+ },
93
+ destroy() {},
94
+ unref() {},
95
+ },
96
+ kill(signal?: NodeJS.Signals | number) {
97
+ kills.push(signal);
98
+ return true;
99
+ },
100
+ unref() {},
101
+ once(event: "exit" | "error", listener: (...args: unknown[]) => void) {
102
+ if (event === "exit") exitListeners.push(listener);
103
+ },
104
+ /** driver: the backend writes to its stderr (its dying words). */
105
+ writeStderr(text: string) {
106
+ for (const listener of [...stderrListeners]) listener(Buffer.from(text));
107
+ },
108
+ /** driver: the backend process ends. */
109
+ die(code: number | null, signal: NodeJS.Signals | null = null) {
110
+ child.exitCode = code;
111
+ child.signalCode = signal;
112
+ for (const listener of exitListeners.splice(0)) listener(code, signal);
113
+ },
114
+ };
115
+ return child;
116
+ }
117
+
118
+ const EMPTY_MCP_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
119
+ const DEFAULT_RESOLVED_CONFIG: any = {
120
+ settingSources: [],
121
+ strictMcpConfig: true,
122
+ showToolNotifications: true,
123
+ mcpServers: [],
124
+ mcpServersHash: EMPTY_MCP_HASH,
125
+ tools: ["Read"],
126
+ skillPlugins: [],
127
+ permissionAllow: ["Read(*)"],
128
+ disallowedTools: [],
129
+ };
130
+
131
+ /**
132
+ * One backend world: a single fake child + a retained fake connection whose
133
+ * `prompt` stays PENDING until the cell settles it. `close()` rejects that
134
+ * pending request exactly as the real SDK does (jsonrpc `close()` rejects every
135
+ * pending response), which is what makes the abort-escalation path observable.
136
+ */
137
+ function makeHarness(recordDir: string) {
138
+ const children: ReturnType<typeof makeFakeChild>[] = [];
139
+ const cancels: Array<{ sessionId: string }> = [];
140
+ const closes: unknown[] = [];
141
+ let pending: Deferred<{ stopReason?: string }> | undefined;
142
+ let promptCount = 0;
143
+ let notifier: ((update: Record<string, unknown>) => Promise<void>) | undefined;
144
+
145
+ const makeConnection = (handlers: any) => ({
146
+ initialize: async () => ({ agentCapabilities: {} }),
147
+ newSession: async () => ({ sessionId: "ACP-1" }),
148
+ setSessionConfigOption: async () => ({}),
149
+ prompt: ({ sessionId }: any) => {
150
+ promptCount++;
151
+ notifier = (update) => handlers.sessionUpdate({ update, sessionId });
152
+ pending = deferred<{ stopReason?: string }>();
153
+ return pending.promise;
154
+ },
155
+ cancel: (params: { sessionId: string }) => {
156
+ cancels.push(params);
157
+ },
158
+ close: (err?: unknown) => {
159
+ closes.push(err ?? null);
160
+ pending?.reject(err ?? new Error("ACP connection closed"));
161
+ },
162
+ });
163
+
164
+ return {
165
+ children,
166
+ cancels,
167
+ closes,
168
+ get promptCount() {
169
+ return promptCount;
170
+ },
171
+ /** the agent answers the in-flight prompt */
172
+ settle(stopReason: string) {
173
+ pending?.resolve({ stopReason });
174
+ },
175
+ /** the agent streams something mid-turn (proof the turn is progressing) */
176
+ async progress(text: string) {
177
+ await notifier?.({ sessionUpdate: "agent_message_chunk", content: { type: "text", text } });
178
+ },
179
+ deps: {
180
+ resolveLaunch: () => ({ command: "node", args: ["fake"] }),
181
+ ensureOverlay: () => {},
182
+ spawnChild: () => {
183
+ const c = makeFakeChild();
184
+ children.push(c);
185
+ return c;
186
+ },
187
+ createConnection: (_child: any, handlers: any) => makeConnection(handlers),
188
+ lifecyclePolicy: () => "process-scoped",
189
+ loadConfig: () => DEFAULT_RESOLVED_CONFIG,
190
+ now: () => "2026-07-30T00:00:00Z",
191
+ sessionDir: recordDir,
192
+ abortGraceMs: TEST_ABORT_GRACE_MS,
193
+ },
194
+ };
195
+ }
196
+
197
+ /** Start a turn and collect its events in the background. */
198
+ function startTurn(
199
+ backend: any,
200
+ context: Context,
201
+ options: Record<string, unknown>,
202
+ deps: unknown,
203
+ ): { events: AssistantMessageEvent[]; done: Promise<void> } {
204
+ const events: AssistantMessageEvent[] = [];
205
+ const stream = backend.streamAcpTurn(sonnet, context, options, deps) as Stream;
206
+ const done = (async () => {
207
+ for await (const ev of stream) events.push(ev);
208
+ })();
209
+ return { events, done };
210
+ }
211
+
212
+ const sealed = (events: AssistantMessageEvent[]) =>
213
+ events.filter((e) => e.type === "done" || e.type === "error") as any[];
214
+
215
+ const userCtx = (text: string): Context => ({ messages: [{ role: "user", content: text, timestamp: 0 }] }) as Context;
216
+
217
+ /** A reuse-shaped context: prior user, assistant, new user. */
218
+ function reuseCtx(prior: string, latest: string): Context {
219
+ return {
220
+ messages: [
221
+ { role: "user", content: prior, timestamp: 0 },
222
+ {
223
+ role: "assistant",
224
+ content: [{ type: "text", text: "ok" }],
225
+ api: "x",
226
+ provider: "x",
227
+ model: "x",
228
+ usage: {
229
+ input: 0,
230
+ output: 0,
231
+ cacheRead: 0,
232
+ cacheWrite: 0,
233
+ totalTokens: 0,
234
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
235
+ },
236
+ stopReason: "stop",
237
+ timestamp: 0,
238
+ } as unknown as Message,
239
+ { role: "user", content: latest, timestamp: 0 },
240
+ ],
241
+ } as Context;
242
+ }
243
+
244
+ const TMP_EMIT = ".tmp-verify/acp-prompt-lifecycle";
245
+ rmSync(TMP_EMIT, { recursive: true, force: true });
246
+ const recordDir = mkdtempSync(resolve(tmpdir(), "acp-prompt-life-"));
247
+
248
+ try {
249
+ execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
250
+ stdio: "pipe",
251
+ });
252
+ const promptsOut = resolve(TMP_EMIT, "pi-extensions/lib/acp/prompts");
253
+ mkdirSync(promptsOut, { recursive: true });
254
+ copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
255
+ const backend = (await import(pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href)) as any;
256
+
257
+ // ----------------------------------------------------------------------
258
+ // CELL 1 — a long / quiet turn is NOT killed.
259
+ //
260
+ // The prompt stays pending across an observation window with no lifecycle
261
+ // event at all. Nothing may seal it: not the silence, not the elapsed time.
262
+ // Then it answers, and the turn ends normally — the SAME turn, not a replay.
263
+ // ----------------------------------------------------------------------
264
+ {
265
+ const h = makeHarness(recordDir);
266
+ const turn = startTurn(backend, userCtx("a long tool-using turn"), { sessionId: "life-stall" }, h.deps);
267
+
268
+ await delay(STALL_OBSERVATION_MS / 2);
269
+ await h.progress("still working");
270
+ await delay(STALL_OBSERVATION_MS / 2);
271
+
272
+ assert.deepEqual(
273
+ sealed(turn.events).map((e) => `${e.type}:${e.reason}`),
274
+ [],
275
+ "[QK:PROMPT-STALL-NOT-KILLED] an in-flight prompt with no lifecycle event must stay open — no wall clock, no " +
276
+ "silence timer, and no 'still working' heuristic may seal a turn the backend has not answered",
277
+ );
278
+ assert.equal(h.children.length, 1, "the observed turn spawned exactly one child and never replaced it");
279
+
280
+ h.settle("end_turn");
281
+ await turn.done;
282
+ const seal = sealed(turn.events);
283
+ assert.equal(seal.length, 1, "the turn seals exactly once");
284
+ assert.equal(seal[0].type, "done", "the long turn ends as a normal done");
285
+ assert.equal(seal[0].message.stopReason, "stop", "…with stopReason stop");
286
+ assert.equal(h.promptCount, 1, "the answer came from the ORIGINAL prompt — no cold replay was issued");
287
+ }
288
+
289
+ // ----------------------------------------------------------------------
290
+ // CELL 2 — a user abort is a PROTOCOL event, not a signal race.
291
+ //
292
+ // ACP `session/cancel` asks the agent to end its own turn; the spec has it
293
+ // answer the pending session/prompt with `cancelled`, which maps to aborted.
294
+ // A child that honors it is never signalled at all.
295
+ // ----------------------------------------------------------------------
296
+ {
297
+ const h = makeHarness(recordDir);
298
+ const controller = new AbortController();
299
+ const turn = startTurn(
300
+ backend,
301
+ userCtx("abort me"),
302
+ { sessionId: "life-abort", signal: controller.signal },
303
+ h.deps,
304
+ );
305
+ await delay(30);
306
+ controller.abort();
307
+ await delay(5);
308
+
309
+ assert.deepEqual(
310
+ h.cancels,
311
+ [{ sessionId: "ACP-1" }],
312
+ "[QK:ABORT-SENDS-PROTOCOL-CANCEL] an abort must send ACP session/cancel for the live session FIRST — the " +
313
+ "protocol's own ending, so the agent can close its turn instead of being killed mid-tool",
314
+ );
315
+ assert.deepEqual(h.children[0].kills, [], "a cancel that is being honored must not signal the child");
316
+
317
+ // The agent answers the cancel the way the protocol requires.
318
+ h.settle("cancelled");
319
+ await turn.done;
320
+ const seal = sealed(turn.events);
321
+ assert.equal(seal[0].type, "error", "an aborted turn seals as an error event (never a done)");
322
+ assert.equal(seal[0].reason, "aborted", "…with reason aborted");
323
+ assert.equal(seal[0].error.stopReason, "aborted", "…and a final message stopReason of aborted");
324
+ assert.equal(seal[0].error.rawStopReason, "cancelled", "…preserving the raw ACP reason");
325
+ assert.equal(h.children.length, 1, "an abort spawns NO new child");
326
+ assert.equal(h.promptCount, 1, "an abort issues no replacement prompt");
327
+ }
328
+
329
+ // ----------------------------------------------------------------------
330
+ // CELL 3 — an abort against a WEDGED agent still returns, bounded.
331
+ //
332
+ // The agent ignores session/cancel. After the bounded grace the backend
333
+ // escalates: process-group teardown + connection close (which rejects the
334
+ // pending request), so the operator's abort is answered either way.
335
+ // ----------------------------------------------------------------------
336
+ {
337
+ const h = makeHarness(recordDir);
338
+ const controller = new AbortController();
339
+ const turn = startTurn(backend, userCtx("wedged"), { sessionId: "life-wedged", signal: controller.signal }, h.deps);
340
+ await delay(30);
341
+ const startedAt = Date.now();
342
+ controller.abort();
343
+ await turn.done;
344
+ const elapsed = Date.now() - startedAt;
345
+
346
+ // The bound is the claim. Escalation's teardown is belt-and-braces with the
347
+ // error path's own teardown (asserted below as a standing guarantee), so
348
+ // only the WAIT can be isolated: an abort must be answered within the grace
349
+ // it was given, never on some other clock.
350
+ assert.ok(
351
+ elapsed < TEST_ABORT_GRACE_MS * 25,
352
+ `[QK:ABORT-ESCALATION-BOUNDED] an abort against an agent that ignores session/cancel must be answered within ` +
353
+ `the grace it was GIVEN (${TEST_ABORT_GRACE_MS}ms) — it waited ${elapsed}ms, so the cleanup window is ` +
354
+ "running on a clock the caller does not control",
355
+ );
356
+ assert.ok(
357
+ h.children[0].kills.includes("SIGTERM") && h.closes.length > 0,
358
+ `the escalated child is signalled and its connection closed — kills=${JSON.stringify(h.children[0].kills)} ` +
359
+ `closes=${h.closes.length}`,
360
+ );
361
+ const seal = sealed(turn.events);
362
+ assert.equal(seal[0].reason, "aborted", "the wedged abort still seals as aborted");
363
+ assert.equal(h.children.length, 1, "escalation spawns NO new child");
364
+ }
365
+
366
+ // ----------------------------------------------------------------------
367
+ // CELL 4 — a child that dies mid-prompt is DIAGNOSED, and its error is not
368
+ // a transient one.
369
+ //
370
+ // The SDK's own rejection for this case says only "ACP connection closed".
371
+ // The turn must instead name the exit status and carry the child's stderr —
372
+ // and must not be worded in a way that makes pi replay the whole prompt.
373
+ // ----------------------------------------------------------------------
374
+ let childDeathMessage = "";
375
+ {
376
+ const h = makeHarness(recordDir);
377
+ const turn = startTurn(backend, userCtx("dies mid-turn"), { sessionId: "life-death" }, h.deps);
378
+ await delay(30);
379
+ h.children[0].writeStderr("claude: fatal: FATAL-STDERR-MARK\n");
380
+ h.children[0].die(1, null);
381
+ await turn.done;
382
+
383
+ const seal = sealed(turn.events);
384
+ assert.equal(seal[0].type, "error", "a child death seals as an error event");
385
+ childDeathMessage = String(seal[0].error.errorMessage);
386
+ assert.ok(
387
+ childDeathMessage.includes("ended while the prompt was still in flight") &&
388
+ childDeathMessage.includes("exit code 1") &&
389
+ childDeathMessage.includes("FATAL-STDERR-MARK"),
390
+ "[QK:CHILD-EXIT-DIAGNOSED] a mid-prompt child death must report HOW it ended (exit status) and WHAT it said " +
391
+ `(stderr tail) — a bare "ACP connection closed" is undiagnosable. Got: ${JSON.stringify(childDeathMessage)}`,
392
+ );
393
+ }
394
+
395
+ // ----------------------------------------------------------------------
396
+ // CELL 5 — the SAME diagnostics on a REUSE turn.
397
+ //
398
+ // This is the shape a live sonnet session hit on 2026-07-30: turn N was a
399
+ // reuse turn, the child died mid-tool, and the operator got "ACP connection
400
+ // closed" with nothing else — because the stderr buffer belonged to the
401
+ // turn that spawned the child and the reuse path passed no tail at all.
402
+ // ----------------------------------------------------------------------
403
+ {
404
+ const h = makeHarness(recordDir);
405
+ const t1 = startTurn(backend, userCtx("first NONCE-R1"), { sessionId: "life-reuse" }, h.deps);
406
+ await delay(20);
407
+ h.settle("end_turn");
408
+ await t1.done;
409
+ assert.equal(sealed(t1.events)[0].type, "done", "turn 1 completes so the session is retained for reuse");
410
+
411
+ const t2 = startTurn(backend, reuseCtx("first NONCE-R1", "second NONCE-R2"), { sessionId: "life-reuse" }, h.deps);
412
+ await delay(30);
413
+ assert.equal(h.children.length, 1, "turn 2 reused the live child (no respawn)");
414
+ h.children[0].writeStderr("claude: fatal: REUSE-STDERR-MARK\n");
415
+ h.children[0].die(null, "SIGKILL");
416
+ await t2.done;
417
+
418
+ const message = String(sealed(t2.events)[0].error.errorMessage);
419
+ assert.ok(
420
+ message.includes("signal SIGKILL") && message.includes("REUSE-STDERR-MARK"),
421
+ "[QK:REUSE-CARRIES-CHILD-DIAGNOSTICS] a reuse turn must report the child's exit status and stderr tail too — " +
422
+ `the buffer is session-scoped precisely so a resident session's death is readable. Got: ${JSON.stringify(message)}`,
423
+ );
424
+ }
425
+
426
+ // ----------------------------------------------------------------------
427
+ // CELL 6 — a death BETWEEN turns is announced by the next turn.
428
+ //
429
+ // Nobody is waiting on the child when it dies idle, so that death has no
430
+ // turn to fail. Without an announcement the next turn opens a fresh child
431
+ // and reads as an ordinary cold start, hiding that the backend session the
432
+ // operator was talking to is gone. Opening a new child for a NEW user turn
433
+ // is not a replay — but it must not be silent either.
434
+ // ----------------------------------------------------------------------
435
+ {
436
+ const h = makeHarness(recordDir);
437
+ const t1 = startTurn(backend, userCtx("first NONCE-D1"), { sessionId: "life-idle-death" }, h.deps);
438
+ await delay(20);
439
+ h.settle("end_turn");
440
+ await t1.done;
441
+
442
+ // … the operator reads the answer, and while they think, the child dies.
443
+ h.children[0].die(137, "SIGKILL");
444
+ await delay(5);
445
+
446
+ const t2 = startTurn(
447
+ backend,
448
+ reuseCtx("first NONCE-D1", "second NONCE-D2"),
449
+ { sessionId: "life-idle-death" },
450
+ h.deps,
451
+ );
452
+ await delay(30);
453
+ const notices = t2.events
454
+ .filter((e: any) => e.type === "text" || e.type === "text_start" || e.type === "text_delta")
455
+ .map((e: any) => String(e.delta ?? e.text ?? e.content ?? ""))
456
+ .join("");
457
+ const announced = notices.includes("previous claude session ended between turns");
458
+ assert.ok(
459
+ announced && notices.includes("exit code 137") && notices.includes("signal SIGKILL"),
460
+ "[QK:IDLE-DEATH-ANNOUNCED] a backend session that died BETWEEN turns must be announced by the next turn, with " +
461
+ `how it ended — a silent respawn hides that the session the operator was talking to is gone. Saw: ${JSON.stringify(notices.slice(0, 400))}`,
462
+ );
463
+ assert.ok(
464
+ notices.indexOf("previous claude session ended between turns") <
465
+ notices.indexOf("[acp: preparing claude session]"),
466
+ "the death is announced BEFORE the bootstrap notice — otherwise it reads as an ordinary cold start",
467
+ );
468
+
469
+ h.settle("end_turn");
470
+ await t2.done;
471
+ assert.equal(sealed(t2.events)[0].type, "done", "the announcement does not fail the turn — it explains it");
472
+
473
+ // Announced ONCE: a third turn is a plain cold start again.
474
+ const t3 = startTurn(backend, userCtx("third NONCE-D3"), { sessionId: "life-idle-death" }, h.deps);
475
+ await delay(20);
476
+ h.settle("end_turn");
477
+ await t3.done;
478
+ const laterNotices = t3.events
479
+ .filter((e: any) => e.type === "text" || e.type === "text_start" || e.type === "text_delta")
480
+ .map((e: any) => String(e.delta ?? e.text ?? e.content ?? ""))
481
+ .join("");
482
+ assert.ok(
483
+ !laterNotices.includes("previous claude session ended between turns"),
484
+ "the announcement is read-and-clear — a stale death must not be re-announced every turn",
485
+ );
486
+ }
487
+
488
+ // ----------------------------------------------------------------------
489
+ // CELL 7 — a DELIBERATE teardown is not news.
490
+ //
491
+ // A turn-scoped session (plain interactive / `pi -p`) tears its child down
492
+ // after every turn by design, and that teardown reaches the very same exit
493
+ // path a real death does. If the backend cannot tell the two apart, every
494
+ // ordinary turn announces the previous turn's routine cleanup as a death —
495
+ // an alarm that fires constantly is worse than none, because it trains the
496
+ // operator to ignore the one that matters.
497
+ // ----------------------------------------------------------------------
498
+ {
499
+ const h = makeHarness(recordDir);
500
+ const turnScopedDeps = { ...h.deps, lifecyclePolicy: () => "turn-scoped" };
501
+
502
+ const t1 = startTurn(backend, userCtx("first NONCE-T1"), { sessionId: "life-turn-scoped" }, turnScopedDeps);
503
+ await delay(20);
504
+ h.settle("end_turn");
505
+ await t1.done;
506
+ assert.equal(sealed(t1.events)[0].type, "done", "turn 1 completes");
507
+ // The teardown SIGTERMs the child; a real child would exit right after.
508
+ assert.ok(h.children[0].kills.includes("SIGTERM"), "a turn-scoped turn tears its child down after the turn");
509
+ h.children[0].die(0, "SIGTERM");
510
+ await delay(5);
511
+
512
+ const t2 = startTurn(backend, userCtx("second NONCE-T2"), { sessionId: "life-turn-scoped" }, turnScopedDeps);
513
+ await delay(20);
514
+ const notices = t2.events
515
+ .filter((e: any) => e.type === "text" || e.type === "text_start" || e.type === "text_delta")
516
+ .map((e: any) => String(e.delta ?? e.text ?? e.content ?? ""))
517
+ .join("");
518
+ assert.ok(
519
+ !notices.includes("previous claude session ended between turns"),
520
+ "[QK:RETIRED-TEARDOWN-NOT-ANNOUNCED] a teardown WE performed must not be reported as a death — otherwise " +
521
+ `every ordinary turn-scoped turn cries wolf about its own cleanup. Saw: ${JSON.stringify(notices.slice(0, 300))}`,
522
+ );
523
+ h.settle("end_turn");
524
+ await t2.done;
525
+ }
526
+
527
+ // ----------------------------------------------------------------------
528
+ // CELL 8 — our prompt-phase failure text is not transient, judged by pi.
529
+ // ----------------------------------------------------------------------
530
+ assert.equal(
531
+ isRetryableAssistantError({ stopReason: "error", errorMessage: RETIRED_TIMEOUT_TEXT } as any),
532
+ true,
533
+ "positive control: pi still classifies the RETIRED 600s cutoff text as transient — that classification is " +
534
+ "exactly why one wall-clock kill cost four full cold turns",
535
+ );
536
+ assert.equal(
537
+ isRetryableAssistantError({ stopReason: "error", errorMessage: childDeathMessage } as any),
538
+ false,
539
+ "[QK:PROMPT-ERROR-NOT-TRANSIENT] pi must NOT classify a prompt-phase lifecycle failure we authored as a " +
540
+ "transient provider error — a retry here is a cold replay of the whole prompt, not a cheap retry. " +
541
+ `Judged text: ${JSON.stringify(childDeathMessage)}`,
542
+ );
543
+ } finally {
544
+ rmSync(TMP_EMIT, { recursive: true, force: true });
545
+ try {
546
+ // A leftover EMPTY parent dir reads as IMPURE tree drift in the
547
+ // qualification harness; a concurrent sibling gate's emit keeps it alive
548
+ // and this rmdir simply fails.
549
+ rmdirSync(".tmp-verify");
550
+ } catch {
551
+ // non-empty or already gone — fine either way
552
+ }
553
+ rmSync(recordDir, { recursive: true, force: true });
554
+ }
555
+
556
+ console.log(
557
+ "[check-acp-prompt-lifecycle] ok — a prompt ends on lifecycle events only: a quiet in-flight turn survives an " +
558
+ "observation window and then completes on its ORIGINAL prompt (no wall clock, no replay); a user abort sends ACP " +
559
+ "session/cancel first and seals cancelled→aborted without signalling a cooperating child; a wedged agent is torn " +
560
+ "down after the bounded grace and still returns promptly with no new child; a child that dies mid-prompt is " +
561
+ "reported with its exit status AND stderr tail on BOTH the new and the reuse path; a death BETWEEN turns is " +
562
+ "announced once by the next turn while a teardown WE performed stays silent; and pi's own " +
563
+ "isRetryableAssistantError refuses " +
564
+ "to classify that failure as transient while still matching the retired 600s text",
565
+ );