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