@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.2

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 (198) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/dist/cli.js +3556 -3504
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +10 -0
  14. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  15. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  16. package/dist/types/eval/backend.d.ts +3 -3
  17. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  18. package/dist/types/eval/js/context-manager.d.ts +5 -3
  19. package/dist/types/eval/js/process-entry.d.ts +6 -0
  20. package/dist/types/eval/js/worker-core.d.ts +15 -1
  21. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  22. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  23. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  24. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  25. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  26. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  27. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  28. package/dist/types/goals/guided-setup.d.ts +12 -0
  29. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  30. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  31. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  32. package/dist/types/main.d.ts +1 -0
  33. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  34. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  35. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  36. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  37. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  38. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  39. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  40. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  41. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  42. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  43. package/dist/types/modes/interactive-mode.d.ts +1 -0
  44. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  45. package/dist/types/modes/orchestrate.d.ts +1 -1
  46. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  47. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  48. package/dist/types/modes/types.d.ts +1 -0
  49. package/dist/types/modes/ultrathink.d.ts +1 -1
  50. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  51. package/dist/types/modes/workflow.d.ts +1 -1
  52. package/dist/types/session/agent-session.d.ts +6 -0
  53. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  54. package/dist/types/session/messages.d.ts +15 -0
  55. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  56. package/dist/types/subprocess/worker-client.d.ts +6 -0
  57. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  58. package/dist/types/tools/grep.d.ts +0 -2
  59. package/dist/types/tools/read.d.ts +0 -4
  60. package/dist/types/web/search/provider.d.ts +10 -3
  61. package/dist/types/web/search/providers/codex.d.ts +5 -4
  62. package/package.json +12 -12
  63. package/src/advisor/__tests__/advisor.test.ts +917 -42
  64. package/src/advisor/advise-tool.ts +17 -1
  65. package/src/advisor/runtime.ts +270 -67
  66. package/src/autolearn/controller.ts +15 -3
  67. package/src/autoresearch/dashboard.ts +2 -2
  68. package/src/cli/args.ts +12 -0
  69. package/src/cli/auth-broker-cli.ts +30 -11
  70. package/src/cli/auth-gateway-cli.ts +5 -1
  71. package/src/cli/config-cli.ts +15 -3
  72. package/src/cli/dry-balance-cli.ts +14 -4
  73. package/src/cli/flag-tables.ts +21 -7
  74. package/src/cli/update-cli.ts +62 -11
  75. package/src/cli/usage-cli.ts +58 -5
  76. package/src/cli/usage-error.ts +7 -0
  77. package/src/cli.ts +23 -1
  78. package/src/commands/acp.ts +11 -2
  79. package/src/commands/launch.ts +12 -3
  80. package/src/commands/token.ts +3 -1
  81. package/src/config/api-key-resolver.ts +12 -3
  82. package/src/config/config-file.ts +30 -12
  83. package/src/config/model-registry.ts +7 -7
  84. package/src/config/model-resolver.ts +21 -7
  85. package/src/config/models-config.ts +1 -1
  86. package/src/config/settings-schema.ts +10 -0
  87. package/src/cursor.ts +2 -0
  88. package/src/discovery/claude-plugins.ts +9 -3
  89. package/src/discovery/omp-plugins.ts +6 -2
  90. package/src/discovery/substitute-plugin-root.ts +32 -0
  91. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  92. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  93. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  94. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  95. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  96. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  97. package/src/eval/agent-bridge.ts +121 -116
  98. package/src/eval/backend.ts +3 -3
  99. package/src/eval/bridge-timeout.ts +20 -2
  100. package/src/eval/executor-base.ts +85 -7
  101. package/src/eval/jl/kernel.ts +2 -1
  102. package/src/eval/js/context-manager.ts +109 -32
  103. package/src/eval/js/process-entry.ts +27 -0
  104. package/src/eval/js/shared/runtime.ts +1 -1
  105. package/src/eval/js/worker-core.ts +70 -9
  106. package/src/eval/js/worker-entry.ts +1 -1
  107. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  108. package/src/eval/py/kernel.ts +2 -1
  109. package/src/eval/py/prelude.py +28 -1
  110. package/src/eval/py/spawn-options.ts +13 -0
  111. package/src/eval/py/tool-bridge.ts +13 -14
  112. package/src/eval/rb/kernel.ts +2 -1
  113. package/src/exec/bash-executor.ts +30 -43
  114. package/src/extensibility/custom-tools/types.ts +3 -0
  115. package/src/extensibility/extensions/runner.ts +3 -0
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/extensions/wrapper.ts +18 -18
  118. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  119. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  120. package/src/extensibility/plugins/manager.ts +28 -7
  121. package/src/goals/guided-setup.ts +29 -1
  122. package/src/internal-urls/history-protocol.ts +95 -15
  123. package/src/internal-urls/memory-protocol.ts +13 -9
  124. package/src/internal-urls/registry-helpers.ts +50 -1
  125. package/src/launch/broker.ts +38 -25
  126. package/src/lsp/client.ts +7 -1
  127. package/src/main.ts +29 -0
  128. package/src/mcp/oauth-discovery.ts +20 -1
  129. package/src/mcp/oauth-flow.ts +3 -1
  130. package/src/mcp/tool-bridge.ts +57 -6
  131. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  132. package/src/modes/components/agent-dashboard.ts +2 -2
  133. package/src/modes/components/agent-hub.ts +15 -2
  134. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  135. package/src/modes/components/chat-transcript-builder.ts +26 -4
  136. package/src/modes/components/dynamic-border.ts +9 -6
  137. package/src/modes/components/extensions/extension-list.ts +2 -2
  138. package/src/modes/components/hook-selector.ts +10 -4
  139. package/src/modes/components/login-dialog.ts +5 -0
  140. package/src/modes/components/mcp-add-wizard.ts +5 -0
  141. package/src/modes/components/plan-review-overlay.ts +11 -11
  142. package/src/modes/components/read-tool-group.ts +1 -8
  143. package/src/modes/components/status-line/component.ts +10 -1
  144. package/src/modes/components/transcript-container.ts +110 -7
  145. package/src/modes/controllers/command-controller.ts +12 -4
  146. package/src/modes/controllers/event-controller.ts +80 -15
  147. package/src/modes/controllers/input-controller.ts +4 -2
  148. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  149. package/src/modes/controllers/selector-controller.ts +24 -5
  150. package/src/modes/controllers/todo-command-controller.ts +18 -14
  151. package/src/modes/interactive-mode.ts +7 -3
  152. package/src/modes/magic-keyword-boundary.ts +23 -0
  153. package/src/modes/orchestrate.ts +6 -5
  154. package/src/modes/print-mode.ts +9 -0
  155. package/src/modes/prompt-action-autocomplete.ts +6 -1
  156. package/src/modes/rpc/host-tools.ts +15 -0
  157. package/src/modes/rpc/rpc-mode.ts +123 -48
  158. package/src/modes/types.ts +1 -1
  159. package/src/modes/ultrathink.ts +6 -5
  160. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  161. package/src/modes/utils/ui-helpers.ts +27 -1
  162. package/src/modes/workflow.ts +6 -5
  163. package/src/prompts/advisor/system.md +1 -0
  164. package/src/prompts/system/system-prompt.md +1 -0
  165. package/src/prompts/tools/eval.md +2 -2
  166. package/src/prompts/tools/grep.md +1 -2
  167. package/src/prompts/tools/read.md +2 -4
  168. package/src/sdk.ts +61 -34
  169. package/src/session/agent-session.ts +283 -39
  170. package/src/session/exit-diagnostics.ts +108 -0
  171. package/src/session/messages.test.ts +66 -0
  172. package/src/session/messages.ts +37 -0
  173. package/src/session/streaming-output.ts +40 -12
  174. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  175. package/src/slash-commands/helpers/logout.ts +23 -3
  176. package/src/slash-commands/helpers/usage-report.ts +14 -2
  177. package/src/subprocess/worker-client.ts +9 -2
  178. package/src/system-prompt.test.ts +36 -0
  179. package/src/system-prompt.ts +1 -1
  180. package/src/task/executor.ts +8 -0
  181. package/src/task/render.test.ts +36 -0
  182. package/src/task/render.ts +55 -43
  183. package/src/tools/bash-skill-urls.ts +4 -1
  184. package/src/tools/bash.ts +1 -0
  185. package/src/tools/browser/registry.ts +17 -3
  186. package/src/tools/eval.ts +14 -9
  187. package/src/tools/gh.ts +3 -1
  188. package/src/tools/grep.ts +5 -45
  189. package/src/tools/path-utils.ts +7 -1
  190. package/src/tools/read.ts +23 -74
  191. package/src/tools/write.ts +82 -9
  192. package/src/tools/yield.ts +29 -1
  193. package/src/utils/title-generator.ts +10 -6
  194. package/src/web/search/index.ts +39 -22
  195. package/src/web/search/provider.ts +33 -16
  196. package/src/web/search/providers/codex.ts +68 -21
  197. package/src/web/search/providers/perplexity-auth.ts +20 -11
  198. package/src/web/search/providers/perplexity.ts +14 -2
