@oh-my-pi/pi-coding-agent 17.1.5 → 17.1.6

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 (207) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/dist/{CHANGELOG-qs3vd6xf.md → CHANGELOG-x9zt79k8.md} +31 -0
  3. package/dist/cli.js +2989 -2978
  4. package/dist/types/capability/index.d.ts +1 -1
  5. package/dist/types/capability/types.d.ts +23 -1
  6. package/dist/types/config/settings-schema.d.ts +23 -1
  7. package/dist/types/internal-urls/mcp-protocol.d.ts +3 -2
  8. package/dist/types/internal-urls/parse.d.ts +12 -0
  9. package/dist/types/internal-urls/router.d.ts +6 -0
  10. package/dist/types/internal-urls/types.d.ts +6 -0
  11. package/dist/types/lsp/config.d.ts +1 -0
  12. package/dist/types/lsp/types.d.ts +2 -0
  13. package/dist/types/mcp/manager.d.ts +5 -0
  14. package/dist/types/mcp/tool-bridge.d.ts +13 -0
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +1 -1
  16. package/dist/types/modes/rpc/rpc-mode.d.ts +2 -0
  17. package/dist/types/sdk.d.ts +3 -1
  18. package/dist/types/session/agent-session-types.d.ts +3 -1
  19. package/dist/types/session/agent-session.d.ts +2 -0
  20. package/dist/types/session/model-controls.d.ts +3 -0
  21. package/dist/types/session/session-advisors.d.ts +7 -1
  22. package/dist/types/session/tool-choice-queue.d.ts +6 -4
  23. package/dist/types/session/turn-recovery.d.ts +4 -2
  24. package/dist/types/task/index.d.ts +1 -1
  25. package/dist/types/task/types.d.ts +3 -11
  26. package/dist/types/thinking.d.ts +21 -2
  27. package/dist/types/utils/title-generator.d.ts +17 -16
  28. package/package.json +12 -12
  29. package/src/capability/index.ts +43 -12
  30. package/src/capability/mcp.ts +21 -0
  31. package/src/capability/types.ts +20 -1
  32. package/src/cli/read-cli.ts +44 -2
  33. package/src/config/settings-schema.ts +27 -1
  34. package/src/eval/py/runner.py +16 -2
  35. package/src/extensibility/extensions/runner.ts +91 -5
  36. package/src/extensibility/extensions/types.ts +0 -1
  37. package/src/extensibility/hooks/types.ts +0 -1
  38. package/src/internal-urls/mcp-protocol.ts +17 -3
  39. package/src/internal-urls/parse.ts +31 -0
  40. package/src/internal-urls/router.ts +24 -4
  41. package/src/internal-urls/types.ts +6 -0
  42. package/src/live/transport.ts +2 -2
  43. package/src/lsp/client.ts +2 -2
  44. package/src/lsp/config.ts +4 -0
  45. package/src/lsp/types.ts +2 -0
  46. package/src/mcp/config.ts +26 -14
  47. package/src/mcp/manager.ts +26 -9
  48. package/src/mcp/tool-bridge.ts +52 -1
  49. package/src/memories/index.ts +25 -6
  50. package/src/modes/components/status-line/segments.ts +3 -1
  51. package/src/modes/controllers/command-controller.ts +10 -10
  52. package/src/modes/controllers/extension-ui-controller.ts +3 -3
  53. package/src/modes/rpc/rpc-mode.ts +60 -47
  54. package/src/prompts/steering/user-interjection.md +2 -5
  55. package/src/prompts/tools/task.md +4 -2
  56. package/src/sdk.ts +17 -8
  57. package/src/session/agent-session-types.ts +3 -0
  58. package/src/session/agent-session.ts +21 -4
  59. package/src/session/model-controls.ts +43 -7
  60. package/src/session/session-advisors.ts +30 -14
  61. package/src/session/session-tools.ts +4 -2
  62. package/src/session/tool-choice-queue.ts +19 -4
  63. package/src/session/turn-recovery.ts +21 -2
  64. package/src/task/executor.ts +11 -3
  65. package/src/task/index.ts +43 -32
  66. package/src/task/types.ts +12 -17
  67. package/src/thinking.ts +68 -5
  68. package/src/tools/read.ts +2 -2
  69. package/src/utils/title-generator.ts +88 -34
  70. package/dist/types/advisor/__tests__/advisor.test.d.ts +0 -1
  71. package/dist/types/advisor/__tests__/config.test.d.ts +0 -1
  72. package/dist/types/advisor/__tests__/emission-guard.test.d.ts +0 -1
  73. package/dist/types/cli/__tests__/auth-gateway-catalog.test.d.ts +0 -1
  74. package/dist/types/cli/update-cli.test.d.ts +0 -1
  75. package/dist/types/config/__tests__/model-registry.test.d.ts +0 -1
  76. package/dist/types/eval/__tests__/agent-bridge.test.d.ts +0 -1
  77. package/dist/types/eval/__tests__/bridge-timeout.test.d.ts +0 -1
  78. package/dist/types/eval/__tests__/budget-bridge.test.d.ts +0 -1
  79. package/dist/types/eval/__tests__/completion-bridge.test.d.ts +0 -1
  80. package/dist/types/eval/__tests__/helpers-local-roots.test.d.ts +0 -1
  81. package/dist/types/eval/__tests__/idle-timeout.test.d.ts +0 -1
  82. package/dist/types/eval/__tests__/js-context-manager.test.d.ts +0 -1
  83. package/dist/types/eval/__tests__/julia-prelude.test.d.ts +0 -1
  84. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +0 -1
  85. package/dist/types/eval/__tests__/prelude-agent.test.d.ts +0 -1
  86. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +0 -1
  87. package/dist/types/eval/py/__tests__/prelude.test.d.ts +0 -1
  88. package/dist/types/eval/py/__tests__/runner-shell-output.test.d.ts +0 -1
  89. package/dist/types/hindsight/client.test.d.ts +0 -1
  90. package/dist/types/internal-urls/__tests__/agent-protocol-nested.test.d.ts +0 -1
  91. package/dist/types/internal-urls/__tests__/ssh-protocol.test.d.ts +0 -1
  92. package/dist/types/launch/broker-list-order.test.d.ts +0 -1
  93. package/dist/types/launch/broker-output-snapshot.test.d.ts +0 -1
  94. package/dist/types/launch/protocol.test.d.ts +0 -1
  95. package/dist/types/launch/spawn-options.test.d.ts +0 -1
  96. package/dist/types/launch/terminal-output.test.d.ts +0 -1
  97. package/dist/types/live/protocol.test.d.ts +0 -1
  98. package/dist/types/mcp/config-writer.test.d.ts +0 -1
  99. package/dist/types/mcp/smithery-auth.test.d.ts +0 -1
  100. package/dist/types/mcp/smithery-registry.test.d.ts +0 -1
  101. package/dist/types/mcp/transports/stdio.test.d.ts +0 -1
  102. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +0 -1
  103. package/dist/types/modes/components/__tests__/move-overlay.test.d.ts +0 -1
  104. package/dist/types/modes/components/__tests__/pause-screen.test.d.ts +0 -1
  105. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +0 -1
  106. package/dist/types/modes/components/custom-editor-plugin-ctor.test.d.ts +0 -1
  107. package/dist/types/modes/components/custom-editor.test.d.ts +0 -1
  108. package/dist/types/modes/components/login-dialog.test.d.ts +0 -1
  109. package/dist/types/modes/components/status-line/component.jj-cache.test.d.ts +0 -1
  110. package/dist/types/modes/components/status-line/component.test.d.ts +0 -1
  111. package/dist/types/modes/components/tool-execution.test.d.ts +0 -1
  112. package/dist/types/modes/controllers/extension-ui-controller.test.d.ts +0 -1
  113. package/dist/types/modes/noninteractive-dispose.test.d.ts +0 -1
  114. package/dist/types/modes/print-mode.test.d.ts +0 -1
  115. package/dist/types/modes/session-teardown.test.d.ts +0 -1
  116. package/dist/types/modes/theme/mermaid-rendering.test.d.ts +0 -1
  117. package/dist/types/modes/utils/transcript-render-helpers.test.d.ts +0 -1
  118. package/dist/types/modes/warp-events.test.d.ts +0 -1
  119. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +0 -1
  120. package/dist/types/plan-mode/model-transition.test.d.ts +0 -1
  121. package/dist/types/plan-mode/reentry-prompt.test.d.ts +0 -1
  122. package/dist/types/session/agent-session-error-log.test.d.ts +0 -1
  123. package/dist/types/session/blob-store.test.d.ts +0 -1
  124. package/dist/types/session/messages.test.d.ts +0 -1
  125. package/dist/types/session/session-context.test.d.ts +0 -1
  126. package/dist/types/ssh/__tests__/connection-manager-args.test.d.ts +0 -1
  127. package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +0 -1
  128. package/dist/types/ssh/__tests__/file-transfer-posix-guard.test.d.ts +0 -1
  129. package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +0 -1
  130. package/dist/types/system-prompt.test.d.ts +0 -1
  131. package/dist/types/task/render.test.d.ts +0 -1
  132. package/dist/types/task/spawn-policy.test.d.ts +0 -1
  133. package/dist/types/tools/__tests__/eval-description.test.d.ts +0 -1
  134. package/dist/types/tools/__tests__/glob.test.d.ts +0 -1
  135. package/dist/types/tools/__tests__/json-tree.test.d.ts +0 -1
  136. package/dist/types/tools/__tests__/vibe-render.test.d.ts +0 -1
  137. package/dist/types/tools/hub/launch-compat.test.d.ts +0 -1
  138. package/dist/types/vibe/__tests__/token-rate.test.d.ts +0 -1
  139. package/src/advisor/__tests__/advisor.test.ts +0 -4889
  140. package/src/advisor/__tests__/config.test.ts +0 -349
  141. package/src/advisor/__tests__/emission-guard.test.ts +0 -147
  142. package/src/cli/__tests__/auth-gateway-catalog.test.ts +0 -111
  143. package/src/cli/update-cli.test.ts +0 -28
  144. package/src/config/__tests__/model-registry.test.ts +0 -182
  145. package/src/eval/__tests__/agent-bridge.test.ts +0 -1509
  146. package/src/eval/__tests__/bridge-timeout.test.ts +0 -170
  147. package/src/eval/__tests__/budget-bridge.test.ts +0 -80
  148. package/src/eval/__tests__/completion-bridge.test.ts +0 -412
  149. package/src/eval/__tests__/helpers-local-roots.test.ts +0 -55
  150. package/src/eval/__tests__/idle-timeout.test.ts +0 -80
  151. package/src/eval/__tests__/js-context-manager.test.ts +0 -456
  152. package/src/eval/__tests__/julia-prelude.test.ts +0 -66
  153. package/src/eval/__tests__/kernel-spawn.test.ts +0 -115
  154. package/src/eval/__tests__/prelude-agent.test.ts +0 -156
  155. package/src/eval/__tests__/process-entry-import.test.ts +0 -137
  156. package/src/eval/py/__tests__/prelude.test.ts +0 -104
  157. package/src/eval/py/__tests__/runner-shell-output.test.ts +0 -157
  158. package/src/hindsight/client.test.ts +0 -75
  159. package/src/internal-urls/__tests__/agent-protocol-nested.test.ts +0 -141
  160. package/src/internal-urls/__tests__/ssh-protocol.test.ts +0 -331
  161. package/src/launch/broker-list-order.test.ts +0 -89
  162. package/src/launch/broker-output-snapshot.test.ts +0 -126
  163. package/src/launch/protocol.test.ts +0 -59
  164. package/src/launch/spawn-options.test.ts +0 -31
  165. package/src/launch/terminal-output.test.ts +0 -107
  166. package/src/live/protocol.test.ts +0 -140
  167. package/src/mcp/config-writer.test.ts +0 -43
  168. package/src/mcp/smithery-auth.test.ts +0 -29
  169. package/src/mcp/smithery-registry.test.ts +0 -51
  170. package/src/mcp/transports/stdio.test.ts +0 -427
  171. package/src/modes/components/__tests__/dynamic-border.test.ts +0 -55
  172. package/src/modes/components/__tests__/move-overlay.test.ts +0 -252
  173. package/src/modes/components/__tests__/pause-screen.test.ts +0 -143
  174. package/src/modes/components/__tests__/skill-message.test.ts +0 -94
  175. package/src/modes/components/custom-editor-plugin-ctor.test.ts +0 -36
  176. package/src/modes/components/custom-editor.test.ts +0 -510
  177. package/src/modes/components/login-dialog.test.ts +0 -56
  178. package/src/modes/components/status-line/component.jj-cache.test.ts +0 -229
  179. package/src/modes/components/status-line/component.test.ts +0 -84
  180. package/src/modes/components/tool-execution.test.ts +0 -162
  181. package/src/modes/controllers/extension-ui-controller.test.ts +0 -250
  182. package/src/modes/noninteractive-dispose.test.ts +0 -73
  183. package/src/modes/print-mode.test.ts +0 -71
  184. package/src/modes/session-teardown.test.ts +0 -219
  185. package/src/modes/theme/mermaid-rendering.test.ts +0 -53
  186. package/src/modes/utils/transcript-render-helpers.test.ts +0 -38
  187. package/src/modes/warp-events.test.ts +0 -794
  188. package/src/plan-mode/approved-plan-prompt.test.ts +0 -36
  189. package/src/plan-mode/model-transition.test.ts +0 -60
  190. package/src/plan-mode/reentry-prompt.test.ts +0 -41
  191. package/src/session/agent-session-error-log.test.ts +0 -59
  192. package/src/session/blob-store.test.ts +0 -56
  193. package/src/session/messages.test.ts +0 -282
  194. package/src/session/session-context.test.ts +0 -384
  195. package/src/ssh/__tests__/connection-manager-args.test.ts +0 -191
  196. package/src/ssh/__tests__/connection-manager-timeout.test.ts +0 -61
  197. package/src/ssh/__tests__/file-transfer-posix-guard.test.ts +0 -105
  198. package/src/ssh/__tests__/sshfs-mount.test.ts +0 -13
  199. package/src/system-prompt.test.ts +0 -236
  200. package/src/task/render.test.ts +0 -290
  201. package/src/task/spawn-policy.test.ts +0 -62
  202. package/src/tools/__tests__/eval-description.test.ts +0 -18
  203. package/src/tools/__tests__/glob.test.ts +0 -37
  204. package/src/tools/__tests__/json-tree.test.ts +0 -35
  205. package/src/tools/__tests__/vibe-render.test.ts +0 -210
  206. package/src/tools/hub/launch-compat.test.ts +0 -40
  207. package/src/vibe/__tests__/token-rate.test.ts +0 -96
