@junghanacs/entwurf 0.12.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 (183) hide show
  1. package/AGENTS.md +240 -0
  2. package/BASELINE.md +227 -0
  3. package/CHANGELOG.md +1210 -0
  4. package/CONTRIBUTING.md +63 -0
  5. package/DELIVERY.md +209 -0
  6. package/LICENSE +21 -0
  7. package/README.md +504 -0
  8. package/VERIFY.md +260 -0
  9. package/demo/README.md +188 -0
  10. package/demo/demo-baseline.sh +156 -0
  11. package/demo/demo.sh +183 -0
  12. package/docs/assets/entwurf-demo.gif +0 -0
  13. package/docs/assets/entwurf-doomemacs.gif +0 -0
  14. package/docs/assets/entwurf-entwurf.gif +0 -0
  15. package/docs/assets/entwurf-hero.jpg +0 -0
  16. package/docs/setup-clean-host.md +305 -0
  17. package/mcp/entwurf-bridge/src/index.ts +513 -0
  18. package/mcp/entwurf-bridge/start.sh +25 -0
  19. package/mcp/entwurf-bridge/test.sh +54 -0
  20. package/mcp/tsconfig.json +29 -0
  21. package/package.json +130 -0
  22. package/pi/entwurf-capabilities.json +9 -0
  23. package/pi/entwurf-targets.json +20 -0
  24. package/pi/meta-bridge/.claude-plugin/marketplace.json +12 -0
  25. package/pi/meta-bridge/entwurf-meta-receive/.claude-plugin/plugin.json +5 -0
  26. package/pi/meta-bridge/entwurf-meta-receive/hooks/hooks.json +50 -0
  27. package/pi/meta-bridge/entwurf-meta-receive/scripts/doorbell.sh +54 -0
  28. package/pi/settings.reference.json +43 -0
  29. package/pi/skill-plugin-example/.claude-plugin/plugin.json +4 -0
  30. package/pi/skill-plugin-example/skills/hello/SKILL.md +18 -0
  31. package/pi-extensions/acp-provider.ts +63 -0
  32. package/pi-extensions/entwurf-control.ts +1692 -0
  33. package/pi-extensions/lib/acp/acp-client.ts +90 -0
  34. package/pi-extensions/lib/acp/augment.ts +238 -0
  35. package/pi-extensions/lib/acp/backend-adapter.ts +319 -0
  36. package/pi-extensions/lib/acp/backend.ts +873 -0
  37. package/pi-extensions/lib/acp/config.ts +552 -0
  38. package/pi-extensions/lib/acp/context.ts +177 -0
  39. package/pi-extensions/lib/acp/engraving.ts +123 -0
  40. package/pi-extensions/lib/acp/event-mapper.ts +339 -0
  41. package/pi-extensions/lib/acp/models.ts +102 -0
  42. package/pi-extensions/lib/acp/overlay.ts +220 -0
  43. package/pi-extensions/lib/acp/prompts/engraving.md +1 -0
  44. package/pi-extensions/lib/acp/session-store.ts +418 -0
  45. package/pi-extensions/lib/acp/tool-surface.ts +184 -0
  46. package/pi-extensions/lib/entwurf-control-rpc.ts +208 -0
  47. package/pi-extensions/lib/entwurf-core.ts +2033 -0
  48. package/pi-extensions/lib/entwurf-deliverability.ts +123 -0
  49. package/pi-extensions/lib/entwurf-fact-provider.ts +158 -0
  50. package/pi-extensions/lib/entwurf-facts.ts +251 -0
  51. package/pi-extensions/lib/entwurf-mailbox-guard.ts +100 -0
  52. package/pi-extensions/lib/entwurf-peers-render.ts +154 -0
  53. package/pi-extensions/lib/entwurf-preflight.ts +247 -0
  54. package/pi-extensions/lib/entwurf-resume-args.ts +88 -0
  55. package/pi-extensions/lib/entwurf-self-address.ts +117 -0
  56. package/pi-extensions/lib/entwurf-v2-contract.ts +427 -0
  57. package/pi-extensions/lib/entwurf-v2-decider.ts +441 -0
  58. package/pi-extensions/lib/entwurf-v2-lock.ts +412 -0
  59. package/pi-extensions/lib/entwurf-v2-mailbox.ts +87 -0
  60. package/pi-extensions/lib/entwurf-v2-production.ts +326 -0
  61. package/pi-extensions/lib/entwurf-v2-release.ts +149 -0
  62. package/pi-extensions/lib/entwurf-v2-resume-marker.ts +34 -0
  63. package/pi-extensions/lib/entwurf-v2-runner.ts +196 -0
  64. package/pi-extensions/lib/entwurf-v2-send-fallback.ts +166 -0
  65. package/pi-extensions/lib/entwurf-v2-send.ts +271 -0
  66. package/pi-extensions/lib/entwurf-v2-spawn-production.ts +337 -0
  67. package/pi-extensions/lib/entwurf-v2-spawn.ts +323 -0
  68. package/pi-extensions/lib/entwurf-v2-surface.ts +205 -0
  69. package/pi-extensions/lib/meta-mailbox-body.ts +80 -0
  70. package/pi-extensions/lib/meta-session.ts +1951 -0
  71. package/pi-extensions/lib/project-trust-handler.ts +154 -0
  72. package/pi-extensions/lib/session-id.js +57 -0
  73. package/pi-extensions/lib/socket-discovery.ts +346 -0
  74. package/pi-extensions/lib/socket-probe.ts +92 -0
  75. package/pi-extensions/meta-bridge-hook.ts +250 -0
  76. package/pi-extensions/model-lock.ts +235 -0
  77. package/prompts/engraving.md +27 -0
  78. package/protocol.js +31 -0
  79. package/run.sh +2832 -0
  80. package/scripts/check-acp-backend-preflight.ts +130 -0
  81. package/scripts/check-acp-carrier-augment.ts +297 -0
  82. package/scripts/check-acp-config.ts +322 -0
  83. package/scripts/check-acp-event-mapper.ts +253 -0
  84. package/scripts/check-acp-overlay.ts +154 -0
  85. package/scripts/check-acp-prompt-builder.ts +191 -0
  86. package/scripts/check-acp-provider-surface.ts +159 -0
  87. package/scripts/check-acp-sdk-surface.ts +211 -0
  88. package/scripts/check-acp-session-reuse.ts +756 -0
  89. package/scripts/check-acp-session-store.ts +387 -0
  90. package/scripts/check-acp-tool-surface.ts +159 -0
  91. package/scripts/check-entwurf-bridge-boot.ts +199 -0
  92. package/scripts/check-entwurf-capabilities.ts +123 -0
  93. package/scripts/check-entwurf-control-rpc.ts +187 -0
  94. package/scripts/check-entwurf-deliverability.ts +158 -0
  95. package/scripts/check-entwurf-fact-provider.ts +316 -0
  96. package/scripts/check-entwurf-facts.ts +358 -0
  97. package/scripts/check-entwurf-mailbox-guard.ts +264 -0
  98. package/scripts/check-entwurf-peers-surface.ts +235 -0
  99. package/scripts/check-entwurf-resume-args.ts +149 -0
  100. package/scripts/check-entwurf-self-address.ts +209 -0
  101. package/scripts/check-entwurf-session-identity.ts +703 -0
  102. package/scripts/check-entwurf-v2-contract.ts +536 -0
  103. package/scripts/check-entwurf-v2-decider.ts +795 -0
  104. package/scripts/check-entwurf-v2-lock.ts +368 -0
  105. package/scripts/check-entwurf-v2-mailbox.ts +228 -0
  106. package/scripts/check-entwurf-v2-matrix.ts +437 -0
  107. package/scripts/check-entwurf-v2-production.ts +406 -0
  108. package/scripts/check-entwurf-v2-release.ts +245 -0
  109. package/scripts/check-entwurf-v2-runner.ts +452 -0
  110. package/scripts/check-entwurf-v2-send-fallback.ts +371 -0
  111. package/scripts/check-entwurf-v2-send.ts +454 -0
  112. package/scripts/check-entwurf-v2-spawn-production.ts +452 -0
  113. package/scripts/check-entwurf-v2-spawn.ts +398 -0
  114. package/scripts/check-entwurf-v2-surface.ts +352 -0
  115. package/scripts/check-keyset-overlap.py +120 -0
  116. package/scripts/check-mailbox-receipt-state.ts +170 -0
  117. package/scripts/check-meta-capability-source.ts +112 -0
  118. package/scripts/check-meta-dual-consumers.ts +154 -0
  119. package/scripts/check-meta-dual-read.ts +158 -0
  120. package/scripts/check-meta-listing.ts +138 -0
  121. package/scripts/check-meta-mailbox-state-write.ts +135 -0
  122. package/scripts/check-meta-migration.ts +212 -0
  123. package/scripts/check-meta-receiver-marker.ts +185 -0
  124. package/scripts/check-meta-record-v2.ts +191 -0
  125. package/scripts/check-meta-session.ts +673 -0
  126. package/scripts/check-model-lock.ts +408 -0
  127. package/scripts/check-package-source-routing.ts +253 -0
  128. package/scripts/check-pi-preflight.ts +304 -0
  129. package/scripts/check-project-trust-handler.ts +265 -0
  130. package/scripts/check-shell-quote.ts +121 -0
  131. package/scripts/check-socket-discovery.ts +428 -0
  132. package/scripts/check-socket-probe.ts +106 -0
  133. package/scripts/fixtures/probe-mcp-server.ts +33 -0
  134. package/scripts/gnew-rpc-drive.ts +211 -0
  135. package/scripts/lib/acp-child-cleanup.ts +116 -0
  136. package/scripts/meta-bridge-doctor.sh +315 -0
  137. package/scripts/meta-bridge-hook-log.sh +26 -0
  138. package/scripts/meta-bridge-install.sh +135 -0
  139. package/scripts/meta-bridge-prune.ts +199 -0
  140. package/scripts/meta-bridge-state.py +549 -0
  141. package/scripts/meta-bridge-statusline.sh +192 -0
  142. package/scripts/meta-bridge-store-doctor.ts +64 -0
  143. package/scripts/meta-bridge-uninstall.sh +39 -0
  144. package/scripts/new-session-id.ts +25 -0
  145. package/scripts/postinstall-chmod.cjs +58 -0
  146. package/scripts/raw-async-delivery/README.md +258 -0
  147. package/scripts/raw-async-delivery/cc-enqueue-addressed.sh +35 -0
  148. package/scripts/raw-async-delivery/cc-mailbox-rewake.sh +38 -0
  149. package/scripts/raw-async-delivery/cc-watch-filechanged.sh +19 -0
  150. package/scripts/raw-async-delivery/cc-watch-sessionstart.sh +15 -0
  151. package/scripts/raw-async-delivery/codex-local-appserver.sh +39 -0
  152. package/scripts/raw-async-delivery/plugin-entwurf-receive/.claude-plugin/plugin.json +5 -0
  153. package/scripts/raw-async-delivery/plugin-entwurf-receive/hooks/hooks.json +28 -0
  154. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-filechanged.sh +28 -0
  155. package/scripts/raw-async-delivery/plugin-entwurf-receive/scripts/watch-sessionstart.sh +27 -0
  156. package/scripts/raw-async-delivery/raw-agy-send.sh +29 -0
  157. package/scripts/raw-async-delivery/raw-claude-enqueue.sh +30 -0
  158. package/scripts/raw-async-delivery/raw-codex-ws-turn-start.py +164 -0
  159. package/scripts/raw-async-delivery/repro-addressed-routing.sh +96 -0
  160. package/scripts/raw-async-delivery/repro-plugin-idle-wake.sh +104 -0
  161. package/scripts/resolve-acp-bridge.ts +25 -0
  162. package/scripts/smoke-acp-bundled-mcp-live.ts +292 -0
  163. package/scripts/smoke-acp-carrier-augment-live.ts +192 -0
  164. package/scripts/smoke-acp-mcp-live.ts +129 -0
  165. package/scripts/smoke-acp-memory-containment-live.ts +389 -0
  166. package/scripts/smoke-acp-overlay-live.ts +314 -0
  167. package/scripts/smoke-acp-provider-live.ts +162 -0
  168. package/scripts/smoke-acp-raw-turn-live.ts +261 -0
  169. package/scripts/smoke-acp-session-reuse-live.ts +172 -0
  170. package/scripts/smoke-acp-skill-live.ts +144 -0
  171. package/scripts/smoke-acp-socket-citizen-live.ts +168 -0
  172. package/scripts/smoke-claude-native-resume-live.sh +198 -0
  173. package/scripts/smoke-entwurf-v2-matrix-live.ts +398 -0
  174. package/scripts/smoke-entwurf-v2-spawn-live.ts +175 -0
  175. package/scripts/smoke-entwurf-v2-spawn-resume-live.ts +428 -0
  176. package/scripts/smoke-meta-async-drift.sh +171 -0
  177. package/scripts/smoke-meta-honesty.sh +147 -0
  178. package/scripts/smoke-meta-install-state.sh +403 -0
  179. package/scripts/smoke-meta-keyset-guard.sh +111 -0
  180. package/scripts/smoke-meta-prune.sh +174 -0
  181. package/scripts/smoke-resident-garden-guard.sh +433 -0
  182. package/scripts/smoke-session-id-name.ts +187 -0
  183. package/scripts/tsconfig.json +34 -0
