@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
@@ -3,7 +3,7 @@ import { Settings } from "../config/settings";
3
3
  import { OutputSink } from "../session/streaming-output";
4
4
  import type { ToolSession } from "../tools";
5
5
  import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
6
- import { isEvalTimeoutControlEvent } from "./bridge-timeout";
6
+ import { EVAL_TIMEOUT_PAUSE_OP, EVAL_TIMEOUT_RESUME_OP, isEvalTimeoutControlEvent } from "./bridge-timeout";
7
7
  import type { JsStatusEvent } from "./js/shared/types";
8
8
  import type { KernelDisplayOutput } from "./py/display";
9
9
  import { registerPyToolBridge } from "./py/tool-bridge";
@@ -158,6 +158,77 @@ export async function waitForPromiseWithCancellation<T>(
158
158
  return await resultPromise;
159
159
  }
160
160
 
161
+ interface BridgeAbortShield {
162
+ signal: AbortSignal | undefined;
163
+ abortRequested: boolean;
164
+ timedOut: boolean;
165
+ handleStatus?: (event: JsStatusEvent) => void;
166
+ dispose?: () => void;
167
+ }
168
+
169
+ function createBridgeAbortShield(source: AbortSignal | undefined): BridgeAbortShield {
170
+ const shield: BridgeAbortShield = {
171
+ signal: undefined,
172
+ abortRequested: false,
173
+ timedOut: false,
174
+ };
175
+ if (!source) return shield;
176
+
177
+ const controller = new AbortController();
178
+ let pauseDepth = 0;
179
+ let abortReason: unknown;
180
+ let removeAbortListener: (() => void) | undefined;
181
+
182
+ const requestAbort = (reason: unknown): void => {
183
+ shield.abortRequested = true;
184
+ shield.timedOut =
185
+ shield.timedOut ||
186
+ (typeof DOMException !== "undefined" && reason instanceof DOMException
187
+ ? reason.name === "TimeoutError"
188
+ : reason instanceof Error && reason.name === "TimeoutError");
189
+ abortReason = reason;
190
+ if (pauseDepth > 0 || controller.signal.aborted) return;
191
+ controller.abort(reason);
192
+ };
193
+
194
+ const onAbort = (): void => {
195
+ const reason = source.reason;
196
+ requestAbort(reason);
197
+ };
198
+
199
+ shield.signal = controller.signal;
200
+ shield.handleStatus = (event: JsStatusEvent): void => {
201
+ if (event.deferExternalAbort !== true) return;
202
+ if (event.op === EVAL_TIMEOUT_PAUSE_OP) {
203
+ pauseDepth++;
204
+ return;
205
+ }
206
+ if (event.op !== EVAL_TIMEOUT_RESUME_OP || pauseDepth === 0) return;
207
+ pauseDepth--;
208
+ if (shield.abortRequested && !controller.signal.aborted) controller.abort(abortReason);
209
+ };
210
+ shield.dispose = (): void => {
211
+ removeAbortListener?.();
212
+ removeAbortListener = undefined;
213
+ };
214
+
215
+ if (source.aborted) {
216
+ requestAbort(source.reason);
217
+ } else {
218
+ source.addEventListener("abort", onAbort, { once: true });
219
+ removeAbortListener = () => {
220
+ source.removeEventListener("abort", onAbort);
221
+ };
222
+ if (source.aborted) {
223
+ source.removeEventListener("abort", onAbort);
224
+ removeAbortListener = undefined;
225
+ requestAbort(source.reason);
226
+ }
227
+ }
228
+
229
+ return shield;
230
+ }
231
+
161
232
  export function createCancelledKernelResult(output: string): KernelExecutionResult {
162
233
  const outputBytes = Buffer.byteLength(output, "utf-8");
163
234
  const outputLines = output.length > 0 ? 1 : 0;
@@ -304,9 +375,11 @@ export async function executeWithKernelBase<
304
375
  const displayOutputs: KernelDisplayOutput[] = [];
305
376
  const deadlineMs = (resolveDeadlineMs ?? getExecutionDeadlineMs)(options);
306
377
  let executionTimeoutMs: number | undefined;
378
+ const abortShield = createBridgeAbortShield(options?.signal);
307
379
 
308
380
  const collectDisplay = (output: KernelDisplayOutput): void => {
309
381
  if (output.type === "status") {
382
+ abortShield.handleStatus?.(output.event);
310
383
  options?.onStatus?.(output.event);
311
384
  if (!isJulia && isEvalTimeoutControlEvent(output.event)) return;
312
385
  }
@@ -320,8 +393,11 @@ export async function executeWithKernelBase<
320
393
  options?.toolSession && options?.bridgeSessionId
321
394
  ? registerPyToolBridge(options.bridgeSessionId, runId, {
322
395
  toolSession: options.toolSession,
323
- signal: options.signal,
396
+ signal: abortShield.signal,
324
397
  emitStatus,
398
+ abortRequested: () => {
399
+ return abortShield.abortRequested;
400
+ },
325
401
  })
326
402
  : null;
327
403
 
@@ -338,14 +414,15 @@ export async function executeWithKernelBase<
338
414
  cwd: options?.cwd,
339
415
  env: buildKernelEnvPatch(options ?? ({} as TOptions)),
340
416
  id: runId,
341
- signal: options?.signal,
417
+ signal: abortShield.signal,
342
418
  timeoutMs: executionTimeoutMs,
343
419
  onChunk: text => sink.push(text),
344
420
  onDisplay: output => collectDisplay(output),
345
421
  });
