@oh-my-pi/pi-coding-agent 14.5.11 → 14.5.13

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 (89) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/package.json +18 -10
  3. package/src/cli/jupyter-cli.ts +1 -1
  4. package/src/config/model-equivalence.ts +49 -16
  5. package/src/config/model-registry.ts +100 -25
  6. package/src/config/model-resolver.ts +29 -15
  7. package/src/config/settings-schema.ts +20 -6
  8. package/src/config/settings.ts +9 -8
  9. package/src/config.ts +9 -0
  10. package/src/eval/backend.ts +43 -0
  11. package/src/eval/eval.lark +43 -0
  12. package/src/eval/index.ts +5 -0
  13. package/src/eval/js/context-manager.ts +717 -0
  14. package/src/eval/js/executor.ts +131 -0
  15. package/src/eval/js/index.ts +46 -0
  16. package/src/eval/js/prelude.ts +2 -0
  17. package/src/eval/js/prelude.txt +84 -0
  18. package/src/eval/js/tool-bridge.ts +124 -0
  19. package/src/eval/parse.ts +337 -0
  20. package/src/{ipy → eval/py}/executor.ts +2 -180
  21. package/src/{ipy → eval/py}/gateway-coordinator.ts +4 -3
  22. package/src/eval/py/index.ts +58 -0
  23. package/src/{ipy → eval/py}/kernel.ts +5 -41
  24. package/src/{ipy → eval/py}/prelude.py +39 -227
  25. package/src/eval/types.ts +48 -0
  26. package/src/export/html/template.generated.ts +1 -1
  27. package/src/export/html/template.js +23 -17
  28. package/src/extensibility/extensions/types.ts +2 -3
  29. package/src/internal-urls/docs-index.generated.ts +5 -5
  30. package/src/lsp/client.ts +9 -0
  31. package/src/lsp/index.ts +395 -0
  32. package/src/lsp/types.ts +15 -4
  33. package/src/main.ts +25 -14
  34. package/src/mcp/oauth-flow.ts +1 -1
  35. package/src/memories/index.ts +1 -1
  36. package/src/modes/acp/acp-event-mapper.ts +1 -1
  37. package/src/modes/components/{python-execution.ts → eval-execution.ts} +11 -4
  38. package/src/modes/components/login-dialog.ts +1 -1
  39. package/src/modes/components/oauth-selector.ts +2 -1
  40. package/src/modes/components/tool-execution.ts +3 -4
  41. package/src/modes/controllers/command-controller.ts +28 -8
  42. package/src/modes/controllers/input-controller.ts +4 -4
  43. package/src/modes/controllers/selector-controller.ts +2 -1
  44. package/src/modes/interactive-mode.ts +4 -5
  45. package/src/modes/types.ts +3 -3
  46. package/src/modes/utils/ui-helpers.ts +2 -2
  47. package/src/prompts/system/system-prompt.md +3 -3
  48. package/src/prompts/tools/atom.md +3 -2
  49. package/src/prompts/tools/browser.md +61 -16
  50. package/src/prompts/tools/eval.md +92 -0
  51. package/src/prompts/tools/lsp.md +7 -3
  52. package/src/sdk.ts +45 -31
  53. package/src/session/agent-session.ts +44 -54
  54. package/src/session/messages.ts +1 -1
  55. package/src/slash-commands/builtin-registry.ts +1 -1
  56. package/src/system-prompt.ts +34 -66
  57. package/src/task/executor.ts +5 -9
  58. package/src/tools/browser/attach.ts +175 -0
  59. package/src/tools/browser/launch.ts +576 -0
  60. package/src/tools/browser/readable.ts +90 -0
  61. package/src/tools/browser/registry.ts +198 -0
  62. package/src/tools/browser/render.ts +212 -0
  63. package/src/tools/browser/tab-protocol.ts +101 -0
  64. package/src/tools/browser/tab-supervisor.ts +429 -0
  65. package/src/tools/browser/tab-worker-entry.ts +21 -0
  66. package/src/tools/browser/tab-worker.ts +1006 -0
  67. package/src/tools/browser.ts +231 -1567
  68. package/src/tools/checkpoint.ts +2 -2
  69. package/src/tools/{python.ts → eval.ts} +324 -315
  70. package/src/tools/exit-plan-mode.ts +1 -1
  71. package/src/tools/index.ts +62 -100
  72. package/src/tools/plan-mode-guard.ts +27 -1
  73. package/src/tools/read.ts +0 -6
  74. package/src/tools/recipe/runners/pkg.ts +34 -32
  75. package/src/tools/renderers.ts +4 -2
  76. package/src/tools/resolve.ts +7 -2
  77. package/src/tools/todo-write.ts +0 -1
  78. package/src/tools/tool-timeouts.ts +2 -2
  79. package/src/utils/markit.ts +15 -7
  80. package/src/utils/tools-manager.ts +5 -5
  81. package/src/web/search/index.ts +5 -5
  82. package/src/web/search/provider.ts +121 -39
  83. package/src/web/search/providers/gemini.ts +2 -2
  84. package/src/web/search/render.ts +2 -2
  85. package/src/ipy/modules.ts +0 -144
  86. package/src/prompts/tools/python.md +0 -57
  87. /package/src/{ipy → eval/py}/cancellation.ts +0 -0
  88. /package/src/{ipy → eval/py}/prelude.ts +0 -0
  89. /package/src/{ipy → eval/py}/runtime.ts +0 -0