@@ -1,427 +0,0 @@
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";
5
-
6
- import { resolveStdioSpawnCommand, StdioTransport, terminateStdioProcess } from "./stdio";
7
-
8
- describe("resolveStdioSpawnCommand", () => {
9
- it("hides Windows executable MCP servers when the host has no console", async () => {
10
- // Hidden so a console-app child does not allocate a visible window when
11
- // OMP is launched without a terminal console (#3536).
12
- await expect(
13
- resolveStdioSpawnCommand(
14
- { command: "server.exe", args: ["--stdio"] },
15
- { cwd: process.cwd(), env: {}, platform: "win32", hostHasInheritableConsole: false },
16
- ),
17
- ).resolves.toEqual({
18
- cmd: ["server.exe", "--stdio"],
19
- windowsHide: true,
20
- detached: false,
21
- });
22
- });
23
-
24
- it("inherits an attached Windows console instead of forcing CREATE_NO_WINDOW", async () => {
25
- await expect(
26
- resolveStdioSpawnCommand(
27
- { command: "server.exe", args: ["--stdio"] },
28
- { cwd: process.cwd(), env: {}, platform: "win32", hostHasInheritableConsole: true },
29
- ),
30
- ).resolves.toEqual({
31
- cmd: ["server.exe", "--stdio"],
32
- windowsHide: false,
33
- detached: false,
34
- });
35
- });
36
-
37
- it("keeps Darwin stdio MCP servers attached so TCC Apple Events prompts can resolve", async () => {
38
- await expect(
39
- resolveStdioSpawnCommand(
40
- { command: "xcrun", args: ["mcpbridge"] },
41
- { cwd: process.cwd(), env: {}, platform: "darwin" },
42
- ),
43
- ).resolves.toEqual({
44
- cmd: ["xcrun", "mcpbridge"],
45
- detached: false,
46
- });
47
- });
48
-
49
- it("detaches off-Windows MCP servers so terminal job-control signals cannot stop them", async () => {
50
- await expect(
51
- resolveStdioSpawnCommand(
52
- { command: "server.exe", args: ["--stdio"] },
53
- { cwd: process.cwd(), env: {}, platform: "linux" },
54
- ),
55
- ).resolves.toEqual({
56
- cmd: ["server.exe", "--stdio"],
57
- detached: true,
58
- });
59
- });
60
- });
61
-
62
- describe("StdioTransport.connect", () => {
63
- it("passes argv as Bun.spawn's first argument and process options as the second", async () => {
64
- const cwd = process.cwd();
65
- const envValue = "stdio-spawn-shape";
66
- const argv = [process.execPath, "-e", "process.exit(0)"];
67
- const transport = new StdioTransport({
68
- command: argv[0],
69
- args: argv.slice(1),
70
- cwd,
71
- env: {
72
- OMP_STDIO_SPAWN_SHAPE: envValue,
73
- },
74
- });
75
- const spawnSpy = spyOn(Bun, "spawn");
76
-
77
- try {
78
- await transport.connect();
79
-
80
- expect(spawnSpy).toHaveBeenCalledTimes(1);
81
- const call = spawnSpy.mock.calls[0];
82
- if (!call) throw new Error("expected StdioTransport.connect() to spawn exactly one subprocess");
83
-
84
- const [spawnArgv, spawnOptions] = call;
85
- expect(spawnArgv).toEqual(argv);
86
- expect(spawnOptions).toEqual(
87
- expect.objectContaining({
88
- cwd,
89
- detached: !(process.platform === "darwin" || process.platform === "win32"),
90
- env: expect.objectContaining({
91
- OMP_STDIO_SPAWN_SHAPE: envValue,
92
- }),
93
- stderr: "pipe",
94
- stdin: "pipe",
95
- stdout: "pipe",
96
- windowsHide: process.platform === "win32" ? expect.any(Boolean) : undefined,
97
- }),
98
- );
99
- } finally {
100
- await transport.close();
101
- spawnSpy.mockRestore();
102
- }
103
- });
104
- });
105
-
106
- // Regression for #3945: request() awaited stdin.write/flush, so a child that
107
- // stops draining stdin would park the async fn past the timeout timer and past
108
- // `return promise`, orphaning the deferred rejection and hanging the caller
109
- // forever. `sleep` is POSIX-only, so the check is scoped to non-Windows hosts.
110
- describe.skipIf(process.platform === "win32")("StdioTransport request write stall", () => {
111
- it("rejects with the timeout error when the child never drains stdin", async () => {
112
- const timeoutMs = 400;
113
- const orphaned: Error[] = [];
114
- const captureOrphan = (reason: unknown) => {
115
- if (reason instanceof Error) orphaned.push(reason);
116
- };
117
- process.on("unhandledRejection", captureOrphan);
118
-
119
- // `sleep 60` accepts a stdin pipe but never reads it; a 1 MB payload
120
- // overruns the OS pipe buffer plus any FileSink JS-side buffering, so
121
- // Bun's write() returns a Promise that only settles if the child reads.
122
- const transport = new StdioTransport({
123
- command: "sleep",
124
- args: ["60"],
125
- timeout: timeoutMs,
126
- });
127
-
128
- try {
129
- await transport.connect();
130
-
131
- const bigParam = "x".repeat(1024 * 1024);
132
- const started = performance.now();
133
- const outcome = await transport.request("tools/call", { name: "noop", arguments: { blob: bigParam } }).then(
134
- () => ({ kind: "resolved" as const }),
135
- (error: unknown) => ({ kind: "rejected" as const, error }),
136
- );
137
- const elapsedMs = performance.now() - started;
138
-
139
- expect(outcome.kind).toBe("rejected");
140
- if (outcome.kind !== "rejected") return;
141
- if (!(outcome.error instanceof Error)) {
142
- throw new Error(`expected Error rejection, got ${String(outcome.error)}`);
143
- }
144
- expect(outcome.error.message).toContain(`Request timeout after ${timeoutMs}ms`);
145
- // Generous ceiling: bare rejection latency plus room for slow CI. The
146
- // pre-fix behavior was an unbounded hang, not a slightly-late reject.
147
- expect(elapsedMs).toBeLessThan(timeoutMs + 1500);
148
- expect(orphaned).toEqual([]);
149
- } finally {
150
- process.off("unhandledRejection", captureOrphan);
151
- await transport.close();
152
- }
153
- }, 8000);
154
- });
155
-
156
- // `kill(pid, 0)` succeeds for a zombie too: a grandchild whose parent (the
157
- // killed leader) is gone sits as <defunct> until whatever reaps orphans
158
- // (init/subreaper) gets around to it — which can lag on some hosts. A
159
- // zombie already received and honored the group SIGKILL; it is just not
160
- // harvested yet, so treating it as "still alive" would make the group-kill
161
- // assertions below flaky rather than testing what they claim to test.
162
- function processExists(pid: number): boolean {
163
- try {
164
- process.kill(pid, 0);
165
- } catch {
166
- return false;
167
- }
168
- const result = Bun.spawnSync(["ps", "-o", "stat=", "-p", String(pid)]);
169
- const state = result.stdout.toString().trim();
170
- return result.exitCode === 0 && state.length > 0 && !state.startsWith("Z");
171
- }
172
-
173
- // Regression for #5578: `close()` used a bare `this.#process.kill()` (direct
174
- // SIGTERM, no wait, no escalate, no process-group signal), so a detached
175
- // session-leader child (or a grandchild it spawned) that ignores/traps
176
- // SIGTERM survived host exit and became an orphan pinned to PID 1 on Linux.
177
- // `sleep`/`bun`/POSIX signal semantics are exercised directly here rather
178
- // than through `StdioTransport.connect()`, because `connect()` derives
179
- // `detached` from `resolveStdioSpawnCommand()`, which is tied to the host's
180
- // real `process.platform` — a POSIX detached session cannot be reproduced
181
- // end-to-end through `connect()` on a non-Linux dev/CI host, but a real
182
- // detached process group can still be spawned directly on any POSIX host.
183
- describe.skipIf(process.platform === "win32")("terminateStdioProcess", () => {
184
- it("escalates a detached child that traps SIGTERM to SIGKILL", async () => {
185
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-kill-solo-"));
186
- const scriptPath = path.join(tempDir, "child.mjs");
187
- const readyPath = path.join(tempDir, "ready");
188
- await fs.writeFile(
189
- scriptPath,
190
- [
191
- "import { writeFileSync } from 'node:fs';",
192
- "process.on('SIGTERM', () => {});",
193
- `writeFileSync(${JSON.stringify(readyPath)}, '1');`,
194
- "setInterval(() => {}, 60_000);",
195
- ].join("\n"),
196
- );
197
- const proc = Bun.spawn(["bun", "run", scriptPath], {
198
- stdin: "ignore",
199
- stdout: "ignore",
200
- stderr: "ignore",
201
- detached: true,
202
- });
203
- try {
204
- // Wait for the child to actually register its SIGTERM handler before
205
- // signaling it: signaling too early races the child's startup and
206
- // hits the default (terminate) action instead of exercising the trap.
207
- for (let i = 0; i < 100; i++) {
208
- try {
209
- await fs.access(readyPath);
210
- break;
211
- } catch {
212
- await Bun.sleep(20);
213
- }
214
- }
215
-
216
- const started = performance.now();
217
- await terminateStdioProcess(proc, true);
218
- await proc.exited;
219
- const elapsedMs = performance.now() - started;
220
-
221
- expect(proc.signalCode).toBe("SIGKILL");
222
- // Escalation only fires after the ~1s SIGTERM grace window elapses —
223
- // a too-fast exit would mean SIGKILL fired without waiting.
224
- expect(elapsedMs).toBeGreaterThanOrEqual(900);
225
- } finally {
226
- try {
227
- process.kill(-proc.pid, "SIGKILL");
228
- } catch {
229
- // Already gone.
230
- }
231
- await fs.rm(tempDir, { recursive: true, force: true });
232
- }
233
- }, 5000);
234
-
235
- it("reaches a SIGTERM-trapping grandchild through the group SIGKILL, not just the direct child", async () => {
236
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-group-kill-"));
237
- const grandchildScriptPath = path.join(tempDir, "grandchild.mjs");
238
- const parentScriptPath = path.join(tempDir, "parent.mjs");
239
- const grandchildPidPath = path.join(tempDir, "grandchild.pid");
240
-
241
- // Grandchild also traps SIGTERM, so only an unrestricted SIGKILL to the
242
- // whole group — not a signal to the direct (parent) child alone — can
243
- // reach and stop it.
244
- await fs.writeFile(
245
- grandchildScriptPath,
246
- [
247
- "import { writeFileSync } from 'node:fs';",
248
- "process.on('SIGTERM', () => {});",
249
- `writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
250
- "setInterval(() => {}, 60_000);",
251
- ].join("\n"),
252
- );
253
- await fs.writeFile(
254
- parentScriptPath,
255
- [
256
- "process.on('SIGTERM', () => {});",
257
- `Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`,
258
- "setInterval(() => {}, 60_000);",
259
- ].join("\n"),
260
- );
261
-
262
- const proc = Bun.spawn(["bun", "run", parentScriptPath], {
263
- stdin: "ignore",
264
- stdout: "ignore",
265
- stderr: "ignore",
266
- detached: true,
267
- });
268
-
269
- try {
270
- // Polls real wall-clock time rather than an event/promise: the
271
- // grandchild process is a real external OS process writing to a real
272
- // file, with no in-process signal this test can `await` directly.
273
- let grandchildPid: number | undefined;
274
- for (let i = 0; i < 100 && grandchildPid === undefined; i++) {
275
- try {
276
- grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
277
- } catch {
278
- await Bun.sleep(20);
279
- }
280
- }
281
- if (grandchildPid === undefined) throw new Error("grandchild never reported its pid");
282
- expect(processExists(grandchildPid)).toBe(true);
283
-
284
- await terminateStdioProcess(proc, true);
285
- await proc.exited;
286
- expect(proc.signalCode).toBe("SIGKILL");
287
-
288
- // The group SIGKILL is delivered to every member simultaneously, but
289
- // give the kernel a brief window to finish reaping before asserting.
290
- let grandchildAlive = processExists(grandchildPid);
291
- for (let i = 0; i < 25 && grandchildAlive; i++) {
292
- await Bun.sleep(20);
293
- grandchildAlive = processExists(grandchildPid);
294
- }
295
- expect(grandchildAlive).toBe(false);
296
- } finally {
297
- try {
298
- process.kill(-proc.pid, "SIGKILL");
299
- } catch {
300
- // Already gone.
301
- }
302
- await fs.rm(tempDir, { recursive: true, force: true });
303
- }
304
- }, 8000);
305
-
306
- it("still reaps a SIGTERM-trapping grandchild after the detached leader exits cooperatively", async () => {
307
- // Regression: the leader exiting within the SIGTERM grace window used to
308
- // be treated as proof the whole process group was gone, so `close()`
309
- // returned early and never delivered a group SIGKILL — leaving exactly
310
- // the orphaned grandchild this change is meant to reap. Unlike the
311
- // group-SIGKILL test above, the leader here does NOT trap SIGTERM, so
312
- // it exits promptly on its own; only the grandchild ignores signals.
313
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-stdio-leader-exit-group-kill-"));
314
- const grandchildScriptPath = path.join(tempDir, "grandchild.mjs");
315
- const parentScriptPath = path.join(tempDir, "parent.mjs");
316
- const grandchildPidPath = path.join(tempDir, "grandchild.pid");
317
-
318
- await fs.writeFile(
319
- grandchildScriptPath,
320
- [
321
- "import { writeFileSync } from 'node:fs';",
322
- "process.on('SIGTERM', () => {});",
323
- `writeFileSync(${JSON.stringify(grandchildPidPath)}, String(process.pid));`,
324
- "setInterval(() => {}, 60_000);",
325
- ].join("\n"),
326
- );
327
- // No SIGTERM handler here: the default action (terminate) fires as soon
328
- // as the group SIGTERM lands, well inside TERM_GRACE_MS.
329
- await fs.writeFile(
330
- parentScriptPath,
331
- [
332
- `Bun.spawn(["bun", "run", ${JSON.stringify(grandchildScriptPath)}], { stdout: "ignore", stderr: "ignore", stdin: "ignore" });`,
333
- "setInterval(() => {}, 60_000);",
334
- ].join("\n"),
335
- );
336
-
337
- const proc = Bun.spawn(["bun", "run", parentScriptPath], {
338
- stdin: "ignore",
339
- stdout: "ignore",
340
- stderr: "ignore",
341
- detached: true,
342
- });
343
-
344
- try {
345
- let grandchildPid: number | undefined;
346
- for (let i = 0; i < 100 && grandchildPid === undefined; i++) {
347
- try {
348
- grandchildPid = Number.parseInt(await fs.readFile(grandchildPidPath, "utf8"), 10);
349
- } catch {
350
- await Bun.sleep(20);
351
- }
352
- }
353
- if (grandchildPid === undefined) throw new Error("grandchild never reported its pid");
354
- expect(processExists(grandchildPid)).toBe(true);
355
-
356
- const started = performance.now();
357
- await terminateStdioProcess(proc, true);
358
- await proc.exited;
359
- const elapsedMs = performance.now() - started;
360
-
361
- // The leader exits on the initial SIGTERM (no trap), so this must not
362
- // block for the ~1s TERM grace window before sweeping the group.
363
- expect(elapsedMs).toBeLessThan(700);
364
-
365
- let grandchildAlive = processExists(grandchildPid);
366
- for (let i = 0; i < 25 && grandchildAlive; i++) {
367
- await Bun.sleep(20);
368
- grandchildAlive = processExists(grandchildPid);
369
- }
370
- expect(grandchildAlive).toBe(false);
371
- } finally {
372
- try {
373
- process.kill(-proc.pid, "SIGKILL");
374
- } catch {
375
- // Already gone.
376
- }
377
- await fs.rm(tempDir, { recursive: true, force: true });
378
- }
379
- }, 8000);
380
-
381
- it("never attempts a process-group signal when the transport did not spawn detached", async () => {
382
- const proc = Bun.spawn(["bun", "-e", "await Bun.sleep(60_000)"], {
383
- stdin: "ignore",
384
- stdout: "ignore",
385
- stderr: "ignore",
386
- detached: false,
387
- });
388
- const killSpy = spyOn(process, "kill");
389
- try {
390
- await terminateStdioProcess(proc, false);
391
- await proc.exited;
392
-
393
- // Only the direct-child `Subprocess.kill()` path may run; the global
394
- // `process.kill()` (used exclusively for the negative-pid group
395
- // signal) must never be reached.
396
- expect(killSpy).not.toHaveBeenCalled();
397
- } finally {
398
- killSpy.mockRestore();
399
- try {
400
- proc.kill("SIGKILL");
401
- } catch {
402
- // Already gone.
403
- }
404
- }
405
- }, 5000);
406
- });
407
-
408
- describe.skipIf(process.platform === "win32")("StdioTransport.close teardown", () => {
409
- it("closes a well-behaved child promptly without escalating to SIGKILL", async () => {
410
- const transport = new StdioTransport({
411
- command: "bun",
412
- args: ["-e", "await Bun.sleep(60_000)"],
413
- });
414
- try {
415
- await transport.connect();
416
- const started = performance.now();
417
- await transport.close();
418
- const elapsedMs = performance.now() - started;
419
-
420
- // No SIGTERM trap => the child dies almost immediately; close() must
421
- // not block for the full ~1s TERM grace window before returning.
422
- expect(elapsedMs).toBeLessThan(700);
423
- } finally {
424
- await transport.close();
425
- }
426
- }, 5000);
427
- });
@@ -1,55 +0,0 @@
1
- import { describe, expect, it } 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";
5
- import { getThemeByName, setThemeInstance, theme } from "../../theme/theme";
6
- import { DynamicBorder } from "../dynamic-border";
7
-
8
- const componentPath = path.resolve(import.meta.dir, "../dynamic-border.ts");
9
-
10
- describe("DynamicBorder", () => {
11
- // Regression for #5366: extensions importing legacy pi UI components get a
12
- // second `src` module graph whose module-level `theme` is never assigned by
13
- // host startup. `render()` must degrade to plain glyphs instead of throwing
14
- // "undefined is not an object (evaluating 'theme.boxRound')" and killing the
15
- // TUI. Other test files in the same process initialize the theme module, so
16
- // the uninitialized state is only reachable in a fresh module graph — a
17
- // subprocess reproduces the actual reported scenario deterministically.
18
- it("renders plain glyphs when the module-level theme is uninitialized", async () => {
19
- const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-dynamic-border-"));
20
- try {
21
- const script = [
22
- `import { DynamicBorder } from ${JSON.stringify(componentPath)};`,
23
- 'const custom = new DynamicBorder(str => "<" + str + ">");',
24
- `if (JSON.stringify(custom.render(4)) !== JSON.stringify(["<────>"])) process.exit(1);`,
25
- "const plain = new DynamicBorder();",
26
- `if (JSON.stringify(plain.render(3)) !== JSON.stringify(["───"])) process.exit(2);`,
27
- "",
28
- ].join("\n");
29
- const file = path.join(dir, "uninitialized-theme.ts");
30
- await Bun.write(file, script);
31
- const proc = Bun.spawn(["bun", file], { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
32
- const [exitCode, stderr] = await Promise.all([
33
- proc.exited,
34
- new Response(proc.stderr as ReadableStream<Uint8Array>).text(),
35
- ]);
36
- expect(stderr).toBe("");
37
- expect(exitCode).toBe(0);
38
- } finally {
39
- await fs.rm(dir, { recursive: true, force: true });
40
- }
41
- });
42
-
43
- it("paints with theme.boxRound.horizontal once the theme is initialized", async () => {
44
- const previous = theme;
45
- const loaded = await getThemeByName("dark");
46
- if (!loaded) throw new Error("theme unavailable");
47
- setThemeInstance(loaded);
48
- try {
49
- const border = new DynamicBorder(str => `<${str}>`);
50
- expect(border.render(3)).toEqual([`<${loaded.boxRound.horizontal.repeat(3)}>`]);
51
- } finally {
52
- if (previous) setThemeInstance(previous);
53
- }
54
- });
55
- });