@@ -1,8 +1,14 @@
1
1
  import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
2
4
  import { TempDir } from "@oh-my-pi/pi-utils";
3
5
  import { Settings } from "../../config/settings";
4
6
  import type { ToolSession } from "../../tools";
5
- import { disposeAllVmContexts, setWorkerCloseTimeoutMsForTests } from "../js/context-manager";
7
+ import {
8
+ disposeAllVmContexts,
9
+ setJsEvalWorkerThreadForTests,
10
+ setWorkerCloseTimeoutMsForTests,
11
+ } from "../js/context-manager";
6
12
  import { executeJs } from "../js/executor";
7
13
 
8
14
  const originalWorker = globalThis.Worker;
@@ -181,7 +187,9 @@ function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior)
181
187
 
182
188
  describe("JavaScript eval worker lifecycle", () => {
183
189
  let restoreCloseTimeoutMs = 0;
190
+ let restoreWorkerThread = false;
184
191
  beforeEach(() => {
192
+ restoreWorkerThread = setJsEvalWorkerThreadForTests(true);
185
193
  // Shrink the graceful-close grace period so the "close acked but the worker
186
194
  // never exits -> force terminate" contract is proven without a real 1s wait.
187
195
  restoreCloseTimeoutMs = setWorkerCloseTimeoutMsForTests(1);
@@ -197,6 +205,7 @@ describe("JavaScript eval worker lifecycle", () => {
197
205
  writable: true,
198
206
  value: originalWorker,
199
207
  });
208
+ setJsEvalWorkerThreadForTests(restoreWorkerThread);
200
209
  });
201
210
 
202
211
  it("exits a real worker on graceful close even with ref'ed user handles", async () => {
@@ -271,6 +280,73 @@ describe("JavaScript eval worker lifecycle", () => {
271
280
  expect(stats.terminateCalls).toBe(1);
272
281
  });
273
282
 
283
+ it("falls back to a Bun Worker when the subprocess cannot spawn", async () => {
284
+ using tempDir = TempDir.createSync("@omp-js-spawn-fallback-");
285
+ // Exercise the production ladder (process -> worker -> inline), not the
286
+ // worker-thread test seam the surrounding describe enables.
287
+ setJsEvalWorkerThreadForTests(false);
288
+ const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
289
+ installFakeWorker(stats, { exitOnClose: true, settleRuns: true });
290
+ const originalSpawn = Bun.spawn;
291
+ let spawnAttempts = 0;
292
+ Bun.spawn = ((): never => {
293
+ spawnAttempts++;
294
+ throw new Error("subprocess spawn unavailable");
295
+ }) as unknown as typeof Bun.spawn;
296
+
297
+ try {
298
+ const session = makeSession(tempDir.path());
299
+ const sessionId = `js-spawn-fallback:${crypto.randomUUID()}`;
300
+ // The fake Worker settles runs without executing the cell, so an empty
301
+ // output proves the middle rung handled it — the inline fallback would
302
+ // have actually evaluated the expression and printed 42.
303
+ const result = await executeJs("return String(6 * 7);", { cwd: tempDir.path(), sessionId, session });
304
+ expect(result.exitCode).toBe(0);
305
+ expect(result.output.trim()).toBe("");
306
+ expect(spawnAttempts).toBe(1);
307
+ } finally {
308
+ Bun.spawn = originalSpawn;
309
+ }
310
+ });
311
+
312
+ it("falls back to a Bun Worker when the subprocess fails during initialization", async () => {
313
+ using tempDir = TempDir.createSync("@omp-js-init-fallback-");
314
+ // Exercise the production ladder (process -> worker -> inline), not the
315
+ // worker-thread test seam the surrounding describe enables.
316
+ setJsEvalWorkerThreadForTests(false);
317
+ const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
318
+ installFakeWorker(stats, { exitOnClose: true, settleRuns: true });
319
+ const originalSpawn = Bun.spawn;
320
+ let spawnAttempts = 0;
321
+ Bun.spawn = ((options: unknown) => {
322
+ spawnAttempts++;
323
+ const spawnOptions = options as {
324
+ onExit?: (proc: unknown, exitCode: number | null, signalCode: string | null) => void;
325
+ };
326
+ const fakeProcess = {
327
+ send: () => undefined,
328
+ kill: () => undefined,
329
+ unref: () => undefined,
330
+ };
331
+ queueMicrotask(() => spawnOptions.onExit?.(fakeProcess, 1, null));
332
+ return fakeProcess;
333
+ }) as unknown as typeof Bun.spawn;
334
+
335
+ try {
336
+ const session = makeSession(tempDir.path());
337
+ const sessionId = `js-init-fallback:${crypto.randomUUID()}`;
338
+ // The fake Worker settles runs without executing the cell, so empty
339
+ // output proves the middle rung handled the retry. Inline execution
340
+ // would evaluate the expression and print 42.
341
+ const result = await executeJs("return String(6 * 7);", { cwd: tempDir.path(), sessionId, session });
342
+ expect(result.exitCode).toBe(0);
343
+ expect(result.output.trim()).toBe("");
344
+ expect(spawnAttempts).toBe(1);
345
+ } finally {
346
+ Bun.spawn = originalSpawn;
347
+ }
348
+ });
349
+
274
350
  it("falls back to the inline worker when the spawned worker errors during startup", async () => {
275
351
  using tempDir = TempDir.createSync("@omp-js-worker-error-");
276
352
  const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
@@ -289,3 +365,84 @@ describe("JavaScript eval worker lifecycle", () => {
289
365
  expect(stats.terminateCalls).toBe(1);
290
366
  });
291
367
  });
368
+
369
+ describe.skipIf(process.platform === "win32")("JavaScript eval process isolation", () => {
370
+ afterEach(async () => {
371
+ await disposeAllVmContexts();
372
+ });
373
+
374
+ it("runs spawned commands in the isolated POSIX process group", async () => {
375
+ using tempDir = TempDir.createSync("@omp-js-process-isolation-");
376
+ const session = makeSession(tempDir.path());
377
+ const evalSessionId = `js-isolation:${crypto.randomUUID()}`;
378
+ const result = await executeJs(
379
+ [
380
+ `const child = Bun.spawn(["/bin/sh", "-c", 'pgid=$(ps -o pgid= -p $$); printf "%s %s\\n" "$pgid" "$PPID"'], { stdout: "pipe" });`,
381
+ "return await new Response(child.stdout).text();",
382
+ ].join("\n"),
383
+ { cwd: tempDir.path(), sessionId: evalSessionId, session },
384
+ );
385
+ const [processGroupId, parentProcessId] = result.output.trim().split(/\s+/).map(Number);
386
+ expect(parentProcessId).not.toBe(process.pid);
387
+ expect(processGroupId).toBe(parentProcessId);
388
+
389
+ await executeJs("var saved = 41; function increment(value) { return value + 1; }", {
390
+ cwd: tempDir.path(),
391
+ sessionId: evalSessionId,
392
+ session,
393
+ });
394
+ const reused = await executeJs("return increment(saved);", {
395
+ cwd: tempDir.path(),
396
+ sessionId: evalSessionId,
397
+ session,
398
+ });
399
+ expect(reused.output.trim()).toBe("42");
400
+ });
401
+
402
+ it("mirrors the session cwd onto the subprocess's real cwd", async () => {
403
+ using tempDir = TempDir.createSync("@omp-js-process-cwd-");
404
+ const session = makeSession(tempDir.path());
405
+ const evalSessionId = `js-cwd:${crypto.randomUUID()}`;
406
+ const result = await executeJs("return process.cwd();", {
407
+ cwd: tempDir.path(),
408
+ sessionId: evalSessionId,
409
+ session,
410
+ });
411
+ // process.chdir resolves symlinks (macOS tempdirs live under /var ->
412
+ // /private/var), so compare physical paths.
413
+ expect(result.output.trim()).toBe(fs.realpathSync(tempDir.path()));
414
+ });
415
+
416
+ it("still runs cells when the session cwd does not exist", async () => {
417
+ using tempDir = TempDir.createSync("@omp-js-process-cwd-missing-");
418
+ const missingCwd = path.join(tempDir.path(), "deleted");
419
+ const session = makeSession(missingCwd);
420
+ const result = await executeJs("return String(6 * 7);", {
421
+ cwd: missingCwd,
422
+ sessionId: `js-cwd-missing:${crypto.randomUUID()}`,
423
+ session,
424
+ });
425
+ expect(result.exitCode).toBe(0);
426
+ expect(result.output.trim()).toBe("42");
427
+ });
428
+
429
+ it("keeps the isolated process alive after a stackless floated rejection", async () => {
430
+ using tempDir = TempDir.createSync("@omp-js-process-rejection-");
431
+ const session = makeSession(tempDir.path());
432
+ const evalSessionId = `js-rejection:${crypto.randomUUID()}`;
433
+ const rejected = await executeJs(
434
+ 'var savedAfterRejection = 41; Promise.reject("stackless rejection"); await Bun.sleep(10);',
435
+ { cwd: tempDir.path(), sessionId: evalSessionId, session },
436
+ );
437
+ expect(rejected.exitCode).toBe(1);
438
+ expect(rejected.output).toContain("Unhandled rejection (missing await?): stackless rejection");
439
+
440
+ const reused = await executeJs("return savedAfterRejection + 1;", {
441
+ cwd: tempDir.path(),
442
+ sessionId: evalSessionId,
443
+ session,
444
+ });
445
+ expect(reused.exitCode).toBe(0);
446
+ expect(reused.output.trim()).toBe("42");
447
+ });
448
+ });
@@ -3,9 +3,21 @@ import {
3
3
  __resetWindowsConsoleProbeCache,
4
4
  consoleAttachedViaTTY,
5
5
  hostHasInheritableConsole,
6
+ shouldDetachKernel,
6
7
  shouldHideKernelWindow,
7
8
  } from "../py/spawn-options";
8
9
 
10
+ describe("shouldDetachKernel", () => {
11
+ it("starts POSIX kernels in a new session", () => {
12
+ expect(shouldDetachKernel("darwin")).toBe(true);
13
+ expect(shouldDetachKernel("linux")).toBe(true);
14
+ });
15
+
16
+ it("leaves Windows console inheritance to windowsHide", () => {
17
+ expect(shouldDetachKernel("win32")).toBe(false);
18
+ });
19
+ });
20
+
9
21
  /**
10
22
  * `shouldHideKernelWindow` decides whether the long-lived Python kernel
11
23
  * subprocess is spawned with `windowsHide: true`. On Windows, Bun maps that
@@ -105,3 +105,23 @@ describe("eval js agent() handle", () => {
105
105
  expect("branchName" in node).toBe(false);
106
106
  });
107
107
  });
108
+
109
+ describe("eval js read() URI delegation", () => {
110
+ it("appends line selectors to delegated URI paths", async () => {
111
+ const calls: Array<{ name: string; args: unknown }> = [];
112
+ const sandbox = loadPrelude(async (name, args) => {
113
+ calls.push({ name, args });
114
+ return { text: "resource contents" };
115
+ });
116
+
117
+ const result = await vm.runInContext(`read("mcp://server/resource", { offset: 10, limit: 5 })`, sandbox);
118
+
119
+ expect(result).toBe("resource contents");
120
+ expect(calls).toEqual([
121
+ {
122
+ name: "read",
123
+ args: { path: "mcp://server/resource:10-14" },
124
+ },
125
+ ]);
126
+ });
127
+ });
@@ -0,0 +1,27 @@
1
+ import { expect, it } from "bun:test";
2
+ import * as path from "node:path";
3
+ import { TempDir } from "@oh-my-pi/pi-utils";
4
+
5
+ it("imports the JS process entry without loading dotenv before profile bootstrap", async () => {
6
+ using tempDir = TempDir.createSync("@omp-js-process-import-");
7
+ await Bun.write(path.join(tempDir.path(), ".env"), "OMP_PROCESS_ENTRY_ENV_PROBE=loaded-too-early\n");
8
+ const env = Object.fromEntries(
9
+ Object.entries(process.env).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
10
+ );
11
+ delete env.OMP_PROCESS_ENTRY_ENV_PROBE;
12
+ env.HOME = tempDir.path();
13
+ const fixture = path.resolve(import.meta.dir, "../../../test/fixtures/js-process-entry-import.ts");
14
+ const proc = Bun.spawn([process.execPath, fixture], {
15
+ env,
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ });
19
+ const [exitCode, stdout, stderr] = await Promise.all([
20
+ proc.exited,
21
+ new Response(proc.stdout).text(),
22
+ new Response(proc.stderr).text(),
23
+ ]);
24
+ expect(exitCode).toBe(0);
25
+ expect(stdout).toBe("");
26
+ expect(stderr).toBe("");
27
+ });
@@ -449,130 +449,135 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
449
449
  // runtime is parked waiting for the result, and the cell timeout must
450
450
  // not abort us mid-cherry-pick or mid-nested-commit. The clock restarts
451
451
  // only after we hand control back to the runtime.
452
- const { result, mergeSummary, changesApplied } = await withBridgeTimeoutPause(options.emitStatus, async () => {
453
- let isolationContext: IsolationContext | null = null;
454
- if (isIsolated) {
455
- try {
456
- isolationContext = await prepareIsolationContext(options.session.cwd);
457
- } catch (err) {
458
- const message = err instanceof Error ? err.message : String(err);
459
- throw new ToolError(`Isolated agent() execution requires a git repository. ${message}`);
460
- }
461
- }
462
- const preferredBackend = isIsolated ? parseIsolationMode(isolationMode) : undefined;
463
-
464
- const result = await (async () => {
465
- if (!isolationContext) {
466
- return taskExecutor.runSubprocess(baseRunOptions);
467
- }
468
- const taskStart = Date.now();
469
- return runIsolatedSubprocess({
470
- baseOptions: baseRunOptions,
471
- context: isolationContext,
472
- preferredBackend,
473
- agentId: id,
474
- mergeMode,
475
- artifactsDir,
476
- description: trimToUndefined(parsed.label),
477
- buildCommitMessage,
478
- buildFailureResult: err => {
452
+ const { result, mergeSummary, changesApplied } = await withBridgeTimeoutPause(
453
+ options.emitStatus,
454
+ async () => {
455
+ let isolationContext: IsolationContext | null = null;
456
+ if (isIsolated) {
457
+ try {
458
+ isolationContext = await prepareIsolationContext(options.session.cwd);
459
+ } catch (err) {
479
460
  const message = err instanceof Error ? err.message : String(err);
480
- return {
481
- index: 0,
482
- id,
483
- agent: effectiveAgent.name,
484
- agentSource: effectiveAgent.source,
485
- task: renderSubagentPrompt(assignment),
486
- assignment,
487
- description: trimToUndefined(parsed.label),
488
- exitCode: 1,
489
- output: "",
490
- stderr: message,
491
- truncated: false,
492
- durationMs: Date.now() - taskStart,
493
- tokens: 0,
494
- requests: 0,
495
- modelOverride,
496
- error: message,
497
- };
498
- },
499
- });
500
- })();
501
-
502
- if (result.exitCode !== 0 || result.error || result.aborted) {
503
- const failureMessage = buildSubagentFailureMessage(agentName, result);
504
- const recoveryHint = isIsolated ? await buildIsolationRecoveryHint(result, artifactsDir) : "";
505
- throw new ToolError(`${failureMessage}${recoveryHint}`);
506
- }
507
-
508
- let mergeSummary = "";
509
- let changesApplied: boolean | null = null;
510
- if (isIsolated && isolationContext) {
511
- if (applyChanges) {
512
- const outcome = await mergeIsolatedChanges({
513
- result,
514
- repoRoot: isolationContext.repoRoot,
515
- mergeMode,
516
- });
517
- mergeSummary = outcome.summary;
518
- changesApplied = outcome.changesApplied;
519
- if (outcome.changesApplied === false) {
520
- const summaryText = outcome.summary.trim();
521
- const recoveryHint = await buildIsolationRecoveryHint(result, artifactsDir);
522
- throw new ToolError(
523
- `agent() isolated apply failed for ${result.id}${summaryText ? `: ${summaryText}` : ""}${recoveryHint}`,
524
- );
461
+ throw new ToolError(`Isolated agent() execution requires a git repository. ${message}`);
525
462
  }
463
+ }
464
+ const preferredBackend = isIsolated ? parseIsolationMode(isolationMode) : undefined;
526
465
 
527
- const nestedSummary = await applyEligibleNestedPatches({
528
- result,
529
- repoRoot: isolationContext.repoRoot,
466
+ const result = await (async () => {
467
+ if (!isolationContext) {
468
+ return taskExecutor.runSubprocess(baseRunOptions);
469
+ }
470
+ const taskStart = Date.now();
471
+ return runIsolatedSubprocess({
472
+ baseOptions: baseRunOptions,
473
+ context: isolationContext,
474
+ preferredBackend,
475
+ agentId: id,
530
476
  mergeMode,
531
- changesApplied: outcome.changesApplied,
532
- mergedBranchForNestedPatches: outcome.mergedBranchForNestedPatches,
533
- commitMessage: buildCommitMessage(),
477
+ artifactsDir,
478
+ description: trimToUndefined(parsed.label),
479
+ buildCommitMessage,
480
+ buildFailureResult: err => {
481
+ const message = err instanceof Error ? err.message : String(err);
482
+ return {
483
+ index: 0,
484
+ id,
485
+ agent: effectiveAgent.name,
486
+ agentSource: effectiveAgent.source,
487
+ task: renderSubagentPrompt(assignment),
488
+ assignment,
489
+ description: trimToUndefined(parsed.label),
490
+ exitCode: 1,
491
+ output: "",
492
+ stderr: message,
493
+ truncated: false,
494
+ durationMs: Date.now() - taskStart,
495
+ tokens: 0,
496
+ requests: 0,
497
+ modelOverride,
498
+ error: message,
499
+ };
500
+ },
534
501
  });
535
- mergeSummary += nestedSummary;
536
- if (structured && nestedSummary.trim()) {
537
- const recoveryHint = await buildIsolationRecoveryHint(
538
- { ...result, patchPath: undefined, branchName: undefined },
539
- artifactsDir,
540
- );
541
- throw new ToolError(
542
- `agent() isolated nested patch apply failed for ${result.id}: ${plainIsolationSummary(nestedSummary)}${recoveryHint}`,
543
- );
544
- }
545
- } else if (result.branchName) {
546
- mergeSummary = `\n\nIsolation: changes captured on branch \`${result.branchName}\` (apply=false). Not merged.`;
547
- } else if (result.patchPath) {
548
- mergeSummary = `\n\nIsolation: changes captured at \`${result.patchPath}\` (apply=false). Not applied.`;
549
- } else {
550
- const nestedPatches = result.nestedPatches ?? [];
551
- if (nestedPatches.length > 0) {
552
- mergeSummary = `\n\nIsolation: changes captured for ${nestedPatches.length} nested repositor${nestedPatches.length === 1 ? "y" : "ies"} (apply=false). Not applied.`;
502
+ })();
503
+
504
+ if (result.exitCode !== 0 || result.error || result.aborted) {
505
+ const failureMessage = buildSubagentFailureMessage(agentName, result);
506
+ const recoveryHint = isIsolated ? await buildIsolationRecoveryHint(result, artifactsDir) : "";
507
+ throw new ToolError(`${failureMessage}${recoveryHint}`);
508
+ }
509
+
510
+ let mergeSummary = "";
511
+ let changesApplied: boolean | null = null;
512
+ if (isIsolated && isolationContext) {
513
+ if (applyChanges) {
514
+ const outcome = await mergeIsolatedChanges({
515
+ result,
516
+ repoRoot: isolationContext.repoRoot,
517
+ mergeMode,
518
+ });
519
+ mergeSummary = outcome.summary;
520
+ changesApplied = outcome.changesApplied;
521
+ if (outcome.changesApplied === false) {
522
+ const summaryText = outcome.summary.trim();
523
+ const recoveryHint = await buildIsolationRecoveryHint(result, artifactsDir);
524
+ throw new ToolError(
525
+ `agent() isolated apply failed for ${result.id}${summaryText ? `: ${summaryText}` : ""}${recoveryHint}`,
526
+ );
527
+ }
528
+
529
+ const nestedSummary = await applyEligibleNestedPatches({
530
+ result,
531
+ repoRoot: isolationContext.repoRoot,
532
+ mergeMode,
533
+ changesApplied: outcome.changesApplied,
534
+ mergedBranchForNestedPatches: outcome.mergedBranchForNestedPatches,
535
+ commitMessage: buildCommitMessage(),
536
+ });
537
+ mergeSummary += nestedSummary;
538
+ if (structured && nestedSummary.trim()) {
539
+ const recoveryHint = await buildIsolationRecoveryHint(
540
+ { ...result, patchPath: undefined, branchName: undefined },
541
+ artifactsDir,
542
+ );
543
+ throw new ToolError(
544
+ `agent() isolated nested patch apply failed for ${result.id}: ${plainIsolationSummary(nestedSummary)}${recoveryHint}`,
545
+ );
546
+ }
547
+ } else if (result.branchName) {
548
+ mergeSummary = `\n\nIsolation: changes captured on branch \`${result.branchName}\` (apply=false). Not merged.`;
549
+ } else if (result.patchPath) {
550
+ mergeSummary = `\n\nIsolation: changes captured at \`${result.patchPath}\` (apply=false). Not applied.`;
553
551
  } else {
554
- mergeSummary = "\n\nIsolation: no changes captured.";
552
+ const nestedPatches = result.nestedPatches ?? [];
553
+ if (nestedPatches.length > 0) {
554
+ mergeSummary = `\n\nIsolation: changes captured for ${nestedPatches.length} nested repositor${nestedPatches.length === 1 ? "y" : "ies"} (apply=false). Not applied.`;
555
+ } else {
556
+ mergeSummary = "\n\nIsolation: no changes captured.";
557
+ }
555
558
  }
556
559
  }
557
- }
558
-
559
- // Clean up the temp artifacts dir we created for this call only when the
560
- // caller will not need files from it later. Keep it when the runtime helper
561
- // will return an `agent://` handle (the `.md`/`.jsonl` backing files live
562
- // here) and on `apply=false` (`changesApplied === null`) where the caller
563
- // consumes `details.patchPath` / `details.branchName` /
564
- // `details.nestedPatches` out of band. Failed isolated applies throw
565
- // earlier with a recovery hint, so they never reach this gate.
566
- const shouldCleanupTempArtifacts = tempArtifactsDir && !parsed.handle && (!isIsolated || changesApplied === true);
567
- if (shouldCleanupTempArtifacts) {
568
- await fs.rm(artifactsDir, { recursive: true, force: true });
569
- unregisterArtifactsDir?.();
570
- }
571
-
572
- options.session.recordEvalSubagentUsage?.(result.usage?.output ?? 0);
573
-
574
- return { result, mergeSummary, changesApplied };
575
- });
560
+
561
+ // Clean up the temp artifacts dir we created for this call only when the
562
+ // caller will not need files from it later. Keep it when the runtime helper
563
+ // will return an `agent://` handle (the `.md`/`.jsonl` backing files live
564
+ // here) and on `apply=false` (`changesApplied === null`) where the caller
565
+ // consumes `details.patchPath` / `details.branchName` /
566
+ // `details.nestedPatches` out of band. Failed isolated applies throw
567
+ // earlier with a recovery hint, so they never reach this gate.
568
+ const shouldCleanupTempArtifacts =
569
+ tempArtifactsDir && !parsed.handle && (!isIsolated || changesApplied === true);
570
+ if (shouldCleanupTempArtifacts) {
571
+ await fs.rm(artifactsDir, { recursive: true, force: true });
572
+ unregisterArtifactsDir?.();
573
+ }
574
+
575
+ options.session.recordEvalSubagentUsage?.(result.usage?.output ?? 0);
576
+
577
+ return { result, mergeSummary, changesApplied };
578
+ },
579
+ { deferExternalAbort: true },
580
+ );
576
581
 
577
582
  return {
578
583
  text: structured ? result.output : result.output + mergeSummary,
@@ -15,10 +15,10 @@ export interface ExecutorBackendExecOptions {
15
15
  * driven entirely by `signal`, which the eval tool arms as a watchdog that
16
16
  * pauses on bridge timeout-control status events and fires a `TimeoutError`
17
17
  * reason only while the Python/JS runtime owns control. Backends use this
18
- * value only for timeout-annotation text and as cold-start headroom; they MUST
19
- * NOT derive a competing wall-clock timer from it.
18
+ * value only for timeout-annotation text and as cold-start headroom; undefined
19
+ * disables the cell timeout. Backends MUST NOT derive a competing wall-clock timer from it.
20
20
  */
21
- idleTimeoutMs: number;
21
+ idleTimeoutMs?: number;
22
22
  reset: boolean;
23
23
  onChunk: (chunk: string) => void;
24
24
  /**
@@ -26,6 +26,15 @@ export function isEvalTimeoutControlEvent(event: JsStatusEvent): boolean {
26
26
  return event.op === EVAL_TIMEOUT_PAUSE_OP || event.op === EVAL_TIMEOUT_RESUME_OP;
27
27
  }
28
28
 
29
+ /** Optional behavior for a timeout pause around a host bridge call. */
30
+ export interface BridgeTimeoutPauseOptions {
31
+ /**
32
+ * Marks the pause as an `agent()` call whose already-started work must finish
33
+ * before an external eval abort reaches the kernel.
34
+ */
35
+ deferExternalAbort?: boolean;
36
+ }
37
+
29
38
  /**
30
39
  * Run {@link operation} while suspending the eval watchdog through
31
40
  * {@link emitStatus}. A no-op wrapper when no status sink is wired.
@@ -33,12 +42,21 @@ export function isEvalTimeoutControlEvent(event: JsStatusEvent): boolean {
33
42
  export async function withBridgeTimeoutPause<T>(
34
43
  emitStatus: ((event: JsStatusEvent) => void) | undefined,
35
44
  operation: () => Promise<T>,
45
+ options?: BridgeTimeoutPauseOptions,
36
46
  ): Promise<T> {
37
47
  if (!emitStatus) return operation();
38
- emitStatus({ op: EVAL_TIMEOUT_PAUSE_OP });
48
+ emitStatus(
49
+ options?.deferExternalAbort
50
+ ? { op: EVAL_TIMEOUT_PAUSE_OP, deferExternalAbort: true }
51
+ : { op: EVAL_TIMEOUT_PAUSE_OP },
52
+ );
39
53
  try {
40
54
  return await operation();
41
55
  } finally {
42
- emitStatus({ op: EVAL_TIMEOUT_RESUME_OP });
56
+ emitStatus(
57
+ options?.deferExternalAbort
58
+ ? { op: EVAL_TIMEOUT_RESUME_OP, deferExternalAbort: true }
59
+ : { op: EVAL_TIMEOUT_RESUME_OP },
60
+ );
43
61
  }
44
62
  }