@@ -0,0 +1,717 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import * as util from "node:util";
4
+ import * as vm from "node:vm";
5
+
6
+ import * as Diff from "diff";
7
+ import type { ToolSession } from "../../tools";
8
+ import { ToolError } from "../../tools/tool-errors";
9
+ import { JAVASCRIPT_PRELUDE_SOURCE } from "./prelude";
10
+ import { callSessionTool, type JsStatusEvent } from "./tool-bridge";
11
+
12
+ export type JsDisplayOutput =
13
+ | { type: "json"; data: unknown }
14
+ | { type: "image"; data: string; mimeType: string }
15
+ | { type: "status"; event: JsStatusEvent };
16
+
17
+ export interface VmRunState {
18
+ signal?: AbortSignal;
19
+ onText?: (chunk: string) => void;
20
+ onDisplay?: (output: JsDisplayOutput) => void;
21
+ }
22
+
23
+ interface VmHelperOptions {
24
+ path?: string;
25
+ hidden?: boolean;
26
+ maxDepth?: number;
27
+ ignoreCase?: boolean;
28
+ literal?: boolean;
29
+ limit?: number;
30
+ offset?: number;
31
+ globPattern?: string;
32
+ flags?: string;
33
+ reverse?: boolean;
34
+ unique?: boolean;
35
+ count?: boolean;
36
+ cwd?: string;
37
+ timeoutMs?: number;
38
+ timeout?: number;
39
+ }
40
+
41
+ interface VmContextState {
42
+ sessionKey: string;
43
+ cwd: string;
44
+ sessionId: string;
45
+ session: ToolSession;
46
+ context: vm.Context;
47
+ env: Map<string, string>;
48
+ timers: Set<NodeJS.Timeout>;
49
+ intervals: Set<NodeJS.Timeout>;
50
+ currentRun?: VmRunState;
51
+ queue: Promise<void>;
52
+ }
53
+
54
+ const vmContexts = new Map<string, VmContextState>();
55
+ const utf8Encoder = new TextEncoder();
56
+
57
+ function getMergedEnv(state: VmContextState): Record<string, string> {
58
+ const env: Record<string, string> = {};
59
+ for (const [key, value] of Object.entries(Bun.env)) {
60
+ if (typeof value === "string") {
61
+ env[key] = value;
62
+ }
63
+ }
64
+ for (const [key, value] of state.env) {
65
+ env[key] = value;
66
+ }
67
+ return env;
68
+ }
69
+
70
+ function resolvePath(state: VmContextState, value: string): string {
71
+ if (value.includes("://")) {
72
+ throw new ToolError(`Protocol paths are not supported by this helper: ${value}`);
73
+ }
74
+ return path.isAbsolute(value) ? path.normalize(value) : path.join(state.cwd, value);
75
+ }
76
+
77
+ async function resolveRegularFile(
78
+ state: VmContextState,
79
+ rawPath: string,
80
+ ): Promise<{ filePath: string; file: Bun.BunFile; size: number }> {
81
+ const filePath = resolvePath(state, rawPath);
82
+ const file = Bun.file(filePath);
83
+ const info = await file.stat().catch(() => undefined);
84
+ if (!info) {
85
+ throw new ToolError(`File not found: ${filePath}`);
86
+ }
87
+ if (info.isDirectory()) {
88
+ throw new ToolError(`Directory paths are not supported by this helper: ${filePath}`);
89
+ }
90
+ return { filePath, file, size: info.size };
91
+ }
92
+
93
+ function getDataSize(data: string | Blob | ArrayBuffer | ArrayBufferView): number {
94
+ if (typeof data === "string") {
95
+ return utf8Encoder.encode(data).byteLength;
96
+ }
97
+ if (data instanceof Blob) {
98
+ return data.size;
99
+ }
100
+ if (data instanceof ArrayBuffer) {
101
+ return data.byteLength;
102
+ }
103
+ return data.byteLength;
104
+ }
105
+
106
+ function isWriteData(value: unknown): value is string | Blob | ArrayBuffer | ArrayBufferView {
107
+ return (
108
+ typeof value === "string" || value instanceof Blob || value instanceof ArrayBuffer || ArrayBuffer.isView(value)
109
+ );
110
+ }
111
+
112
+ function emitText(state: VmContextState, text: string): void {
113
+ if (!text) return;
114
+ state.currentRun?.onText?.(text.endsWith("\n") ? text : `${text}\n`);
115
+ }
116
+
117
+ function emitStatus(state: VmContextState, event: JsStatusEvent): void {
118
+ state.currentRun?.onDisplay?.({ type: "status", event });
119
+ }
120
+
121
+ function displayValue(state: VmContextState, value: unknown): void {
122
+ if (value === undefined) return;
123
+ if (value && typeof value === "object") {
124
+ const record = value as Record<string, unknown>;
125
+ if (record.type === "image" && typeof record.data === "string" && typeof record.mimeType === "string") {
126
+ state.currentRun?.onDisplay?.({
127
+ type: "image",
128
+ data: record.data,
129
+ mimeType: record.mimeType,
130
+ });
131
+ return;
132
+ }
133
+ state.currentRun?.onDisplay?.({
134
+ type: "json",
135
+ data: structuredClone(value),
136
+ });
137
+ return;
138
+ }
139
+ emitText(state, String(value));
140
+ }
141
+
142
+ function formatConsoleArgs(args: unknown[]): string {
143
+ return args
144
+ .map(arg => (typeof arg === "string" ? arg : util.inspect(arg, { depth: 6, colors: false, breakLength: 120 })))
145
+ .join(" ");
146
+ }
147
+
148
+ function createTrackedTimeout(state: VmContextState, repeat: boolean) {
149
+ return (callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => {
150
+ const fn = () => callback(...args);
151
+ const timer = repeat ? setInterval(fn, delay) : setTimeout(fn, delay);
152
+ if (repeat) {
153
+ state.intervals.add(timer);
154
+ } else {
155
+ state.timers.add(timer);
156
+ }
157
+ return timer;
158
+ };
159
+ }
160
+
161
+ function clearTrackedTimeout(state: VmContextState, repeat: boolean, timer: NodeJS.Timeout | undefined): void {
162
+ if (!timer) return;
163
+ if (repeat) {
164
+ clearInterval(timer);
165
+ state.intervals.delete(timer);
166
+ return;
167
+ }
168
+ clearTimeout(timer);
169
+ state.timers.delete(timer);
170
+ }
171
+
172
+ async function listFiles(
173
+ state: VmContextState,
174
+ pattern: string,
175
+ searchPath: string,
176
+ options: VmHelperOptions,
177
+ ): Promise<string[]> {
178
+ const resolved = resolvePath(state, searchPath);
179
+ const hasRecursivePattern = pattern.includes("**");
180
+ const normalizedPattern = hasRecursivePattern ? pattern : `**/${pattern}`;
181
+ const matches = await Array.fromAsync(
182
+ new Bun.Glob(normalizedPattern).scan({
183
+ cwd: resolved,
184
+ dot: options.hidden ?? false,
185
+ absolute: true,
186
+ onlyFiles: false,
187
+ }),
188
+ );
189
+ const limited = matches.slice(0, options.limit ?? 1000).map(match => path.normalize(match));
190
+ return limited.sort();
191
+ }
192
+
193
+ async function grepFile(
194
+ filePath: string,
195
+ pattern: string,
196
+ options: VmHelperOptions,
197
+ ): Promise<Array<{ line: number; text: string }>> {
198
+ const content = await Bun.file(filePath).text();
199
+ const lines = content.split(/\r?\n/);
200
+ const matcher = options.literal
201
+ ? (line: string) =>
202
+ options.ignoreCase ? line.toLowerCase().includes(pattern.toLowerCase()) : line.includes(pattern)
203
+ : (line: string) => new RegExp(pattern, options.flags ?? (options.ignoreCase ? "i" : "")).test(line);
204
+ const hits: Array<{ line: number; text: string }> = [];
205
+ for (let index = 0; index < lines.length; index++) {
206
+ if (matcher(lines[index])) {
207
+ hits.push({ line: index + 1, text: lines[index] });
208
+ if (hits.length >= (options.limit ?? 200)) {
209
+ break;
210
+ }
211
+ }
212
+ }
213
+ return hits;
214
+ }
215
+
216
+ async function createHelpers(state: VmContextState) {
217
+ return {
218
+ read: async (rawPath: string, options: VmHelperOptions = {}): Promise<string> => {
219
+ const { filePath, file, size } = await resolveRegularFile(state, rawPath);
220
+ let text = await file.text();
221
+ const offset = typeof options.offset === "number" ? options.offset : 1;
222
+ const limit = typeof options.limit === "number" ? options.limit : undefined;
223
+ if (offset > 1 || limit !== undefined) {
224
+ const lines = text.split(/\r?\n/);
225
+ const start = Math.max(0, offset - 1);
226
+ const end = limit !== undefined ? start + limit : lines.length;
227
+ text = lines.slice(start, end).join("\n");
228
+ }
229
+ emitStatus(state, { op: "read", path: filePath, bytes: size, chars: text.length });
230
+ return text;
231
+ },
232
+ writeFile: async (rawPath: string, data: unknown): Promise<string> => {
233
+ if (!isWriteData(data)) {
234
+ throw new ToolError("write() expects string, Blob, ArrayBuffer, or TypedArray data");
235
+ }
236
+ const filePath = resolvePath(state, rawPath);
237
+ if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) {
238
+ await Bun.write(filePath, data);
239
+ } else {
240
+ await Bun.write(filePath, new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
241
+ }
242
+ emitStatus(state, { op: "write", path: filePath, bytes: getDataSize(data) });
243
+ return filePath;
244
+ },
245
+ append: async (rawPath: string, content: string): Promise<string> => {
246
+ const target = resolvePath(state, rawPath);
247
+ await Bun.write(
248
+ target,
249
+ `${await Bun.file(target)
250
+ .text()
251
+ .catch(() => "")}${content}`,
252
+ );
253
+ emitStatus(state, {
254
+ op: "append",
255
+ path: target,
256
+ chars: content.length,
257
+ bytes: utf8Encoder.encode(content).byteLength,
258
+ });
259
+ return target;
260
+ },
261
+ stat: async (
262
+ rawPath: string,
263
+ ): Promise<{ path: string; size: number; is_file: boolean; is_dir: boolean; mtime: string }> => {
264
+ const target = resolvePath(state, rawPath);
265
+ const info = await Bun.file(target).stat();
266
+ const result = {
267
+ path: target,
268
+ size: info.size,
269
+ is_file: info.isFile(),
270
+ is_dir: info.isDirectory(),
271
+ mtime: new Date(info.mtimeMs).toISOString(),
272
+ };
273
+ emitStatus(state, { op: "stat", path: target, size: result.size, is_dir: result.is_dir, mtime: result.mtime });
274
+ return result;
275
+ },
276
+ find: async (pattern: string, searchPath = ".", options: VmHelperOptions = {}): Promise<string[]> => {
277
+ const matches = await listFiles(state, pattern, searchPath, options);
278
+ emitStatus(state, {
279
+ op: "find",
280
+ pattern,
281
+ path: resolvePath(state, searchPath),
282
+ count: matches.length,
283
+ matches: matches.slice(0, 20),
284
+ });
285
+ return matches;
286
+ },
287
+ glob: async (pattern: string, searchPath = ".", options: VmHelperOptions = {}): Promise<string[]> => {
288
+ const resolved = resolvePath(state, searchPath);
289
+ const matches = await Array.fromAsync(
290
+ new Bun.Glob(pattern).scan({
291
+ cwd: resolved,
292
+ dot: options.hidden ?? false,
293
+ absolute: true,
294
+ onlyFiles: false,
295
+ }),
296
+ );
297
+ const limited = matches
298
+ .slice(0, options.limit ?? 1000)
299
+ .map(match => path.normalize(match))
300
+ .sort();
301
+ emitStatus(state, {
302
+ op: "glob",
303
+ pattern,
304
+ path: resolved,
305
+ count: limited.length,
306
+ matches: limited.slice(0, 20),
307
+ });
308
+ return limited;
309
+ },
310
+ grep: async (
311
+ pattern: string,
312
+ rawPath: string,
313
+ options: VmHelperOptions = {},
314
+ ): Promise<Array<{ line: number; text: string }>> => {
315
+ const filePath = resolvePath(state, rawPath);
316
+ const hits = await grepFile(filePath, pattern, options);
317
+ emitStatus(state, { op: "grep", pattern, path: filePath, count: hits.length, hits: hits.slice(0, 10) });
318
+ return hits;
319
+ },
320
+ rgrep: async (
321
+ pattern: string,
322
+ searchPath = ".",
323
+ options: VmHelperOptions = {},
324
+ ): Promise<Array<{ file: string; line: number; text: string }>> => {
325
+ const files = await listFiles(state, options.globPattern ?? "*", searchPath, {
326
+ ...options,
327
+ limit: options.limit ?? 100,
328
+ });
329
+ const hits: Array<{ file: string; line: number; text: string }> = [];
330
+ for (const file of files) {
331
+ const fileStat = await Bun.file(file)
332
+ .stat()
333
+ .catch(() => undefined);
334
+ if (!fileStat || fileStat.isDirectory()) continue;
335
+ for (const hit of await grepFile(file, pattern, options)) {
336
+ hits.push({ file, line: hit.line, text: hit.text });
337
+ if (hits.length >= (options.limit ?? 100)) {
338
+ emitStatus(state, {
339
+ op: "rgrep",
340
+ pattern,
341
+ path: resolvePath(state, searchPath),
342
+ count: hits.length,
343
+ hits: hits.slice(0, 10),
344
+ });
345
+ return hits;
346
+ }
347
+ }
348
+ }
349
+ emitStatus(state, {
350
+ op: "rgrep",
351
+ pattern,
352
+ path: resolvePath(state, searchPath),
353
+ count: hits.length,
354
+ hits: hits.slice(0, 10),
355
+ });
356
+ return hits;
357
+ },
358
+ sortText: (text: string, options: VmHelperOptions = {}): string => {
359
+ const lines = String(text).split(/\r?\n/);
360
+ const deduped = options.unique ? Array.from(new Set(lines)) : lines;
361
+ const sorted = deduped.sort((a, b) => a.localeCompare(b));
362
+ if (options.reverse) {
363
+ sorted.reverse();
364
+ }
365
+ const result = sorted.join("\n");
366
+ emitStatus(state, {
367
+ op: "sort",
368
+ lines: sorted.length,
369
+ reverse: options.reverse === true,
370
+ unique: options.unique === true,
371
+ });
372
+ return result;
373
+ },
374
+ uniqText: (text: string, options: VmHelperOptions = {}): string | Array<[number, string]> => {
375
+ const lines = String(text)
376
+ .split(/\r?\n/)
377
+ .filter(line => line.length > 0);
378
+ const groups: Array<[number, string]> = [];
379
+ for (const line of lines) {
380
+ const last = groups.at(-1);
381
+ if (last && last[1] === line) {
382
+ last[0] += 1;
383
+ continue;
384
+ }
385
+ groups.push([1, line]);
386
+ }
387
+ emitStatus(state, { op: "uniq", groups: groups.length, count_mode: options.count === true });
388
+ if (options.count) {
389
+ return groups;
390
+ }
391
+ return groups.map(([, line]) => line).join("\n");
392
+ },
393
+ counter: (items: string | string[], options: VmHelperOptions = {}): Array<[number, string]> => {
394
+ const values = Array.isArray(items) ? items : String(items).split(/\r?\n/).filter(Boolean);
395
+ const counts = new Map<string, number>();
396
+ for (const item of values) {
397
+ counts.set(item, (counts.get(item) ?? 0) + 1);
398
+ }
399
+ const entries = Array.from(counts.entries())
400
+ .map(([item, count]) => [count, item] as [number, string])
401
+ .sort((a, b) => (options.reverse === false ? a[0] - b[0] : b[0] - a[0]) || a[1].localeCompare(b[1]));
402
+ const limited = entries.slice(0, options.limit ?? entries.length);
403
+ emitStatus(state, { op: "counter", unique: counts.size, total: values.length, top: limited.slice(0, 10) });
404
+ return limited;
405
+ },
406
+ sed: async (
407
+ rawPath: string,
408
+ pattern: string,
409
+ replacement: string,
410
+ options: VmHelperOptions = {},
411
+ ): Promise<number> => {
412
+ const filePath = resolvePath(state, rawPath);
413
+ const content = await Bun.file(filePath).text();
414
+ const regex = new RegExp(pattern, options.flags ?? "g");
415
+ const matches = content.match(regex);
416
+ const updated = content.replace(regex, replacement);
417
+ await Bun.write(filePath, updated);
418
+ const count = matches?.length ?? 0;
419
+ emitStatus(state, { op: "sed", path: filePath, count });
420
+ return count;
421
+ },
422
+ diff: async (rawA: string, rawB: string): Promise<string> => {
423
+ const fileA = resolvePath(state, rawA);
424
+ const fileB = resolvePath(state, rawB);
425
+ const [a, b] = await Promise.all([Bun.file(fileA).text(), Bun.file(fileB).text()]);
426
+ const result = Diff.createTwoFilesPatch(fileA, fileB, a, b, "", "", { context: 3 });
427
+ emitStatus(state, {
428
+ op: "diff",
429
+ file_a: fileA,
430
+ file_b: fileB,
431
+ identical: a === b,
432
+ preview: result.slice(0, 500),
433
+ });
434
+ return result;
435
+ },
436
+ tree: async (searchPath = ".", options: VmHelperOptions = {}): Promise<string> => {
437
+ const root = resolvePath(state, searchPath);
438
+ const maxDepth = options.maxDepth ?? 3;
439
+ const showHidden = options.hidden ?? false;
440
+ const lines: string[] = [`${root}/`];
441
+ let entryCount = 0;
442
+ const walk = async (dir: string, prefix: string, depth: number): Promise<void> => {
443
+ if (depth > maxDepth) return;
444
+ const entries = (await fs.promises.readdir(dir, { withFileTypes: true }))
445
+ .filter(entry => showHidden || !entry.name.startsWith("."))
446
+ .sort((a, b) => a.name.localeCompare(b.name));
447
+ for (let index = 0; index < entries.length; index++) {
448
+ const entry = entries[index];
449
+ const isLast = index === entries.length - 1;
450
+ const connector = isLast ? "└── " : "├── ";
451
+ const suffix = entry.isDirectory() ? "/" : "";
452
+ lines.push(`${prefix}${connector}${entry.name}${suffix}`);
453
+ entryCount += 1;
454
+ if (entry.isDirectory()) {
455
+ await walk(path.join(dir, entry.name), `${prefix}${isLast ? " " : "│ "}`, depth + 1);
456
+ }
457
+ }
458
+ };
459
+ await walk(root, "", 1);
460
+ const result = lines.join("\n");
461
+ emitStatus(state, { op: "tree", path: root, entries: entryCount, preview: result.slice(0, 1000) });
462
+ return result;
463
+ },
464
+ run: async (
465
+ command: string,
466
+ options: VmHelperOptions = {},
467
+ ): Promise<{ stdout: string; stderr: string; exit_code: number }> => {
468
+ const cwd = options.cwd ? resolvePath(state, options.cwd) : state.cwd;
469
+ const timeoutMs =
470
+ typeof options.timeoutMs === "number"
471
+ ? options.timeoutMs
472
+ : typeof options.timeout === "number"
473
+ ? options.timeout * 1000
474
+ : undefined;
475
+ const timeoutSignal =
476
+ typeof timeoutMs === "number" && Number.isFinite(timeoutMs) && timeoutMs > 0
477
+ ? AbortSignal.timeout(timeoutMs)
478
+ : undefined;
479
+ const signal =
480
+ state.currentRun?.signal && timeoutSignal
481
+ ? AbortSignal.any([state.currentRun.signal, timeoutSignal])
482
+ : (state.currentRun?.signal ?? timeoutSignal);
483
+ const child = Bun.spawn(["bash", "-lc", command], {
484
+ cwd,
485
+ env: getMergedEnv(state),
486
+ stdout: "pipe",
487
+ stderr: "pipe",
488
+ signal,
489
+ });
490
+ const [stdout, stderr, exit_code] = await Promise.all([
491
+ new Response(child.stdout as ReadableStream<Uint8Array>).text(),
492
+ new Response(child.stderr as ReadableStream<Uint8Array>).text(),
493
+ child.exited,
494
+ ]);
495
+ const output = `${stdout}${stderr}`.slice(0, 500);
496
+ emitStatus(state, { op: "run", cmd: command.slice(0, 120), code: exit_code, output });
497
+ return { stdout, stderr, exit_code };
498
+ },
499
+ env: (key?: string, value?: string): string | Record<string, string> | undefined => {
500
+ if (!key) {
501
+ const env = Object.fromEntries(Object.entries(getMergedEnv(state)).sort(([a], [b]) => a.localeCompare(b)));
502
+ emitStatus(state, { op: "env", count: Object.keys(env).length, keys: Object.keys(env).slice(0, 20) });
503
+ return env;
504
+ }
505
+ if (value !== undefined) {
506
+ state.env.set(key, value);
507
+ emitStatus(state, { op: "env", key, value, action: "set" });
508
+ return value;
509
+ }
510
+ const result = state.env.get(key) ?? Bun.env[key];
511
+ emitStatus(state, { op: "env", key, value: result, action: "get" });
512
+ return result;
513
+ },
514
+ };
515
+ }
516
+
517
+ function createProcessSubset(cwd: string): Record<string, unknown> {
518
+ return Object.freeze({
519
+ arch: process.arch,
520
+ cwd: () => cwd,
521
+ platform: process.platform,
522
+ release: Object.freeze({ ...process.release }),
523
+ version: process.version,
524
+ versions: Object.freeze({ ...process.versions }),
525
+ });
526
+ }
527
+
528
+ async function createVmState(
529
+ sessionKey: string,
530
+ sessionId: string,
531
+ cwd: string,
532
+ session: ToolSession,
533
+ ): Promise<VmContextState> {
534
+ const state: VmContextState = {
535
+ sessionKey,
536
+ cwd,
537
+ sessionId,
538
+ session,
539
+ context: {} as vm.Context,
540
+ env: new Map(),
541
+ timers: new Set(),
542
+ intervals: new Set(),
543
+ queue: Promise.resolve(),
544
+ };
545
+
546
+ const helpers = await createHelpers(state);
547
+ const contextGlobals: Record<string, unknown> = {
548
+ __omp_session__: { cwd, sessionId },
549
+ __omp_helpers__: helpers,
550
+ __omp_call_tool__: async (name: string, args: unknown) =>
551
+ callSessionTool(name, args, {
552
+ session: state.session,
553
+ signal: state.currentRun?.signal,
554
+ emitStatus: event => emitStatus(state, event),
555
+ }),
556
+ __omp_emit_status__: (op: string, data: Record<string, unknown> = {}) => emitStatus(state, { op, ...data }),
557
+ __omp_log__: (level: string, ...args: unknown[]) => {
558
+ const prefix = level === "error" ? "[error] " : level === "warn" ? "[warn] " : "";
559
+ emitText(state, `${prefix}${formatConsoleArgs(args)}`);
560
+ },
561
+ __omp_display__: (value: unknown) => displayValue(state, value),
562
+ setTimeout: createTrackedTimeout(state, false),
563
+ setInterval: createTrackedTimeout(state, true),
564
+ clearTimeout: (timer?: NodeJS.Timeout) => clearTrackedTimeout(state, false, timer),
565
+ clearInterval: (timer?: NodeJS.Timeout) => clearTrackedTimeout(state, true, timer),
566
+ queueMicrotask,
567
+ URL,
568
+ URLSearchParams,
569
+ TextEncoder,
570
+ TextDecoder,
571
+ AbortController,
572
+ AbortSignal,
573
+ structuredClone,
574
+ crypto,
575
+ webcrypto: crypto,
576
+ performance,
577
+ atob,
578
+ btoa,
579
+ Buffer,
580
+ process: createProcessSubset(cwd),
581
+ fs,
582
+ fetch,
583
+ Blob,
584
+ File,
585
+ Headers,
586
+ Request,
587
+ Response,
588
+ globalThis: undefined,
589
+ };
590
+ const context = vm.createContext(contextGlobals);
591
+ context.globalThis = context;
592
+ state.context = context;
593
+ vm.runInContext(JAVASCRIPT_PRELUDE_SOURCE, context, { filename: "js-prelude.js" });
594
+ return state;
595
+ }
596
+
597
+ async function getOrCreateVmState(
598
+ sessionKey: string,
599
+ sessionId: string,
600
+ cwd: string,
601
+ session: ToolSession,
602
+ ): Promise<VmContextState> {
603
+ const existing = vmContexts.get(sessionKey);
604
+ if (existing) {
605
+ existing.cwd = cwd;
606
+ existing.sessionId = sessionId;
607
+ existing.session = session;
608
+ return existing;
609
+ }
610
+ const created = await createVmState(sessionKey, sessionId, cwd, session);
611
+ vmContexts.set(sessionKey, created);
612
+ return created;
613
+ }
614
+
615
+ async function disposeState(state: VmContextState): Promise<void> {
616
+ for (const timer of state.timers) {
617
+ clearTimeout(timer);
618
+ }
619
+ state.timers.clear();
620
+ for (const timer of state.intervals) {
621
+ clearInterval(timer);
622
+ }
623
+ state.intervals.clear();
624
+ state.currentRun = undefined;
625
+ }
626
+
627
+ async function runQueued<T>(state: VmContextState, work: () => Promise<T>): Promise<T> {
628
+ const previous = state.queue;
629
+ const { promise, resolve } = Promise.withResolvers<void>();
630
+ state.queue = promise;
631
+ await previous;
632
+ try {
633
+ return await work();
634
+ } finally {
635
+ resolve();
636
+ }
637
+ }
638
+
639
+ function wrapCode(code: string): { source: string; asyncWrapped: boolean } {
640
+ const needsAsyncWrapper = /\bawait\b|\breturn\b/.test(code);
641
+ if (!needsAsyncWrapper) {
642
+ return { source: code, asyncWrapped: false };
643
+ }
644
+ return {
645
+ source: `(async () => {\n${code}\n})()`,
646
+ asyncWrapped: true,
647
+ };
648
+ }
649
+
650
+ async function awaitMaybePromise<T>(value: T | Promise<T>, signal?: AbortSignal): Promise<T> {
651
+ if (!value || typeof value !== "object" || typeof (value as { then?: unknown }).then !== "function") {
652
+ return value;
653
+ }
654
+ const promised = value as Promise<T>;
655
+ if (!signal) {
656
+ return promised;
657
+ }
658
+ const { promise, resolve, reject } = Promise.withResolvers<T>();
659
+ if (signal.aborted) {
660
+ reject(signal.reason ?? new Error("Execution aborted"));
661
+ return promise;
662
+ }
663
+ const onAbort = () => reject(signal.reason ?? new Error("Execution aborted"));
664
+ signal.addEventListener("abort", onAbort, { once: true });
665
+ promised.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
666
+ return promise;
667
+ }
668
+
669
+ export async function executeInVmContext(options: {
670
+ sessionKey: string;
671
+ sessionId: string;
672
+ cwd: string;
673
+ session: ToolSession;
674
+ reset?: boolean;
675
+ code: string;
676
+ filename: string;
677
+ timeoutMs?: number;
678
+ runState: VmRunState;
679
+ }): Promise<{ value: unknown }> {
680
+ if (options.reset) {
681
+ await resetVmContext(options.sessionKey);
682
+ }
683
+ const state = await getOrCreateVmState(options.sessionKey, options.sessionId, options.cwd, options.session);
684
+ return runQueued(state, async () => {
685
+ state.currentRun = options.runState;
686
+ try {
687
+ if (options.runState.signal?.aborted) {
688
+ throw options.runState.signal.reason ?? new Error("Execution aborted");
689
+ }
690
+ const wrapped = wrapCode(options.code);
691
+ const value = vm.runInContext(wrapped.source, state.context, {
692
+ filename: options.filename,
693
+ timeout: options.timeoutMs,
694
+ });
695
+ const awaited = await awaitMaybePromise(value, options.runState.signal);
696
+ displayValue(state, awaited);
697
+ return { value: awaited };
698
+ } finally {
699
+ state.currentRun = undefined;
700
+ }
701
+ });
702
+ }
703
+
704
+ export async function resetVmContext(sessionKey: string): Promise<void> {
705
+ const existing = vmContexts.get(sessionKey);
706
+ if (!existing) return;
707
+ vmContexts.delete(sessionKey);
708
+ await disposeState(existing);
709
+ }
710
+
711
+ export async function disposeAllVmContexts(): Promise<void> {
712
+ const states = Array.from(vmContexts.values());
713
+ vmContexts.clear();
714
+ for (const state of states) {
715
+ await disposeState(state);
716
+ }
717
+ }