@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.3

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 (179) hide show
  1. package/CHANGELOG.md +162 -40
  2. package/dist/cli.js +4559 -4543
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +39 -10
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/eval/agent-bridge.d.ts +7 -19
  13. package/dist/types/exec/bash-executor.d.ts +2 -0
  14. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  15. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  17. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +55 -1
  18. package/dist/types/extensibility/shared-events.d.ts +6 -0
  19. package/dist/types/extensibility/utils.d.ts +2 -2
  20. package/dist/types/lsp/client.d.ts +2 -0
  21. package/dist/types/lsp/types.d.ts +3 -0
  22. package/dist/types/mcp/transports/stdio.d.ts +42 -1
  23. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  24. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  25. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  26. package/dist/types/modes/components/model-hub.d.ts +5 -2
  27. package/dist/types/modes/components/session-selector.d.ts +2 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  30. package/dist/types/modes/print-mode.d.ts +1 -1
  31. package/dist/types/modes/warp-events.d.ts +24 -0
  32. package/dist/types/modes/warp-events.test.d.ts +1 -0
  33. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  34. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  35. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  36. package/dist/types/registry/persisted-agents.d.ts +3 -0
  37. package/dist/types/sdk.d.ts +27 -5
  38. package/dist/types/session/agent-session.d.ts +34 -10
  39. package/dist/types/session/session-entries.d.ts +6 -1
  40. package/dist/types/session/session-history-format.d.ts +10 -0
  41. package/dist/types/session/session-manager.d.ts +19 -0
  42. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  43. package/dist/types/task/executor.d.ts +26 -1
  44. package/dist/types/task/index.d.ts +1 -1
  45. package/dist/types/task/label.d.ts +1 -1
  46. package/dist/types/task/parallel.d.ts +14 -0
  47. package/dist/types/task/structured-subagent.d.ts +111 -0
  48. package/dist/types/task/types.d.ts +51 -0
  49. package/dist/types/telemetry-export.d.ts +34 -9
  50. package/dist/types/tools/approval.d.ts +8 -0
  51. package/dist/types/tools/bash.d.ts +2 -0
  52. package/dist/types/tools/essential-tools.d.ts +29 -0
  53. package/dist/types/tools/fetch.d.ts +4 -5
  54. package/dist/types/tools/hub/messaging.d.ts +5 -1
  55. package/dist/types/tools/image-gen.d.ts +2 -1
  56. package/dist/types/tools/index.d.ts +17 -1
  57. package/dist/types/tools/todo.d.ts +31 -0
  58. package/dist/types/tools/xdev.d.ts +11 -2
  59. package/dist/types/tui/tree-list.d.ts +7 -0
  60. package/dist/types/utils/markit.d.ts +11 -0
  61. package/dist/types/utils/title-generator.d.ts +2 -1
  62. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  63. package/dist/types/web/search/types.d.ts +1 -1
  64. package/package.json +21 -16
  65. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  66. package/src/advisor/__tests__/config.test.ts +58 -2
  67. package/src/advisor/config.ts +76 -24
  68. package/src/advisor/runtime.ts +445 -92
  69. package/src/autolearn/controller.ts +23 -28
  70. package/src/cli/file-processor.ts +1 -2
  71. package/src/cli.ts +5 -1
  72. package/src/config/model-discovery.ts +81 -21
  73. package/src/config/model-registry.ts +25 -6
  74. package/src/config/model-resolver.ts +14 -7
  75. package/src/config/settings-schema.ts +46 -9
  76. package/src/config/settings.ts +405 -25
  77. package/src/cursor.ts +20 -3
  78. package/src/debug/report-bundle.ts +40 -4
  79. package/src/discovery/helpers.ts +28 -5
  80. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  81. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  82. package/src/eval/agent-bridge.ts +104 -477
  83. package/src/eval/jl/prelude.jl +7 -6
  84. package/src/eval/js/shared/prelude.txt +5 -4
  85. package/src/eval/py/prelude.py +11 -39
  86. package/src/eval/rb/prelude.rb +5 -6
  87. package/src/exec/bash-executor.ts +14 -5
  88. package/src/extensibility/custom-tools/loader.ts +3 -3
  89. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  90. package/src/extensibility/extensions/loader.ts +3 -3
  91. package/src/extensibility/extensions/managed-timers.ts +83 -0
  92. package/src/extensibility/extensions/runner.ts +26 -0
  93. package/src/extensibility/extensions/types.ts +18 -0
  94. package/src/extensibility/extensions/wrapper.ts +2 -1
  95. package/src/extensibility/hooks/loader.ts +3 -3
  96. package/src/extensibility/legacy-pi-coding-agent-shim.ts +149 -8
  97. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  98. package/src/extensibility/plugins/manager.ts +2 -2
  99. package/src/extensibility/shared-events.ts +6 -0
  100. package/src/extensibility/utils.ts +91 -25
  101. package/src/irc/bus.ts +22 -3
  102. package/src/launch/broker.ts +3 -2
  103. package/src/launch/client.ts +2 -2
  104. package/src/launch/presence.ts +2 -2
  105. package/src/lsp/client.ts +58 -1
  106. package/src/lsp/index.ts +62 -6
  107. package/src/lsp/types.ts +3 -0
  108. package/src/main.ts +11 -8
  109. package/src/mcp/manager.ts +9 -3
  110. package/src/mcp/oauth-flow.ts +20 -0
  111. package/src/mcp/transports/stdio.test.ts +269 -1
  112. package/src/mcp/transports/stdio.ts +255 -24
  113. package/src/modes/components/advisor-config.ts +65 -3
  114. package/src/modes/components/agent-hub.ts +1 -72
  115. package/src/modes/components/ask-dialog.ts +1 -1
  116. package/src/modes/components/bash-execution.ts +7 -3
  117. package/src/modes/components/eval-execution.ts +3 -1
  118. package/src/modes/components/hook-editor.ts +18 -3
  119. package/src/modes/components/model-hub.ts +138 -42
  120. package/src/modes/components/session-selector.ts +4 -0
  121. package/src/modes/components/status-line/component.test.ts +1 -0
  122. package/src/modes/components/status-line/segments.ts +21 -6
  123. package/src/modes/controllers/command-controller.ts +167 -47
  124. package/src/modes/controllers/event-controller.ts +5 -0
  125. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  126. package/src/modes/controllers/input-controller.ts +12 -12
  127. package/src/modes/controllers/selector-controller.ts +191 -31
  128. package/src/modes/interactive-mode.ts +169 -62
  129. package/src/modes/print-mode.ts +3 -3
  130. package/src/modes/rpc/host-tools.ts +2 -1
  131. package/src/modes/rpc/rpc-mode.ts +19 -4
  132. package/src/modes/warp-events.test.ts +794 -0
  133. package/src/modes/warp-events.ts +232 -0
  134. package/src/plan-mode/model-transition.test.ts +60 -0
  135. package/src/plan-mode/model-transition.ts +51 -0
  136. package/src/prompts/system/system-prompt.md +1 -1
  137. package/src/prompts/tools/eval.md +5 -2
  138. package/src/prompts/tools/read.md +1 -1
  139. package/src/prompts/tools/task.md +12 -8
  140. package/src/prompts/tools/write.md +1 -1
  141. package/src/registry/agent-lifecycle.ts +133 -18
  142. package/src/registry/persisted-agents.ts +74 -0
  143. package/src/sdk.ts +343 -122
  144. package/src/session/agent-session.ts +1359 -368
  145. package/src/session/session-entries.ts +6 -1
  146. package/src/session/session-history-format.ts +20 -5
  147. package/src/session/session-manager.ts +57 -0
  148. package/src/session/streaming-output.ts +41 -1
  149. package/src/slash-commands/builtin-registry.ts +7 -0
  150. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  151. package/src/system-prompt.ts +7 -2
  152. package/src/task/executor.ts +100 -22
  153. package/src/task/index.ts +258 -429
  154. package/src/task/label.ts +2 -0
  155. package/src/task/parallel.ts +43 -0
  156. package/src/task/persisted-revive.ts +19 -7
  157. package/src/task/structured-subagent.ts +642 -0
  158. package/src/task/types.ts +58 -0
  159. package/src/telemetry-export.ts +453 -97
  160. package/src/tools/__tests__/eval-description.test.ts +1 -1
  161. package/src/tools/approval.ts +11 -0
  162. package/src/tools/bash.ts +71 -38
  163. package/src/tools/essential-tools.ts +45 -0
  164. package/src/tools/fetch.ts +28 -105
  165. package/src/tools/gh.ts +169 -2
  166. package/src/tools/hub/messaging.ts +16 -3
  167. package/src/tools/image-gen.ts +69 -7
  168. package/src/tools/index.ts +50 -15
  169. package/src/tools/path-utils.ts +1 -0
  170. package/src/tools/read.ts +62 -29
  171. package/src/tools/todo.ts +126 -13
  172. package/src/tools/write.ts +22 -4
  173. package/src/tools/xdev.ts +14 -3
  174. package/src/tui/tree-list.ts +39 -0
  175. package/src/utils/markit.ts +12 -0
  176. package/src/utils/title-generator.ts +15 -4
  177. package/src/utils/zip.ts +16 -1
  178. package/src/web/search/providers/kimi.ts +18 -12
  179. package/src/web/search/types.ts +6 -1
