@oh-my-pi/pi-coding-agent 14.9.5 → 14.9.7

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 (44) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/package.json +7 -7
  3. package/src/cli/setup-cli.ts +14 -161
  4. package/src/cli/stats-cli.ts +56 -2
  5. package/src/cli.ts +0 -1
  6. package/src/config/settings-schema.ts +0 -10
  7. package/src/eval/eval.lark +30 -10
  8. package/src/eval/js/context-manager.ts +334 -564
  9. package/src/eval/js/shared/helpers.ts +237 -0
  10. package/src/eval/js/shared/indirect-eval.ts +30 -0
  11. package/src/eval/js/shared/rewrite-imports.ts +211 -0
  12. package/src/eval/js/shared/runtime.ts +168 -0
  13. package/src/eval/js/shared/types.ts +18 -0
  14. package/src/eval/js/tool-bridge.ts +2 -4
  15. package/src/eval/js/worker-core.ts +146 -0
  16. package/src/eval/js/worker-entry.ts +24 -0
  17. package/src/eval/js/worker-protocol.ts +41 -0
  18. package/src/eval/parse.ts +218 -49
  19. package/src/eval/py/display.ts +71 -0
  20. package/src/eval/py/executor.ts +74 -89
  21. package/src/eval/py/index.ts +1 -2
  22. package/src/eval/py/kernel.ts +472 -900
  23. package/src/eval/py/prelude.py +95 -7
  24. package/src/eval/py/runner.py +879 -0
  25. package/src/eval/py/runtime.ts +3 -16
  26. package/src/eval/py/tool-bridge.ts +137 -0
  27. package/src/export/html/template.generated.ts +1 -1
  28. package/src/export/html/template.js +93 -5
  29. package/src/internal-urls/docs-index.generated.ts +3 -3
  30. package/src/modes/controllers/command-controller.ts +0 -23
  31. package/src/prompts/tools/eval.md +14 -27
  32. package/src/session/agent-session.ts +0 -1
  33. package/src/session/history-storage.ts +77 -19
  34. package/src/tools/browser/tab-protocol.ts +4 -0
  35. package/src/tools/browser/tab-supervisor.ts +86 -5
  36. package/src/tools/browser/tab-worker.ts +104 -58
  37. package/src/tools/eval.ts +1 -1
  38. package/src/web/search/index.ts +6 -4
  39. package/src/cli/jupyter-cli.ts +0 -106
  40. package/src/commands/jupyter.ts +0 -32
  41. package/src/eval/py/cancellation.ts +0 -28
  42. package/src/eval/py/gateway-coordinator.ts +0 -424
  43. /package/src/eval/js/{prelude.ts → shared/prelude.ts} +0 -0
  44. /package/src/eval/js/{prelude.txt → shared/prelude.txt} +0 -0
@@ -1,21 +1,25 @@
1
- import * as fs from "node:fs";
2
- import { createRequire } from "node:module";
3
- import * as path from "node:path";
4
- import { pathToFileURL } from "node:url";
5
- import * as util from "node:util";
6
- import * as vm from "node:vm";
7
-
8
- import { parse as babelParse } from "@babel/parser";
9
- import * as Diff from "diff";
1
+ import { logger, Snowflake } from "@oh-my-pi/pi-utils";
10
2
  import type { ToolSession } from "../../tools";
11
- import { ToolError } from "../../tools/tool-errors";
12
- import { JAVASCRIPT_PRELUDE_SOURCE } from "./prelude";
3
+ import { ToolAbortError, ToolError } from "../../tools/tool-errors";
13
4
  import { callSessionTool, type JsStatusEvent } from "./tool-bridge";
14
-
15
- export type JsDisplayOutput =
16
- | { type: "json"; data: unknown }
17
- | { type: "image"; data: string; mimeType: string }
18
- | { type: "status"; event: JsStatusEvent };
5
+ import { WorkerCore } from "./worker-core";
6
+ // Imported with `type: "file"` so Bun's bundler statically discovers the worker entry and
7
+ // embeds it inside `bun build --compile` single-file binaries. Mirrors the browser tab
8
+ // worker setup; see packages/coding-agent/src/tools/browser/tab-supervisor.ts for the
9
+ // rationale.
10
+ // @ts-expect-error -- Bun file-URL import attribute is not modeled by tsgo.
11
+ import jsWorkerEntryUrl from "./worker-entry.ts" with { type: "file" };
12
+ import type {
13
+ JsDisplayOutput,
14
+ RunErrorPayload,
15
+ SessionSnapshot,
16
+ Transport,
17
+ WorkerInbound,
18
+ WorkerOutbound,
19
+ } from "./worker-protocol";
20
+
21
+ export { rewriteStaticImports } from "./shared/rewrite-imports";
22
+ export type { JsDisplayOutput } from "./worker-protocol";
19
23
 
