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

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 (121) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/cli.js +3336 -3318
  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/eval/__tests__/process-entry-import.test.d.ts +1 -0
  14. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  15. package/dist/types/eval/js/context-manager.d.ts +5 -3
  16. package/dist/types/eval/js/process-entry.d.ts +6 -0
  17. package/dist/types/eval/js/worker-core.d.ts +15 -1
  18. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  19. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  20. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  24. package/dist/types/main.d.ts +1 -0
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  27. package/dist/types/modes/orchestrate.d.ts +1 -1
  28. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  29. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  30. package/dist/types/modes/ultrathink.d.ts +1 -1
  31. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  32. package/dist/types/modes/workflow.d.ts +1 -1
  33. package/dist/types/session/agent-session.d.ts +6 -0
  34. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  35. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  36. package/dist/types/subprocess/worker-client.d.ts +6 -0
  37. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  38. package/dist/types/web/search/provider.d.ts +10 -3
  39. package/dist/types/web/search/providers/codex.d.ts +5 -4
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +830 -42
  42. package/src/advisor/advise-tool.ts +17 -1
  43. package/src/advisor/runtime.ts +288 -67
  44. package/src/autolearn/controller.ts +15 -3
  45. package/src/cli/args.ts +12 -0
  46. package/src/cli/auth-broker-cli.ts +30 -11
  47. package/src/cli/auth-gateway-cli.ts +5 -1
  48. package/src/cli/dry-balance-cli.ts +14 -4
  49. package/src/cli/flag-tables.ts +21 -7
  50. package/src/cli/update-cli.ts +62 -11
  51. package/src/cli/usage-cli.ts +58 -5
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/cli.ts +23 -1
  54. package/src/commands/acp.ts +11 -2
  55. package/src/commands/launch.ts +12 -3
  56. package/src/commands/token.ts +3 -1
  57. package/src/config/api-key-resolver.ts +12 -3
  58. package/src/config/config-file.ts +30 -12
  59. package/src/config/model-registry.ts +7 -7
  60. package/src/config/model-resolver.ts +21 -7
  61. package/src/config/models-config.ts +1 -1
  62. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  63. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  64. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  65. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  66. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  67. package/src/eval/agent-bridge.ts +121 -116
  68. package/src/eval/bridge-timeout.ts +20 -2
  69. package/src/eval/executor-base.ts +85 -7
  70. package/src/eval/jl/kernel.ts +2 -1
  71. package/src/eval/js/context-manager.ts +109 -32
  72. package/src/eval/js/process-entry.ts +27 -0
  73. package/src/eval/js/shared/runtime.ts +1 -1
  74. package/src/eval/js/worker-core.ts +70 -9
  75. package/src/eval/js/worker-entry.ts +1 -1
  76. package/src/eval/py/kernel.ts +2 -1
  77. package/src/eval/py/spawn-options.ts +13 -0
  78. package/src/eval/py/tool-bridge.ts +13 -14
  79. package/src/eval/rb/kernel.ts +2 -1
  80. package/src/extensibility/custom-tools/types.ts +3 -0
  81. package/src/extensibility/extensions/runner.ts +3 -0
  82. package/src/extensibility/extensions/types.ts +3 -0
  83. package/src/extensibility/plugins/manager.ts +21 -0
  84. package/src/internal-urls/memory-protocol.ts +13 -9
  85. package/src/lsp/client.ts +7 -1
  86. package/src/main.ts +29 -0
  87. package/src/mcp/tool-bridge.ts +57 -6
  88. package/src/modes/components/chat-transcript-builder.ts +22 -1
  89. package/src/modes/components/status-line/component.ts +10 -1
  90. package/src/modes/components/transcript-container.ts +110 -7
  91. package/src/modes/controllers/command-controller.ts +12 -4
  92. package/src/modes/controllers/event-controller.ts +80 -15
  93. package/src/modes/controllers/selector-controller.ts +15 -3
  94. package/src/modes/magic-keyword-boundary.ts +23 -0
  95. package/src/modes/orchestrate.ts +6 -5
  96. package/src/modes/print-mode.ts +9 -0
  97. package/src/modes/rpc/host-tools.ts +15 -0
  98. package/src/modes/rpc/rpc-mode.ts +123 -48
  99. package/src/modes/ultrathink.ts +6 -5
  100. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  101. package/src/modes/utils/ui-helpers.ts +27 -1
  102. package/src/modes/workflow.ts +6 -5
  103. package/src/prompts/advisor/system.md +1 -0
  104. package/src/sdk.ts +33 -3
  105. package/src/session/agent-session.ts +239 -17
  106. package/src/session/exit-diagnostics.ts +108 -0
  107. package/src/session/streaming-output.ts +40 -12
  108. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  109. package/src/slash-commands/helpers/logout.ts +23 -3
  110. package/src/slash-commands/helpers/usage-report.ts +14 -2
  111. package/src/subprocess/worker-client.ts +9 -2
  112. package/src/task/executor.ts +8 -0
  113. package/src/task/render.test.ts +36 -0
  114. package/src/task/render.ts +55 -43
  115. package/src/tools/bash-skill-urls.ts +4 -1
  116. package/src/tools/bash.ts +1 -0
  117. package/src/tools/write.ts +82 -9
  118. package/src/tools/yield.ts +29 -1
  119. package/src/web/search/index.ts +39 -22
  120. package/src/web/search/provider.ts +33 -16
  121. package/src/web/search/providers/codex.ts +68 -21