346
422
 
347
- if (result.cancelled) {
348
- const annotation = result.timedOut
423
+ if (result.cancelled || abortShield.abortRequested) {
424
+ const timedOut = result.timedOut || abortShield.timedOut;
425
+ const annotation = timedOut
349
426
  ? formatKernelTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs, result.kernelKilled ?? false)
350
427
  : undefined;
351
428
  const dumped = await sink.dump(annotation);
@@ -397,8 +474,8 @@ export async function executeWithKernelBase<
397
474
  stdinRequested: false,
398
475
  };
399
476
  } catch (err) {
400
- if (isCancellationError(err, cancelledErrorClass) || options?.signal?.aborted) {
401
- const timedOut = isTimedOutCancellation(err, cancelledErrorClass, options?.signal);
477
+ if (isCancellationError(err, cancelledErrorClass) || abortShield.abortRequested || abortShield.signal?.aborted) {
478
+ const timedOut = abortShield.timedOut || isTimedOutCancellation(err, cancelledErrorClass, abortShield.signal);
402
479
  const dumped = await sink.dump(
403
480
  timedOut ? formatTimeoutAnnotation(executionTimeoutMs ?? options?.idleTimeoutMs) : undefined,
404
481
  );
@@ -421,5 +498,6 @@ export async function executeWithKernelBase<
421
498
  throw error;
422
499
  } finally {
423
500
  unregisterBridge?.();
501
+ abortShield.dispose?.();
424
502
  }
425
503
  }
@@ -13,7 +13,7 @@ import { $ } from "bun";
13
13
  import { Settings } from "../../config/settings";
14
14
  import { BaseKernel, getRemainingTimeMs, type KernelStartOptions } from "../kernel-base";
15
15
  import type { KernelDisplayOutput } from "../py/display";
16
- import { hostHasInheritableConsole, shouldHideKernelWindow } from "../py/spawn-options";
16
+ import { hostHasInheritableConsole, shouldDetachKernel, shouldHideKernelWindow } from "../py/spawn-options";
17
17
  import { JULIA_PRELUDE } from "./prelude";
18
18
  import RUNNER_SCRIPT from "./runner.jl" with { type: "text" };
19
19
  import {
@@ -187,6 +187,7 @@ export class JuliaKernel extends BaseKernel<KernelExecuteOptions> {
187
187
  [runtime.juliaPath, "--startup-file=no", "--history-file=no", "--color=no", "--project=@.", scriptPath],
188
188
  {
189
189
  cwd: options.cwd,
190
+ detached: shouldDetachKernel(process.platform),
190
191
  env: spawnEnv,
191
192
  stdin: "pipe",
192
193
  stdout: "pipe",
@@ -1,6 +1,14 @@
1
- import { logger, Snowflake, workerHostEntry } from "@oh-my-pi/pi-utils";
1
+ import { logger, postmortem, Snowflake, workerHostEntry } from "@oh-my-pi/pi-utils";
2
+ import {
3
+ createWorkerHandle,
4
+ createWorkerSubprocess,
5
+ resolveWorkerSpawnCmd,
6
+ workerEnvFromParent,
7
+ } from "../../subprocess/worker-client";
2
8
  import type { ToolSession } from "../../tools";
3
9
  import { ToolAbortError, ToolError } from "../../tools/tool-errors";
10
+ import { safeSend as safeSendIpc } from "../../utils/ipc";
11
+ import { shouldDetachKernel } from "../py/spawn-options";
4
12
  import { callSessionTool, type JsStatusEvent } from "./tool-bridge";
5
13
  import { WorkerCore } from "./worker-core";
6
14
  // Coding-agent binary/bundle workers route through the CLI entrypoint with a
@@ -24,7 +32,7 @@ export interface VmRunState {
24
32
  }
25
33
 
26
34
  interface WorkerHandle {
27
- mode: "worker" | "inline";
35
+ mode: "process" | "worker" | "inline";
28
36
  send(msg: WorkerInbound): void;
29
37
  onMessage(handler: (msg: WorkerOutbound) => void): () => void;
30
38
  onError(handler: (error: Error) => void): () => void;
@@ -57,16 +65,18 @@ const resettingSessions = new Map<string, Promise<void>>();
57
65
  // Worker startup (module-graph import + WorkerCore construction) is infrastructure
58
66
  // cost, not user compute. Floor it independently of Bun's 5s default per-test timeout
59
67
  // so a slow cold-start under load isn't aborted mid-init — terminating a still-
60
- // initializing Bun worker triggers the same kind of terminate-race that motivates
68
+ // initializing eval runtime triggers the same kind of terminate-race that motivates
61
69
  // avoiding `vm.runInContext` (see shared/indirect-eval.ts), here surfacing as a
62
70
  // SIGILL/SIGSEGV. Callers that pass a larger per-cell budget still dominate.
63
71
  const WORKER_INIT_TIMEOUT_MS = 15_000;
64
72
  const WORKER_CLOSE_TIMEOUT_MS = 1_000;
73
+ const JS_EVAL_PROCESS_ARG = "__omp_worker_js_eval_process";
65
74
  // Active graceful-close grace period before a worker that ack'd `close` but never
66
75
  // emitted its `close` event is force-terminated. Defaults to the production floor;
67
76
  // tests override it (and restore it) to exercise the close-timeout -> terminate
68
77
  // path without a real wall-clock wait.
69
78
  let workerCloseTimeoutMs: number = WORKER_CLOSE_TIMEOUT_MS;
79
+ let useWorkerThreadForTests = false;
70
80
 
71
81
  /**
72
82
  * Test-only seam: override the graceful-close grace period (ms). Returns the
@@ -79,6 +89,13 @@ export function setWorkerCloseTimeoutMsForTests(ms: number): number {
79
89
  return previous;
80
90
  }
81
91
 
92
+ /** Test-only seam for the legacy Worker lifecycle mocks. */
93
+ export function setJsEvalWorkerThreadForTests(enabled: boolean): boolean {
94
+ const previous = useWorkerThreadForTests;
95
+ useWorkerThreadForTests = enabled;
96
+ return previous;
97
+ }
98
+
82
99
  export async function executeInVmContext(options: {
83
100
  sessionKey: string;
84
101
  sessionId: string;
@@ -144,9 +161,9 @@ export async function disposeAllVmContexts(): Promise<void> {
144
161
  }
145
162
 
146
163
  /**
147
- * Smoke probe: spawn the JS eval worker through the worker-host entry and prove
148
- * it answers the `init` handshake on a real worker thread (not the inline
149
- * fallback). Catches the silent worker-load and init-message-drop regressions
164
+ * Smoke probe: spawn the JS evaluator through the worker-host entry and prove
165
+ * it answers the `init` handshake in a real isolated subprocess (not the inline
166
+ * fallback). Catches silent process-load and init-message regressions
150
167
  * that otherwise strand every cell on the init timeout in a distribution build —
151
168
  * the failure mode that motivated `installWorkerInbox`. Wired into
152
169
  * `omp --smoke-test` so binary / source / tarball installs all exercise it.
@@ -163,8 +180,8 @@ export async function smokeTestJsEvalWorker(): Promise<void> {
163
180
  };
164
181
  try {
165
182
  await initWorker(session, { cwd: process.cwd(), sessionId: "smoke" }, WORKER_INIT_TIMEOUT_MS);
166
- if (worker.mode !== "worker") {
167
- throw new Error("JS eval worker smoke fell back to the inline worker (real worker failed to start)");
183
+ if (worker.mode !== "process") {
184
+ throw new Error("JS eval worker smoke fell back from the isolated subprocess");
168
185
  }
169
186
  } finally {
170
187
  await worker.terminate().catch(() => undefined);
@@ -237,10 +254,8 @@ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, tim
237
254
  if (starting) return await starting;
238
255
 
239
256
  const startup = (async (): Promise<JsSession> => {
240
- // The message listener must be attached synchronously after `new Worker`:
241
- // Bun drops messages posted before a listener exists, and WorkerCore emits
242
- // `ready` from its constructor on load. `spawnJsWorker` + `initWorker` run with
243
- // no intervening await, so `ready` can never race the attach.
257
+ // Attach the message listener before sending init. Both Bun Worker messages
258
+ // and subprocess IPC can arrive immediately after the evaluator loads.
244
259
  const worker = spawnJsWorker();
245
260
  const session: JsSession = {
246
261
  sessionKey,
@@ -253,27 +268,29 @@ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, tim
253
268
  // Init headroom is the fixed infrastructure floor; the caller's per-cell timeout
254
269
  // dominates when larger so users can grant more by raising `timeout` on a cell.
255
270
  const readyTimeoutMs = Math.max(WORKER_INIT_TIMEOUT_MS, timeoutMs ?? 0);
256
- try {
257
- await initWorker(session, snapshot, readyTimeoutMs);
258
- } catch (error) {
259
- // Worker-thread crash/load failures surface asynchronously via the worker
260
- // `error` event — after `spawnJsWorker`'s synchronous try/catch already
261
- // returned — so the only signal is the rejected handshake. Retry on the
262
- // inline worker so a broken module graph fails fast instead of stalling
263
- // every cell on the init timeout and then dying with exitCode 1.
264
- await worker.terminate().catch(() => undefined);
265
- if (worker.mode === "inline") throw error;
266
- logger.warn("JS eval worker init failed; retrying with inline worker (no sync-loop guard)", {
267
- error: error instanceof Error ? error.message : String(error),
268
- });
269
- const inline = spawnInlineWorker();
270
- session.worker = inline;
271
- session.state = "alive";
271
+ while (true) {
272
272
  try {
273
273
  await initWorker(session, snapshot, readyTimeoutMs);
274
- } catch (inlineError) {
275
- await inline.terminate().catch(() => undefined);
276
- throw inlineError;
274
+ break;
275
+ } catch (error) {
276
+ // Runtime crash/load failures surface asynchronously via the runtime's
277
+ // error callback, after the synchronous spawn try/catch has returned.
278
+ // Preserve the full process -> Worker -> inline ladder for those failures.
279
+ const failed = session.worker;
280
+ await failed.terminate().catch(() => undefined);
281
+ if (failed.mode === "inline") throw error;
282
+ if (failed.mode === "process") {
283
+ logger.warn("JS eval subprocess init failed; retrying with a Bun Worker", {
284
+ error: error instanceof Error ? error.message : String(error),
285
+ });
286
+ session.worker = spawnBunWorker();
287
+ } else {
288
+ logger.warn("JS eval worker init failed; retrying with inline worker (no sync-loop guard)", {
289
+ error: error instanceof Error ? error.message : String(error),
290
+ });
291
+ session.worker = spawnInlineWorker();
292
+ }
293
+ session.state = "alive";
277
294
  }
278
295
  }
279
296
  sessions.set(sessionKey, session);
@@ -480,6 +497,22 @@ async function raceWithTimeout<T>(promise: Promise<T>, timeoutMs: number, reason
480
497
  }
481
498
 
482
499
  function spawnJsWorker(): WorkerHandle {
500
+ if (!useWorkerThreadForTests) {
501
+ try {
502
+ return spawnJsProcess();
503
+ } catch (err) {
504
+ // Fall through to the Bun Worker rung: a worker thread still interrupts
505
+ // synchronous infinite loops via terminate(), which the inline fallback
506
+ // cannot.
507
+ logger.warn("JS eval subprocess spawn failed; falling back to a Bun Worker", {
508
+ error: err instanceof Error ? err.message : String(err),
509
+ });
510
+ }
511
+ }
512
+ return spawnBunWorker();
513
+ }
514
+
515
+ function spawnBunWorker(): WorkerHandle {
483
516
  try {
484
517
  const hostEntry = workerHostEntry();
485
518
  const worker = hostEntry
@@ -494,6 +527,47 @@ function spawnJsWorker(): WorkerHandle {
494
527
  }
495
528
  }
496
529
 
530
+ function spawnJsProcess(): WorkerHandle {
531
+ const spawned = createWorkerSubprocess<WorkerOutbound>({
532
+ spawnCommand: resolveWorkerSpawnCmd(JS_EVAL_PROCESS_ARG),
533
+ env: workerEnvFromParent(),
534
+ exitLabel: "JS eval worker",
535
+ detached: shouldDetachKernel(process.platform),
536
+ reportCleanExit: true,
537
+ unref: false,
538
+ });
539
+ const base = createWorkerHandle<WorkerInbound, WorkerOutbound>(spawned, message =>
540
+ safeSendIpc(spawned.proc, message, "js-eval"),
541
+ );
542
+ return {
543
+ mode: "process",
544
+ send: message => base.send(message),
545
+ onMessage: handler => base.onMessage(handler),
546
+ onError: handler => base.onError(handler),
547
+ async close() {
548
+ const { promise, resolve } = Promise.withResolvers<boolean>();
549
+ let settled = false;
550
+ let timeout: NodeJS.Timeout | undefined;
551
+ let unsubscribe = (): void => {};
552
+ const finish = (value: boolean): void => {
553
+ if (settled) return;
554
+ settled = true;
555
+ if (timeout) clearTimeout(timeout);
556
+ unsubscribe();
557
+ resolve(value);
558
+ };
559
+ unsubscribe = base.onMessage(message => {
560
+ if (message.type !== "closed") return;
561
+ void base.terminate().finally(() => finish(true));
562
+ });
563
+ timeout = setTimeout(() => finish(false), workerCloseTimeoutMs);
564
+ base.send({ type: "close" });
565
+ return await promise;
566
+ },
567
+ terminate: () => base.terminate(),
568
+ };
569
+ }
570
+
497
571
  function wrapBunWorker(worker: Worker): WorkerHandle {
498
572
  return {
499
573
  mode: "worker",
@@ -582,7 +656,10 @@ function spawnInlineWorker(): WorkerHandle {
582
656
  },
583
657
  close: () => {},
584
658
  };
585
- const core = new WorkerCore(workerTransport);
659
+ const core = new WorkerCore(workerTransport, {
660
+ mode: "inline",
661
+ interceptUnhandledRejections: postmortem.interceptUnhandledRejections,
662
+ });
586
663
  return {
587
664
  mode: "inline",
588
665
  send: msg =>
@@ -0,0 +1,27 @@
1
+ import { WorkerCore } from "./worker-core";
2
+ import type { WorkerInbound, WorkerOutbound } from "./worker-protocol";
3
+
4
+ /** Start the JavaScript evaluator inside a subprocess IPC transport. */
5
+ export function startJsEvalProcess(transport: {
6
+ send(message: WorkerOutbound): void;
7
+ onMessage(handler: (message: WorkerInbound) => void): () => void;
8
+ }): void {
9
+ new WorkerCore(
10
+ {
11
+ send: message => transport.send(message),
12
+ onMessage: handler => transport.onMessage(handler),
13
+ // The parent owns process lifetime and kills the subprocess after the
14
+ // WorkerCore `closed` acknowledgement has crossed IPC.
15
+ close: () => {},
16
+ },
17
+ {
18
+ mode: "isolated",
19
+ // The subprocess starts with its real cwd at the worker-host entry dir
20
+ // (a `resolveWorkerSpawnCmd` requirement); mirror the session cwd so
21
+ // cell code using relative paths or spawning children resolves against
22
+ // the project instead of the install dir. Worker threads cannot pass
23
+ // this — `process.chdir` is unavailable there.
24
+ chdir: cwd => process.chdir(cwd),
25
+ },
26
+ );
27
+ }
@@ -6,7 +6,7 @@ import * as path from "node:path";
6
6
  import { Writable } from "node:stream";
7
7
  import * as util from "node:util";
8
8
 
9
- import { logger } from "@oh-my-pi/pi-utils";
9
+ import * as logger from "@oh-my-pi/pi-utils/logger";
10
10
 
11
11
  import { createHelpers, type HelperBundle } from "./helpers";
12
12
  import { awaitMaybePromise, indirectEval } from "./indirect-eval";
@@ -1,5 +1,3 @@
1
- import { isMainThread } from "node:worker_threads";
2
- import { postmortem } from "@oh-my-pi/pi-utils";
3
1
  import { ToolError } from "../../tools/tool-errors";
4
2
  import { JsRuntime, type RuntimeHooks } from "./shared/runtime";
5
3
  import type {
@@ -27,6 +25,23 @@ interface ActiveRun {
27
25
 
28
26
  type RunResult = Extract<WorkerOutbound, { type: "result" }>;
29
27
 
28
+ export type WorkerCoreOptions =
29
+ | {
30
+ mode: "isolated";
31
+ /**
32
+ * Mirror the session cwd onto the real process cwd so cell code using
33
+ * `process.cwd()`, relative paths, or child processes without an explicit
34
+ * `cwd` resolves against the project. Only the dedicated subprocess may
35
+ * pass this: `process.chdir` is unavailable in Worker threads and would
36
+ * mutate the host's own cwd on the inline fallback.
37
+ */
38
+ chdir?: (cwd: string) => void;
39
+ }
40
+ | {
41
+ mode: "inline";
42
+ interceptUnhandledRejections(handler: (reason: unknown) => boolean): () => void;
43
+ };
44
+
30
45
  /** Finished-cell filenames retained for attributing rejections that surface after the run settled. */
31
46
  const RECENT_CELL_FILES_MAX = 256;
32
47
 
@@ -82,9 +97,11 @@ export class WorkerCore {
82
97
  #recentCellFiles = new Set<string>();
83
98
  #unsubscribe: () => void;
84
99
  #uninstallRejectionGuard: () => void;
100
+ #options: WorkerCoreOptions;
85
101
 
86
- constructor(transport: Transport) {
102
+ constructor(transport: Transport, options: WorkerCoreOptions) {
87
103
  this.#transport = transport;
104
+ this.#options = options;
88
105
  this.#unsubscribe = transport.onMessage(msg => this.#handle(msg));
89
106
  this.#uninstallRejectionGuard = this.#installRejectionGuard();
90
107
  }
@@ -98,8 +115,8 @@ export class WorkerCore {
98
115
  * without a usable stack, while anything else keeps its default fatality.
99
116
  */
100
117
  #installRejectionGuard(): () => void {
101
- if (isMainThread) {
102
- return postmortem.interceptUnhandledRejections(reason => this.#consumeRejection(reason));
118
+ if (this.#options.mode === "inline") {
119
+ return this.#options.interceptUnhandledRejections(reason => this.#consumeRejection(reason));
103
120
  }
104
121
  const onRejection = (reason: unknown): void => {
105
122
  if (this.#consumeRejection(reason)) return;
@@ -161,7 +178,7 @@ export class WorkerCore {
161
178
  return true;
162
179
  }
163
180
  }
164
- if (!isMainThread && this.#runs.size > 0) {
181
+ if (this.#options.mode === "isolated" && this.#runs.size > 0) {
165
182
  // Dedicated eval worker: during a live run, a rejection without a cell
166
183
  // frame (e.g. `Promise.reject("msg")` or a library-created reason) is
167
184
  // still cell activity — nothing else runs user code in this realm.
@@ -206,7 +223,8 @@ export class WorkerCore {
206
223
  }
207
224
  }
208
225
 
209
- #ensureRuntime(snapshot: SessionSnapshot): JsRuntime {
226
+ #ensureRuntime(snapshot: SessionSnapshot, currentRunId?: string): JsRuntime {
227
+ this.#syncProcessCwd(snapshot.cwd, currentRunId);
210
228
  if (this.#runtime) {
211
229
  this.#runtime.setCwd(snapshot.cwd);
212
230
  return this.#runtime;
@@ -219,6 +237,41 @@ export class WorkerCore {
219
237
  return this.#runtime;
220
238
  }
221
239
 
240
+ #syncProcessCwd(cwd: string, currentRunId?: string): void {
241
+ if (this.#options.mode !== "isolated" || !this.#options.chdir) return;
242
+ try {
243
+ if (process.cwd() === cwd) return;
244
+ } catch {
245
+ // The current cwd was deleted; the chdir below is the recovery.
246
+ }
247
+ // Process cwd is realm-wide state. Moving it while another cell is mid-run
248
+ // would silently redirect that cell's `process.cwd()`, relative fs access,
249
+ // and child spawns, so keep it in place; this run still resolves against
250
+ // its own virtual cwd, and the next cell to start alone lands the move.
251
+ for (const runId of this.#runs.keys()) {
252
+ if (runId === currentRunId) continue;
253
+ this.#transport.send({
254
+ type: "log",
255
+ level: "warn",
256
+ msg: "JS eval subprocess kept its process cwd: other cells are mid-run",
257
+ meta: { cwd },
258
+ });
259
+ return;
260
+ }
261
+ try {
262
+ this.#options.chdir(cwd);
263
+ } catch (error) {
264
+ // `process.chdir` throws when the session cwd no longer exists; keep
265
+ // the cell on the runtime's virtual cwd instead of failing the run.
266
+ this.#transport.send({
267
+ type: "log",
268
+ level: "warn",
269
+ msg: "JS eval subprocess could not enter the session cwd",
270
+ meta: { cwd, error: errorPayload(error) },
271
+ });
272
+ }
273
+ }
274
+
222
275
  async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise<void> {
223
276
  const active: ActiveRun = { runId, filename, pendingTools: new Map(), floatingRejections: [] };
224
277
  this.#runs.set(runId, active);
@@ -229,7 +282,7 @@ export class WorkerCore {
229
282
  };
230
283
  let result: RunResult;
231
284
  try {
232
- const runtime = this.#ensureRuntime(snapshot);
285
+ const runtime = this.#ensureRuntime(snapshot, runId);
233
286
  runtime.setCwd(snapshot.cwd);
234
287
  const value = await runtime.run(code, filename, hooks, { runId, cwd: snapshot.cwd });
235
288
  runtime.displayValue(value, hooks);
@@ -263,7 +316,15 @@ export class WorkerCore {
263
316
  const id = `tc-${active.runId}-${crypto.randomUUID()}`;
264
317
  const { promise, resolve, reject } = Promise.withResolvers<unknown>();
265
318
  active.pendingTools.set(id, { runId: active.runId, resolve, reject });
266
- this.#transport.send({ type: "tool-call", id, runId: active.runId, name, args });
319
+ try {
320
+ this.#transport.send({ type: "tool-call", id, runId: active.runId, name, args });
321
+ } catch (error) {
322
+ // Non-serializable args (DataCloneError from postMessage / IPC send).
323
+ // No reply will ever arrive; fail this call instead of stranding a
324
+ // pending entry until close.
325
+ active.pendingTools.delete(id);
326
+ reject(error);
327
+ }
267
328
  return await promise;
268
329
  }
269
330
 
@@ -34,4 +34,4 @@ const transport: Transport = {
34
34
  },
35
35
  };
36
36
 
37
- new WorkerCore(transport);
37
+ new WorkerCore(transport, { mode: "isolated" });
@@ -1,6 +1,30 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import { PYTHON_PRELUDE } from "../prelude";
3
3
 
4
+ const pythonPath = Bun.env.PYTHON ?? "python3";
5
+
6
+ async function runPrelude(
7
+ code: string,
8
+ env: Record<string, string>,
9
+ ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
10
+ const prelude = PYTHON_PRELUDE.replace(
11
+ "from __future__ import annotations",
12
+ "from __future__ import annotations\n__omp_display = lambda *args, **kwargs: None",
13
+ );
14
+ const script = `${prelude}\n${code}`;
15
+ const proc = Bun.spawn([pythonPath, "-c", script], {
16
+ stdout: "pipe",
17
+ stderr: "pipe",
18
+ env: { ...process.env, ...env },
19
+ });
20
+ const [stdout, stderr, exitCode] = await Promise.all([
21
+ new Response(proc.stdout).text(),
22
+ new Response(proc.stderr).text(),
23
+ proc.exited,
24
+ ]);
25
+ return { stdout, stderr, exitCode };
26
+ }
27
+
4
28
  describe("python prelude", () => {
5
29
  it("exposes read(path, offset?, limit?) with positional optional args", () => {
6
30
  // The eval docs advertise `read(path, offset?=1, limit?=None)`. A
@@ -17,6 +41,54 @@ describe("python prelude", () => {
17
41
  expect(signature).toContain("limit");
18
42
  });
19
43
 
44
+ it("appends line selectors to delegated URI paths", async () => {
45
+ const requests: unknown[] = [];
46
+ const server = Bun.serve({
47
+ hostname: "127.0.0.1",
48
+ port: 0,
49
+ fetch: async request => {
50
+ requests.push(await request.json());
51
+ return Response.json({
52
+ ok: true,
53
+ value: { text: "resource contents", details: { resolvedPath: "/tmp/resource.txt" } },
54
+ });
55
+ },
56
+ });
57
+
58
+ try {
59
+ const result = await runPrelude(
60
+ [`print(read("artifact://21", 3, 2))`, `print(read("mcp://server/resource", 10, 5))`].join("\n"),
61
+ {
62
+ PI_TOOL_BRIDGE_URL: server.url.toString(),
63
+ PI_TOOL_BRIDGE_TOKEN: "test-token",
64
+ PI_TOOL_BRIDGE_SESSION: "test-session",
65
+ },
66
+ );
67
+
68
+ expect(result).toEqual({
69
+ stdout: "resource contents\nresource contents\n",
70
+ stderr: "",
71
+ exitCode: 0,
72
+ });
73
+ expect(requests).toEqual([
74
+ {
75
+ session: "test-session",
76
+ run: null,
77
+ name: "read",
78
+ args: { path: "artifact://21:3-4" },
79
+ },
80
+ {
81
+ session: "test-session",
82
+ run: null,
83
+ name: "read",
84
+ args: { path: "mcp://server/resource:10-14" },
85
+ },
86
+ ]);
87
+ } finally {
88
+ server.stop(true);
89
+ }
90
+ });
91
+
20
92
  it("exposes isolation artifacts on the agent() handle node", () => {
21
93
  // agent(..., handle=True) is the only escape hatch for
22
94
  // recovering apply=False patch/branch/nested artifacts (the bare
@@ -23,7 +23,7 @@ import {
23
23
  resolveExplicitPythonRuntime,
24
24
  resolvePythonRuntime,
25
25
  } from "./runtime";
26
- import { hostHasInheritableConsole, shouldHideKernelWindow } from "./spawn-options";
26
+ import { hostHasInheritableConsole, shouldDetachKernel, shouldHideKernelWindow } from "./spawn-options";
27
27
 
28
28
  export type {
29
29
  KernelExecuteOptions,
@@ -193,6 +193,7 @@ export class PythonKernel extends BaseKernel {
193
193
 
194
194
  const proc = Bun.spawn([runtime.pythonPath, "-u", scriptPath], {
195
195
  cwd: options.cwd,
196
+ detached: shouldDetachKernel(process.platform),
196
197
  env: spawnEnv,
197
198
  stdin: "pipe",
198
199
  stdout: "pipe",