@@ -1,6 +1,9 @@
1
1
  import { describe, expect, it, spyOn } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
2
5
 
3
- import { resolveStdioSpawnCommand, StdioTransport } from "./stdio";
6
+ import { resolveStdioSpawnCommand, StdioTransport, terminateStdioProcess } from "./stdio";
4
7
 
5
8
  describe("resolveStdioSpawnCommand", () => {
6
9
  it("hides Windows executable MCP servers when the host has no console", async () => {
@@ -149,3 +152,268 @@ describe.skipIf(process.platform === "win32")("StdioTransport request write stal
149
152
  }
150
153
  }, 8000);
151
154
  });
155
+
156
+ function processExists(pid: number): boolean {
157
+ try {
158
+ process.kill(pid, 0);
159
+ return true;
160
+ } catch {
161
+ return false;
162
+ }
163
+ }
164
+
165
+ // Regression for #5578: `close()` used a bare `this.#process.kill()` (direct
166
+ // SIGTERM, no wait, no escalate, no process-group signal), so a detached
167
+ // session-leader child (or a grandchild it spawned) that ignores/traps
168
+ // SIGTERM survived host exit and became an orphan pinned to PID 1 on Linux.
169
+ // `sleep`/`bun`/POSIX signal semantics are exercised directly here rather
170
+ // than through `StdioTransport.connect()`, because `connect()` derives
171
+ // `detached` from `resolveStdioSpawnCommand()`, which is tied to the host's
172
+ // real `process.platform` — a POSIX detached session cannot be reproduced
173
+ // end-to-end through `connect()` on a non-Linux dev/CI host, but a real
174
+ // detached process group can still be spawned directly on any POSIX host.
175
+ describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => {
176
+ it("escalates a detached child that traps SIGTERM to SIGKILL", async () => {
177
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-kill-solo-"));
178
+ const scriptPath = path.join(tempDir, "child.mjs");
179
+ const readyPath = path.join(tempDir, "ready");
180
+ await fs.writeFile(
181
+ scriptPath,
182
+ [
183
+ "import { writeFileSync } from 'node:fs';",
184
+ "process.on('SIGTERM', () => {});",
185
+ `writeFileSync(${JSON.stringify(readyPath)}, '1');`,
186
+ "setInterval(() => {}, 60_000);",
187
+ ].join("\n"),
188
+ );
189
+ const proc = Bun.spawn(["bun", "run", scriptPath], {
190
+ stdin: "ignore",
191
+ stdout: "ignore",
192
+ stderr: "ignore",
193
+ detached: true,
194
+ });
195
+ try {
196
+ // Wait for the child to actually register its SIGTERM handler before
197
+ // signaling it: signaling too early races the child's startup and
198
+ // hits the default (terminate) action instead of exercising the trap.
199
+ for (let i = 0; i < 100; i++) {
200
+ try {
201
+ await fs.access(readyPath);
202
+ break;
203
+ } catch {
204
+ await Bun.sleep(20);
205
+ }
206
+ }
207
+
208
+ const started = performance.now();
209
+ await terminateStdioProcess(proc, true);
210
+ await proc.exited;
211
+ const elapsedMs = performance.now() - started;
212
+
213
+ expect(proc.signalCode).toBe("SIGKILL");
214
+ // Escalation only fires after the ~1s SIGTERM grace window elapses —
215
+ // a too-fast exit would mean SIGKILL fired without waiting.
216
+ expect(elapsedMs).toBeGreaterThanOrEqual(900);
217
+ } finally {
218
+ try {
219
+ process.kill(-proc.pid, "SIGKILL");
220
+ } catch {
221
+ // Already gone.
222
+ }
223
+ await fs.rm(tempDir, { recursive: true, force: true });
224
+ }
225
+ }, 5000);
226
+
227
+ it("reaches a SIGTERM-trapping grandchild through the group SIGKILL, not just the direct child", async () => {
228
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-group-kill-"));
229
+ const grandchildScriptPath = path.join(tempDir, "grandchild.mjs");
230
+ const parentScriptPath = path.join(tempDir, "parent.mjs");
231
+ const grandchildPidPath = path.join(tempDir, "grandchild.pid");
232
+
233
+ // Grandchild also traps SIGTERM, so only an unrestricted SIGKILL to the
234
+ // whole group — not a signal to the direct (parent) child alone — can
235
+ // reach and stop it.
236
+ await fs.writeFile(
237
+ grandchildScriptPath,
238
+ [
239
+ "import { writeFileSync } from 'node:fs';",
240
+ "process.on('SIGTERM', () => {});",
241
+ `writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
242
+ "setInterval(() => {}, 60_000);",
243
+ ].join("\n"),
244
+ );
245
+ await fs.writeFile(
246
+ parentScriptPath,
247
+ [
248
+ "process.on('SIGTERM', () => {});",
249
+ `Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`,
250
+ "setInterval(() => {}, 60_000);",
251
+ ].join("\n"),
252
+ );
253
+
254
+ const proc = Bun.spawn(["bun", "run", parentScriptPath], {
255
+ stdin: "ignore",
256
+ stdout: "ignore",
257
+ stderr: "ignore",
258
+ detached: true,
259
+ });
260
+
261
+ try {
262
+ // Polls real wall-clock time rather than an event/promise: the
263
+ // grandchild process is a real external OS process writing to a real
264
+ // file, with no in-process signal this test can `await` directly.
265
+ let grandchildPid: number | undefined;
266
+ for (let i = 0; i < 100 && grandchildPid === undefined; i++) {
267
+ try {
268
+ grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
269
+ } catch {
270
+ await Bun.sleep(20);
271
+ }
272
+ }
273
+ if (grandchildPid === undefined) throw new Error("grandchild never reported its pid");
274
+ expect(processExists(grandchildPid)).toBe(true);
275
+
276
+ await terminateStdioProcess(proc, true);
277
+ await proc.exited;
278
+ expect(proc.signalCode).toBe("SIGKILL");
279
+
280
+ // The group SIGKILL is delivered to every member simultaneously, but
281
+ // give the kernel a brief window to finish reaping before asserting.
282
+ let grandchildAlive = processExists(grandchildPid);
283
+ for (let i = 0; i < 25 && grandchildAlive; i++) {
284
+ await Bun.sleep(20);
285
+ grandchildAlive = processExists(grandchildPid);
286
+ }
287
+ expect(grandchildAlive).toBe(false);
288
+ } finally {
289
+ try {
290
+ process.kill(-proc.pid, "SIGKILL");
291
+ } catch {
292
+ // Already gone.
293
+ }
294
+ await fs.rm(tempDir, { recursive: true, force: true });
295
+ }
296
+ }, 8000);
297
+
298
+ it("still reaps a SIGTERM-trapping grandchild after the detached leader exits cooperatively", async () => {
299
+ // Regression: the leader exiting within the SIGTERM grace window used to
300
+ // be treated as proof the whole process group was gone, so `close()`
301
+ // returned early and never delivered a group SIGKILL — leaving exactly
302
+ // the orphaned grandchild this change is meant to reap. Unlike the
303
+ // group-SIGKILL test above, the leader here does NOT trap SIGTERM, so
304
+ // it exits promptly on its own; only the grandchild ignores signals.
305
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-leader-exit-group-kill-"));
306
+ const grandchildScriptPath = path.join(tempDir, "grandchild.mjs");
307
+ const parentScriptPath = path.join(tempDir, "parent.mjs");
308
+ const grandchildPidPath = path.join(tempDir, "grandchild.pid");
309
+
310
+ await fs.writeFile(
311
+ grandchildScriptPath,
312
+ [
313
+ "import { writeFileSync } from 'node:fs';",
314
+ "process.on('SIGTERM', () => {});",
315
+ `writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
316
+ "setInterval(() => {}, 60_000);",
317
+ ].join("\n"),
318
+ );
319
+ // No SIGTERM handler here: the default action (terminate) fires as soon
320
+ // as the group SIGTERM lands, well inside TERM_GRACE_MS.
321
+ await fs.writeFile(
322
+ parentScriptPath,
323
+ [
324
+ `Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`,
325
+ "setInterval(() => {}, 60_000);",
326
+ ].join("\n"),
327
+ );
328
+
329
+ const proc = Bun.spawn(["bun", "run", parentScriptPath], {
330
+ stdin: "ignore",
331
+ stdout: "ignore",
332
+ stderr: "ignore",
333
+ detached: true,
334
+ });
335
+
336
+ try {
337
+ let grandchildPid: number | undefined;
338
+ for (let i = 0; i < 100 && grandchildPid === undefined; i++) {
339
+ try {
340
+ grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
341
+ } catch {
342
+ await Bun.sleep(20);
343
+ }
344
+ }
345
+ if (grandchildPid === undefined) throw new Error("grandchild never reported its pid");
346
+ expect(processExists(grandchildPid)).toBe(true);
347
+
348
+ const started = performance.now();
349
+ await terminateStdioProcess(proc, true);
350
+ await proc.exited;
351
+ const elapsedMs = performance.now() - started;
352
+
353
+ // The leader exits on the initial SIGTERM (no trap), so this must not
354
+ // block for the ~1s TERM grace window before sweeping the group.
355
+ expect(elapsedMs).toBeLessThan(700);
356
+
357
+ let grandchildAlive = processExists(grandchildPid);
358
+ for (let i = 0; i < 25 && grandchildAlive; i++) {
359
+ await Bun.sleep(20);
360
+ grandchildAlive = processExists(grandchildPid);
361
+ }
362
+ expect(grandchildAlive).toBe(false);
363
+ } finally {
364
+ try {
365
+ process.kill(-proc.pid, "SIGKILL");
366
+ } catch {
367
+ // Already gone.
368
+ }
369
+ await fs.rm(tempDir, { recursive: true, force: true });
370
+ }
371
+ }, 8000);
372
+
373
+ it("never attempts a process-group signal when the transport did not spawn detached", async () => {
374
+ const proc = Bun.spawn(["bun", "-e", "await Bun.sleep(60_000)"], {
375
+ stdin: "ignore",
376
+ stdout: "ignore",
377
+ stderr: "ignore",
378
+ detached: false,
379
+ });
380
+ const killSpy = spyOn(process, "kill");
381
+ try {
382
+ await terminateStdioProcess(proc, false);
383
+ await proc.exited;
384
+
385
+ // Only the direct-child `Subprocess.kill()` path may run; the global
386
+ // `process.kill()` (used exclusively for the negative-pid group
387
+ // signal) must never be reached.
388
+ expect(killSpy).not.toHaveBeenCalled();
389
+ } finally {
390
+ killSpy.mockRestore();
391
+ try {
392
+ proc.kill("SIGKILL");
393
+ } catch {
394
+ // Already gone.
395
+ }
396
+ }
397
+ }, 5000);
398
+ });
399
+
400
+ describe.skipIf(process.platform === "win32")("StdioTransport.close teardown", () => {
401
+ it("closes a well-behaved child promptly without escalating to SIGKILL", async () => {
402
+ const transport = new StdioTransport({
403
+ command: "bun",
404
+ args: ["-e", "await Bun.sleep(60_000)"],
405
+ });
406
+ try {
407
+ await transport.connect();
408
+ const started = performance.now();
409
+ await transport.close();
410
+ const elapsedMs = performance.now() - started;
411
+
412
+ // No SIGTERM trap => the child dies almost immediately; close() must
413
+ // not block for the full ~1s TERM grace window before returning.
414
+ expect(elapsedMs).toBeLessThan(700);
415
+ } finally {
416
+ await transport.close();
417
+ }
418
+ }, 5000);
419
+ });
@@ -54,6 +54,18 @@ export interface StdioSpawnCommand {
54
54
  * grandchildren keep stdout routed through our pipe (#3544).
55
55
  */
56
56
  detached: boolean;
57
+ /**
58
+ * Pass argv to `Bun.spawn` verbatim (Windows only), suppressing the
59
+ * default libuv backslash-quoting.
60
+ *
61
+ * Set when `cmd` already holds a `cmd.exe /d /e:ON /v:OFF /c "<line>"`
62
+ * command line escaped for `cmd.exe`'s parser (see `buildCmdExeArgv`).
63
+ * libuv's quoting targets `CommandLineToArgvW`, not `cmd.exe`, so letting
64
+ * it re-quote a batch launch would corrupt arguments and re-open the
65
+ * `%VAR%` / quote-injection holes the escaping closes (BatBadBut,
66
+ * CVE-2024-24576).
67
+ */
68
+ windowsVerbatimArguments?: boolean;
57
69
  }
58
70
 
59
71
  /** Inputs used to resolve platform-specific stdio spawn behavior. */
@@ -203,23 +215,6 @@ async function resolveWindowsNpmShimCommand(
203
215
  };
204
216
  }
