import { AsyncLocalStorage } from "node:async_hooks"; import { Console } from "node:console"; import * as fs from "node:fs"; import { createRequire } from "node:module"; import * as path from "node:path"; import { Writable } from "node:stream"; import * as util from "node:util"; import * as vm from "node:vm"; // Subpath imports only: the computer worker's readiness graph includes this runtime and must not // load pi_natives (verified under `--no-addons`); the `@oh-my-pi/pi-utils` barrel loads it eagerly. import * as logger from "@oh-my-pi/pi-utils/logger"; import { isRecord } from "@oh-my-pi/pi-utils/type-guards"; import type { EvalTableColumn } from "@oh-my-pi/pi-tui/tools/eval"; import { type EvalDisplayOutput, evalImageMetadata } from "../../types"; import type { EvalPreludeSource } from "../worker-protocol"; import { createHelpers, type HelperBundle } from "./helpers"; import { awaitMaybePromise, indirectEval } from "./indirect-eval"; import { LocalModuleLoader } from "./local-module-loader"; import { JAVASCRIPT_PRELUDE_SOURCE } from "./prelude"; import { diagnoseCellSyntaxError, wrapCode } from "./rewrite-imports"; import type { JsStatusEvent } from "./types"; export interface RuntimeCallIdentity { siteId: string; occurrence: number; } /** * Per-run callbacks. Runtime globals resolve these from AsyncLocalStorage so * overlapping async cells can route output/tool calls back to their own run. */ export interface RuntimeHooks { onText(chunk: string): void; onDisplay(output: EvalDisplayOutput): void; callTool(name: string, args: unknown, identity?: RuntimeCallIdentity): Promise; /** * Called by `run` right before the cell's code is evaluated, once compiling it (which may load Babel) finished; * a throw fails the run without evaluating the cell. */ onEvaluate?(): void; } /** * Bridged tool results carry image blocks as a base64 `images` array that no * cell code consumes. Emit each block as an image display output so the model * receives real image content — matching the direct tool path — and replace * the payload with a note so echoing the result cannot flood the transcript * with base64. */ function surfaceBridgedToolImages(value: unknown, hooks: RuntimeHooks): unknown { if (!isRecord(value)) return value; const { images, ...rest } = value; if (!Array.isArray(images) || images.length === 0) return value; let displayed = 0; for (const image of images) { if (!isRecord(image)) continue; const { data, mimeType } = image; if (typeof data !== "string" || typeof mimeType !== "string") continue; hooks.onDisplay({ type: "image", data, mimeType, ...evalImageMetadata(image) }); displayed++; } if (displayed === 0) return value; return { ...rest, images: `(${displayed} image${displayed === 1 ? "" : "s"} displayed)` }; } export interface RunContext { runId: string; hooks: RuntimeHooks; cwd: string; /** Internal-URL roots the run started with; a later run's {@link JsRuntime.setLocalRoots} must not move them. */ localRoots: Record; filename?: string; callOccurrences: Map; finalExpressionSet: boolean; finalExpressionValue: unknown; } export interface RuntimeOptions { initialCwd: string; sessionId: string; /** * Extra globals installed alongside `__omp_helpers__` / prelude. Use for stable, lifetime- * of-the-worker bindings (e.g. browser's `page`, `browser`). Per-run scope should be set * via `setRunScope()` instead. */ extraGlobals?: Record; /** * On-disk roots the helpers substitute for internal-URL schemes (e.g. * `{ local: "/…/artifacts/local" }`). Stable for the worker's lifetime. */ localRoots?: Record; /** Selected package directory consulted after the importing file's project. */ packageRoot?: string; } // Strict base64: characters from the standard alphabet plus optional `=` padding, and a // length that is a multiple of four. URL-safe base64 and embedded whitespace are not // accepted — the Anthropic API only honors strict base64 in image sources. const BASE64_STRICT_RE = /^[A-Za-z0-9+/]+={0,2}$/; const DECIMAL_CSV_RE = /^\d{1,3}(?:,\d{1,3})*$/; const PRELUDE_GLOBAL_KEYS = [ "__omp_js_prelude_loaded__", "__omp_tools__", "console", "print", "tool", "completion", "output", "agent", "wait", "AgentHandle", "CompletionHandle", "judge", "judgeBatch", "JudgmentBatch", "JudgmentItem", "workpool", "WorkPool", "log", "phase", "__omp_with_call_site__", "budget", "read", "write", "env", "sh", ]; /** * Which mutable global intrinsics still have their original identity. * * A retained cell can replace e.g. `String` with an arbitrary value; the shadow * projector models `String(...)`/`JSON.stringify(...)`/`Array.prototype.join` * calls as pure transforms only for names flagged intact here, so speculation * never performs I/O from a branch the real cell cannot reach. */ export type ShadowInitialGlobals = Readonly<{ String: boolean; JSON: boolean; "JSON.stringify": boolean; "Array.prototype.join": boolean; /** * Whether `Object.prototype.toString` still has its original identity. * Implicit string coercion (templates, `+`) dispatches it for objects * (including arrays containing objects); a retained replacement changes * authoritative paths while leaving no snapshot trace. Absent only for * hand-built snapshots, where the intrinsic is assumed intact. */ "Object.prototype.toString"?: boolean; /** * Whether the prelude tool bridge dispatcher still has its installed * identity. The proxy resolves `__omp_call_tool__` per call, so the flag * must track this binding; it also joins the snapshot digest, invalidating * plans if the installation ever changes under them. Absent only for * hand-built snapshots, where the bridge is assumed intact. */ __omp_call_tool__?: boolean; }>; export type ShadowSnapshot = Readonly<{ revision: number; values: Readonly>; initialGlobals: ShadowInitialGlobals; }>; export function shadowSnapshotDigest(snapshot: ShadowSnapshot): string { // Intrinsic-identity flags participate: an async `globalThis.String = null` that // lands after planning changes no value and bumps no revision, so a // values-only digest would still match at verify time while projections made // against the builtin no longer describe the cell. The template conversion // avoids the mutable `String` global this function itself must survive. return `${Bun.hash(JSON.stringify({ values: snapshot.values, initialGlobals: snapshot.initialGlobals }))}`; } const SHADOW_SNAPSHOT_MAX_DEPTH = 16; const SHADOW_SNAPSHOT_MAX_NODES = 2_000; const SHADOW_SNAPSHOT_MAX_STRING_BYTES = 8 * 1024 * 1024; function copyShadowValue( value: unknown, depth: number, state: { nodes: number; bytes: number; seen: Set }, ): unknown | undefined { if (depth > SHADOW_SNAPSHOT_MAX_DEPTH || ++state.nodes > SHADOW_SNAPSHOT_MAX_NODES) return undefined; if (value === null || typeof value === "boolean") return value; if (typeof value === "number") return Number.isFinite(value) ? value : undefined; if (typeof value === "string") { state.bytes += Buffer.byteLength(value); return state.bytes <= SHADOW_SNAPSHOT_MAX_STRING_BYTES ? value : undefined; } if (typeof value !== "object" || util.types.isProxy(value) || state.seen.has(value)) return undefined; state.seen.add(value); try { if (Array.isArray(value)) { const keys = Reflect.ownKeys(value); if (keys.length !== value.length + 1 || !keys.includes("length")) return undefined; const copied: unknown[] = []; for (let index = 0; index < value.length; index++) { const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); if (!descriptor || !("value" in descriptor)) return undefined; const next = copyShadowValue(descriptor.value, depth + 1, state); if (next === undefined) return undefined; copied.push(next); } return copied; } if (Object.getPrototypeOf(value) !== Object.prototype) return undefined; const keys = Object.keys(value); if (Reflect.ownKeys(value).length !== keys.length) return undefined; const copied: Record = {}; for (const key of keys) { const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor || !("value" in descriptor)) return undefined; const next = copyShadowValue(descriptor.value, depth + 1, state); if (next === undefined) return undefined; copied[key] = next; } return copied; } finally { state.seen.delete(value); } } function isStrictBase64(s: string): boolean { if (s.length === 0 || s.length % 4 !== 0) return false; return BASE64_STRICT_RE.test(s); } /** * Normalize the `data` field of a printed `{ type: "image", data, mimeType }` record * into strict base64. Accepts: * - already-valid base64 strings (passed through verbatim) * - `Uint8Array` / `Buffer` / `ArrayBuffer` / typed array views * - `{ type: "Buffer", data: number[] }` (the shape Node serializes Buffers to via * `JSON.stringify`) * - decimal-CSV byte strings (the output of `uint8array.toString("base64")`, which * silently ignores the encoding argument and returns `Array.prototype.toString` — * a footgun for callers expecting `Buffer.toString` semantics) * Returns `null` if no recovery is possible. */ function coerceImageBase64(data: unknown): string | null { if (typeof data === "string") { if (isStrictBase64(data)) return data; if (DECIMAL_CSV_RE.test(data)) { const parts = data.split(","); const bytes = new Uint8Array(parts.length); for (let i = 0; i < parts.length; i++) { const n = Number(parts[i]); if (!Number.isInteger(n) || n < 0 || n > 255) return null; bytes[i] = n; } return Buffer.from(bytes).toString("base64"); } return null; } if (data instanceof Uint8Array) return Buffer.from(data).toString("base64"); if (data instanceof ArrayBuffer) return Buffer.from(data).toString("base64"); if (ArrayBuffer.isView(data)) { const view = data as ArrayBufferView; return Buffer.from(view.buffer, view.byteOffset, view.byteLength).toString("base64"); } if (data && typeof data === "object") { const obj = data as { type?: unknown; data?: unknown }; if (obj.type === "Buffer" && Array.isArray(obj.data)) { const arr = obj.data as unknown[]; const bytes = new Uint8Array(arr.length); for (let i = 0; i < arr.length; i++) { const n = arr[i]; if (typeof n !== "number" || !Number.isInteger(n) || n < 0 || n > 255) return null; bytes[i] = n; } return Buffer.from(bytes).toString("base64"); } } return null; } /** Compiles `source` as a global script without running it (matches indirect eval semantics). */ function compiles(source: string): boolean { try { new vm.Script(source); return true; } catch { return false; } } /** * Shared JS runtime for the eval worker and the browser tab worker. Owns the prelude, * helper bag, console bridge, and indirect-eval execution. Emits text/output/tool-call * back through `RuntimeHooks` that the embedder supplies — wire format is the embedder's * concern. */ export class JsRuntime { #globalOwner = Symbol("JsRuntime globals"); #ownedGlobalKeys = new Set(); #reservedGlobalKeys = new Set(); #installedPreludes = new Map(); #disposed = false; #runHookResolver = () => this.#als.getStore()?.hooks; #ownGlobal(key: string): void { if (this.#ownedGlobalKeys.has(key)) return; claimGlobalKey(key, this.#globalOwner); this.#ownedGlobalKeys.add(key); } #releaseGlobal(key: string): void { if (!this.#ownedGlobalKeys.delete(key)) return; releaseGlobalKey(key, this.#globalOwner); } #activateGlobals(action: string): void { if (this.#disposed) throw new Error(`Cannot ${action} on a disposed JS runtime`); activateGlobalOwner(this.#globalOwner, this.#ownedGlobalKeys, action); } /** * Capture the current values of every owned global back into this owner's * global stack. A cell may rebind a reserved injected global (e.g. * `var fs = await import("node:fs/promises")`); without this, the next * `activateGlobalOwner` would restore the install-time value and silently * clobber the reassignment. Called after each run so bindings persist across * cells like the eval persistence contract promises. */ #recordGlobals(): void { for (const key of this.#ownedGlobalKeys) recordGlobalValue(key, this.#globalOwner); } readonly helpers: HelperBundle; #cwd: string; #session: { cwd: string; sessionId: string }; readonly sessionId: string; #env: Map; #als = new AsyncLocalStorage(); #callSiteAls = new AsyncLocalStorage(); #moduleLoader: LocalModuleLoader; #localRoots: Record; #namespaceRevision = 0; #initialGlobalKeys = new Set(); #initialIntrinsics = { String: globalThis.String, JSON: globalThis.JSON, stringify: globalThis.JSON.stringify, arrayJoin: Array.prototype.join, objectToString: Object.prototype.toString, }; #installedCallTool: unknown; snapshotUserGlobals(): ShadowSnapshot { this.#activateGlobals("snapshot user globals"); const values: Record = {}; const snapshotState = { nodes: 0, bytes: 0, seen: new Set() }; for (const key of Object.getOwnPropertyNames(globalThis)) { if (this.#ownedGlobalKeys.has(key) || this.#initialGlobalKeys.has(key)) continue; const descriptor = Object.getOwnPropertyDescriptor(globalThis, key); if (!descriptor || !("value" in descriptor)) continue; const copied = copyShadowValue(descriptor.value, 0, snapshotState); if (copied !== undefined) values[key] = copied; } // A retained cell may replace these without leaving any snapshot trace // (functions are never JSON-safe, and `delete` removes the key entirely), // so record their identity explicitly. The `JSON` short-circuit guards the // property access when a prior cell nulled the whole object. const currentJSON = globalThis.JSON; const initialGlobals: ShadowInitialGlobals = { String: globalThis.String === this.#initialIntrinsics.String, JSON: currentJSON === this.#initialIntrinsics.JSON, "JSON.stringify": typeof currentJSON === "object" && currentJSON !== null && currentJSON === this.#initialIntrinsics.JSON && currentJSON.stringify === this.#initialIntrinsics.stringify, "Array.prototype.join": Array.prototype.join === this.#initialIntrinsics.arrayJoin, "Object.prototype.toString": Object.prototype.toString === this.#initialIntrinsics.objectToString, __omp_call_tool__: (globalThis as Record).__omp_call_tool__ === this.#installedCallTool, }; return Object.freeze({ revision: this.#namespaceRevision, values: Object.freeze(values), initialGlobals: Object.freeze(initialGlobals), }); } constructor(opts: RuntimeOptions) { this.#cwd = opts.initialCwd; this.#session = { cwd: opts.initialCwd, sessionId: opts.sessionId }; this.sessionId = opts.sessionId; this.#env = new Map(); this.#moduleLoader = new LocalModuleLoader(this.sessionId); this.#moduleLoader.setPackageRoot(opts.packageRoot); this.#localRoots = opts.localRoots ?? {}; this.helpers = createHelpers({ cwd: () => this.#activeCwd(), env: this.#env, localRoots: () => this.#als.getStore()?.localRoots ?? this.#localRoots, emitStatus: event => this.#activeHooks("emitStatus")?.onDisplay({ type: "status", event }), }); this.#initialGlobalKeys = new Set(Object.getOwnPropertyNames(globalThis)); this.#install(opts.extraGlobals); } get cwd(): string { return this.#cwd; } setCwd(cwd: string): void { if (this.#disposed) throw new Error("Cannot set cwd on a disposed JS runtime"); // Always stamp the runtime and session state: WorkerCore/browser/cmux call // setCwd from init and pre-run paths that may race another same-realm // runtime, and a throw here used to escape the harness microtask path as // a fatal unhandledRejection that killed the whole session. // #session is the same object saved in this owner's global stack entry, // so the new cwd survives deferred activation and is visible to this // runtime's next run; run()/setRunScope still assert exclusive ownership. this.#cwd = cwd; this.#session.cwd = cwd; if (activeGlobalRunOwner === null || activeGlobalRunOwner === this.#globalOwner) { this.#activateGlobals("set cwd"); } } setPackageRoot(packageRoot: string | undefined): void { if (this.#disposed) throw new Error("Cannot set package root on a disposed JS runtime"); this.#moduleLoader.setPackageRoot(packageRoot); } /** The session's internal-URL roots move with it (a re-adopted session has a new artifacts dir). */ setLocalRoots(localRoots: Record | undefined): void { this.#localRoots = localRoots ?? {}; } /** * Install per-run globals. Intended for run-scoped state (browser's `tab`, `wait`, * etc.). Overwrites previous assignments — caller is responsible for any * cleanup it wants. */ setRunScope(scope: Record): void { this.#activateGlobals("set run scope"); for (const key in scope) { this.#ownGlobal(key); (globalThis as Record)[key] = scope[key]; recordGlobalValue(key, this.#globalOwner); } } /** * Synchronize enabled capability snippets before an ordinary eval cell. * Unchanged sources retain their objects; removed or replaced definitions * release every declared global before replacement source runs. */ syncPreludes(preludes: readonly EvalPreludeSource[]): void { if (preludes.length === 0 && this.#installedPreludes.size === 0) return; this.#activateGlobals("sync eval preludes"); const desired = new Map(); const exportOwners = new Map(); for (const prelude of preludes) { if (desired.has(prelude.name)) throw new Error(`Duplicate eval prelude name: ${prelude.name}`); for (const name of prelude.exports) { if (this.#reservedGlobalKeys.has(name)) { throw new Error(`Eval prelude ${prelude.name} cannot replace reserved global ${name}`); } const owner = exportOwners.get(name); if (owner) throw new Error(`Eval preludes ${owner} and ${prelude.name} both export ${name}`); exportOwners.set(name, prelude.name); } desired.set(prelude.name, prelude); } for (const [name, installed] of this.#installedPreludes) { const next = desired.get(name); if ( next && next.source === installed.source && next.exports.length === installed.exports.length && next.exports.every((value, index) => value === installed.exports[index]) ) { continue; } for (const exported of installed.exports) this.#releaseGlobal(exported); this.#installedPreludes.delete(name); } for (const [name, prelude] of desired) { if (this.#installedPreludes.has(name)) continue; for (const exported of prelude.exports) this.#ownGlobal(exported); try { indirectEval(prelude.source, `[eval-prelude:${name}]`); for (const exported of prelude.exports) recordGlobalValue(exported, this.#globalOwner); this.#installedPreludes.set(name, { name, exports: [...prelude.exports], source: prelude.source, }); } catch (error) { for (const exported of prelude.exports) this.#releaseGlobal(exported); throw error; } } } /** Read a prelude-owned global from this runtime's active global set. */ getGlobal(name: string): unknown { this.#activateGlobals("read runtime global"); return Reflect.get(globalThis, name); } /** Invoke a callback inside this runtime's async run context. */ async runCallback(runId: string, hooks: RuntimeHooks, callback: () => T | Promise): Promise { this.#activateGlobals("run callback"); const leaveRun = enterGlobalRun(this.#globalOwner, "run callback"); const context: RunContext = { runId, hooks, cwd: this.#cwd, localRoots: this.#localRoots, finalExpressionSet: false, finalExpressionValue: undefined, callOccurrences: new Map(), }; try { return await this.#als.run(context, callback); } finally { this.#recordGlobals(); leaveRun(); } } async run( code: string, filename: string | undefined, hooks: RuntimeHooks, options: { runId?: string; cwd?: string; localRoots?: Record } = {}, ): Promise { this.#activateGlobals("run code"); this.#namespaceRevision++; const leaveRun = enterGlobalRun(this.#globalOwner, "run code"); const context: RunContext = { runId: options.runId ?? crypto.randomUUID(), hooks, cwd: options.cwd ?? this.#cwd, localRoots: options.localRoots ?? this.#localRoots, filename, finalExpressionSet: false, finalExpressionValue: undefined, callOccurrences: new Map(), }; try { return await this.#als.run(context, async () => { const wrapped = await wrapCode(code); let value: unknown; hooks.onEvaluate?.(); try { value = indirectEval(wrapped.source, filename); } catch (error) { // The engine's own compile SyntaxError has no usable cell position; surface Babel's. // Sync cells run inside indirectEval, so a SyntaxError may also be a runtime one // (e.g. JSON.parse); only diagnose when the source itself fails to compile as a script. if (error instanceof SyntaxError && !compiles(wrapped.source)) { throw (await diagnoseCellSyntaxError(code)) ?? error; } throw error; } if (wrapped.finalExpressionReturned) { const awaited = await awaitMaybePromise(value); if (context.finalExpressionSet) { const finalValue = context.finalExpressionValue; context.finalExpressionSet = false; context.finalExpressionValue = undefined; return await awaitMaybePromise(finalValue); } return awaited; } return await awaitMaybePromise(value); }); } finally { this.#recordGlobals(); leaveRun(); } } /** Echoes a cell's final value exactly as `console.log(value)` would; `undefined` prints nothing. */ echo(value: unknown, hooks: RuntimeHooks): void { if (value === undefined) return; logConsoleArgs("log", [value], hooks); } #activeCwd(): string { return this.#als.getStore()?.cwd ?? this.#cwd; } #activeFilename(): string | undefined { const filename = this.#als.getStore()?.filename; return filename && path.isAbsolute(filename) ? filename : undefined; } #activeHooks(action: string): RuntimeHooks | undefined { const hooks = this.#als.getStore()?.hooks; if (!hooks) { logger.warn("js runtime helper called outside an active run", { action }); } return hooks; } #activeRequire(moduleUrlOrPath?: string): NodeJS.Require { return this.#moduleLoader.requireForFile(moduleUrlOrPath ?? this.#activeFilename(), this.#activeCwd()); } #moduleFilename(moduleUrlOrPath?: string): string { return ( this.#moduleLoader.filenameForUrl(moduleUrlOrPath ?? this.#activeFilename()) ?? path.join(this.#activeCwd(), "[eval]") ); } #moduleDirname(moduleUrlOrPath?: string): string { return this.#moduleLoader.dirnameForUrl(moduleUrlOrPath ?? this.#activeFilename(), this.#activeCwd()); } #buildDynamicRequire(): NodeJS.Require { const dynamicRequire = ((id: string) => this.#activeRequire()(id)) as NodeJS.Require; const resolve = ((id: string, options?: { paths?: string[] }) => this.#activeRequire().resolve(id, options)) as NodeJS.Require["resolve"] & { paths(request: string): string[] | null; }; resolve.paths = request => this.#activeRequire().resolve.paths(request); Object.defineProperties(dynamicRequire, { resolve: { value: resolve, configurable: true }, cache: { get: () => this.#activeRequire().cache, configurable: true }, extensions: { get: () => this.#activeRequire().extensions, configurable: true }, main: { get: () => this.#activeRequire().main, configurable: true }, }); return dynamicRequire; } #install(extraGlobals: Record | undefined): void { // Constructing a runtime while another same-realm runtime is mid-run would // silently replace the live runtime's globals (Object.assign + prelude eval // below). Fail before any global/stack mutation; WorkerCore reports it as // init-failed instead of corrupting the active run. assertCanUseGlobalOwner(this.#globalOwner, "initialize a JS runtime"); const injected: Record = { __omp_session__: this.#session, __omp_helpers__: this.helpers, __omp_with_call_site__: (siteId: string, action: () => T): T => { const context = this.#als.getStore(); if (!context) return action(); const occurrence = context.callOccurrences.get(siteId) ?? 0; context.callOccurrences.set(siteId, occurrence + 1); return this.#callSiteAls.run({ siteId, occurrence }, action); }, __omp_call_tool__: async (name: string, args: unknown) => { const hooks = this.#activeHooks("tool"); if (!hooks) return undefined; return surfaceBridgedToolImages(await hooks.callTool(name, args, this.#callSiteAls.getStore()), hooks); }, __omp_prelude__: async (name: string, parameters: unknown) => { const hooks = this.#activeHooks("prelude"); if (!hooks) return undefined; const payload = { name, parameters }; return surfaceBridgedToolImages(await hooks.callTool("__prelude__", payload), hooks); }, __omp_import__: async (source: string, options?: ImportCallOptions) => { const filename = this.#activeFilename(); const baseDir = filename ? path.dirname(filename) : this.#activeCwd(); const resolved = await this.#moduleLoader.resolveForRun(baseDir, source); if (resolved.mode === "local") return resolved.value; const target = resolved.target; return options !== undefined ? await import(target, options) : await import(target); }, __omp_import_from__: async (moduleUrl: string, source: string, options?: ImportCallOptions) => { const resolved = await this.#moduleLoader.resolveForModule(moduleUrl, source, this.#activeCwd()); if (resolved.mode === "local") return resolved.value; const target = resolved.target; return options !== undefined ? await import(target, options) : await import(target); }, __omp_get_require__: (moduleUrl?: string) => this.#activeRequire(moduleUrl), __omp_get_filename__: (moduleUrl?: string) => this.#moduleFilename(moduleUrl), __omp_get_dirname__: (moduleUrl?: string) => this.#moduleDirname(moduleUrl), __omp_emit_status__: (op: string, data: Record = {}) => { const event: JsStatusEvent = { op, ...data }; this.#activeHooks("emitStatus")?.onDisplay({ type: "status", event }); }, __omp_log__: (level: string, ...args: unknown[]) => { const hooks = this.#activeHooks("log"); if (hooks) logConsoleArgs(level, args, hooks); }, __omp_table__: (...args: unknown[]) => { const hooks = this.#activeHooks("table"); if (!hooks) return; let buffer = ""; const stream = new Writable({ write(chunk, _enc, cb) { buffer += typeof chunk === "string" ? chunk : (chunk as Buffer).toString("utf8"); cb(); }, }); const tableConsole = new Console({ stdout: stream, colorMode: false }); // `table` is missing from some @types/node Console shapes, but the // Node runtime always provides it. const tableCapable = tableConsole as unknown as { table: (...args: unknown[]) => void }; tableCapable.table(...args); hooks.onText(buffer.endsWith("\n") ? buffer : `${buffer}\n`); const table = parseConsoleTable(buffer); if (table) hooks.onDisplay({ type: "view", text: buffer, view: table }); }, __omp_set_final_expr__: (value: unknown) => { const context = this.#als.getStore(); if (!context) { logger.warn("js runtime final expression set outside an active run"); return; } context.finalExpressionSet = true; context.finalExpressionValue = value; }, webcrypto: crypto, // `process` is intentionally not overridden — user code gets the host worker's real // `process` object. Subsetting it caused segfaults in workers that share state with // puppeteer/worker_threads internals. require: this.#buildDynamicRequire(), createRequire, fs, }; const allGlobalKeys = new Set([ ...Object.keys(injected), ...Object.keys(extraGlobals ?? {}), ...PRELUDE_GLOBAL_KEYS, ]); this.#reservedGlobalKeys = allGlobalKeys; for (const key of allGlobalKeys) { this.#ownGlobal(key); } Object.assign(globalThis, injected, extraGlobals ?? {}); // Prelude assigns console bridge + short aliases (`read`, `write`, `tool`, `print`, ...) // onto globalThis. Must run after helpers are in place. indirectEval(JAVASCRIPT_PRELUDE_SOURCE); for (const key of allGlobalKeys) recordGlobalValue(key, this.#globalOwner); // Capture the installed bridge dispatcher for the snapshot identity // flag below. The prelude tool proxy resolves `__omp_call_tool__` per // call, so the flag must track this binding. (Steady-state retained // replacements self-heal: owned-global activation restores the recorded // value before any observation. The `tool` binding itself needs no // flag: the prelude declares it as a lexical const, which shadows any // `globalThis.tool` replacement for bare references.) this.#installedCallTool = (globalThis as Record).__omp_call_tool__; RUN_HOOK_RESOLVERS.add(this.#runHookResolver); patchStdioOnce(); } dispose(): void { if (this.#disposed) return; this.#disposed = true; RUN_HOOK_RESOLVERS.delete(this.#runHookResolver); for (const key of this.#ownedGlobalKeys) releaseGlobalKey(key, this.#globalOwner); this.#ownedGlobalKeys.clear(); this.#reservedGlobalKeys.clear(); this.#installedPreludes.clear(); } } interface GlobalSnapshot { exists: boolean; value: unknown; } interface GlobalOwnerEntry { owner: symbol; value: unknown; } interface GlobalStack { base: GlobalSnapshot; entries: GlobalOwnerEntry[]; } // Same-realm harnesses and cmux tabs can create multiple JsRuntime instances in one Bun realm. // Track reserved helper globals by owner so disposing one runtime restores the next active // owner (or the original process global after the last owner), not a stale snapshot. const GLOBAL_STACKS = new Map(); function snapshotGlobal(key: string): GlobalSnapshot { return { exists: key in globalThis, value: (globalThis as Record)[key], }; } function restoreGlobal(key: string, state: GlobalSnapshot): void { if (state.exists) { (globalThis as Record)[key] = state.value; } else { delete (globalThis as Record)[key]; } } function claimGlobalKey(key: string, owner: symbol): void { let stack = GLOBAL_STACKS.get(key); if (!stack) { stack = { base: snapshotGlobal(key), entries: [] }; GLOBAL_STACKS.set(key, stack); } stack.entries.push({ owner, value: (globalThis as Record)[key] }); } function recordGlobalValue(key: string, owner: symbol): void { const stack = GLOBAL_STACKS.get(key); const entry = stack?.entries.findLast(item => item.owner === owner); if (entry) entry.value = (globalThis as Record)[key]; } function releaseGlobalKey(key: string, owner: symbol): void { const stack = GLOBAL_STACKS.get(key); if (!stack) return; const index = stack.entries.findIndex(entry => entry.owner === owner); if (index === -1) return; const wasTop = index === stack.entries.length - 1; stack.entries.splice(index, 1); if (!wasTop) return; const next = stack.entries.at(-1); if (next) { (globalThis as Record)[key] = next.value; return; } restoreGlobal(key, stack.base); GLOBAL_STACKS.delete(key); } // Plain globalThis cannot safely serve two different runtimes at the same instant: // helpers dereference reserved globals on every call. Sequential cmux tab revisits // re-activate their owner stack; overlapping cross-runtime runs fail explicitly. let activeGlobalRunOwner: symbol | null = null; let activeGlobalRunDepth = 0; function assertCanUseGlobalOwner(owner: symbol, action: string): void { if (activeGlobalRunOwner === null || activeGlobalRunOwner === owner) return; throw new Error(`Cannot ${action} while another same-realm JS runtime is running`); } function activateGlobalOwner(owner: symbol, keys: Iterable, action: string): void { assertCanUseGlobalOwner(owner, action); for (const key of keys) { const stack = GLOBAL_STACKS.get(key); const index = stack?.entries.findIndex(entry => entry.owner === owner) ?? -1; if (!stack || index === -1) throw new Error(`Cannot ${action} on a disposed JS runtime`); const entry = stack.entries[index]; stack.entries.splice(index, 1); stack.entries.push(entry); (globalThis as Record)[key] = entry.value; } } function enterGlobalRun(owner: symbol, action: string): () => void { assertCanUseGlobalOwner(owner, action); activeGlobalRunOwner = owner; activeGlobalRunDepth++; let left = false; return () => { if (left) return; left = true; activeGlobalRunDepth--; if (activeGlobalRunDepth === 0) activeGlobalRunOwner = null; }; } /** Resolvers for each live runtime's active-run hooks (one per JsRuntime instance). */ const RUN_HOOK_RESOLVERS = new Set<() => RuntimeHooks | undefined>(); /** Streams whose `write` the runtime has already wrapped (patch-once guard). */ const PATCHED_STDIO_STREAMS = new WeakSet(); /** Hooks for whichever registered runtime currently has an active run, if any. */ function activeRunHooks(): RuntimeHooks | undefined { for (const resolve of RUN_HOOK_RESOLVERS) { const hooks = resolve(); if (hooks) return hooks; } return undefined; } /** * Wrap `process.stdout` / `process.stderr` `write` exactly once per process so * user `process.stdout.write(...)` lands in the active run's text sink. Models * reach for it out of Node habit, but `process` is intentionally the host * worker's real object (see {@link JsRuntime} `#install`), so unrouted writes * escape to the worker's own stdio and never reach the cell — and `write()` * returns a boolean, so a cell ending in `process.stdout.write("x")` captured * `true` while losing the text. Patch only the `write` method (never replace * `process`), preserve exact bytes (no trailing newline), and fall through to * the real stream when no run is active so the worker's own logging is intact. */ function patchStdioOnce(): void { const streams: NodeJS.WriteStream[] = [process.stdout, process.stderr]; for (const stream of streams) { if (!stream || PATCHED_STDIO_STREAMS.has(stream)) continue; PATCHED_STDIO_STREAMS.add(stream); const original = stream.write.bind(stream) as (...args: unknown[]) => boolean; const routed = (chunk: unknown, encoding?: unknown, callback?: unknown): boolean => { const hooks = activeRunHooks(); if (!hooks) return original(chunk, encoding, callback); const cb = typeof encoding === "function" ? encoding : callback; const enc = typeof encoding === "string" ? (encoding as BufferEncoding) : undefined; hooks.onText(chunkToString(chunk, enc)); if (typeof cb === "function") (cb as (error?: Error | null) => void)(); return true; }; stream.write = routed as unknown as typeof stream.write; } } /** Coerce a `write()` chunk to text, honoring an explicit encoding for byte chunks. */ function chunkToString(chunk: unknown, encoding?: BufferEncoding): string { if (typeof chunk === "string") return chunk; if (chunk instanceof Uint8Array) return Buffer.from(chunk).toString(encoding ?? "utf8"); return String(chunk); } /** Inspect text or JSON text longer than this (or spanning lines) also gets a JSON tree view. */ const JSON_VIEW_MIN_CHARS = 80; /** The host drops views whose JSON exceeds 32 KiB; longer JSON text is not parsed for one. */ const JSON_TEXT_VIEW_MAX_CHARS = 32 * 1024; const JSON_TEXT_START = /^\s*[[{]/; /** One console argument's contribution to the printed line, plus the output it carries. */ interface ConsolePiece { text: string; output?: EvalDisplayOutput; } function isPlainStructure(value: object): boolean { if (Array.isArray(value)) return true; const proto = Object.getPrototypeOf(value); return proto === Object.prototype || proto === null; } /** JSON round trip with bigint as decimal strings; `undefined` when the value has no JSON form. */ function toJsonValue(value: unknown): unknown { try { const json = JSON.stringify(value, (_key, item) => (typeof item === "bigint" ? item.toString() : item)); return json === undefined ? undefined : JSON.parse(json); } catch { return undefined; } } /** A JSON view of a printed string holding a JSON object or array, when long or multi-line. */ function jsonTextView(text: string): EvalDisplayOutput | undefined { if (text.length <= JSON_VIEW_MIN_CHARS && !text.includes("\n")) return undefined; if (text.length > JSON_TEXT_VIEW_MAX_CHARS || !JSON_TEXT_START.test(text)) return undefined; try { return { type: "view", text, view: { kind: "json", value: JSON.parse(text) } }; } catch { return undefined; } } function formatConsoleArg(arg: unknown): ConsolePiece { if (typeof arg === "string") { const output = jsonTextView(arg); return output ? { text: arg, output } : { text: arg }; } if (isRecord(arg) && arg.type === "image" && typeof arg.mimeType === "string") { const data = coerceImageBase64(arg.data); if (data !== null) { return { text: ``, output: { type: "image", data, mimeType: arg.mimeType, ...evalImageMetadata(arg) }, }; } } const text = util.inspect(arg, { depth: 6, colors: false, breakLength: 120 }); if ( arg !== null && typeof arg === "object" && isPlainStructure(arg) && (text.length > JSON_VIEW_MIN_CHARS || text.includes("\n")) ) { const value = toJsonValue(arg); if (value !== undefined) return { text, output: { type: "view", text, view: { kind: "json", value } } }; } return { text }; } /** * Prints `args` as one console line (space-joined, `[warn]`/`[error]` prefixed), then emits * each argument's image or JSON view. A view's `text` is exactly its argument's piece of the line. */ function logConsoleArgs(level: string, args: unknown[], hooks: RuntimeHooks): void { const pieces = args.map(formatConsoleArg); const prefix = level === "error" ? "[error] " : level === "warn" ? "[warn] " : ""; const text = `${prefix}${pieces.map(piece => piece.text).join(" ")}`; hooks.onText(text.endsWith("\n") ? text : `${text}\n`); for (const piece of pieces) { if (piece.output) hooks.onDisplay(piece.output); } } const NUMERIC_CELL_RE = /^[-+]?(?:(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?|\d+n|Infinity)$|^NaN$/; /** Splits a `│ a │ b │` box row into trimmed cells; `null` when the line is not a row. */ function tableRowCells(line: string): string[] | null { if (line.length < 2 || !line.startsWith("│") || !line.endsWith("│")) return null; return line .slice(1, -1) .split(" │ ") .map(cell => cell.trim()); } /** * Parses the box `console.table` just rendered into a table view. Returns `null` for * output that is not a box (non-tabular data) or rows whose cells cannot be told apart. */ function parseConsoleTable(rendered: string): { kind: "table"; columns: EvalTableColumn[]; rows: string[][] } | null { const lines = rendered.trimEnd().split("\n"); if (lines.length < 4 || !lines[0].startsWith("┌") || !lines[2].startsWith("├") || !lines.at(-1)?.startsWith("└")) { return null; } const heads = tableRowCells(lines[1]); if (!heads) return null; const rows: string[][] = []; for (const line of lines.slice(3, -1)) { const cells = tableRowCells(line); if (!cells || cells.length !== heads.length) return null; rows.push(cells); } if (rows.length === 0) return null; const columns = heads.map((head, index): EvalTableColumn => { const column: EvalTableColumn = { head: head === "(index)" || head === "(iteration index)" ? "" : head }; let sawValue = false; let numeric = true; for (const row of rows) { const cell = row[index]; if (cell === "") continue; sawValue = true; if (!NUMERIC_CELL_RE.test(cell)) { numeric = false; break; } } if (sawValue && numeric) column.numeric = true; return column; }); return { kind: "table", columns, rows: rows.map(row => row.map(unquoteCell)) }; } /** A string cell's text without the quotes `inspect` puts around it (`'Ada'` → `Ada`). */ function unquoteCell(cell: string): string { const quote = cell[0]; if (cell.length < 2 || (quote !== "'" && quote !== '"' && quote !== "`") || cell.at(-1) !== quote) return cell; return cell.slice(1, -1).replaceAll(`\\${quote}`, quote); }