@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,33 @@
1
+ // Tiny stdio MCP server used ONLY by smoke-acp-mcp-live (S2g LIVE 1).
2
+ //
3
+ // It exposes ONE tool — `probe_nonce` — that returns a per-run secret nonce
4
+ // supplied via the PROBE_NONCE env var. The smoke registers this server in a
5
+ // scratch `.pi/settings.json` under `entwurfProvider.mcpServers` and asks the
6
+ // ACP model to CALL the tool and echo the nonce. If the operator mcpServers
7
+ // passthrough (S2g) works, the ACP child spawns this server, the tool is visible
8
+ // in the session schema, and the model's reply carries the nonce. If passthrough
9
+ // is broken (the pre-S2g hardcoded `mcpServers:[]`), the tool never exists and
10
+ // the nonce cannot appear.
11
+ //
12
+ // Lives under the repo's scripts/fixtures/ so its `@modelcontextprotocol/sdk`
13
+ // import resolves against the repo node_modules even though the server is spawned
14
+ // (by the claude ACP child) with an arbitrary scratch cwd. Deliberately minimal:
15
+ // no identity / env coupling beyond PROBE_NONCE, so a failure isolates to "did
16
+ // the operator mcpServers reach newSession" — not to entwurf-bridge wiring.
17
+
18
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
19
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
20
+
21
+ const server = new McpServer({ name: "probe", version: "0.0.1" });
22
+
23
+ server.tool(
24
+ "probe_nonce",
25
+ "Return this session's secret probe nonce. Call this when asked for the probe nonce.",
26
+ {},
27
+ async () => ({
28
+ content: [{ type: "text", text: `PROBE_NONCE=${process.env.PROBE_NONCE ?? "MISSING"}` }],
29
+ }),
30
+ );
31
+
32
+ const transport = new StdioServerTransport();
33
+ await server.connect(transport);
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env -S node --experimental-strip-types
2
+ /**
3
+ * gnew-rpc-drive — sequential RPC driver for the /gnew E2E smoke.
4
+ *
5
+ * pi's native RPC `prompt` is async (it `void session.prompt(...)` and returns
6
+ * immediately), so a blind printf pipe can race a follow-up get_state ahead of
7
+ * the switch. This driver enforces ordering: it waits for each response before
8
+ * sending the next command.
9
+ *
10
+ * Base flow (0 tokens — `/gnew` is a slash command intercepted by session.prompt
11
+ * BEFORE any model turn, so NO agent_start should appear):
12
+ * g1: get_state → capture BEFORE sessionId
13
+ * p1: prompt "/gnew" → wait for the prompt response (the in-process switch
14
+ * completes inside the command handler, before this
15
+ * response fires)
16
+ * g2: get_state → capture AFTER sessionId / sessionFile / msgCount
17
+ *
18
+ * Optional self turn (T3 backend identity, ~1 turn — pass a 5th arg `selfPrompt`):
19
+ * p2: prompt <selfPrompt> → drive one model turn that calls entwurf_self; scan
20
+ * the stream for the identity envelope and capture
21
+ * every sessionId it reports (proves PI_SESSION_ID
22
+ * reached the backend MCP child AFTER the switch).
23
+ *
24
+ * Emits a single JSON object on stdout for the bash smoke to assert against.
25
+ *
26
+ * Usage: node --experimental-strip-types gnew-rpc-drive.ts <sessionId> <provider> <model> [timeoutMs] [selfPrompt]
27
+ */
28
+ import { spawn } from "node:child_process";
29
+ import fs from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import readline from "node:readline";
33
+ import { fileURLToPath } from "node:url";
34
+
35
+ const ENTWURF_DIR = path.join(os.homedir(), ".pi", "entwurf-control");
36
+ const listSockets = (): string[] => {
37
+ try {
38
+ return fs.readdirSync(ENTWURF_DIR).filter((f) => f.endsWith(".sock"));
39
+ } catch {
40
+ return [];
41
+ }
42
+ };
43
+
44
+ const [sessionId, provider, model, timeoutMsArg, selfPrompt] = process.argv.slice(2);
45
+ if (!sessionId || !provider || !model) {
46
+ process.stderr.write("usage: gnew-rpc-drive.ts <sessionId> <provider> <model> [timeoutMs] [selfPrompt]\n");
47
+ process.exit(2);
48
+ }
49
+ const timeoutMs = Number(timeoutMsArg) || 90_000;
50
+ const wantSelf = typeof selfPrompt === "string" && selfPrompt.length > 0;
51
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
52
+ const REPO_EXTENSION_ARGS = ["--no-extensions", "-e", REPO_ROOT] as const;
53
+
54
+ interface DriveResult {
55
+ before: string | null;
56
+ after: string | null;
57
+ afterFile: string | null;
58
+ afterMsgCount: number | null;
59
+ promptOk: boolean;
60
+ agentStartSeen: boolean;
61
+ extensionErrors: Array<{ path: unknown; event: unknown; error: unknown }>;
62
+ socketsAfterSwitch: string[];
63
+ selfEnvelopeSessionIds: string[];
64
+ selfTurnEnded: boolean;
65
+ timedOut: boolean;
66
+ }
67
+
68
+ const result: DriveResult = {
69
+ before: null,
70
+ after: null,
71
+ afterFile: null,
72
+ afterMsgCount: null,
73
+ promptOk: false,
74
+ agentStartSeen: false,
75
+ extensionErrors: [],
76
+ // Control sockets present right after the switch, while pi is still alive (the
77
+ // socket vanishes on shutdown, so it can only be observed mid-run). Proves
78
+ // startControlServer rebound to the new garden id and dropped the old one.
79
+ socketsAfterSwitch: [],
80
+ selfEnvelopeSessionIds: [],
81
+ selfTurnEnded: false,
82
+ timedOut: false,
83
+ };
84
+
85
+ const child = spawn(
86
+ "pi",
87
+ [
88
+ ...REPO_EXTENSION_ARGS,
89
+ "--session-id",
90
+ sessionId,
91
+ "--entwurf-control",
92
+ "--provider",
93
+ provider,
94
+ "--model",
95
+ model,
96
+ "--mode",
97
+ "rpc",
98
+ ],
99
+ { stdio: ["pipe", "pipe", "inherit"] },
100
+ );
101
+
102
+ const send = (obj: Record<string, unknown>): void => {
103
+ child.stdin?.write(`${JSON.stringify(obj)}\n`);
104
+ };
105
+
106
+ // phases: 0 await g1 · 1 await prompt/gnew · 2 await g2 · 3 self turn (optional) · 4 done
107
+ let phase = 0;
108
+ // finish() can be reached from three racing paths — the timeout timer, child
109
+ // `exit`, and the normal phase-2/phase-3 completion. Guard so the single result
110
+ // JSON is emitted exactly once; a double emit would tear the bash JSON parse.
111
+ let finished = false;
112
+ const finish = (): void => {
113
+ if (finished) return;
114
+ finished = true;
115
+ clearTimeout(timer);
116
+ try {
117
+ child.stdin?.end();
118
+ } catch {
119
+ /* best-effort */
120
+ }
121
+ process.stdout.write(`${JSON.stringify(result)}\n`);
122
+ setTimeout(() => {
123
+ try {
124
+ child.kill("SIGTERM");
125
+ } catch {
126
+ /* best-effort */
127
+ }
128
+ process.exit(0);
129
+ }, 1500);
130
+ };
131
+
132
+ const timer = setTimeout(() => {
133
+ result.timedOut = true;
134
+ finish();
135
+ }, timeoutMs);
136
+
137
+ const rl = readline.createInterface({ input: child.stdout! });
138
+ rl.on("line", (line: string) => {
139
+ const trimmed = line.trim();
140
+ if (!trimmed) return;
141
+ let evt: Record<string, unknown>;
142
+ try {
143
+ evt = JSON.parse(trimmed) as Record<string, unknown>;
144
+ } catch {
145
+ return;
146
+ }
147
+
148
+ if (evt.type === "agent_start") result.agentStartSeen = true;
149
+ if (evt.type === "extension_error") {
150
+ result.extensionErrors.push({ path: evt.extensionPath, event: evt.event, error: evt.error });
151
+ }
152
+
153
+ // entwurf_self identity envelope: streamed as a tool result embedded as a STRING
154
+ // inside the message event, so its JSON quotes are escaped (\"sessionId\":...) —
155
+ // match the garden-id TOKEN instead (alphanumerics, never escaped). The envelope
156
+ // is marked by `agentId` (get_state never emits it); the only garden-shaped token
157
+ // in it is the sessionId (= the backend MCP child's PI_SESSION_ID after the switch).
158
+ if (phase === 3 && trimmed.includes("agentId")) {
159
+ const m = trimmed.match(/\d{8}T\d{6}-[0-9a-f]{6}/g);
160
+ if (m) {
161
+ for (const id of m) {
162
+ if (!result.selfEnvelopeSessionIds.includes(id)) result.selfEnvelopeSessionIds.push(id);
163
+ }
164
+ }
165
+ }
166
+ if (phase === 3 && evt.type === "agent_end") {
167
+ result.selfTurnEnded = true;
168
+ finish();
169
+ return;
170
+ }
171
+
172
+ if (evt.type !== "response") return;
173
+ const data = (evt.data ?? null) as { sessionId?: string; sessionFile?: string; messageCount?: number } | null;
174
+
175
+ if (phase === 0 && evt.command === "get_state") {
176
+ result.before = data?.sessionId ?? null;
177
+ phase = 1;
178
+ send({ type: "prompt", message: "/gnew", id: "p1" });
179
+ return;
180
+ }
181
+ if (phase === 1 && evt.command === "prompt") {
182
+ result.promptOk = evt.success === true;
183
+ phase = 2;
184
+ send({ type: "get_state", id: "g2" });
185
+ return;
186
+ }
187
+ if (phase === 2 && evt.command === "get_state") {
188
+ result.after = data?.sessionId ?? null;
189
+ result.afterFile = data?.sessionFile ?? null;
190
+ result.afterMsgCount = data?.messageCount ?? null;
191
+ result.socketsAfterSwitch = listSockets(); // pi still alive here — snapshot now
192
+ if (wantSelf) {
193
+ phase = 3;
194
+ send({ type: "prompt", message: selfPrompt, id: "p2" });
195
+ } else {
196
+ phase = 4;
197
+ finish();
198
+ }
199
+ }
200
+ });
201
+
202
+ child.on("error", (err: Error) => {
203
+ process.stderr.write(`gnew-rpc-drive: spawn failed: ${err.message}\n`);
204
+ process.exit(2);
205
+ });
206
+ child.on("exit", () => {
207
+ if (phase < 4) finish();
208
+ });
209
+
210
+ // kick off
211
+ send({ type: "get_state", id: "g1" });
@@ -0,0 +1,116 @@
1
+ // Shared bounded child-process teardown for the LIVE smokes (ACP + entwurf-v2).
2
+ //
3
+ // WHY THIS EXISTS (test-infra, not a Phase-1/Phase-2 feature):
4
+ // Every `smoke-acp-*-live.ts` carried its own copy of `terminateChild`, and
5
+ // each copy ended its SIGKILL path with an UNBOUNDED `await exited`. When the
6
+ // spawned child (or a grandchild ACP backend that still holds the stdio pipes)
7
+ // never emits an "exit" event, that await hangs FOREVER — *after* the smoke has
8
+ // already printed its PASS line. Individually the smokes pass; it is only this
9
+ // post-PASS cleanup that wedges, which is exactly what froze the aggregate
10
+ // `release-gate` runner (observed 2026-06-24: raw-turn PASS, then no further
11
+ // output for >25min while the runner sat in cleanup).
12
+ //
13
+ // Crash-don't-warn (AGENTS.md): every wait here is BOUNDED, and if the child is
14
+ // genuinely still alive after SIGKILL + a bounded grace we THROW. A leaked live
15
+ // backend is broken tool state, not a warning. The only swallowed errors are
16
+ // liveness PROBES (kill() / signal-0 / stdio destroy) — the one sanctioned use
17
+ // of `catch {}` (environment probing), never control flow we lie about.
18
+
19
+ import type { ChildProcess } from "node:child_process";
20
+
21
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
22
+
23
+ function destroyChildStdio(child: ChildProcess): void {
24
+ for (const stream of [child.stdin, child.stdout, child.stderr]) {
25
+ try {
26
+ stream?.destroy();
27
+ } catch {
28
+ // best-effort teardown; never load-bearing
29
+ }
30
+ }
31
+ }
32
+
33
+ export interface TerminateOptions {
34
+ /** Grace after SIGTERM before escalating to SIGKILL. */
35
+ graceMs?: number;
36
+ /** Bounded wait after SIGKILL before the signal-0 liveness probe. */
37
+ killWaitMs?: number;
38
+ }
39
+
40
+ /**
41
+ * Terminate a spawned smoke child with bounded waits and a loud failure.
42
+ *
43
+ * SIGTERM → grace → SIGKILL → bounded wait → signal-0 liveness probe.
44
+ * Returns when the child is gone (or was never alive); throws only if the
45
+ * child is provably STILL alive after SIGKILL + the bounded wait.
46
+ */
47
+ export async function terminateChild(
48
+ child: ChildProcess,
49
+ { graceMs = 2_000, killWaitMs = 2_000 }: TerminateOptions = {},
50
+ ): Promise<void> {
51
+ // Already reaped, but still close our side of the stdio pipes. With the SDK
52
+ // fluent connection a smoke can otherwise print PASS and keep Node alive on
53
+ // a retained pipe/read handle.
54
+ if (child.exitCode !== null || child.signalCode !== null) {
55
+ destroyChildStdio(child);
56
+ return;
57
+ }
58
+
59
+ const exited = new Promise<void>((resolve) => child.once("exit", () => resolve()));
60
+
61
+ try {
62
+ child.kill("SIGTERM");
63
+ } catch {
64
+ // kill() throws ESRCH when the process is already gone — treat as exited.
65
+ destroyChildStdio(child);
66
+ return;
67
+ }
68
+
69
+ const afterTerm = await Promise.race([
70
+ exited.then(() => "exited" as const),
71
+ sleep(graceMs).then(() => "timeout" as const),
72
+ ]);
73
+ if (afterTerm === "exited") {
74
+ destroyChildStdio(child);
75
+ return;
76
+ }
77
+
78
+ // Grace elapsed — escalate. (The old bug: the wait AFTER this kill was
79
+ // unbounded `await exited`.)
80
+ try {
81
+ child.kill("SIGKILL");
82
+ } catch {
83
+ destroyChildStdio(child);
84
+ return; // raced to death between the grace race and this kill
85
+ }
86
+
87
+ // Release any stdio the helper may be pinning, so a missed "exit" event does
88
+ // not keep the event loop (and the pipes) alive. Probe-only — best effort.
89
+ destroyChildStdio(child);
90
+
91
+ const afterKill = await Promise.race([
92
+ exited.then(() => "exited" as const),
93
+ sleep(killWaitMs).then(() => "timeout" as const),
94
+ ]);
95
+ if (afterKill === "exited") {
96
+ destroyChildStdio(child);
97
+ return;
98
+ }
99
+
100
+ // The "exit" event may simply have been missed even though the process is
101
+ // dead. Probe with signal 0: a throw (ESRCH/EPERM on a reaped pid) means the
102
+ // process is gone, so proceed; a clean return means it is genuinely alive.
103
+ const pid = child.pid;
104
+ if (pid === undefined) {
105
+ destroyChildStdio(child);
106
+ return; // never acquired a pid; nothing can leak
107
+ }
108
+ try {
109
+ process.kill(pid, 0);
110
+ } catch {
111
+ destroyChildStdio(child);
112
+ return; // gone — the exit event was just missed
113
+ }
114
+
115
+ throw new Error(`terminateChild: child pid=${pid} still alive after SIGKILL + ${killWaitMs}ms bounded wait`);
116
+ }