@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.
- package/CHANGELOG.md +26 -0
- package/dist/cli.js +3336 -3318
- package/dist/types/advisor/advise-tool.d.ts +12 -1
- package/dist/types/advisor/runtime.d.ts +41 -1
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/cli/update-cli.d.ts +4 -1
- package/dist/types/cli/usage-cli.d.ts +3 -0
- package/dist/types/cli/usage-error.d.ts +4 -0
- package/dist/types/config/api-key-resolver.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +3 -3
- package/dist/types/config/model-resolver.d.ts +8 -1
- package/dist/types/config/models-config.d.ts +1 -1
- package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
- package/dist/types/eval/bridge-timeout.d.ts +9 -1
- package/dist/types/eval/js/context-manager.d.ts +5 -3
- package/dist/types/eval/js/process-entry.d.ts +6 -0
- package/dist/types/eval/js/worker-core.d.ts +15 -1
- package/dist/types/eval/py/spawn-options.d.ts +10 -0
- package/dist/types/eval/py/tool-bridge.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
- package/dist/types/main.d.ts +1 -0
- package/dist/types/modes/components/transcript-container.d.ts +3 -2
- package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
- package/dist/types/modes/orchestrate.d.ts +1 -1
- package/dist/types/modes/rpc/host-tools.d.ts +2 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
- package/dist/types/modes/ultrathink.d.ts +1 -1
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
- package/dist/types/modes/workflow.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +6 -0
- package/dist/types/session/exit-diagnostics.d.ts +11 -0
- package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
- package/dist/types/subprocess/worker-client.d.ts +6 -0
- package/dist/types/tools/bash-skill-urls.d.ts +1 -0
- package/dist/types/web/search/provider.d.ts +10 -3
- package/dist/types/web/search/providers/codex.d.ts +5 -4
- package/package.json +12 -12
- package/src/advisor/__tests__/advisor.test.ts +830 -42
- package/src/advisor/advise-tool.ts +17 -1
- package/src/advisor/runtime.ts +288 -67
- package/src/autolearn/controller.ts +15 -3
- package/src/cli/args.ts +12 -0
- package/src/cli/auth-broker-cli.ts +30 -11
- package/src/cli/auth-gateway-cli.ts +5 -1
- package/src/cli/dry-balance-cli.ts +14 -4
- package/src/cli/flag-tables.ts +21 -7
- package/src/cli/update-cli.ts +62 -11
- package/src/cli/usage-cli.ts +58 -5
- package/src/cli/usage-error.ts +7 -0
- package/src/cli.ts +23 -1
- package/src/commands/acp.ts +11 -2
- package/src/commands/launch.ts +12 -3
- package/src/commands/token.ts +3 -1
- package/src/config/api-key-resolver.ts +12 -3
- package/src/config/config-file.ts +30 -12
- package/src/config/model-registry.ts +7 -7
- package/src/config/model-resolver.ts +21 -7
- package/src/config/models-config.ts +1 -1
- package/src/eval/__tests__/agent-bridge.test.ts +19 -14
- package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
- package/src/eval/__tests__/js-context-manager.test.ts +158 -1
- package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
- package/src/eval/__tests__/process-entry-import.test.ts +27 -0
- package/src/eval/agent-bridge.ts +121 -116
- package/src/eval/bridge-timeout.ts +20 -2
- package/src/eval/executor-base.ts +85 -7
- package/src/eval/jl/kernel.ts +2 -1
- package/src/eval/js/context-manager.ts +109 -32
- package/src/eval/js/process-entry.ts +27 -0
- package/src/eval/js/shared/runtime.ts +1 -1
- package/src/eval/js/worker-core.ts +70 -9
- package/src/eval/js/worker-entry.ts +1 -1
- package/src/eval/py/kernel.ts +2 -1
- package/src/eval/py/spawn-options.ts +13 -0
- package/src/eval/py/tool-bridge.ts +13 -14
- package/src/eval/rb/kernel.ts +2 -1
- package/src/extensibility/custom-tools/types.ts +3 -0
- package/src/extensibility/extensions/runner.ts +3 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/extensibility/plugins/manager.ts +21 -0
- package/src/internal-urls/memory-protocol.ts +13 -9
- package/src/lsp/client.ts +7 -1
- package/src/main.ts +29 -0
- package/src/mcp/tool-bridge.ts +57 -6
- package/src/modes/components/chat-transcript-builder.ts +22 -1
- package/src/modes/components/status-line/component.ts +10 -1
- package/src/modes/components/transcript-container.ts +110 -7
- package/src/modes/controllers/command-controller.ts +12 -4
- package/src/modes/controllers/event-controller.ts +80 -15
- package/src/modes/controllers/selector-controller.ts +15 -3
- package/src/modes/magic-keyword-boundary.ts +23 -0
- package/src/modes/orchestrate.ts +6 -5
- package/src/modes/print-mode.ts +9 -0
- package/src/modes/rpc/host-tools.ts +15 -0
- package/src/modes/rpc/rpc-mode.ts +123 -48
- package/src/modes/ultrathink.ts +6 -5
- package/src/modes/utils/transcript-render-helpers.ts +54 -0
- package/src/modes/utils/ui-helpers.ts +27 -1
- package/src/modes/workflow.ts +6 -5
- package/src/prompts/advisor/system.md +1 -0
- package/src/sdk.ts +33 -3
- package/src/session/agent-session.ts +239 -17
- package/src/session/exit-diagnostics.ts +108 -0
- package/src/session/streaming-output.ts +40 -12
- package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
- package/src/slash-commands/helpers/logout.ts +23 -3
- package/src/slash-commands/helpers/usage-report.ts +14 -2
- package/src/subprocess/worker-client.ts +9 -2
- package/src/task/executor.ts +8 -0
- package/src/task/render.test.ts +36 -0
- package/src/task/render.ts +55 -43
- package/src/tools/bash-skill-urls.ts +4 -1
- package/src/tools/bash.ts +1 -0
- package/src/tools/write.ts +82 -9
- package/src/tools/yield.ts +29 -1
- package/src/web/search/index.ts +39 -22
- package/src/web/search/provider.ts +33 -16
- 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
|
|
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
|
|
148
|
-
* it answers the `init` handshake
|
|
149
|
-
* fallback). Catches
|
|
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 !== "
|
|
167
|
-
throw new Error("JS eval worker smoke fell back
|
|
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
|
-
//
|
|
241
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
|
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 (
|
|
102
|
-
return
|
|
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 (
|
|
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
|
-
|
|
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
|
|
package/src/eval/py/kernel.ts
CHANGED
|
@@ -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}
|
|
35
|
-
*
|
|
36
|
-
* to fully tear down.
|
|
35
|
+
* Forward a bridge call to {@link callSessionTool} while respecting eval abort
|
|
36
|
+
* shielding.
|
|
37
37
|
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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,
|
package/src/eval/rb/kernel.ts
CHANGED
|
@@ -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(),
|