205
217
 
206
- function quoteCmdArg(value: string): string {
207
- if (value.length === 0) return '""';
208
- let result = '"';
209
- for (const char of value) {
210
- if (char === '"') {
211
- result += '^"';
212
- } else if (char === "^") {
213
- result += "^^";
214
- } else if (char === "%") {
215
- result += "^%";
216
- } else {
217
- result += char;
218
- }
219
- }
220
- return `${result}"`;
221
- }
222
-
223
218
  function isWindowsBatchCommand(command: string): boolean {
224
219
  return WINDOWS_BATCH_EXTENSIONS.has(path.extname(command).toLowerCase());
225
220
  }
@@ -229,10 +224,93 @@ function resolveComSpec(env: Record<string, string | undefined>): string {
229
224
  return comspec && comspec.length > 0 ? comspec : "cmd.exe";
230
225
  }
231
226
 
232
- /** `cmd /s /c` strips one outer quote pair; keep inner argv quotes intact. */
233
- function buildCmdExeCommand(command: string, args: readonly string[]): string {
234
- const quotedCommand = [command, ...args].map(quoteCmdArg).join(" ");
235
- return `"${quotedCommand}"`;
227
+ // Argument bytes cmd.exe delivers unchanged without quoting. Anything outside
228
+ // this set (spaces, quotes, `%`, shell metacharacters, non-ASCII) forces the
229
+ // quoted+escaped path below. Mirrors the fuzz-tested allow-list from Zig's
230
+ // BatBadBut mitigation.
231
+ const CMD_SAFE_ARG = /^[A-Za-z0-9#$*+\-./:?@\\_]+$/;
232
+
233
+ /**
234
+ * Escape the interior of a `cmd.exe`-quoted token: neutralize `%VAR%` expansion
235
+ * and double any backslash run that precedes a quote (including the caller's
236
+ * closing quote) so `CommandLineToArgvW` delivers the backslashes literally.
237
+ *
238
+ * `cmd.exe` re-parses the whole `/c` string and expands `%…%` *before* the
239
+ * batch shim's own argv split runs, so both the command path and every argument
240
+ * must pass through this. Percent → `%%cd:~,%` (which expands to nothing,
241
+ * leaving a literal `%`) and `"` → `""` are the documented BatBadBut mitigation
242
+ * (CVE-2024-24576). The caller supplies the surrounding double quotes.
243
+ *
244
+ * @see https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/
245
+ */
246
+ function escapeCmdQuotedInterior(value: string): string {
247
+ let out = "";
248
+ let backslashes = 0;
249
+ for (const ch of value) {
250
+ if (ch === "\\") {
251
+ backslashes += 1;
252
+ out += ch;
253
+ } else if (ch === '"') {
254
+ out += "\\".repeat(backslashes);
255
+ out += '""';
256
+ backslashes = 0;
257
+ } else if (ch === "%") {
258
+ out += "%%cd:~,%";
259
+ backslashes = 0;
260
+ } else {
261
+ backslashes = 0;
262
+ out += ch;
263
+ }
264
+ }
265
+ // Double the trailing backslash run so it stays literal before the closing
266
+ // quote the caller appends.
267
+ out += "\\".repeat(backslashes);
268
+ return out;
269
+ }
270
+
271
+ /** Reject bytes that cannot round-trip through `cmd.exe`'s `/c` command line. */
272
+ function assertCmdBatchToken(value: string, kind: "command" | "argument"): void {
273
+ // NUL/LF act as an end-of-command marker and CR is stripped, so any of them
274
+ // would silently truncate or corrupt the launch.
275
+ if (/[\0\r\n]/.test(value)) {
276
+ throw new Error(`Windows batch MCP ${kind} cannot contain NUL, CR, or LF characters`);
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Escape one argument for `cmd.exe`'s command-line pre-parse so a `.cmd`/`.bat`
282
+ * shim receives it verbatim. Quotes only when the argument is empty, ends in a
283
+ * backslash, or holds a byte outside {@link CMD_SAFE_ARG}; the quoted body is
284
+ * escaped by {@link escapeCmdQuotedInterior}.
285
+ *
286
+ * @throws when the argument contains NUL, CR, or LF (see {@link assertCmdBatchToken}).
287
+ */
288
+ function escapeCmdBatchArg(arg: string): string {
289
+ assertCmdBatchToken(arg, "argument");
290
+ const needsQuotes = arg.length === 0 || arg.endsWith("\\") || !CMD_SAFE_ARG.test(arg);
291
+ // An unquoted arg is pure allow-list bytes (no `%`, `"`, or trailing `\`), so
292
+ // it needs no interior escaping.
293
+ return needsQuotes ? `"${escapeCmdQuotedInterior(arg)}"` : arg;
294
+ }
295
+
296
+ /**
297
+ * Build the `cmd.exe` argv for a Windows `.cmd`/`.bat` (or unresolved bare)
298
+ * MCP command.
299
+ *
300
+ * The trailing element is a single `/c` string wrapped in an outer quote pair
301
+ * that `cmd.exe` strips (its opening-quote rule). The command token is always
302
+ * quoted and, like every argument, escaped so a `%` in the resolved path (e.g.
303
+ * `C:\work\%TOKEN%\server.cmd`) is not expanded before the shim launches.
304
+ * `/e:ON` keeps command extensions on (required for the `%%cd:~,%` trick) and
305
+ * `/v:OFF` disables delayed expansion. The result MUST be spawned with
306
+ * `windowsVerbatimArguments` so libuv passes it through unmodified.
307
+ */
308
+ function buildCmdExeArgv(comspec: string, command: string, args: readonly string[]): string[] {
309
+ assertCmdBatchToken(command, "command");
310
+ let line = `""${escapeCmdQuotedInterior(command)}"`;
311
+ for (const arg of args) line += ` ${escapeCmdBatchArg(arg)}`;
312
+ line += '"';
313
+ return [comspec, "/d", "/e:ON", "/v:OFF", "/c", line];
236
314
  }
237
315
 
238
316
  /**
@@ -245,7 +323,7 @@ function buildCmdExeCommand(command: string, args: readonly string[]): string {
245
323
  * only appends `.exe` for extensionless names — `.cmd`/`.bat` are never
246
324
  * tried, so `npx` (which exists only as `npx.cmd` on Windows) crashes the
247
325
  * subprocess immediately. When the resolver can't pin the command down,
248
- * route through `cmd.exe /d /s /c` so Windows's own PATHEXT lookup runs.
326
+ * route through `cmd.exe` so Windows's own PATHEXT lookup runs.
249
327
  */
250
328
  export async function resolveStdioSpawnCommand(
251
329
  config: MCPStdioServerConfig,
@@ -271,9 +349,10 @@ export async function resolveStdioSpawnCommand(
271
349
  if (!needsCmdExe) return { cmd: [resolvedCommand, ...args], windowsHide, detached };
272
350
 
273
351
  return {
274
- cmd: [resolveComSpec(options.env), "/d", "/s", "/c", buildCmdExeCommand(resolvedCommand, args)],
352
+ cmd: buildCmdExeArgv(resolveComSpec(options.env), resolvedCommand, args),
275
353
  windowsHide,
276
354
  detached,
355
+ windowsVerbatimArguments: true,
277
356
  };
278
357
  }
279
358
 
@@ -325,6 +404,132 @@ export function writeFrame(stdin: FrameSink, frame: string): boolean {
325
404
  }
326
405
  }
327
406
 
407
+ /** Grace window to observe a cooperative exit after SIGTERM before escalating to SIGKILL. */
408
+ const TERM_GRACE_MS = 1000;
409
+ /** Grace window to observe SIGKILL taking effect before `close()` gives up and returns. */
410
+ const KILL_GRACE_MS = 500;
411
+
412
+ /**
413
+ * The subset of `Subprocess` that termination needs. Decoupled from the
414
+ * `Subprocess<In, Out, Err>` stdio generics — `#process`'s pipes are
415
+ * irrelevant to signaling — so tests can exercise it against a plain
416
+ * `Bun.spawn(cmd, { stdio: "ignore" })` child without fighting the generics.
417
+ */
418
+ interface KillableSubprocess {
419
+ readonly pid: number;
420
+ readonly exited: Promise<number>;
421
+ kill(signal?: number | NodeJS.Signals): void;
422
+ }
423
+
424
+ /**
425
+ * Race `exited` against a timer. Resolves `true` once the process has exited
426
+ * within `timeoutMs`, `false` if the timer wins first. `exited` resolving OR
427
+ * rejecting both count as "exited" — mirrors `waitForExit()` in
428
+ * `lsp/client.ts`, which treats the same ambiguity (Bun documents
429
+ * `Subprocess.exited` as resolve-only, but a settle either way means there is
430
+ * nothing left to wait on).
431
+ *
432
+ * The timer is always cleared before returning — win or lose — so a process
433
+ * that exits promptly never leaves a dangling `timeoutMs` timer holding the
434
+ * event loop open behind it.
435
+ */
436
+ async function waitForProcessExit(exited: Promise<number>, timeoutMs: number): Promise<boolean> {
437
+ const { promise: timedOut, resolve: resolveTimedOut } = Promise.withResolvers<false>();
438
+ const timer = setTimeout(() => resolveTimedOut(false), timeoutMs);
439
+ try {
440
+ return await Promise.race([
441
+ exited.then(
442
+ () => true,
443
+ () => true,
444
+ ),
445
+ timedOut,
446
+ ]);
447
+ } finally {
448
+ clearTimeout(timer);
449
+ }
450
+ }
451
+
452
+ /** `true` when `error` is a Node errno exception carrying the given `code`. */
453
+ function isErrnoCode(error: unknown, code: string): boolean {
454
+ if (typeof error !== "object" || error === null || !("code" in error)) return false;
455
+ return error.code === code;
456
+ }
457
+
458
+ /**
459
+ * Signal `signal` to `proc`. When `detached` is true on a POSIX platform,
460
+ * targets the whole process group via the negative-pid convention
461
+ * (`process.kill(-pid, signal)`) so a detached session leader's descendants —
462
+ * not just the direct child — receive it too; a bare direct-child signal
463
+ * never reaches grandchildren the child itself spawned.
464
+ *
465
+ * `ESRCH` from the group signal means the group is already gone — that is a
466
+ * success (nothing left to signal), not a failure — so it does not fall
467
+ * through. Any other group-signal failure (e.g. `EPERM`) falls back to
468
+ * signaling the direct child as a last resort. Non-detached transports
469
+ * (macOS, Windows, or POSIX where detach did not apply) always signal the
470
+ * direct child only: a negative-pid signal outside a detached session could
471
+ * hit an unrelated process group.
472
+ */
473
+ function signalStdioProcess(
474
+ proc: KillableSubprocess,
475
+ detached: boolean,
476
+ signal: NodeJS.Signals,
477
+ platform: NodeJS.Platform,
478
+ ): void {
479
+ if (detached && platform !== "win32") {
480
+ try {
481
+ process.kill(-proc.pid, signal);
482
+ return;
483
+ } catch (error) {
484
+ if (isErrnoCode(error, "ESRCH")) return;
485
+ // Fall through to the direct-child signal below.
486
+ }
487
+ }
488
+ try {
489
+ proc.kill(signal);
490
+ } catch {
491
+ // Already gone.
492
+ }
493
+ }
494
+
495
+ /**
496
+ * Terminate an MCP stdio subprocess: SIGTERM (process-group when `detached`
497
+ * on POSIX, direct child otherwise), wait up to `TERM_GRACE_MS` for a
498
+ * cooperative exit, then escalate to SIGKILL — waiting up to `KILL_GRACE_MS`
499
+ * more only when the leader itself hadn't already exited. A detached
500
+ * leader's cooperative exit does not prove the whole process group is gone
501
+ * (a grandchild can outlive it and ignore SIGTERM), so detached transports
502
+ * always fire the group SIGKILL sweep, even after a clean SIGTERM exit.
503
+ * Every step is a no-op-safe signal against an already-exited target, so
504
+ * repeat calls (idempotent `close()`) never throw.
505
+ *
506
+ * Exported so tests can exercise group-signal escalation with an explicit
507
+ * `detached`/`platform` pair: `StdioTransport.connect()` derives `detached`
508
+ * from `resolveStdioSpawnCommand()`, which is tied to the host's real
509
+ * `process.platform`, so a POSIX detached session cannot be reproduced
510
+ * end-to-end through `connect()` on a non-Linux dev/CI host.
511
+ */
512
+ export async function terminateStdioProcess(
513
+ proc: KillableSubprocess,
514
+ detached: boolean,
515
+ platform: NodeJS.Platform = process.platform,
516
+ ): Promise<void> {
517
+ signalStdioProcess(proc, detached, "SIGTERM", platform);
518
+ const exitedOnTerm = await waitForProcessExit(proc.exited, TERM_GRACE_MS);
519
+ // A non-detached transport has no process group beyond the leader itself:
520
+ // once it exits, there is nothing left to signal. A detached transport's
521
+ // leader exiting is NOT proof the group is empty — a grandchild it spawned
522
+ // can still be alive and ignoring SIGTERM — so detached transports always
523
+ // fall through to the group SIGKILL, even on a cooperative leader exit.
524
+ if (exitedOnTerm && !detached) return;
525
+ signalStdioProcess(proc, detached, "SIGKILL", platform);
526
+ // Once the leader has already exited there is no further `exited` signal
527
+ // to wait on for this call — the SIGKILL above is a fire-and-forget sweep
528
+ // for any surviving group members — so only block on the grace window
529
+ // when the leader itself is still the thing being escalated against.
530
+ if (!exitedOnTerm) await waitForProcessExit(proc.exited, KILL_GRACE_MS);
531
+ }
532
+
328
533
  /**
329
534
  * Stdio transport for MCP servers.
330
535
  * Spawns a subprocess and communicates via stdin/stdout.
@@ -340,6 +545,12 @@ export class StdioTransport implements MCPTransport {
340
545
  >();
341
546
  #connected = false;
342
547
  #readLoop: Promise<void> | null = null;
548
+ /**
549
+ * Set from `resolveStdioSpawnCommand()`'s `detached` flag in `connect()`.
550
+ * Gates process-group signaling in `close()` — only a transport that
551
+ * actually spawned into its own session may target it.
552
+ */
553
+ #detached = false;
343
554
 
344
555
  onClose?: () => void;
345
556
  onError?: (error: Error) => void;
@@ -388,7 +599,9 @@ export class StdioTransport implements MCPTransport {
388
599
  stderr: "pipe",
389
600
  windowsHide: spawnCommand.windowsHide,
390
601
  detached: spawnCommand.detached,
602
+ windowsVerbatimArguments: spawnCommand.windowsVerbatimArguments,
391
603
  });
604
+ this.#detached = spawnCommand.detached;
392
605
 
393
606
  this.#connected = true;
394
607
 
@@ -650,8 +863,26 @@ export class StdioTransport implements MCPTransport {
650
863
  }
651
864
 
652
865
  if (this.#process) {
653
- this.#process.kill();
866
+ // Grab the handle and null the field immediately (before any
867
+ // `await`) so a concurrent/repeat `close()` sees `#process` already
868
+ // cleared and skips straight past this block — no double-signal.
869
+ const proc = this.#process;
654
870
  this.#process = null;
871
+
872
+ // 1. Cooperative EOF first: a well-behaved server sees stdin close
873
+ // and can exit on its own before any signal is sent. Guarded — the
874
+ // sink can throw if the pipe is already closed/dead (e.g. the child
875
+ // already exited and the read loop got there first).
876
+ try {
877
+ proc.stdin.end();
878
+ } catch {
879
+ // Already closed/dead.
880
+ }
881
+
882
+ // 2-3. Group-aware SIGTERM (when this transport actually spawned
883
+ // detached), bounded wait, then escalate to SIGKILL. See
884
+ // `terminateStdioProcess` for the exact signaling/escalation rules.
885
+ await terminateStdioProcess(proc, this.#detached);
655
886
  }
656
887
 
657
888
  if (this.#readLoop) {