20
24
  export interface VmRunState {
21
25
  signal?: AbortSignal;
@@ -23,617 +27,383 @@ export interface VmRunState {
23
27
  onDisplay?: (output: JsDisplayOutput) => void;
24
28
  }
25
29
 
26
- interface VmHelperOptions {
27
- path?: string;
28
- hidden?: boolean;
29
- maxDepth?: number;
30
- limit?: number;
31
- offset?: number;
32
- reverse?: boolean;
33
- unique?: boolean;
34
- count?: boolean;
30
+ interface WorkerHandle {
31
+ mode: "worker" | "inline";
32
+ send(msg: WorkerInbound): void;
33
+ onMessage(handler: (msg: WorkerOutbound) => void): () => void;
34
+ terminate(): Promise<void>;
35
+ }
36
+
37
+ interface PendingRun {
38
+ runId: string;
39
+ runState: VmRunState;
40
+ toolSession: ToolSession;
41
+ resolve(value: { value: unknown }): void;
42
+ reject(error: Error): void;
43
+ toolCalls: Map<string, AbortController>;
44
+ settled: boolean;
35
45
  }
36
46
 
37
- interface VmContextState {
47
+ interface JsSession {
38
48
  sessionKey: string;
39
- cwd: string;
40
- sessionId: string;
41
- session: ToolSession;
42
- context: vm.Context;
43
- env: Map<string, string>;
44
- timers: Set<NodeJS.Timeout>;
45
- intervals: Set<NodeJS.Timeout>;
46
- currentRun?: VmRunState;
49
+ worker: WorkerHandle;
50
+ state: "alive" | "dead";
51
+ pending: Map<string, PendingRun>;
47
52
  queue: Promise<void>;
48
53
  }
49
54
 
50
- const vmContexts = new Map<string, VmContextState>();
51
- const utf8Encoder = new TextEncoder();
55
+ const sessions = new Map<string, JsSession>();
56
+ const READY_TIMEOUT_MS = 5_000;
52
57
 
53
- function getMergedEnv(state: VmContextState): Record<string, string> {
54
- const env: Record<string, string> = {};
55
- for (const [key, value] of Object.entries(Bun.env)) {
56
- if (typeof value === "string") {
57
- env[key] = value;
58
- }
59
- }
60
- for (const [key, value] of state.env) {
61
- env[key] = value;
58
+ export async function executeInVmContext(options: {
59
+ sessionKey: string;
60
+ sessionId: string;
61
+ cwd: string;
62
+ session: ToolSession;
63
+ reset?: boolean;
64
+ code: string;
65
+ filename: string;
66
+ timeoutMs?: number;
67
+ runState: VmRunState;
68
+ }): Promise<{ value: unknown }> {
69
+ if (options.reset) {
70
+ await resetVmContext(options.sessionKey);
62
71
  }
63
- return env;
72
+ const session = await acquireSession(options.sessionKey, {
73
+ cwd: options.cwd,
74
+ sessionId: options.sessionId,
75
+ });
76
+ return await runQueued(session, () => runOnce(session, options));
64
77
  }
65
78
 
66
- function resolvePath(state: VmContextState, value: string): string {
67
- if (value.includes("://")) {
68
- throw new ToolError(`Protocol paths are not supported by this helper: ${value}`);
69
- }
70
- return path.isAbsolute(value) ? path.normalize(value) : path.join(state.cwd, value);
79
+ export async function resetVmContext(sessionKey: string): Promise<void> {
80
+ const session = sessions.get(sessionKey);
81
+ if (!session) return;
82
+ sessions.delete(sessionKey);
83
+ await killSession(session, new ToolError("JS context reset"));
71
84
  }
72
85
 
73
- async function resolveRegularFile(
74
- state: VmContextState,
75
- rawPath: string,
76
- ): Promise<{ filePath: string; file: Bun.BunFile; size: number }> {
77
- const filePath = resolvePath(state, rawPath);
78
- const file = Bun.file(filePath);
79
- const info = await file.stat().catch(() => undefined);
80
- if (!info) {
81
- throw new ToolError(`File not found: ${filePath}`);
82
- }
83
- if (info.isDirectory()) {
84
- throw new ToolError(`Directory paths are not supported by this helper: ${filePath}`);
85
- }
86
- return { filePath, file, size: info.size };
86
+ export async function disposeAllVmContexts(): Promise<void> {
87
+ const all = [...sessions.values()];
88
+ sessions.clear();
89
+ await Promise.all(all.map(session => killSession(session, new ToolError("JS context disposed"))));
87
90
  }
88
91
 
89
- function getDataSize(data: string | Blob | ArrayBuffer | ArrayBufferView): number {
90
- if (typeof data === "string") {
91
- return utf8Encoder.encode(data).byteLength;
92
- }
93
- if (data instanceof Blob) {
94
- return data.size;
95
- }
96
- if (data instanceof ArrayBuffer) {
97
- return data.byteLength;
92
+ async function runQueued<T>(session: JsSession, work: () => Promise<T>): Promise<T> {
93
+ const previous = session.queue;
94
+ const { promise, resolve } = Promise.withResolvers<void>();
95
+ session.queue = promise;
96
+ try {
97
+ await previous;
98
+ } catch {
99
+ // Previous run's failure must not poison this one.
98
100
  }
99
- return data.byteLength;
100
- }
101
-
102
- function isWriteData(value: unknown): value is string | Blob | ArrayBuffer | ArrayBufferView {
103
- return (
104
- typeof value === "string" || value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)
105
- );
106
- }
107
-
108
- function emitText(state: VmContextState, text: string): void {
109
- if (!text) return;
110
- state.currentRun?.onText?.(text.endsWith("\n") ? text : `${text}\n`);
111
- }
112
-
113
- function emitStatus(state: VmContextState, event: JsStatusEvent): void {
114
- state.currentRun?.onDisplay?.({ type: "status", event });
115
- }
116
-
117
- function displayValue(state: VmContextState, value: unknown): void {
118
- if (value === undefined) return;
119
- if (value && typeof value === "object") {
120
- const record = value as Record<string, unknown>;
121
- if (record.type === "image" && typeof record.data === "string" && typeof record.mimeType === "string") {
122
- state.currentRun?.onDisplay?.({
123
- type: "image",
124
- data: record.data,
125
- mimeType: record.mimeType,
126
- });
127
- return;
128
- }
129
- state.currentRun?.onDisplay?.({
130
- type: "json",
131
- data: structuredClone(value),
132
- });
133
- return;
101
+ try {
102
+ return await work();
103
+ } finally {
104
+ resolve();
134
105
  }
135
- emitText(state, String(value));
136
106
  }
137
107
 
138
- function formatConsoleArgs(args: unknown[]): string {
139
- return args
140
- .map(arg => (typeof arg === "string" ? arg : util.inspect(arg, { depth: 6, colors: false, breakLength: 120 })))
141
- .join(" ");
142
- }
143
-
144
- function createTrackedTimeout(state: VmContextState, repeat: boolean) {
145
- return (callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => {
146
- const fn = () => callback(...args);
147
- const timer = repeat ? setInterval(fn, delay) : setTimeout(fn, delay);
148
- if (repeat) {
149
- state.intervals.add(timer);
150
- } else {
151
- state.timers.add(timer);
152
- }
153
- return timer;
108
+ async function runOnce(
109
+ session: JsSession,
110
+ options: {
111
+ sessionId: string;
112
+ cwd: string;
113
+ session: ToolSession;
114
+ code: string;
115
+ filename: string;
116
+ runState: VmRunState;
117
+ },
118
+ ): Promise<{ value: unknown }> {
119
+ const runId = `r-${Snowflake.next()}`;
120
+ const { promise, resolve, reject } = Promise.withResolvers<{ value: unknown }>();
121
+ const pending: PendingRun = {
122
+ runId,
123
+ runState: options.runState,
124
+ toolSession: options.session,
125
+ resolve,
126
+ reject,
127
+ toolCalls: new Map(),
128
+ settled: false,
129
+ };
130
+ session.pending.set(runId, pending);
131
+
132
+ const onAbort = (): void => {
133
+ const reason = options.runState.signal?.reason;
134
+ const abortError = reasonToError(reason, "Execution aborted");
135
+ // Cancel any in-flight tool calls first.
136
+ for (const ctrl of pending.toolCalls.values()) ctrl.abort(abortError);
137
+ // Hard-kill the worker — only way to interrupt synchronous user code.
138
+ void killSessionFor(session, abortError);
154
139
  };
155
- }
156
140
 
157
- function clearTrackedTimeout(state: VmContextState, repeat: boolean, timer: NodeJS.Timeout | undefined): void {
158
- if (!timer) return;
159
- if (repeat) {
160
- clearInterval(timer);
161
- state.intervals.delete(timer);
162
- return;
141
+ if (options.runState.signal?.aborted) {
142
+ queueMicrotask(onAbort);
143
+ } else {
144
+ options.runState.signal?.addEventListener("abort", onAbort, { once: true });
163
145
  }
164
- clearTimeout(timer);
165
- state.timers.delete(timer);
166
- }
167
146
 
168
- async function createHelpers(state: VmContextState) {
169
- return {
170
- read: async (rawPath: string, options: VmHelperOptions = {}): Promise<string> => {
171
- const { filePath, file, size } = await resolveRegularFile(state, rawPath);
172
- let text = await file.text();
173
- const offset = typeof options.offset === "number" ? options.offset : 1;
174
- const limit = typeof options.limit === "number" ? options.limit : undefined;
175
- if (offset > 1 || limit !== undefined) {
176
- const lines = text.split(/\r?\n/);
177
- const start = Math.max(0, offset - 1);
178
- const end = limit !== undefined ? start + limit : lines.length;
179
- text = lines.slice(start, end).join("\n");
180
- }
181
- emitStatus(state, { op: "read", path: filePath, bytes: size, chars: text.length });
182
- return text;
183
- },
184
- writeFile: async (rawPath: string, data: unknown): Promise<string> => {
185
- if (!isWriteData(data)) {
186
- throw new ToolError("write() expects string, Blob, ArrayBuffer, or TypedArray data");
187
- }
188
- const filePath = resolvePath(state, rawPath);
189
- if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) {
190
- await Bun.write(filePath, data);
191
- } else {
192
- await Bun.write(filePath, new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
193
- }
194
- emitStatus(state, { op: "write", path: filePath, bytes: getDataSize(data) });
195
- return filePath;
196
- },
197
- append: async (rawPath: string, content: string): Promise<string> => {
198
- const target = resolvePath(state, rawPath);
199
- await Bun.write(
200
- target,
201
- `${await Bun.file(target)
202
- .text()
203
- .catch(() => "")}${content}`,
204
- );
205
- emitStatus(state, {
206
- op: "append",
207
- path: target,
208
- chars: content.length,
209
- bytes: utf8Encoder.encode(content).byteLength,
210
- });
211
- return target;
212
- },
213
- sortText: (text: string, options: VmHelperOptions = {}): string => {
214
- const lines = String(text).split(/\r?\n/);
215
- const deduped = options.unique ? Array.from(new Set(lines)) : lines;
216
- const sorted = deduped.sort((a, b) => a.localeCompare(b));
217
- if (options.reverse) {
218
- sorted.reverse();
219
- }
220
- const result = sorted.join("\n");
221
- emitStatus(state, {
222
- op: "sort",
223
- lines: sorted.length,
224
- reverse: options.reverse === true,
225
- unique: options.unique === true,
226
- });
227
- return result;
228
- },
229
- uniqText: (text: string, options: VmHelperOptions = {}): string | Array<[number, string]> => {
230
- const lines = String(text)
231
- .split(/\r?\n/)
232
- .filter(line => line.length > 0);
233
- const groups: Array<[number, string]> = [];
234
- for (const line of lines) {
235
- const last = groups.at(-1);
236
- if (last && last[1] === line) {
237
- last[0] += 1;
238
- continue;
239
- }
240
- groups.push([1, line]);
241
- }
242
- emitStatus(state, { op: "uniq", groups: groups.length, count_mode: options.count === true });
243
- if (options.count) {
244
- return groups;
245
- }
246
- return groups.map(([, line]) => line).join("\n");
247
- },
248
- counter: (items: string | string[], options: VmHelperOptions = {}): Array<[number, string]> => {
249
- const values = Array.isArray(items) ? items : String(items).split(/\r?\n/).filter(Boolean);
250
- const counts = new Map<string, number>();
251
- for (const item of values) {
252
- counts.set(item, (counts.get(item) ?? 0) + 1);
253
- }
254
- const entries = Array.from(counts.entries())
255
- .map(([item, count]) => [count, item] as [number, string])
256
- .sort((a, b) => (options.reverse === false ? a[0] - b[0] : b[0] - a[0]) || a[1].localeCompare(b[1]));
257
- const limited = entries.slice(0, options.limit ?? entries.length);
258
- emitStatus(state, { op: "counter", unique: counts.size, total: values.length, top: limited.slice(0, 10) });
259
- return limited;
260
- },
261
- diff: async (rawA: string, rawB: string): Promise<string> => {
262
- const fileA = resolvePath(state, rawA);
263
- const fileB = resolvePath(state, rawB);
264
- const [a, b] = await Promise.all([Bun.file(fileA).text(), Bun.file(fileB).text()]);
265
- const result = Diff.createTwoFilesPatch(fileA, fileB, a, b, "", "", { context: 3 });
266
- emitStatus(state, {
267
- op: "diff",
268
- file_a: fileA,
269
- file_b: fileB,
270
- identical: a === b,
271
- preview: result.slice(0, 500),
272
- });
273
- return result;
274
- },
275
- tree: async (searchPath = ".", options: VmHelperOptions = {}): Promise<string> => {
276
- const root = resolvePath(state, searchPath);
277
- const maxDepth = options.maxDepth ?? 3;
278
- const showHidden = options.hidden ?? false;
279
- const lines: string[] = [`${root}/`];
280
- let entryCount = 0;
281
- const walk = async (dir: string, prefix: string, depth: number): Promise<void> => {
282
- if (depth > maxDepth) return;
283
- const entries = (await fs.promises.readdir(dir, { withFileTypes: true }))
284
- .filter(entry => showHidden || !entry.name.startsWith("."))
285
- .sort((a, b) => a.name.localeCompare(b.name));
286
- for (let index = 0; index < entries.length; index++) {
287
- const entry = entries[index];
288
- const isLast = index === entries.length - 1;
289
- const connector = isLast ? "└── " : "├── ";
290
- const suffix = entry.isDirectory() ? "/" : "";
291
- lines.push(`${prefix}${connector}${entry.name}${suffix}`);
292
- entryCount += 1;
293
- if (entry.isDirectory()) {
294
- await walk(path.join(dir, entry.name), `${prefix}${isLast ? " " : "│ "}`, depth + 1);
295
- }
296
- }
297
- };
298
- await walk(root, "", 1);
299
- const result = lines.join("\n");
300
- emitStatus(state, { op: "tree", path: root, entries: entryCount, preview: result.slice(0, 1000) });
301
- return result;
302
- },
303
- env: (key?: string, value?: string): string | Record<string, string> | undefined => {
304
- if (!key) {
305
- const env = Object.fromEntries(Object.entries(getMergedEnv(state)).sort(([a], [b]) => a.localeCompare(b)));
306
- emitStatus(state, { op: "env", count: Object.keys(env).length, keys: Object.keys(env).slice(0, 20) });
307
- return env;
308
- }
309
- if (value !== undefined) {
310
- state.env.set(key, value);
311
- emitStatus(state, { op: "env", key, value, action: "set" });
312
- return value;
313
- }
314
- const result = state.env.get(key) ?? Bun.env[key];
315
- emitStatus(state, { op: "env", key, value: result, action: "get" });
316
- return result;
317
- },
318
- };
147
+ try {
148
+ session.worker.send({
149
+ type: "run",
150
+ runId,
151
+ code: options.code,
152
+ filename: options.filename,
153
+ snapshot: { cwd: options.cwd, sessionId: options.sessionId },
154
+ });
155
+ return await promise;
156
+ } finally {
157
+ options.runState.signal?.removeEventListener("abort", onAbort);
158
+ session.pending.delete(runId);
159
+ }
319
160
  }
320
161
 
321
- function createProcessSubset(cwd: string): Record<string, unknown> {
322
- return Object.freeze({
323
- arch: process.arch,
324
- cwd: () => cwd,
325
- platform: process.platform,
326
- release: Object.freeze({ ...process.release }),
327
- version: process.version,
328
- versions: Object.freeze({ ...process.versions }),
329
- });
330
- }
162
+ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot): Promise<JsSession> {
163
+ const existing = sessions.get(sessionKey);
164
+ if (existing && existing.state === "alive") return existing;
331
165
 
332
- async function createVmState(
333
- sessionKey: string,
334
- sessionId: string,
335
- cwd: string,
336
- session: ToolSession,
337
- ): Promise<VmContextState> {
338
- const state: VmContextState = {
166
+ const worker = await spawnJsWorker();
167
+ const session: JsSession = {
339
168
  sessionKey,
340
- cwd,
341
- sessionId,
342
- session,
343
- context: {} as vm.Context,
344
- env: new Map(),
345
- timers: new Set(),
346
- intervals: new Set(),
169
+ worker,
170
+ state: "alive",
171
+ pending: new Map(),
347
172
  queue: Promise.resolve(),
348
173
  };
349
-
350
- const helpers = await createHelpers(state);
351
- const contextGlobals: Record<string, unknown> = {
352
- __omp_session__: { cwd, sessionId },
353
- __omp_helpers__: helpers,
354
- __omp_call_tool__: async (name: string, args: unknown) =>
355
- callSessionTool(name, args, {
356
- session: state.session,
357
- signal: state.currentRun?.signal,
358
- emitStatus: event => emitStatus(state, event),
359
- }),
360
- __omp_emit_status__: (op: string, data: Record<string, unknown> = {}) => emitStatus(state, { op, ...data }),
361
- __omp_log__: (level: string, ...args: unknown[]) => {
362
- const prefix = level === "error" ? "[error] " : level === "warn" ? "[warn] " : "";
363
- emitText(state, `${prefix}${formatConsoleArgs(args)}`);
364
- },
365
- __omp_display__: (value: unknown) => displayValue(state, value),
366
- setTimeout: createTrackedTimeout(state, false),
367
- setInterval: createTrackedTimeout(state, true),
368
- clearTimeout: (timer?: NodeJS.Timeout) => clearTrackedTimeout(state, false, timer),
369
- clearInterval: (timer?: NodeJS.Timeout) => clearTrackedTimeout(state, true, timer),
370
- queueMicrotask,
371
- URL,
372
- URLSearchParams,
373
- TextEncoder,
374
- TextDecoder,
375
- AbortController,
376
- AbortSignal,
377
- structuredClone,
378
- crypto,
379
- webcrypto: crypto,
380
- performance,
381
- atob,
382
- btoa,
383
- Buffer,
384
- Bun,
385
- process: createProcessSubset(cwd),
386
- require: buildRequire(cwd),
387
- createRequire,
388
- fs,
389
- fetch,
390
- Blob,
391
- File,
392
- Headers,
393
- Request,
394
- Response,
395
- globalThis: undefined,
396
- };
397
- const context = vm.createContext(contextGlobals);
398
- context.globalThis = context;
399
- state.context = context;
400
- vm.runInContext(JAVASCRIPT_PRELUDE_SOURCE, context, {
401
- filename: "js-prelude.js",
402
- importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
174
+ const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
175
+ let resolved = false;
176
+ const unsubscribe = worker.onMessage(msg => {
177
+ if (!resolved && msg.type === "ready") {
178
+ resolved = true;
179
+ resolveReady();
180
+ return;
181
+ }
182
+ if (!resolved && msg.type === "init-failed") {
183
+ resolved = true;
184
+ rejectReady(errorFromPayload(msg.error));
185
+ return;
186
+ }
187
+ handleSessionMessage(session, msg);
403
188
  });
404
- return state;
405
- }
406
-
407
- async function getOrCreateVmState(
408
- sessionKey: string,
409
- sessionId: string,
410
- cwd: string,
411
- session: ToolSession,
412
- ): Promise<VmContextState> {
413
- const existing = vmContexts.get(sessionKey);
414
- if (existing) {
415
- existing.cwd = cwd;
416
- existing.sessionId = sessionId;
417
- existing.session = session;
418
- return existing;
189
+ try {
190
+ await raceWithTimeout(readyPromise, READY_TIMEOUT_MS, "Timed out initializing JS eval worker");
191
+ } catch (error) {
192
+ unsubscribe();
193
+ await worker.terminate().catch(() => undefined);
194
+ throw error;
419
195
  }
420
- const created = await createVmState(sessionKey, sessionId, cwd, session);
421
- vmContexts.set(sessionKey, created);
422
- return created;
196
+ worker.send({ type: "init", snapshot });
197
+ sessions.set(sessionKey, session);
198
+ return session;
423
199
  }
424
200
 
425
- async function disposeState(state: VmContextState): Promise<void> {
426
- for (const timer of state.timers) {
427
- clearTimeout(timer);
428
- }
429
- state.timers.clear();
430
- for (const timer of state.intervals) {
431
- clearInterval(timer);
201
+ function handleSessionMessage(session: JsSession, msg: WorkerOutbound): void {
202
+ switch (msg.type) {
203
+ case "text": {
204
+ const pending = session.pending.get(msg.runId);
205
+ pending?.runState.onText?.(msg.chunk);
206
+ return;
207
+ }
208
+ case "display": {
209
+ const pending = session.pending.get(msg.runId);
210
+ pending?.runState.onDisplay?.(msg.output);
211
+ return;
212
+ }
213
+ case "tool-call":
214
+ void handleToolCall(session, msg);
215
+ return;
216
+ case "result":
217
+ settlePending(session, msg);
218
+ return;
219
+ case "log":
220
+ logWorkerMessage(msg);
221
+ return;
222
+ case "ready":
223
+ case "init-failed":
224
+ case "closed":
225
+ return;
432
226
  }
433
- state.intervals.clear();
434
- state.currentRun = undefined;
435
227
  }
436
228
 
437
- async function runQueued<T>(state: VmContextState, work: () => Promise<T>): Promise<T> {
438
- const previous = state.queue;
439
- const { promise, resolve } = Promise.withResolvers<void>();
440
- state.queue = promise;
441
- await previous;
229
+ async function handleToolCall(session: JsSession, msg: Extract<WorkerOutbound, { type: "tool-call" }>): Promise<void> {
230
+ const pending = session.pending.get(msg.runId);
231
+ if (!pending) {
232
+ safeSend(session, {
233
+ type: "tool-reply",
234
+ id: msg.id,
235
+ reply: { ok: false, error: { message: "Run no longer active" } },
236
+ });
237
+ return;
238
+ }
239
+ const ctrl = new AbortController();
240
+ pending.toolCalls.set(msg.id, ctrl);
442
241
  try {
443
- return await work();
242
+ const value = await callSessionTool(msg.name, msg.args, {
243
+ session: pending.toolSession,
244
+ signal: ctrl.signal,
245
+ emitStatus: (event: JsStatusEvent) => pending.runState.onDisplay?.({ type: "status", event }),
246
+ });
247
+ safeSend(session, { type: "tool-reply", id: msg.id, reply: { ok: true, value } });
248
+ } catch (error) {
249
+ safeSend(session, { type: "tool-reply", id: msg.id, reply: { ok: false, error: toErrorPayload(error) } });
444
250
  } finally {
445
- resolve();
251
+ pending.toolCalls.delete(msg.id);
446
252
  }
447
253
  }
448
254
 
449
- function buildRequire(cwd: string): NodeJS.Require {
450
- // Anchor `require` resolution at the session cwd. The filename does not need to exist;
451
- // Node only uses it as a base for module resolution.
452
- return createRequire(pathToFileURL(path.join(cwd, "[eval]")).href);
453
- }
454
-
455
- // Static `import ... from "x"` is not valid inside vm.runInContext (script-mode parsing).
456
- // Rewrite top-level static imports to dynamic `await import(...)` so users can paste ESM
457
- // source verbatim. We use a real parser instead of regex matching so imports embedded in
458
- // string literals, template literals, or comments — common in codemods — stay intact.
459
-
460
- type BabelImportDeclaration = {
461
- type: "ImportDeclaration";
462
- start: number;
463
- end: number;
464
- source: { value: string };
465
- specifiers: ReadonlyArray<{
466
- type: "ImportDefaultSpecifier" | "ImportNamespaceSpecifier" | "ImportSpecifier";
467
- local: { name: string };
468
- imported?: { type: "Identifier"; name: string } | { type: "StringLiteral"; value: string };
469
- }>;
470
- attributes?: ReadonlyArray<{
471
- key: { type: "Identifier"; name: string } | { type: "StringLiteral"; value: string };
472
- value: { value: string };
473
- }>;
474
- };
475
-
476
- function buildDynamicImportCall(sourceLiteral: string, withClause: string | undefined): string {
477
- return withClause ? `import(${sourceLiteral}, { with: ${withClause} })` : `import(${sourceLiteral})`;
478
- }
479
-
480
- function buildWithClause(node: BabelImportDeclaration): string | undefined {
481
- const attrs = node.attributes;
482
- if (!attrs || attrs.length === 0) return undefined;
483
- const pairs = attrs.map(attr => {
484
- const key = attr.key.type === "Identifier" ? attr.key.name : JSON.stringify(attr.key.value);
485
- return `${key}: ${JSON.stringify(attr.value.value)}`;
486
- });
487
- return `{ ${pairs.join(", ")} }`;
255
+ function settlePending(session: JsSession, msg: Extract<WorkerOutbound, { type: "result" }>): void {
256
+ const pending = session.pending.get(msg.runId);
257
+ if (!pending || pending.settled) return;
258
+ pending.settled = true;
259
+ if (msg.ok) {
260
+ pending.resolve({ value: undefined });
261
+ return;
262
+ }
263
+ pending.reject(errorFromPayload(msg.error));
488
264
  }
489
265
 
490
- function rewriteImportNode(node: BabelImportDeclaration): string {
491
- const sourceLiteral = JSON.stringify(node.source.value);
492
- const withClause = buildWithClause(node);
493
- const importCall = buildDynamicImportCall(sourceLiteral, withClause);
494
-
495
- let defaultName: string | undefined;
496
- let namespaceName: string | undefined;
497
- const namedPairs: Array<[string, string]> = [];
498
- for (const spec of node.specifiers) {
499
- if (spec.type === "ImportDefaultSpecifier") {
500
- defaultName = spec.local.name;
501
- } else if (spec.type === "ImportNamespaceSpecifier") {
502
- namespaceName = spec.local.name;
503
- } else if (spec.type === "ImportSpecifier" && spec.imported) {
504
- const imported = spec.imported.type === "Identifier" ? spec.imported.name : spec.imported.value;
505
- namedPairs.push([imported, spec.local.name]);
506
- }
266
+ async function killSessionFor(session: JsSession, error: Error): Promise<void> {
267
+ if (sessions.get(session.sessionKey) === session) {
268
+ sessions.delete(session.sessionKey);
507
269
  }
270
+ await killSession(session, error);
271
+ }
508
272
 
509
- if (namedPairs.length > 0) {
510
- const inner = namedPairs.map(([imp, loc]) => (imp === loc ? imp : `${imp}: ${loc}`)).join(", ");
511
- const props = defaultName ? `default: ${defaultName}, ${inner}` : inner;
512
- return `const { ${props} } = await ${importCall};`;
273
+ async function killSession(session: JsSession, error: Error): Promise<void> {
274
+ if (session.state === "dead") return;
275
+ session.state = "dead";
276
+ for (const pending of session.pending.values()) {
277
+ if (pending.settled) continue;
278
+ pending.settled = true;
279
+ for (const ctrl of pending.toolCalls.values()) ctrl.abort(error);
280
+ pending.reject(error);
513
281
  }
514
- if (namespaceName && defaultName) {
515
- return `const ${namespaceName} = await ${importCall}; const ${defaultName} = ${namespaceName}.default;`;
516
- }
517
- if (namespaceName) return `const ${namespaceName} = await ${importCall};`;
518
- if (defaultName) return `const ${defaultName} = (await ${importCall}).default;`;
519
- return `await ${importCall};`;
282
+ session.pending.clear();
283
+ await session.worker.terminate().catch(() => undefined);
520
284
  }
521
285
 
522
- export function rewriteStaticImports(code: string): string {
523
- if (!code.includes("import")) return code;
524
-
525
- let ast: { program: { body: ReadonlyArray<{ type: string }> } };
286
+ function safeSend(session: JsSession, msg: WorkerInbound): void {
287
+ if (session.state !== "alive") return;
526
288
  try {
527
- ast = babelParse(code, {
528
- sourceType: "module",
529
- allowAwaitOutsideFunction: true,
530
- allowReturnOutsideFunction: true,
531
- allowImportExportEverywhere: true,
532
- allowNewTargetOutsideFunction: true,
533
- allowSuperOutsideMethod: true,
534
- allowUndeclaredExports: true,
535
- errorRecovery: true,
536
- }) as unknown as typeof ast;
537
- } catch {
538
- // Parser bailed entirely — let the VM surface the real syntax error.
539
- return code;
289
+ session.worker.send(msg);
290
+ } catch (err) {
291
+ logger.debug("js worker send failed", { error: err instanceof Error ? err.message : String(err) });
540
292
  }
293
+ }
541
294
 
542
- // Only rewrite top-level imports. Anything nested deeper is invalid JS anyway and the
543
- // VM will report it.
544
- const imports: BabelImportDeclaration[] = [];
545
- for (const node of ast.program.body) {
546
- if (node.type === "ImportDeclaration") imports.push(node as unknown as BabelImportDeclaration);
547
- }
548
- if (imports.length === 0) return code;
295
+ function reasonToError(reason: unknown, fallback: string): Error {
296
+ if (reason instanceof Error) return reason;
297
+ if (typeof reason === "string") return new ToolAbortError(reason);
298
+ return new ToolAbortError(fallback);
299
+ }
549
300
 
550
- // Splice from the back so earlier offsets stay valid.
551
- imports.sort((a, b) => b.start - a.start);
552
- let result = code;
553
- for (const node of imports) {
554
- result = result.slice(0, node.start) + rewriteImportNode(node) + result.slice(node.end);
301
+ function errorFromPayload(payload: RunErrorPayload): Error {
302
+ if (payload.isAbort) {
303
+ const err = new ToolAbortError(payload.message || "Execution aborted");
304
+ if (payload.stack) err.stack = payload.stack;
305
+ return err;
555
306
  }
556
- return result;
307
+ const ctor = payload.isToolError ? ToolError : Error;
308
+ const error = new ctor(payload.message);
309
+ if (payload.name) error.name = payload.name;
310
+ if (payload.stack) error.stack = payload.stack;
311
+ return error;
557
312
  }
558
313
 
559
- function wrapCode(code: string): { source: string; asyncWrapped: boolean } {
560
- const rewritten = rewriteStaticImports(code);
561
- const needsAsyncWrapper = /\bawait\b|\breturn\b/.test(rewritten);
562
- if (!needsAsyncWrapper) {
563
- return { source: rewritten, asyncWrapped: false };
314
+ function toErrorPayload(error: unknown): RunErrorPayload {
315
+ if (error instanceof Error) {
316
+ return {
317
+ name: error.name,
318
+ message: error.message,
319
+ stack: error.stack,
320
+ isAbort: error.name === "AbortError" || error.name === "ToolAbortError",
321
+ isToolError: error instanceof ToolError || error.name === "ToolError",
322
+ };
564
323
  }
565
- return {
566
- source: `(async () => {\n${rewritten}\n})()`,
567
- asyncWrapped: true,
568
- };
324
+ return { message: String(error) };
569
325
  }
570
326
 
571
- async function awaitMaybePromise<T>(value: T | Promise<T>, signal?: AbortSignal): Promise<T> {
572
- if (!value || typeof value !== "object" || typeof (value as { then?: unknown }).then !== "function") {
573
- return value;
574
- }
575
- const promised = value as Promise<T>;
576
- if (!signal) {
577
- return promised;
578
- }
579
- const { promise, resolve, reject } = Promise.withResolvers<T>();
580
- if (signal.aborted) {
581
- reject(signal.reason ?? new Error("Execution aborted"));
582
- return promise;
327
+ function logWorkerMessage(msg: Extract<WorkerOutbound, { type: "log" }>): void {
328
+ if (msg.level === "debug") logger.debug(msg.msg, msg.meta);
329
+ else if (msg.level === "warn") logger.warn(msg.msg, msg.meta);
330
+ else logger.error(msg.msg, msg.meta);
331
+ }
332
+
333
+ async function raceWithTimeout<T>(promise: Promise<T>, timeoutMs: number, reason: string): Promise<T> {
334
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
335
+ const { promise: timeoutPromise, reject } = Promise.withResolvers<never>();
336
+ const onAbort = (): void => reject(new ToolError(reason));
337
+ timeoutSignal.addEventListener("abort", onAbort, { once: true });
338
+ try {
339
+ return await Promise.race([promise, timeoutPromise]);
340
+ } finally {
341
+ timeoutSignal.removeEventListener("abort", onAbort);
583
342
  }
584
- const onAbort = () => reject(signal.reason ?? new Error("Execution aborted"));
585
- signal.addEventListener("abort", onAbort, { once: true });
586
- promised.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
587
- return promise;
588
343
  }
589
344
 
590
- export async function executeInVmContext(options: {
591
- sessionKey: string;
592
- sessionId: string;
593
- cwd: string;
594
- session: ToolSession;
595
- reset?: boolean;
596
- code: string;
597
- filename: string;
598
- timeoutMs?: number;
599
- runState: VmRunState;
600
- }): Promise<{ value: unknown }> {
601
- if (options.reset) {
602
- await resetVmContext(options.sessionKey);
345
+ async function spawnJsWorker(): Promise<WorkerHandle> {
346
+ try {
347
+ const worker = new Worker(jsWorkerEntryUrl, { type: "module" });
348
+ return wrapBunWorker(worker);
349
+ } catch (err) {
350
+ logger.warn("Bun Worker spawn failed; using inline JS eval worker (no sync-loop guard)", {
351
+ error: err instanceof Error ? err.message : String(err),
352
+ });
353
+ return spawnInlineWorker();
603
354
  }
604
- const state = await getOrCreateVmState(options.sessionKey, options.sessionId, options.cwd, options.session);
605
- return runQueued(state, async () => {
606
- state.currentRun = options.runState;
607
- try {
608
- if (options.runState.signal?.aborted) {
609
- throw options.runState.signal.reason ?? new Error("Execution aborted");
610
- }
611
- const wrapped = wrapCode(options.code);
612
- const value = vm.runInContext(wrapped.source, state.context, {
613
- filename: options.filename,
614
- timeout: options.timeoutMs,
615
- importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER,
616
- });
617
- const awaited = await awaitMaybePromise(value, options.runState.signal);
618
- displayValue(state, awaited);
619
- return { value: awaited };
620
- } finally {
621
- state.currentRun = undefined;
622
- }
623
- });
624
355
  }
625
356
 
626
- export async function resetVmContext(sessionKey: string): Promise<void> {
627
- const existing = vmContexts.get(sessionKey);
628
- if (!existing) return;
629
- vmContexts.delete(sessionKey);
630
- await disposeState(existing);
357
+ function wrapBunWorker(worker: Worker): WorkerHandle {
358
+ return {
359
+ mode: "worker",
360
+ send(msg) {
361
+ worker.postMessage(msg);
362
+ },
363
+ onMessage(handler) {
364
+ const wrap = (event: MessageEvent): void => handler(event.data as WorkerOutbound);
365
+ worker.addEventListener("message", wrap);
366
+ return () => worker.removeEventListener("message", wrap);
367
+ },
368
+ async terminate() {
369
+ worker.terminate();
370
+ },
371
+ };
631
372
  }
632
373
 
633
- export async function disposeAllVmContexts(): Promise<void> {
634
- const states = Array.from(vmContexts.values());
635
- vmContexts.clear();
636
- for (const state of states) {
637
- await disposeState(state);
638
- }
374
+ /**
375
+ * Inline fallback for environments where Bun cannot spawn the worker entry
376
+ * (e.g. some test runners). Preserves behavior but cannot interrupt synchronous
377
+ * infinite loops because user code runs on the main thread.
378
+ */
379
+ function spawnInlineWorker(): WorkerHandle {
380
+ const hostListeners = new Set<(message: WorkerOutbound) => void>();
381
+ const workerListeners = new Set<(message: WorkerInbound) => void>();
382
+ const workerTransport: Transport = {
383
+ send: msg =>
384
+ queueMicrotask(() => {
385
+ for (const listener of hostListeners) listener(msg);
386
+ }),
387
+ onMessage: handler => {
388
+ workerListeners.add(handler);
389
+ return () => workerListeners.delete(handler);
390
+ },
391
+ close: () => {},
392
+ };
393
+ new WorkerCore(workerTransport);
394
+ return {
395
+ mode: "inline",
396
+ send: msg =>
397
+ queueMicrotask(() => {
398
+ for (const listener of workerListeners) listener(msg);
399
+ }),
400
+ onMessage: handler => {
401
+ hostListeners.add(handler);
402
+ return () => hostListeners.delete(handler);
403
+ },
404
+ async terminate() {
405
+ hostListeners.clear();
406
+ workerListeners.clear();
407
+ },
408
+ };
639
409
  }