@@ -0,0 +1,756 @@
1
+ // Deterministic gate for S2d-1b-2b in-memory session reuse (backend.ts).
2
+ //
3
+ // The whole point of reuse is delta-only: turn 2 of a reused session must send
4
+ // ONLY the latest user delta, NOT the full transcript (the live ACP session
5
+ // already remembers turn 1 — re-sending it would duplicate history). A source
6
+ // check ("does it call buildAcpPrompt?") cannot prove that — "I remembered" does
7
+ // not prove "I did not also re-send turn 1". So this gate injects a FAKE
8
+ // spawn/connection seam and CAPTURES the prompt payload of each turn (GPT ①):
9
+ //
10
+ // turn 1 (new): payload contains the turn-1 nonce (full transcript)
11
+ // turn 2 (reuse): payload contains the turn-2 nonce (delta present)
12
+ // payload does NOT contain the turn-1 nonce (delta-only proof)
13
+ // NO second spawn / newSession (reuse, not respawn)
14
+ //
15
+ // It also proves: the mutable activePromptHandler routes each turn's
16
+ // notifications to THAT turn's stream; a retained child is unref'd (no exit pin)
17
+ // but never torn down between turns; a persisted record is NOT resumed in 1b-2b;
18
+ // a concurrent prompt fails loud BOTH on a retained busy session AND on a same-key
19
+ // first-turn race (GPT blocker 1); an incompatible existing → new closes the old
20
+ // child so it is not orphaned, while a model-lock throw leaves the live child
21
+ // alone (GPT blocker 2); plus source-shape locks.
22
+ //
23
+ // backend.ts imports its siblings with `.js` suffixes (the root/jiti runtime
24
+ // convention), so — like check-acp-backend-preflight — we tsc-emit the project
25
+ // and import the COMPILED backend.js whose `.js` imports resolve to real siblings.
26
+
27
+ import { strict as assert } from "node:assert";
28
+ import { execFileSync } from "node:child_process";
29
+ import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
30
+ import { tmpdir } from "node:os";
31
+ import { resolve } from "node:path";
32
+ import { pathToFileURL } from "node:url";
33
+ import type { Api, AssistantMessageEvent, Context, Message, Model } from "@earendil-works/pi-ai";
34
+
35
+ const sonnet = { id: "claude-sonnet-4-6" } as unknown as Model<Api>;
36
+ const opus = { id: "claude-opus-4-8" } as unknown as Model<Api>;
37
+
38
+ type Stream = AsyncIterable<AssistantMessageEvent> & {
39
+ result: () => Promise<{ stopReason: string; errorMessage?: string }>;
40
+ };
41
+
42
+ async function collect(stream: Stream): Promise<AssistantMessageEvent[]> {
43
+ const events: AssistantMessageEvent[] = [];
44
+ for await (const ev of stream) events.push(ev);
45
+ return events;
46
+ }
47
+
48
+ function deltaText(events: AssistantMessageEvent[]): string {
49
+ return events
50
+ .filter((e): e is Extract<AssistantMessageEvent, { type: "text_delta" }> => e.type === "text_delta")
51
+ .map((e) => e.delta)
52
+ .join("");
53
+ }
54
+
55
+ function errorOf(events: AssistantMessageEvent[]): string | undefined {
56
+ const e = events.find((x): x is Extract<AssistantMessageEvent, { type: "error" }> => x.type === "error");
57
+ return e ? (e.error.errorMessage ?? "error") : undefined;
58
+ }
59
+
60
+ // --- fake spawn/connection seam -------------------------------------------
61
+
62
+ function makeFakeChild() {
63
+ let killed = 0;
64
+ let unrefs = 0;
65
+ const pipe = () => ({
66
+ destroy() {},
67
+ unref() {
68
+ unrefs++;
69
+ },
70
+ });
71
+ return {
72
+ pid: undefined as number | undefined,
73
+ exitCode: null as number | null,
74
+ signalCode: null as NodeJS.Signals | null,
75
+ stdin: pipe(),
76
+ stdout: pipe(),
77
+ stderr: {
78
+ on() {},
79
+ destroy() {},
80
+ unref() {
81
+ unrefs++;
82
+ },
83
+ },
84
+ kill() {
85
+ killed++;
86
+ return true;
87
+ },
88
+ unref() {
89
+ unrefs++;
90
+ },
91
+ once() {},
92
+ get killed() {
93
+ return killed;
94
+ },
95
+ get unrefs() {
96
+ return unrefs;
97
+ },
98
+ };
99
+ }
100
+
101
+ interface PromptCall {
102
+ sessionId: string;
103
+ text: string;
104
+ }
105
+
106
+ // A self-consistent default resolved config (the all-defaults shape). The empty
107
+ // mcpServersHash is sha256("[]") — must match what config.ts produces for an
108
+ // absent mcpServers map (check-acp-config tests the real resolver; this gate
109
+ // only needs internal consistency between loadConfig and the manual records).
110
+ const EMPTY_MCP_HASH = "4f53cda18c2baa0c0354bb5f9a3ecbe5ed12ab4d8e11ba873c2f11161202b945";
111
+ const DEFAULT_RESOLVED_CONFIG: any = {
112
+ settingSources: [],
113
+ strictMcpConfig: true,
114
+ showToolNotifications: true,
115
+ mcpServers: [],
116
+ mcpServersHash: EMPTY_MCP_HASH,
117
+ tools: ["Read", "Bash", "Edit", "Write"],
118
+ skillPlugins: [],
119
+ permissionAllow: ["Read(*)", "Bash(*)", "Edit(*)", "Write(*)", "mcp__*"],
120
+ disallowedTools: [],
121
+ };
122
+
123
+ function makeHarness(recordDir: string, config: any = DEFAULT_RESOLVED_CONFIG, emitToolCall = false) {
124
+ const children: ReturnType<typeof makeFakeChild>[] = [];
125
+ const promptCalls: PromptCall[] = [];
126
+ const newSessionCalls: any[] = [];
127
+ let newSessionSeq = 0;
128
+ let noticeSeq = 0;
129
+ let block: Promise<void> | null = null;
130
+
131
+ const makeConnection = (handlers: any) => ({
132
+ initialize: async () => ({ agentCapabilities: {} }),
133
+ newSession: async (params: any) => {
134
+ newSessionCalls.push(params);
135
+ newSessionSeq++;
136
+ return { sessionId: `ACP-${newSessionSeq}` };
137
+ },
138
+ setSessionConfigOption: async () => ({}),
139
+ prompt: async ({ sessionId, prompt }: any) => {
140
+ promptCalls.push({ sessionId, text: prompt.map((b: { text: string }) => b.text).join("\n") });
141
+ if (block) await block;
142
+ // Optional [tool:*] notice — exercises the showToolNotifications gate (S2g).
143
+ if (emitToolCall) {
144
+ await handlers.sessionUpdate({
145
+ update: { sessionUpdate: "tool_call", toolCallId: "probe-1", title: "probe tool", status: "pending" },
146
+ sessionId,
147
+ });
148
+ }
149
+ noticeSeq++;
150
+ await handlers.sessionUpdate({
151
+ update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text: `NOTICE-${noticeSeq}` } },
152
+ sessionId,
153
+ });
154
+ return { stopReason: "end_turn" };
155
+ },
156
+ });
157
+
158
+ const deps = {
159
+ resolveLaunch: () => ({ command: "node", args: ["fake"] }),
160
+ ensureOverlay: () => {},
161
+ spawnChild: () => {
162
+ const c = makeFakeChild();
163
+ children.push(c);
164
+ return c;
165
+ },
166
+ createConnection: (_child: any, handlers: any) => makeConnection(handlers),
167
+ lifecyclePolicy: () => "process-scoped",
168
+ loadConfig: () => config,
169
+ now: () => "2026-06-18T00:00:00Z",
170
+ sessionDir: recordDir,
171
+ };
172
+
173
+ return {
174
+ deps,
175
+ children,
176
+ promptCalls,
177
+ newSessionCalls,
178
+ config,
179
+ setBlock(p: Promise<void> | null) {
180
+ block = p;
181
+ },
182
+ };
183
+ }
184
+
185
+ /** The config signature backend computes from a given resolved config (S2g). */
186
+ function sigFor(store: any, modelId: string, config: any, engraving = ""): string {
187
+ return store.bridgeConfigSignature({
188
+ backend: "claude",
189
+ modelId,
190
+ appendSystemPrompt: engraving,
191
+ mcpServersHash: config.mcpServersHash,
192
+ settingSources: config.settingSources,
193
+ strictMcpConfig: config.strictMcpConfig,
194
+ tools: config.tools,
195
+ skillPlugins: config.skillPlugins,
196
+ permissionAllow: config.permissionAllow,
197
+ disallowedTools: config.disallowedTools,
198
+ });
199
+ }
200
+
201
+ const ZERO_USAGE = {
202
+ input: 0,
203
+ output: 0,
204
+ cacheRead: 0,
205
+ cacheWrite: 0,
206
+ totalTokens: 0,
207
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
208
+ };
209
+
210
+ /** An assistant message with arbitrary content blocks (S2f signature/replay tests). */
211
+ function mkAssistant(content: Array<{ type: "text"; text: string; textSignature?: string }>): Message {
212
+ return {
213
+ role: "assistant",
214
+ content,
215
+ api: "x",
216
+ provider: "x",
217
+ model: "x",
218
+ usage: ZERO_USAGE,
219
+ stopReason: "stop",
220
+ timestamp: 0,
221
+ } as unknown as Message;
222
+ }
223
+
224
+ // A multi-turn (reuse-shaped) context: a prior user, an assistant, a new user.
225
+ function reuseCtx(prior: string, latest: string): Context {
226
+ return {
227
+ messages: [
228
+ { role: "user", content: prior, timestamp: 0 },
229
+ {
230
+ role: "assistant",
231
+ content: [{ type: "text", text: "ok" }],
232
+ api: "x",
233
+ provider: "x",
234
+ model: "x",
235
+ usage: {
236
+ input: 0,
237
+ output: 0,
238
+ cacheRead: 0,
239
+ cacheWrite: 0,
240
+ totalTokens: 0,
241
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
242
+ },
243
+ stopReason: "stop",
244
+ timestamp: 0,
245
+ },
246
+ { role: "user", content: latest, timestamp: 0 },
247
+ ],
248
+ };
249
+ }
250
+
251
+ const TMP_EMIT = ".tmp-verify/acp-session-reuse";
252
+ rmSync(TMP_EMIT, { recursive: true, force: true });
253
+ const recordDir = mkdtempSync(resolve(tmpdir(), "acp-reuse-rec-"));
254
+
255
+ try {
256
+ execFileSync("node_modules/.bin/tsc", ["--outDir", TMP_EMIT, "--rootDir", ".", "--noEmit", "false"], {
257
+ stdio: "pipe",
258
+ });
259
+ // tsc emits only .ts→.js; the engraving carrier is a .md asset that ships
260
+ // alongside engraving.js in the real package (check-pack-install verifies the
261
+ // tarball). Copy it into the emit tree so loadEngraving finds its shipped
262
+ // default — otherwise its (correct) fail-loud on a missing carrier trips here.
263
+ const promptsOut = resolve(TMP_EMIT, "pi-extensions/lib/acp/prompts");
264
+ mkdirSync(promptsOut, { recursive: true });
265
+ copyFileSync("pi-extensions/lib/acp/prompts/engraving.md", resolve(promptsOut, "engraving.md"));
266
+ const backendUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend.js")).href;
267
+ const storeUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/session-store.js")).href;
268
+ const backend = (await import(backendUrl)) as any;
269
+ const store = (await import(storeUrl)) as any;
270
+ const configUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/config.js")).href;
271
+ const configMod = (await import(configUrl)) as any;
272
+ const adapterUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/backend-adapter.js")).href;
273
+ const adapterMod = (await import(adapterUrl)) as any;
274
+
275
+ // ----------------------------------------------------------------------
276
+ // Section A — capture: new=full transcript, reuse=delta-only, no respawn,
277
+ // mutable routing, retained child unref'd, persisted NOT used
278
+ // ----------------------------------------------------------------------
279
+ {
280
+ const h = makeHarness(recordDir);
281
+ const sessionKey = "pi:gate-A";
282
+
283
+ // Pre-write a COMPATIBLE persisted record at a DIFFERENT acp id — if the
284
+ // backend (wrongly) resumed it, turn 1 would prompt "OLD-RESUME-ID".
285
+ const turn1Ctx: Context = { messages: [{ role: "user", content: "remember NONCE-AAA", timestamp: 0 }] };
286
+ store.writeSessionRecord(
287
+ store.buildSessionRecord(
288
+ {
289
+ sessionKey,
290
+ acpSessionId: "OLD-RESUME-ID",
291
+ cwd: process.cwd(),
292
+ modelId: sonnet.id,
293
+ bridgeConfigSignature: sigFor(store, sonnet.id, h.config),
294
+ contextMessageSignatures: store.contextMessageSignatures(turn1Ctx),
295
+ },
296
+ "2026-06-17T00:00:00Z",
297
+ ),
298
+ recordDir,
299
+ );
300
+
301
+ // --- turn 1: new ---
302
+ const t1 = await collect(backend.streamAcpTurn(sonnet, turn1Ctx, { sessionId: "gate-A" }, h.deps) as Stream);
303
+ assert.equal(h.children.length, 1, "turn 1 spawns exactly one child");
304
+ assert.equal(h.promptCalls.length, 1, "turn 1 issued one prompt");
305
+ assert.equal(
306
+ h.promptCalls[0].sessionId,
307
+ "ACP-1",
308
+ "turn 1 uses the FRESH newSession id — NOT the persisted resume id (persisted resume/load is OFF in 1b-2b)",
309
+ );
310
+ assert.match(
311
+ h.promptCalls[0].text,
312
+ /NONCE-AAA/,
313
+ "turn 1 payload carries the turn-1 nonce (full transcript; false-positive guard)",
314
+ );
315
+ assert.match(deltaText(t1), /NOTICE-1/, "turn 1 notice routed to the turn-1 stream");
316
+ assert.ok(
317
+ t1.some((e) => e.type === "done"),
318
+ "turn 1 completes as done",
319
+ );
320
+ assert.ok(h.children[0].unrefs > 0, "retained child is unref'd on retain (no exit pin) — GPT amber");
321
+ assert.equal(h.children[0].killed, 0, "retained child is NOT torn down on a successful new turn");
322
+
323
+ // --- turn 2: reuse (compatible prefix) ---
324
+ const t2 = await collect(
325
+ backend.streamAcpTurn(
326
+ sonnet,
327
+ reuseCtx("remember NONCE-AAA", "recall NONCE-BBB"),
328
+ { sessionId: "gate-A" },
329
+ h.deps,
330
+ ) as Stream,
331
+ );
332
+ assert.equal(h.children.length, 1, "turn 2 REUSES — no second spawn");
333
+ assert.equal(h.promptCalls.length, 2, "turn 2 issued one prompt on the reused connection");
334
+ assert.match(h.promptCalls[1].text, /NONCE-BBB/, "turn 2 payload carries the turn-2 delta");
335
+ assert.ok(
336
+ !h.promptCalls[1].text.includes("NONCE-AAA"),
337
+ "turn 2 payload is DELTA-ONLY — it must NOT re-send the turn-1 history (no duplicate injection)",
338
+ );
339
+ assert.match(deltaText(t2), /NOTICE-2/, "turn 2 notice routed to the turn-2 stream (mutable handler)");
340
+ assert.ok(!deltaText(t2).includes("NOTICE-1"), "turn 2 stream does not leak turn-1 notices");
341
+ assert.equal(h.children[0].killed, 0, "reused child is NEVER torn down between turns (stdio preserved)");
342
+ }
343
+
344
+ // ----------------------------------------------------------------------
345
+ // Section B — concurrent prompt on a RETAINED live session fails loud
346
+ // ----------------------------------------------------------------------
347
+ {
348
+ const h = makeHarness(recordDir);
349
+ // turn 1 (new) → retained.
350
+ await collect(
351
+ backend.streamAcpTurn(
352
+ sonnet,
353
+ { messages: [{ role: "user", content: "first", timestamp: 0 }] },
354
+ { sessionId: "gate-B" },
355
+ h.deps,
356
+ ) as Stream,
357
+ );
358
+
359
+ // Block the next prompt so two reuse turns collide.
360
+ let release!: () => void;
361
+ h.setBlock(new Promise<void>((r) => (release = r)));
362
+ const ctx2 = reuseCtx("first", "second");
363
+ const aDone = collect(backend.streamAcpTurn(sonnet, ctx2, { sessionId: "gate-B" }, h.deps) as Stream);
364
+ await new Promise((r) => setTimeout(r, 20)); // let turn A set busy
365
+ const bEvents = await collect(backend.streamAcpTurn(sonnet, ctx2, { sessionId: "gate-B" }, h.deps) as Stream);
366
+ assert.match(
367
+ String(errorOf(bEvents) ?? ""),
368
+ /busy/,
369
+ "a concurrent prompt on a busy retained session errors (busy)",
370
+ );
371
+ release();
372
+ const aEvents = await aDone;
373
+ assert.ok(
374
+ aEvents.some((e) => e.type === "done"),
375
+ "the blocking turn still completes once released",
376
+ );
377
+ }
378
+
379
+ // ----------------------------------------------------------------------
380
+ // Section D — same-key FIRST-turn race fails loud BEFORE retention
381
+ // (GPT blocker 1: a NEW turn is not in the map yet)
382
+ // ----------------------------------------------------------------------
383
+ {
384
+ const h = makeHarness(recordDir);
385
+ let release!: () => void;
386
+ h.setBlock(new Promise<void>((r) => (release = r)));
387
+ const ctx: Context = { messages: [{ role: "user", content: "only", timestamp: 0 }] };
388
+ // turn A: first turn, blocks in prompt (still in flight, not yet retained).
389
+ const aDone = collect(backend.streamAcpTurn(sonnet, ctx, { sessionId: "gate-D" }, h.deps) as Stream);
390
+ await new Promise((r) => setTimeout(r, 20)); // let turn A claim the key + spawn
391
+ // turn B: second FIRST turn for the SAME key — must fail loud, not spawn.
392
+ const bEvents = await collect(backend.streamAcpTurn(sonnet, ctx, { sessionId: "gate-D" }, h.deps) as Stream);
393
+ assert.match(
394
+ String(errorOf(bEvents) ?? ""),
395
+ /busy/,
396
+ "a concurrent FIRST turn for the same key errors before retention (no double spawn)",
397
+ );
398
+ assert.equal(h.children.length, 1, "the race spawned exactly ONE child (turn B did not spawn)");
399
+ release();
400
+ await aDone;
401
+ }
402
+
403
+ // ----------------------------------------------------------------------
404
+ // Section E — incompatible existing → new closes the old child (no orphan);
405
+ // model-lock throw leaves the live child alone (GPT blocker 2)
406
+ // ----------------------------------------------------------------------
407
+ {
408
+ // E1: cwd drift → incompatible → new spawns childB + closes childA.
409
+ const h = makeHarness(recordDir);
410
+ await collect(
411
+ backend.streamAcpTurn(
412
+ sonnet,
413
+ { messages: [{ role: "user", content: "x", timestamp: 0 }] },
414
+ { sessionId: "gate-E1", cwd: "/w1" },
415
+ h.deps,
416
+ ) as Stream,
417
+ );
418
+ assert.equal(h.children.length, 1, "E1 turn 1 spawned childA");
419
+ const childA = h.children[0];
420
+ // same key (sessionId), DIFFERENT cwd → existing incompatible → new.
421
+ await collect(
422
+ backend.streamAcpTurn(
423
+ sonnet,
424
+ { messages: [{ role: "user", content: "y", timestamp: 0 }] },
425
+ { sessionId: "gate-E1", cwd: "/w2" },
426
+ h.deps,
427
+ ) as Stream,
428
+ );
429
+ assert.equal(h.children.length, 2, "E1 incompatible existing → a NEW child spawned");
430
+ assert.ok(childA.killed > 0, "E1 the abandoned incompatible child is CLOSED (not orphaned in retainedChildren)");
431
+
432
+ // E2: model-lock throw → live child NOT killed, NOT dropped (survives reuse).
433
+ const h2 = makeHarness(recordDir);
434
+ await collect(
435
+ backend.streamAcpTurn(
436
+ sonnet,
437
+ { messages: [{ role: "user", content: "x", timestamp: 0 }] },
438
+ { sessionId: "gate-E2" },
439
+ h2.deps,
440
+ ) as Stream,
441
+ );
442
+ const lockChild = h2.children[0];
443
+ const lockEvents = await collect(
444
+ backend.streamAcpTurn(opus, reuseCtx("x", "y"), { sessionId: "gate-E2" }, h2.deps) as Stream,
445
+ );
446
+ assert.match(String(errorOf(lockEvents) ?? ""), /locked/, "E2 model mismatch surfaces a model-lock error");
447
+ assert.equal(lockChild.killed, 0, "E2 model-lock throw does NOT kill the live child");
448
+ assert.equal(h2.children.length, 1, "E2 model-lock throw does NOT spawn a replacement");
449
+ // the locked child still serves a subsequent COMPATIBLE (same-model) reuse.
450
+ await collect(backend.streamAcpTurn(sonnet, reuseCtx("x", "z"), { sessionId: "gate-E2" }, h2.deps) as Stream);
451
+ assert.equal(h2.children.length, 1, "E2 the live child survived the lock throw and was reused (no respawn)");
452
+ }
453
+
454
+ // ----------------------------------------------------------------------
455
+ // Section F — S2f progress visibility: lifecycle notices are emitted in the
456
+ // right order, are display-only (NEVER replayed into a `new`
457
+ // full-transcript ACP prompt), and do NOT perturb the reuse
458
+ // signature. Without these the "output-side only" claim is L0.
459
+ // ----------------------------------------------------------------------
460
+ {
461
+ const contextUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/context.js")).href;
462
+ const eventUrl = pathToFileURL(resolve(TMP_EMIT, "pi-extensions/lib/acp/event-mapper.js")).href;
463
+ const ctxMod = (await import(contextUrl)) as any;
464
+ const eventMod = (await import(eventUrl)) as any;
465
+ const MARKER: string = eventMod.LIFECYCLE_NOTICE_SIGNATURE;
466
+ assert.equal(typeof MARKER, "string", "event-mapper exports the lifecycle marker (SSOT)");
467
+ // The two consumer mirrors MUST equal the producer SSOT (strip-types gates
468
+ // forbid a cross-sibling value import, so the constant is mirrored). A drift
469
+ // is what F3/F5 below would catch behaviorally; this is the direct check.
470
+ assert.ok(
471
+ readFileSync("pi-extensions/lib/acp/context.ts", "utf8").includes(`"${MARKER}"`),
472
+ "context.ts mirrors the lifecycle marker exactly (no drift)",
473
+ );
474
+ assert.ok(
475
+ readFileSync("pi-extensions/lib/acp/session-store.ts", "utf8").includes(`"${MARKER}"`),
476
+ "session-store.ts mirrors the lifecycle marker exactly (no drift)",
477
+ );
478
+
479
+ // F1: new-turn notice order — preparing → session ready → prompt sent.
480
+ const h = makeHarness(recordDir);
481
+ const t1 = await collect(
482
+ backend.streamAcpTurn(
483
+ sonnet,
484
+ { messages: [{ role: "user", content: "hi NONCE-F", timestamp: 0 }] },
485
+ { sessionId: "gate-F" },
486
+ h.deps,
487
+ ) as Stream,
488
+ );
489
+ const d1 = deltaText(t1);
490
+ const iPrep = d1.indexOf("[acp: preparing claude session]");
491
+ const iReady = d1.indexOf("[acp: session ready model=claude-sonnet-4-6]");
492
+ const iSent = d1.indexOf("[acp: sending prompt]");
493
+ assert.ok(
494
+ iPrep >= 0 && iReady > iPrep && iSent > iReady,
495
+ "new turn emits preparing → session ready(model) → sending prompt, in order (no silent bootstrap)",
496
+ );
497
+
498
+ // F2: reuse-turn notice — reusing → prompt sent, and NO spawn re-announce.
499
+ const t2 = await collect(
500
+ backend.streamAcpTurn(sonnet, reuseCtx("hi NONCE-F", "again NONCE-G"), { sessionId: "gate-F" }, h.deps) as Stream,
501
+ );
502
+ const d2 = deltaText(t2);
503
+ const iReuse = d2.indexOf("[acp: reusing live session]");
504
+ const iSent2 = d2.indexOf("[acp: sending prompt]");
505
+ assert.ok(iReuse >= 0 && iSent2 > iReuse, "reuse turn emits reusing → sending prompt, in order");
506
+ assert.ok(!d2.includes("[acp: preparing"), "reuse turn does NOT re-announce spawn/preparing (it skipped them)");
507
+
508
+ // F3: lifecycle notices NEVER reach the ACP wire — display-only, not replayed.
509
+ assert.ok(
510
+ h.promptCalls.every((p) => !p.text.includes("[acp:")),
511
+ "no captured ACP prompt carries a lifecycle notice (display-only, never on the wire)",
512
+ );
513
+
514
+ // F4: a `new` full-transcript rebuild DROPS a lifecycle-marked assistant block
515
+ // (context.ts filter) while keeping the real transcript text.
516
+ const ctxWithNotice: Context = {
517
+ messages: [
518
+ { role: "user", content: "real user line", timestamp: 0 },
519
+ mkAssistant([
520
+ { type: "text", text: "\n[acp: session ready model=claude-sonnet-4-6]\n", textSignature: MARKER },
521
+ { type: "text", text: "real assistant line" },
522
+ ]),
523
+ ],
524
+ };
525
+ const built = ctxMod
526
+ .buildAcpPrompt(ctxWithNotice, "new")
527
+ .map((b: { text: string }) => b.text)
528
+ .join("\n");
529
+ assert.ok(!built.includes("[acp:"), "buildAcpPrompt('new') drops lifecycle-marked blocks (no transcript replay)");
530
+ assert.ok(
531
+ built.includes("real assistant line") && built.includes("real user line"),
532
+ "buildAcpPrompt('new') keeps the real transcript text",
533
+ );
534
+
535
+ // F5: a lifecycle-marked block does NOT change the reuse-compat signature —
536
+ // the per-message signature is identical with and without the notice.
537
+ const ctxNoNotice: Context = {
538
+ messages: [
539
+ { role: "user", content: "real user line", timestamp: 0 },
540
+ mkAssistant([{ type: "text", text: "real assistant line" }]),
541
+ ],
542
+ };
543
+ assert.deepEqual(
544
+ store.contextMessageSignatures(ctxWithNotice),
545
+ store.contextMessageSignatures(ctxNoNotice),
546
+ "a lifecycle notice is signature-invariant (display-only does not perturb reuse compatibility)",
547
+ );
548
+ }
549
+
550
+ // ----------------------------------------------------------------------
551
+ // Section C — source-shape locks
552
+ // ----------------------------------------------------------------------
553
+ {
554
+ const src = readFileSync("pi-extensions/lib/acp/backend.ts", "utf8");
555
+ assert.match(
556
+ src,
557
+ /buildAcpPrompt\(context,\s*"new"\)/,
558
+ "new turn must build the full transcript via buildAcpPrompt",
559
+ );
560
+ assert.match(src, /buildAcpPrompt\(context,\s*"reuse"\)/, "reuse turn must build the delta via buildAcpPrompt");
561
+ assert.ok(
562
+ !/\bcontextToAcpPrompt\(/.test(src),
563
+ "backend must not hardcode contextToAcpPrompt — the prompt scope must follow bootstrapPath via buildAcpPrompt",
564
+ );
565
+ assert.match(
566
+ src,
567
+ /session\?\.activePromptHandler\?\./,
568
+ "connection callbacks must delegate to the mutable session.activePromptHandler",
569
+ );
570
+ const applyCalls = src.match(/applyAcpSessionUpdate\(/g) ?? [];
571
+ assert.equal(
572
+ applyCalls.length,
573
+ 1,
574
+ "applyAcpSessionUpdate must be called exactly once (inside the mutable router, never directly in the connection callback)",
575
+ );
576
+ assert.match(src, /unrefRetainedChild\(spawned\)/, "retained success path must unref the child (no exit pin)");
577
+ assert.match(
578
+ src,
579
+ /inFlightKeys\.add\(sessionKey\)/,
580
+ "the turn must claim the key in flight (first-turn race guard)",
581
+ );
582
+ }
583
+
584
+ // ----------------------------------------------------------------------
585
+ // Section G — S2g config passthrough: operator mcpServers / tools / skills /
586
+ // flags reach newSession, the envelope is injected into the
587
+ // entwurf-bridge entry (stale values filtered), engraving/augment
588
+ // see the same server names, and a config change breaks reuse
589
+ // while a session-id-only change does NOT.
590
+ // ----------------------------------------------------------------------
591
+ {
592
+ // Build a real resolved config from a temp project settings file — this
593
+ // exercises the REAL loader (normalize + merge + defaults + Skill auto-add),
594
+ // not a hand-rolled fake, so the wire assertions reflect production resolve.
595
+ const cfgDir = mkdtempSync(resolve(tmpdir(), "acp-cfg-"));
596
+ const skillDir = resolve(cfgDir, "myskill");
597
+ mkdirSync(resolve(skillDir, ".claude-plugin"), { recursive: true });
598
+ writeFileSync(resolve(skillDir, ".claude-plugin", "plugin.json"), JSON.stringify({ name: "myskill" }));
599
+ const projectSettings = resolve(cfgDir, "settings.json");
600
+ writeFileSync(
601
+ projectSettings,
602
+ JSON.stringify({
603
+ entwurfProvider: {
604
+ mcpServers: {
605
+ "entwurf-bridge": {
606
+ command: "node",
607
+ args: ["bridge.js"],
608
+ // A STALE PI_SESSION_ID an operator might paste — must be overwritten.
609
+ env: { PI_SESSION_ID: "STALE-SHOULD-BE-REPLACED", FOO: "bar" },
610
+ },
611
+ weather: { type: "http", url: "https://example.test/mcp" },
612
+ },
613
+ skillPlugins: [skillDir],
614
+ },
615
+ }),
616
+ );
617
+ const NONE = resolve(cfgDir, "__absent__.json");
618
+ const richConfig = configMod.resolveProviderConfig({
619
+ cwd: cfgDir,
620
+ modelId: sonnet.id,
621
+ adapter: adapterMod.claudeAdapter,
622
+ globalSettingsPath: NONE,
623
+ projectSettingsPath: projectSettings,
624
+ });
625
+
626
+ // Resolver sanity: two servers (sorted), Skill auto-added, defaults applied.
627
+ assert.deepEqual(
628
+ richConfig.mcpServers.map((s: { name: string }) => s.name),
629
+ ["entwurf-bridge", "weather"],
630
+ "resolver returns both MCP servers, name-sorted",
631
+ );
632
+ assert.ok(richConfig.tools.includes("Skill"), "nonempty skillPlugins auto-adds the Skill tool");
633
+ assert.ok(richConfig.permissionAllow.includes("Skill(*)"), "nonempty skillPlugins auto-allows Skill(*)");
634
+
635
+ const prevSid = process.env.PI_SESSION_ID;
636
+ process.env.PI_SESSION_ID = "LIVE-SID-123";
637
+ try {
638
+ const h = makeHarness(recordDir, richConfig);
639
+ await collect(
640
+ backend.streamAcpTurn(
641
+ sonnet,
642
+ { messages: [{ role: "user", content: "hi NONCE-G", timestamp: 0 }] },
643
+ { sessionId: "gate-G" },
644
+ h.deps,
645
+ ) as Stream,
646
+ );
647
+ assert.equal(h.newSessionCalls.length, 1, "one newSession for the first turn");
648
+ const ns = h.newSessionCalls[0];
649
+ // G1: nonempty mcpServers reach newSession.
650
+ const wireNames = (ns.mcpServers as Array<{ name: string }>).map((s) => s.name).sort();
651
+ assert.deepEqual(wireNames, ["entwurf-bridge", "weather"], "newSession receives the operator MCP servers");
652
+ // G2: envelope injected into entwurf-bridge, stale PI_SESSION_ID filtered.
653
+ const bridge = (ns.mcpServers as Array<{ name: string; env?: Array<{ name: string; value: string }> }>).find(
654
+ (s) => s.name === "entwurf-bridge",
655
+ );
656
+ const env = Object.fromEntries((bridge?.env ?? []).map((e) => [e.name, e.value]));
657
+ assert.equal(env.PI_SESSION_ID, "LIVE-SID-123", "live PI_SESSION_ID injected (stale value overwritten)");
658
+ assert.equal(env.PI_AGENT_ID, `entwurf/${sonnet.id}`, "PI_AGENT_ID injected from the model id");
659
+ assert.equal(env.FOO, "bar", "operator's own env entries are preserved");
660
+ // G3: the http server has no env carrier (untouched).
661
+ const weather = (ns.mcpServers as Array<{ name: string; env?: unknown }>).find((s) => s.name === "weather");
662
+ assert.ok(!("env" in (weather ?? {})), "http MCP server is not envelope-enriched (no env carrier)");
663
+ // G4: _meta carries the resolved tool surface + skill plugins.
664
+ const opts = ns._meta?.claudeCode?.options;
665
+ assert.ok(opts?.tools?.includes("Skill"), "_meta.claudeCode.options.tools carries the Skill tool");
666
+ assert.ok(Array.isArray(opts?.plugins) && opts.plugins.length === 1, "_meta carries the skill plugin");
667
+ assert.deepEqual(
668
+ opts?.extraArgs,
669
+ { "strict-mcp-config": null },
670
+ "_meta carries strict-mcp-config (default true)",
671
+ );
672
+ // G5: the first-user augment lists the resolved server names.
673
+ assert.match(h.promptCalls[0].text, /entwurf-bridge/, "augment lists the resolved MCP server names");
674
+ assert.match(h.promptCalls[0].text, /weather/, "augment lists every resolved MCP server");
675
+ } finally {
676
+ if (prevSid === undefined) delete process.env.PI_SESSION_ID;
677
+ else process.env.PI_SESSION_ID = prevSid;
678
+ rmSync(cfgDir, { recursive: true, force: true });
679
+ }
680
+
681
+ // G6: a config change (mcpServers hash) breaks reuse → a session-id-only
682
+ // change does NOT. Both checked at the signature layer (the reuse gate).
683
+ const sigEmpty = sigFor(store, sonnet.id, DEFAULT_RESOLVED_CONFIG);
684
+ const sigRich = sigFor(store, sonnet.id, richConfig);
685
+ assert.notEqual(sigEmpty, sigRich, "a config change (mcpServers/tools/skills) changes the bridge config signature");
686
+ assert.equal(
687
+ sigFor(store, sonnet.id, richConfig),
688
+ sigRich,
689
+ "the same resolved config yields the same signature (no per-turn rebuild)",
690
+ );
691
+ }
692
+
693
+ // ----------------------------------------------------------------------
694
+ // Section H — S2g showToolNotifications propagation: the operator flag
695
+ // suppresses [tool:*] / [permission:*] notices, but NEVER the
696
+ // S2f [acp:*] lifecycle notices (always visible). Also confirms it
697
+ // stays OUT of the config signature (display-only, not compat).
698
+ // ----------------------------------------------------------------------
699
+ {
700
+ // false → tool notices suppressed, lifecycle still shown.
701
+ const offConfig = { ...DEFAULT_RESOLVED_CONFIG, showToolNotifications: false };
702
+ const hOff = makeHarness(recordDir, offConfig, /* emitToolCall */ true);
703
+ const off = deltaText(
704
+ await collect(
705
+ backend.streamAcpTurn(
706
+ sonnet,
707
+ { messages: [{ role: "user", content: "hi NONCE-H", timestamp: 0 }] },
708
+ { sessionId: "gate-H-off" },
709
+ hOff.deps,
710
+ ) as Stream,
711
+ ),
712
+ );
713
+ assert.ok(!off.includes("[tool:"), "showToolNotifications:false suppresses [tool:*] notices");
714
+ assert.ok(off.includes("[acp: preparing"), "lifecycle [acp:*] notices stay visible even when tool notices are off");
715
+
716
+ // true → tool notices shown (control).
717
+ const onConfig = { ...DEFAULT_RESOLVED_CONFIG, showToolNotifications: true };
718
+ const hOn = makeHarness(recordDir, onConfig, /* emitToolCall */ true);
719
+ const on = deltaText(
720
+ await collect(
721
+ backend.streamAcpTurn(
722
+ sonnet,
723
+ { messages: [{ role: "user", content: "hi NONCE-H2", timestamp: 0 }] },
724
+ { sessionId: "gate-H-on" },
725
+ hOn.deps,
726
+ ) as Stream,
727
+ ),
728
+ );
729
+ assert.ok(on.includes("[tool:start]"), "showToolNotifications:true shows [tool:*] notices");
730
+
731
+ // H3: the flag is display-only — it does NOT change the config signature.
732
+ assert.equal(
733
+ sigFor(store, sonnet.id, offConfig),
734
+ sigFor(store, sonnet.id, onConfig),
735
+ "showToolNotifications is display-only — never perturbs the bridge config signature (no spurious rebuild)",
736
+ );
737
+ }
738
+ } finally {
739
+ rmSync(TMP_EMIT, { recursive: true, force: true });
740
+ rmSync(recordDir, { recursive: true, force: true });
741
+ }
742
+
743
+ console.log(
744
+ "[check-acp-session-reuse] ok — captured prompts prove new=full transcript / reuse=DELTA-ONLY (turn-2 carries the " +
745
+ "new nonce, never the turn-1 history) with no respawn; mutable activePromptHandler routes each turn's notices to " +
746
+ "its own stream; retained child is unref'd (no exit pin) yet never torn down between turns; persisted record is NOT " +
747
+ "resumed in 1b-2b; concurrent prompts fail loud BOTH on a retained busy session AND on a same-key first-turn race " +
748
+ "(no double spawn); incompatible existing → new closes the old child (no orphan) while a model-lock throw leaves " +
749
+ "the live child alive + reusable; source locks buildAcpPrompt wiring + single-site applyAcpSessionUpdate + unref + " +
750
+ "in-flight claim; S2f progress notices emit in order (new: preparing→ready→sending prompt / reuse: reusing→sending " +
751
+ "prompt), are display-only (never on the ACP wire, dropped from a `new` rebuild, signature-invariant); S2g config " +
752
+ "passthrough: resolved operator mcpServers/tools/skillPlugins reach newSession, the entwurf envelope is injected into " +
753
+ "entwurf-bridge (stale PI_SESSION_ID overwritten, http untouched), _meta carries the tool surface + skill plugins + " +
754
+ "strict-mcp-config, the first-user augment lists the resolved server names, and a config change breaks the signature " +
755
+ "while the same config (and a session-id-only change) does not",
756
+ );