@@ -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" });
@@ -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",
@@ -40,6 +40,19 @@ export function shouldHideKernelWindow(opts: {
40
40
  return !opts.hostHasInheritableConsole;
41
41
  }
42
42
 
43
+ /**
44
+ * Keep eval kernels outside the host's POSIX terminal session.
45
+ *
46
+ * User code can start an interactive shell which calls `tcsetpgrp(3)`. If the
47
+ * kernel shares OMP's session, that shell can replace OMP as the controlling
48
+ * terminal's foreground process group and the host is then stopped by SIGTTIN
49
+ * on its next stdin read. Bun implements `detached: true` with `setsid(2)` on
50
+ * POSIX, making the kernel a session leader with no controlling terminal.
51
+ */
52
+ export function shouldDetachKernel(platform: NodeJS.Platform): boolean {
53
+ return platform !== "win32";
54
+ }
55
+
43
56
  /**
44
57
  * TTY-based fallback used when the Win32 console probe is unavailable.
45
58
  *
@@ -15,6 +15,7 @@ export interface PyToolBridgeEntry {
15
15
  toolSession: ToolSession;
16
16
  signal?: AbortSignal;
17
17
  emitStatus?: (event: JsStatusEvent) => void;
18
+ abortRequested?: () => boolean;
18
19
  }
19
20
 
20
21
  export interface PyToolBridgeInfo {
@@ -31,23 +32,21 @@ const registrations = new Map<string, PyToolBridgeEntry>();
31
32
  let serverPromise: Promise<BridgeServer> | null = null;
32
33
 
33
34
  /**
34
- * Forward a bridge call to {@link callSessionTool}, but resolve the HTTP request
35
- * the instant the cell's signal aborts instead of waiting for the tool/subagent
36
- * to fully tear down.
35
+ * Forward a bridge call to {@link callSessionTool} while respecting eval abort
36
+ * shielding.
37
37
  *
38
- * The kernel invokes this bridge with a *blocking* `urllib` request from a
39
- * worker thread (each `agent()` / `tool.*` call). When the cell is interrupted,
40
- * `parallel()`'s `ThreadPoolExecutor.__exit__` joins those worker threads
41
- * (`shutdown(wait=True)`), so they cannot unwind until their `urllib` call
42
- * returns i.e. until this handler responds. A host-side `agent()` teardown
43
- * (aborting nested LLM streams + tools across a wide fan-out) routinely exceeds
44
- * the kernel's SIGINT escalation window, so the kernel was hard-killed and its
45
- * persistent state lost while the subagents were still winding down. Responding
46
- * immediately on abort lets the kernel raise through the blocked call and settle
47
- * cleanly (preserving state); the already-signaled call keeps tearing down in
48
- * the background, its eventual result/rejection swallowed.
38
+ * Python invokes this bridge with blocking `urllib` requests from worker threads
39
+ * (each `agent()` / `tool.*` call). The base executor defers the registered
40
+ * signal while a bridge call is already paused so in-flight subagents can finish
41
+ * and persist output instead of being orphaned. Once an abort has been requested,
42
+ * later bridge calls are rejected before starting; once the shielded signal
43
+ * finally aborts, this handler still resolves the HTTP request promptly so the
44
+ * kernel can unwind without being hard-killed.
49
45
  */
50
46
  async function callSessionToolPromptOnAbort(name: string, args: unknown, entry: PyToolBridgeEntry): Promise<unknown> {
47
+ if (entry.abortRequested?.()) {
48
+ throw new Error(`bridge call ${JSON.stringify(name)} aborted: eval cell was interrupted`);
49
+ }
51
50
  const call = callSessionTool(name, args, {
52
51
  session: entry.toolSession,
53
52
  signal: entry.signal,
@@ -16,7 +16,7 @@ import { $ } from "bun";
16
16
  import { Settings } from "../../config/settings";
17
17
  import { BaseKernel, getRemainingTimeMs, type KernelRuntimeEnv, type KernelStartOptions } from "../kernel-base";
18
18
  import type { KernelDisplayOutput } from "../py/display";
19
- import { hostHasInheritableConsole, shouldHideKernelWindow } from "../py/spawn-options";
19
+ import { hostHasInheritableConsole, shouldDetachKernel, shouldHideKernelWindow } from "../py/spawn-options";
20
20
  import { RUBY_PRELUDE } from "./prelude";
21
21
  import RUNNER_SCRIPT from "./runner.rb" with { type: "text" };
22
22
  import {
@@ -186,6 +186,7 @@ export class RubyKernel extends BaseKernel<KernelExecuteOptions> {
186
186
 
187
187
  const proc = Bun.spawn([runtime.rubyPath, scriptPath], {
188
188
  cwd: options.cwd,
189
+ detached: shouldDetachKernel(process.platform),
189
190
  env: spawnEnv,
190
191
  stdin: "pipe",
191
192
  stdout: "pipe",
@@ -23,6 +23,7 @@ import type { Settings } from "../../config/settings";
23
23
  import type { ExecOptions, ExecResult } from "../../exec/exec";
24
24
  import type { HookUIContext } from "../../extensibility/hooks/types";
25
25
  import type * as PiCodingAgent from "../../index";
26
+ import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
26
27
  import type { Theme } from "../../modes/theme/theme";
27
28
  import type { ReadonlySessionManager } from "../../session/session-manager";
28
29
  import type { TodoItem } from "../../tools/todo";
@@ -96,6 +97,8 @@ export interface CustomToolContext {
96
97
  settings?: Settings;
97
98
  /** Fetch implementation for outbound HTTP; defaults to global fetch when omitted. */
98
99
  fetch?: FetchImpl;
100
+ /** Calling session's `local://` root mapping for tools that bridge out of the OMP process. */
101
+ localProtocolOptions?: LocalProtocolOptions;
99
102
  /** Whether to auto-approve all destructive tool operations (--auto-approve CLI flag) */
100
103
  autoApprove?: boolean;
101
104
  }
@@ -7,6 +7,7 @@ import type { KeyId } from "@oh-my-pi/pi-tui";
7
7
  import { logger } from "@oh-my-pi/pi-utils";
8
8
  import type { ModelRegistry } from "../../config/model-registry";
9
9
  import type { Settings } from "../../config/settings";
10
+ import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
10
11
  import type { MemoryRuntimeContext } from "../../memory-backend";
11
12
  import { type Theme, theme } from "../../modes/theme/theme";
12
13
  import type { SessionManager } from "../../session/session-manager";
@@ -256,6 +257,7 @@ export class ExtensionRunner {
256
257
  private readonly modelRegistry: ModelRegistry,
257
258
  getMemory?: () => MemoryRuntimeContext | undefined,
258
259
  private readonly settings?: Settings,
260
+ private readonly localProtocolOptions?: LocalProtocolOptions,
259
261
  ) {
260
262
  this.#uiContext = noOpUIContext;
261
263
  this.#getMemoryFn = getMemory;
@@ -538,6 +540,7 @@ export class ExtensionRunner {
538
540
  hasPendingMessages: () => this.#hasPendingMessagesFn(),
539
541
  shutdown: () => this.#shutdownHandler(),
540
542
  getSystemPrompt: () => this.#getSystemPromptFn(),
543
+ localProtocolOptions: this.localProtocolOptions,
541
544
  memory: this.#getMemoryFn?.(),
542
545
  };
543
546
  }
@@ -41,6 +41,7 @@ import type { PythonResult } from "../../eval/py/executor";
41
41
  import type { BashResult } from "../../exec/bash-executor";
42
42
  import type { ExecOptions, ExecResult } from "../../exec/exec";
43
43
  import type * as PiCodingAgent from "../../index";
44
+ import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
44
45
  import type { MemoryRuntimeContext } from "../../memory-backend";
45
46
  import type { CustomEditor } from "../../modes/components/custom-editor";
46
47
  import type { Theme } from "../../modes/theme/theme";
@@ -420,6 +421,8 @@ export interface ExtensionContext {
420
421
  sessionManager: ReadonlySessionManager;
421
422
  /** Model registry for API key resolution */
422
423
  modelRegistry: ModelRegistry;
424
+ /** Calling session's `local://` root mapping for external tool bridges. */
425
+ localProtocolOptions?: LocalProtocolOptions;
423
426
  /** Current model (may be undefined) */
424
427
  model: Model | undefined;
425
428
  /** Read-only model query facade: list / current / resolve / family. */
@@ -211,6 +211,16 @@ export class PluginManager {
211
211
  }
212
212
  }
213
213
 
214
+ async #removeDependencyEntry(pkgJsonPath: string, name: string): Promise<void> {
215
+ const pkgJson: { dependencies?: Record<string, string>; [key: string]: unknown } =
216
+ await Bun.file(pkgJsonPath).json();
217
+ if (!pkgJson.dependencies || !(name in pkgJson.dependencies)) {
218
+ return;
219
+ }
220
+ delete pkgJson.dependencies[name];
221
+ await Bun.write(pkgJsonPath, JSON.stringify(pkgJson, null, 2));
222
+ }
223
+
214
224
  #collectInstalledNames(deps: Record<string, string>, config: PluginRuntimeConfig): Set<string> {
215
225
  const installedNames = new Set<string>();
216
226
  for (const name of Object.keys(deps)) {
@@ -450,6 +460,17 @@ export class PluginManager {
450
460
  // validation throws.
451
461
  let actualName: string | undefined;
452
462
  try {
463
+ // Bun treats a dependency replacement from `repo#old-ref` to the same
464
+ // package at `repo`/`repo#new-ref` as a self-edge and bails with
465
+ // DependencyLoop. Remove only the stale manifest edge; rollback restores
466
+ // the original package.json and node_modules snapshot on failure.
467
+ if (gitSource && existingActualName) {
468
+ const installedSource = parseGitUrl(depsBefore[existingActualName] ?? "");
469
+ if (installedSource && installedSource.ref !== gitSource.ref) {
470
+ await this.#removeDependencyEntry(pkgJsonPath, existingActualName);
471
+ }
472
+ }
473
+
453
474
  // Step 1: write the spec into plugins/package.json + node_modules.
454
475
  const installProc = Bun.spawn(["bun", "install", packageInstallSpec], {
455
476
  cwd: getPluginsDir(),