feat(coding-agent): codemode and MCP

This adds codemode and MCP support to pi. Codemode (packages/codemode) runs
model-written JavaScript in a QuickJS wasm VM inside a worker. From there the
script calls pi's tools as async functions, uses a per-session store, and can
read the model catalog and run classifiers. The codemode tool in coding-agent
is compatible with Codex's exec tool, and codemode.mode works like Codex's
tool modes (on or only). Large results are written to a JSON temp file. MCP
(packages/mcp) is a standalone client for stdio and streamable HTTP servers,
with OAuth.

The core only gets general mechanisms; codemode, tool_search and MCP are
built-in extensions that use them. Other extensions can replace these
built-ins. Tools declare an exposure: direct, model-only, codemode, deferred
or hidden. prepareLoadout() lets an orchestrating tool change which tool
declarations the model sees. ctx.executeTool() runs nested calls through
validation and the tool_call/tool_result hooks. Those calls emit events with
parentToolCallId and are recorded as bounded nestedCalls on the parent
result, which compaction and HTML export use. Tools can also return
structuredContent with an outputSchema, and can report errors with isError
instead of throwing. MCP servers come from mcp.json (global, or per project
after the project is trusted) or from pi.registerMcpServer(). They are
managed with /mcp and pi mcp list|login|logout, and their runtimes load on
first use.

Closes #10040
This commit is contained in:
Armin Ronacher 2026-09-29 09:12:50 +02:00
parent b485fa3128
commit 8562bcf66a
143 changed files with 17620 additions and 225 deletions

View file

@ -0,0 +1,351 @@
import { toCodemodeIdentifier } from "./identifier.ts";
export { toCodemodeIdentifier };
import type { CodemodeJsonSchema, CodemodeTool } from "./types.ts";
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const INDENT = " ";
/** Largest rendered input type, in characters, before it becomes `unknown`. */
export const DEFAULT_INPUT_SCHEMA_MAX_CHARS = 16_000;
/** Local `$ref` expansions per rendered schema, so shared definitions cannot blow up the output. */
const MAX_REF_EXPANSIONS = 32;
/**
* TypeScript types for MCP results, from the MCP `CallToolResult` schema, so `CallToolResult<T>`
* declarations can refer to them.
*/
export const MCP_TYPESCRIPT_PREAMBLE = `type Role = "user" | "assistant";
type MetaObject = Record<string, unknown>;
type Annotations = {
audience?: Role[];
priority?: number;
lastModified?: string;
};
type Icon = {
src: string;
mimeType?: string;
sizes?: string[];
theme?: "light" | "dark";
};
type TextResourceContents = {
uri: string;
mimeType?: string;
_meta?: MetaObject;
text: string;
};
type BlobResourceContents = {
uri: string;
mimeType?: string;
_meta?: MetaObject;
blob: string;
};
type TextContent = {
type: "text";
text: string;
annotations?: Annotations;
_meta?: MetaObject;
};
type ImageContent = {
type: "image";
data: string;
mimeType: string;
annotations?: Annotations;
_meta?: MetaObject;
};
type AudioContent = {
type: "audio";
data: string;
mimeType: string;
annotations?: Annotations;
_meta?: MetaObject;
};
type ResourceLink = {
icons?: Icon[];
name: string;
title?: string;
uri: string;
description?: string;
mimeType?: string;
annotations?: Annotations;
size?: number;
_meta?: MetaObject;
type: "resource_link";
};
type EmbeddedResource = {
type: "resource";
resource: TextResourceContents | BlobResourceContents;
annotations?: Annotations;
_meta?: MetaObject;
};
type ContentBlock =
| TextContent
| ImageContent
| AudioContent
| ResourceLink
| EmbeddedResource;
type CallToolResult<TStructured = { [key: string]: unknown }> = {
_meta?: MetaObject;
content: ContentBlock[];
isError?: boolean;
structuredContent?: TStructured;
[key: string]: unknown;
};`;
export interface RenderDeclarationsOptions {
tools?: readonly CodemodeTool[];
globals?: readonly CodemodeTool[];
}
/**
* Render TypeScript declarations for the script-visible API. Tools become members of
* `declare const tools`, globals become `declare function` statements, and `ns.member` globals
* members of `declare const ns`. Descriptions become doc comments; schemas become types.
*/
export function renderDeclarations(options: RenderDeclarationsOptions): string {
const sections: string[] = [];
const tools = options.tools ?? [];
if (tools.length > 0) {
const members = tools.map(
(tool) => `${docComment(tool.description, INDENT)}${INDENT}${renderToolSignature(tool)}`,
);
sections.push(`declare const tools: {\n${members.join("\n")}\n};`);
}
const namespaces = new Map<string, string[]>();
for (const global of options.globals ?? []) {
const dot = global.name.indexOf(".");
if (dot === -1) {
sections.push(renderGlobal(`declare function ${global.name}`, global, ""));
continue;
}
const namespace = global.name.slice(0, dot);
const members = namespaces.get(namespace) ?? [];
if (members.length === 0) namespaces.set(namespace, members);
members.push(renderGlobal(global.name.slice(dot + 1), global, INDENT));
}
for (const [namespace, members] of namespaces) {
sections.push(`declare const ${namespace}: {\n${members.join("\n")}\n};`);
}
return sections.join("\n\n");
}
/**
* One tool as a member of the `tools` object: `name(args: T): Promise<R>;` with the
* name as the identifier scripts use. Input types longer than `inputMaxChars` render as `unknown`.
* Tools whose output schema is an MCP `CallToolResult` render as `Promise<CallToolResult<T>>`,
* which needs {@link MCP_TYPESCRIPT_PREAMBLE}.
*/
export function renderToolSignature(
tool: Pick<CodemodeTool, "name" | "inputSchema" | "outputSchema">,
options: { inputMaxChars?: number } = {},
): string {
const input =
tool.inputSchema === undefined
? "unknown"
: schemaToType(tool.inputSchema, { maxChars: options.inputMaxChars ?? DEFAULT_INPUT_SCHEMA_MAX_CHARS });
return `${toCodemodeIdentifier(tool.name)}(args: ${input}): Promise<${outputType(tool.outputSchema)}>;`;
}
/**
* A tool's sample: the description followed by the tool's declaration. Used for tool
* listings and `ALL_TOOLS` entries.
*/
export function renderToolSample(
tool: Pick<CodemodeTool, "name" | "description" | "inputSchema" | "outputSchema">,
options: { inputMaxChars?: number } = {},
): string {
const declaration = `declare const tools: { ${renderToolSignature(tool, options)} };`;
return `${tool.description?.trim() ?? ""}\n\ncodemode tool declaration:\n\`\`\`ts\n${declaration}\n\`\`\``;
}
/**
* The `structuredContent` schema of an MCP `CallToolResult` output schema (detected by a
* `content` array of objects, boolean `isError`, and object `_meta`), `true` when it declares none, or
* `undefined` when the schema is not a `CallToolResult`.
*/
export function mcpStructuredContentSchema(schema: CodemodeJsonSchema | undefined): CodemodeJsonSchema | undefined {
if (!isObject(schema) || !isObject(schema.properties)) return undefined;
const { content, isError, _meta, structuredContent } = schema.properties;
if (!isObject(content) || content.type !== "array" || !isObject(content.items) || content.items.type !== "object") {
return undefined;
}
if (!isObject(isError) || isError.type !== "boolean" || !isObject(_meta) || _meta.type !== "object")
return undefined;
return isObject(structuredContent) || typeof structuredContent === "boolean" ? structuredContent : true;
}
function outputType(schema: CodemodeJsonSchema | undefined): string {
const structured = mcpStructuredContentSchema(schema);
if (structured !== undefined) {
const type = schemaToType(structured);
return type === "unknown" ? "CallToolResult" : `CallToolResult<${type}>`;
}
return schema === undefined ? "unknown" : schemaToType(schema);
}
function renderGlobal(head: string, global: CodemodeTool, indent: string): string {
if (global.signature !== undefined) {
return `${docComment(global.description, indent)}${indent}${head}${global.signature};`;
}
const input = global.inputSchema === undefined ? "unknown" : schemaToType(global.inputSchema);
const output = global.outputSchema === undefined ? "unknown" : schemaToType(global.outputSchema);
return `${docComment(global.description, indent)}${indent}${head}(args: ${input}): Promise<${output}>;`;
}
function docComment(description: string | undefined, indent: string): string {
const text = description?.trim();
if (!text) return "";
const lines = text.replaceAll("*/", "*\\/").split(/\r?\n/);
if (lines.length === 1) return `${indent}/** ${lines[0]} */\n`;
return `${indent}/**\n${lines.map((line) => `${indent} *${line ? ` ${line}` : ""}`).join("\n")}\n${indent} */\n`;
}
function propertyKey(name: string): string {
return IDENTIFIER.test(name) ? name : JSON.stringify(name);
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function union(types: string[]): string {
const unique = [...new Set(types)];
if (unique.includes("unknown")) return "unknown";
return unique.length === 0 ? "never" : unique.join(" | ");
}
/**
* Convert a JSON Schema to a TypeScript type expression: objects on one line (`{ a: string; b?: number; }`) with properties sorted by name,
* or one property per line with `//` comments when a property has a description; `Array<T>` for
* arrays. Local references (`#/$defs/...`, `#/definitions/...`) resolve against `schema`;
* recursive and remote references render as `unknown`. A result longer than `maxChars` renders as
* `unknown`.
*/
export function schemaToType(schema: CodemodeJsonSchema, options: { maxChars?: number } = {}): string {
const type = toType(schema, { root: schema, resolving: new Set(), expansions: 0 });
return options.maxChars !== undefined && type.length > options.maxChars ? "unknown" : type;
}
interface SchemaContext {
root: CodemodeJsonSchema;
/** References being expanded on the current path, to stop at recursive types. */
resolving: Set<string>;
expansions: number;
}
function resolveRef(ref: string, root: CodemodeJsonSchema): CodemodeJsonSchema | undefined {
if (ref !== "#" && !ref.startsWith("#/")) return undefined;
let current: unknown = root;
for (const segment of ref.slice(2).split("/").filter(Boolean)) {
const key = decodeURIComponent(segment).replaceAll("~1", "/").replaceAll("~0", "~");
if (!isObject(current) || !(key in current)) return undefined;
current = current[key];
}
return typeof current === "boolean" || isObject(current) ? current : undefined;
}
function toType(schema: CodemodeJsonSchema, context: SchemaContext): string {
if (schema === true) return "unknown";
if (schema === false) return "never";
if (!isObject(schema)) return "unknown";
if (typeof schema.$ref === "string") {
const ref = schema.$ref;
if (context.resolving.has(ref) || context.expansions >= MAX_REF_EXPANSIONS) return "unknown";
const target = resolveRef(ref, context.root);
if (target === undefined) return "unknown";
context.expansions++;
context.resolving.add(ref);
try {
return toType(target, context);
} finally {
context.resolving.delete(ref);
}
}
if ("const" in schema) return JSON.stringify(schema.const) ?? "unknown";
if (Array.isArray(schema.enum)) return union(schema.enum.map((value) => JSON.stringify(value) ?? "unknown"));
const variants = Array.isArray(schema.anyOf) ? schema.anyOf : Array.isArray(schema.oneOf) ? schema.oneOf : undefined;
if (variants) return union(variants.map((variant) => toType(variant as CodemodeJsonSchema, context)));
if (Array.isArray(schema.allOf)) {
const parts = schema.allOf
.map((part) => toType(part as CodemodeJsonSchema, context))
.filter((part) => part !== "unknown");
return parts.length === 0
? "unknown"
: parts.map((part) => (part.includes(" | ") ? `(${part})` : part)).join(" & ");
}
const type = schema.type;
if (Array.isArray(type)) return union(type.map((entry) => toType({ ...schema, type: entry }, context)));
switch (type) {
case "string":
return "string";
case "number":
case "integer":
return "number";
case "boolean":
return "boolean";
case "null":
return "null";
case "array":
return arrayType(schema, context);
case "object":
return objectType(schema, context);
case undefined:
if ("properties" in schema || "additionalProperties" in schema || "required" in schema) {
return objectType(schema, context);
}
if ("items" in schema || "prefixItems" in schema) return arrayType(schema, context);
return "unknown";
default:
return "unknown";
}
}
function arrayType(schema: Record<string, unknown>, context: SchemaContext): string {
if (schema.items !== undefined && !Array.isArray(schema.items)) {
return `Array<${toType(schema.items as CodemodeJsonSchema, context)}>`;
}
const tuple = Array.isArray(schema.prefixItems)
? schema.prefixItems
: Array.isArray(schema.items)
? schema.items
: [];
if (tuple.length > 0) return `[${tuple.map((item) => toType(item as CodemodeJsonSchema, context)).join(", ")}]`;
return "unknown[]";
}
function descriptionOf(property: unknown): string {
return isObject(property) && typeof property.description === "string" ? property.description.trim() : "";
}
function objectType(schema: Record<string, unknown>, context: SchemaContext): string {
const properties = isObject(schema.properties) ? schema.properties : {};
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
const names = Object.keys(properties).sort();
const members = names.map((name) => {
const optional = required.has(name) ? "" : "?";
return `${propertyKey(name)}${optional}: ${toType(properties[name] as CodemodeJsonSchema, context)};`;
});
const additional = schema.additionalProperties;
if (additional !== undefined && additional !== false) {
const type = additional === true ? "unknown" : toType(additional as CodemodeJsonSchema, context);
members.push(`[key: string]: ${type};`);
} else if (additional === undefined && names.length === 0) {
members.push("[key: string]: unknown;");
}
if (members.length === 0) return "{}";
if (!names.some((name) => descriptionOf(properties[name]))) return `{ ${members.join(" ")} }`;
const lines = ["{"];
names.forEach((name, index) => {
for (const line of descriptionOf(properties[name]).split(/\r?\n/)) {
if (line.trim()) lines.push(`${INDENT}// ${line.trim()}`);
}
lines.push(`${INDENT}${members[index].replaceAll("\n", `\n${INDENT}`)}`);
});
for (const member of members.slice(names.length)) lines.push(`${INDENT}${member}`);
lines.push("}");
return lines.join("\n");
}

View file

@ -0,0 +1,12 @@
/**
* The identifier a script uses for a tool: characters that are not valid in a JavaScript
* identifier become `_`. `mcp__docs__search` stays as is, `my-tool` becomes `my_tool`.
*/
export function toCodemodeIdentifier(name: string): string {
let identifier = "";
for (const char of name) {
const valid = identifier === "" ? /^[A-Za-z_$]$/.test(char) : /^[A-Za-z0-9_$]$/.test(char);
identifier += valid ? char : "_";
}
return identifier === "" ? "_" : identifier;
}

View file

@ -0,0 +1,36 @@
export {
DEFAULT_INPUT_SCHEMA_MAX_CHARS,
MCP_TYPESCRIPT_PREAMBLE,
mcpStructuredContentSchema,
type RenderDeclarationsOptions,
renderDeclarations,
renderToolSample,
renderToolSignature,
schemaToType,
} from "./declarations.ts";
export { toCodemodeIdentifier } from "./identifier.ts";
export { CodemodeSandbox } from "./runtime/host.ts";
export { MAX_STORE_TOTAL_CHARS, MAX_STORE_VALUE_CHARS } from "./runtime/prelude-source.ts";
export {
CODEMODE_OPTIONS_PREFIX,
CODEMODE_SOURCE_GRAMMAR,
CodemodeSourceError,
type CodemodeSourceOptions,
type ParsedCodemodeSource,
parseCodemodeSource,
} from "./source.ts";
export type {
CodemodeCall,
CodemodeCallStatus,
CodemodeError,
CodemodeErrorKind,
CodemodeExecuteOptions,
CodemodeJsonSchema,
CodemodeOutputItem,
CodemodeResult,
CodemodeSandboxOptions,
CodemodeStoreWrites,
CodemodeTool,
CodemodeToolContext,
} from "./types.ts";
export { type CodemodeWasmModule, loadQuickJSWasm } from "./wasm.ts";

View file

@ -0,0 +1,361 @@
import { Worker } from "node:worker_threads";
import { toCodemodeIdentifier } from "../identifier.ts";
import type {
CodemodeCall,
CodemodeCallStatus,
CodemodeError,
CodemodeExecuteOptions,
CodemodeOutputItem,
CodemodeResult,
CodemodeSandboxOptions,
CodemodeStoreWrites,
CodemodeTool,
} from "../types.ts";
import { type CodemodeWasmModule, loadQuickJSWasm } from "../wasm.ts";
import {
type HostToWorkerMessage,
isWorkerToHostMessage,
type WorkerData,
type WorkerToHostMessage,
} from "./protocol.ts";
const DEFAULT_TIMEOUT_MS = 300_000;
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
const RESERVED_GLOBALS: ReadonlySet<string> = new Set([
"tools",
"ALL_TOOLS",
"console",
"text",
"image",
"exit",
"globalThis",
"store",
"load",
]);
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function serializeStore(store: Readonly<Record<string, unknown>> | undefined): Record<string, string> {
const serialized: Record<string, string> = {};
for (const [key, value] of Object.entries(store ?? {})) {
const json = JSON.stringify(value);
if (json !== undefined) serialized[key] = json;
}
return serialized;
}
function parseStoreWrites(json: string): CodemodeStoreWrites {
const writes: CodemodeStoreWrites = { set: {}, delete: [] };
for (const [key, value] of JSON.parse(json) as [string, string?][]) {
if (value === undefined) writes.delete.push(key);
else writes.set[key] = JSON.parse(value);
}
return writes;
}
function defaultWorkerUrl(): URL {
// `.ts` when running from source (tests, tsx), `.js` from the published dist.
return new URL(import.meta.url.endsWith(".ts") ? "./worker.ts" : "./worker.js", import.meta.url);
}
interface PendingCall {
record: CodemodeCall | undefined;
startedAt: number;
controller: AbortController;
}
interface ExecutionOptions {
code: string;
tools: ReadonlyMap<string, CodemodeTool>;
globals: ReadonlyMap<string, CodemodeTool>;
timeoutMs: number;
signal: AbortSignal | undefined;
memoryLimitBytes: number | undefined;
store: Record<string, string>;
wasm: Promise<CodemodeWasmModule>;
workerUrl: URL;
}
/**
* One script run in its own worker and QuickJS VM. A fresh worker per run keeps
* termination simple: a runaway script, including one that only spins the
* microtask queue, is killed with `terminate()` and cannot poison a later run.
*/
class Execution {
readonly promise: Promise<CodemodeResult>;
private resolveResult!: (result: CodemodeResult) => void;
private worker: Worker | undefined;
private readonly interrupt = new SharedArrayBuffer(4);
private readonly tools: ReadonlyMap<string, CodemodeTool>;
private readonly globals: ReadonlyMap<string, CodemodeTool>;
private readonly signal: AbortSignal | undefined;
private readonly timer: NodeJS.Timeout | undefined;
private readonly output: CodemodeOutputItem[] = [];
private readonly calls: CodemodeCall[] = [];
private readonly pending = new Map<number, PendingCall>();
private finished = false;
constructor(options: ExecutionOptions) {
this.promise = new Promise<CodemodeResult>((resolve) => {
this.resolveResult = resolve;
});
this.tools = options.tools;
this.globals = options.globals;
this.signal = options.signal;
if (Number.isFinite(options.timeoutMs)) {
this.timer = setTimeout(() => {
this.finish({ kind: "timeout", message: `Execution timed out after ${options.timeoutMs} ms` });
}, options.timeoutMs);
}
if (options.signal) {
if (options.signal.aborted) {
this.onAbort();
} else {
options.signal.addEventListener("abort", this.onAbort, { once: true });
}
}
options.wasm.then(
(wasm) => this.start(options, wasm),
(error: unknown) => {
this.finish({ kind: "sandbox", message: `Failed to load QuickJS: ${errorMessage(error)}` });
},
);
}
abort(message: string): Promise<CodemodeResult> {
this.finish({ kind: "aborted", message });
return this.promise;
}
private start(options: ExecutionOptions, wasm: CodemodeWasmModule): void {
if (this.finished) return;
const workerData: WorkerData = {
code: options.code,
tools: [...options.tools.values()].map((tool) => ({
name: tool.name,
jsName: toCodemodeIdentifier(tool.name),
description: tool.description ?? "",
})),
globals: [...options.globals.values()].map((global) => ({
name: global.name,
spread: global.spread === true,
})),
wasm,
memoryLimitBytes: options.memoryLimitBytes,
store: options.store,
interrupt: this.interrupt,
};
let worker: Worker;
try {
worker = new Worker(options.workerUrl, { workerData });
} catch (error) {
this.finish({ kind: "sandbox", message: `Failed to start worker: ${errorMessage(error)}` });
return;
}
this.worker = worker;
worker.on("message", (message: unknown) => this.handleMessage(message));
worker.on("error", (error: unknown) => {
this.finish({
kind: "sandbox",
name: error instanceof Error ? error.name : undefined,
message: errorMessage(error),
});
});
worker.on("exit", (code) => {
this.finish({ kind: "sandbox", message: `Worker exited with code ${code} before the script settled` });
});
}
private readonly onAbort = (): void => {
const reason: unknown = this.signal?.reason;
this.finish({ kind: "aborted", message: reason instanceof Error ? reason.message : "Execution aborted" });
};
private post(message: HostToWorkerMessage): void {
this.worker?.postMessage(message);
}
private handleMessage(message: unknown): void {
if (this.finished || !isWorkerToHostMessage(message)) return;
switch (message.type) {
case "output":
this.output.push(message.item);
break;
case "call":
void this.handleCall(message);
break;
case "done":
this.handleDone(message);
break;
case "crash":
this.finish({ kind: "sandbox", message: message.message });
break;
}
}
private handleDone(message: Extract<WorkerToHostMessage, { type: "done" }>): void {
if (!message.ok) {
const parsed = JSON.parse(message.error) as Omit<CodemodeError, "kind">;
this.finish({ kind: "script", ...parsed });
return;
}
this.finish(undefined, message.value === undefined ? undefined : JSON.parse(message.value), message.writes);
}
private async handleCall(message: Extract<WorkerToHostMessage, { type: "call" }>): Promise<void> {
const { id, name } = message;
const isTool = message.target === "tool";
const record: CodemodeCall | undefined = isTool ? { name, status: "cancelled", durationMs: 0 } : undefined;
if (record) this.calls.push(record);
const pending: PendingCall = { record, startedAt: performance.now(), controller: new AbortController() };
this.pending.set(id, pending);
let status: CodemodeCallStatus;
let reply: HostToWorkerMessage;
try {
const tool = (isTool ? this.tools : this.globals).get(name);
if (!tool) throw new Error(`Unknown ${isTool ? "tool" : "global"} "${name}"`);
const args: unknown = message.args === undefined ? undefined : JSON.parse(message.args);
const value = await tool.execute(args, { signal: pending.controller.signal });
reply = { type: "result", id, ok: true, payload: value === undefined ? undefined : JSON.stringify(value) };
status = "ok";
} catch (error) {
reply = { type: "result", id, ok: false, payload: errorMessage(error) };
status = "error";
}
// Already cancelled by finish(): the record keeps "cancelled" and the
// worker is gone or going.
if (!this.pending.delete(id)) return;
if (record) {
record.status = status;
record.durationMs = performance.now() - pending.startedAt;
}
this.post(reply);
}
private finish(error: CodemodeError | undefined, value?: unknown, writes?: string): void {
if (this.finished) return;
this.finished = true;
clearTimeout(this.timer);
this.signal?.removeEventListener("abort", this.onAbort);
const now = performance.now();
for (const pending of this.pending.values()) {
if (pending.record) pending.record.durationMs = now - pending.startedAt;
pending.controller.abort();
}
this.pending.clear();
const result: CodemodeResult = error
? { ok: false, error, output: this.output, calls: this.calls }
: {
ok: true,
value,
output: this.output,
calls: this.calls,
storeWrites: writes === undefined ? { set: {}, delete: [] } : parseStoreWrites(writes),
};
if (!this.worker) {
this.resolveResult(result);
return;
}
Atomics.store(new Int32Array(this.interrupt), 0, 1);
this.worker
.terminate()
.catch(() => undefined)
.then(() => this.resolveResult(result));
}
}
/**
* Runs JavaScript in a QuickJS VM (a separate wasm instance) inside a worker
* thread. The script sees `tools.<name>(args)` for every registered tool, `ALL_TOOLS`,
* the output helpers `text`, `image`, `exit`, and `console.*`, `store`/`load`, and the
* configured globals; nothing else (no timers, `fetch`, `process`, `require`, modules).
*
* Each `execute()` gets its own worker and VM; the sandbox only holds the tool
* table and defaults. `close()` aborts in-flight executions.
*/
export class CodemodeSandbox {
private readonly toolsByName = new Map<string, CodemodeTool>();
private readonly globalsByName = new Map<string, CodemodeTool>();
private readonly timeoutMs: number;
private readonly memoryLimitBytes: number | undefined;
private readonly wasm: CodemodeWasmModule | Promise<CodemodeWasmModule> | undefined;
private readonly workerUrl: URL;
private readonly running = new Set<Execution>();
private closed = false;
constructor(options: CodemodeSandboxOptions = {}) {
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.memoryLimitBytes = options.memoryLimitBytes;
this.wasm = options.wasm;
this.workerUrl = options.workerUrl ?? defaultWorkerUrl();
for (const tool of options.tools ?? []) this.registerTool(tool);
const namespaces = new Set<string>();
for (const global of options.globals ?? []) {
const parts = global.name.split(".");
if (parts.length > 2 || !parts.every((part) => IDENTIFIER.test(part)) || RESERVED_GLOBALS.has(parts[0])) {
throw new Error(`Invalid global name "${global.name}"`);
}
if (this.globalsByName.has(global.name)) throw new Error(`Global "${global.name}" is already registered`);
if (parts.length === 2) namespaces.add(parts[0]);
this.globalsByName.set(global.name, global);
}
for (const name of namespaces) {
if (this.globalsByName.has(name)) throw new Error(`Global "${name}" conflicts with the namespace "${name}"`);
}
}
/** Throws if a tool with the same name is already registered. */
registerTool(tool: CodemodeTool): void {
if (this.toolsByName.has(tool.name)) throw new Error(`Tool "${tool.name}" is already registered`);
this.toolsByName.set(tool.name, tool);
}
unregisterTool(name: string): boolean {
return this.toolsByName.delete(name);
}
get tools(): CodemodeTool[] {
return [...this.toolsByName.values()];
}
get globals(): CodemodeTool[] {
return [...this.globalsByName.values()];
}
/**
* `code` is an async function body: `return` and top-level `await` work.
* Never rejects for script failures; those come back as `{ ok: false }`.
* The script can use `store(key, value)` and `load(key)` on `options.store`.
*/
execute(code: string, options: CodemodeExecuteOptions = {}): Promise<CodemodeResult> {
if (this.closed) return Promise.reject(new Error("Sandbox is closed"));
const execution = new Execution({
code,
tools: new Map(this.toolsByName),
globals: this.globalsByName,
timeoutMs: options.timeoutMs ?? this.timeoutMs,
signal: options.signal,
memoryLimitBytes: this.memoryLimitBytes,
store: serializeStore(options.store),
wasm: this.wasm === undefined ? loadQuickJSWasm() : Promise.resolve(this.wasm),
workerUrl: this.workerUrl,
});
this.running.add(execution);
return execution.promise.finally(() => this.running.delete(execution));
}
/** Aborts in-flight executions (they resolve with `kind: "aborted"`) and rejects new ones. */
async close(): Promise<void> {
this.closed = true;
await Promise.all([...this.running].map((execution) => execution.abort("Sandbox closed")));
}
}

View file

@ -0,0 +1,324 @@
/**
* JavaScript evaluated inside the QuickJS VM before the script runs.
*
* The VM is its own wasm instance, so nothing here guards a realm boundary.
* The prelude keeps the host bridge in a closure so the script cannot call it
* directly, and builds `tools`, `ALL_TOOLS`, the output helpers (`text`, `image`,
* `exit`, `console`), and globals on top of it. Tool arguments and results cross
* as JSON strings and are parsed on this side.
*
* Output helpers: `text(value)` appends a text item (non-strings are
* JSON-stringified), `image(urlOrItem)` appends an image from a base64 `data:` URL, an
* `{ image_url }` object, or an MCP `ImageContent` block, and `exit()` ends the script
* successfully. `console.*` appends text items like `text()`.
*
* `store(key, value)` and `load(key)` are synchronous: they work on a snapshot of
* JSON text passed in as `storeJson`, and the keys the script wrote are reported
* with a successful "done".
*
* Evaluates to a function `(bridge, toolsJson, globalsJson, storeJson) => { settle, run, stalled }`.
* `toolsJson` lists `{ name, jsName, description }`: `tools[jsName]` and `tools[name]` call the
* tool, and `ALL_TOOLS` lists `{ name: jsName, description }`.
* `stalled()` reports a script that has not finished while no host call is pending: with no timers
* or I/O in the VM, nothing can ever resume it.
* `globalsJson` lists `{ name, spread }`; `a.b` names are grouped into a frozen `a` object.
* `bridge(kind, a, b, c)` with kind "call" or "global" (id, name, argsJson),
* "output" ("text", text) or ("image", data, mimeType), or "done" (ok, valueJsonOrErrorJson, writesJson).
*/
export const MAX_STORE_VALUE_CHARS = 256 * 1024;
export const MAX_STORE_TOTAL_CHARS = 1024 * 1024;
const IMAGE_HELPER_EXPECTS =
"image expects a non-empty image URL string, an object with image_url, or a raw MCP image block";
export const PRELUDE_SOURCE: string = `(function (bridge, toolsJson, globalsJson, storeJson) {
"use strict";
const stringify = JSON.stringify;
const parse = JSON.parse;
const promiseThen = Promise.prototype.then;
const ErrorCtor = Error;
const TypeErrorCtor = TypeError;
const pending = new Map();
let nextId = 1;
let finished = false;
// Thrown by exit() to unwind the script after it already reported success.
const EXIT = Object.freeze({});
function done(ok, payload, writes) {
if (finished) return;
finished = true;
bridge("done", ok, payload, writes);
}
function serialize(value) {
return value === undefined ? undefined : stringify(value);
}
// QuickJS stacks list frames only. Prefix "Name: message" like V8 so the
// text reads the same as a Node error, and drop this prelude's frames.
function errorText(error) {
const head = error.message ? error.name + ": " + error.message : String(error.name);
const frames =
typeof error.stack === "string"
? error.stack.split("\\n").filter((line) => line.trim() && !line.includes("codemode-prelude.js"))
: [];
return [head, ...frames].join("\\n");
}
function format(value) {
if (typeof value === "string") return value;
if (value instanceof ErrorCtor) return errorText(value);
try {
const json = stringify(value);
return json === undefined ? String(value) : json;
} catch {
return String(value);
}
}
function describeError(error) {
if (error instanceof ErrorCtor) {
return stringify({ name: error.name, message: error.message, stack: errorText(error) });
}
return stringify({ message: format(error) });
}
function caller(kind, name, spread) {
return (...args) =>
new Promise((resolve, reject) => {
let json;
try {
json = serialize(spread ? args : args[0]);
} catch (error) {
reject(error);
return;
}
const id = nextId++;
pending.set(id, { resolve, reject });
bridge(kind, id, name, json);
});
}
const tools = Object.create(null);
const allTools = [];
for (const { name, jsName, description } of parse(toolsJson)) {
const fn = caller("call", name);
// The first tool wins when two names normalize to the same identifier.
if (!(jsName in tools)) {
tools[jsName] = fn;
allTools.push(Object.freeze({ name: jsName, description }));
}
if (!(name in tools)) tools[name] = fn;
}
Object.freeze(tools);
Object.freeze(allTools);
const namespaces = new Map();
for (const { name, spread } of parse(globalsJson)) {
const fn = caller("global", name, spread);
const dot = name.indexOf(".");
if (dot === -1) {
Object.defineProperty(globalThis, name, { value: fn, enumerable: true });
continue;
}
const namespace = name.slice(0, dot);
if (!namespaces.has(namespace)) namespaces.set(namespace, Object.create(null));
namespaces.get(namespace)[name.slice(dot + 1)] = fn;
}
for (const [namespace, members] of namespaces) {
Object.defineProperty(globalThis, namespace, { value: Object.freeze(members), enumerable: true });
}
// key -> JSON text. Sizes count key and JSON characters.
const stored = new Map(Object.entries(parse(storeJson)));
const writes = new Map();
let storedChars = 0;
for (const [key, json] of stored) storedChars += key.length + json.length;
function checkKey(name, key) {
if (typeof key !== "string") throw new TypeError(name + "() key must be a string");
}
function store(key, value) {
checkKey("store", key);
const previous = stored.has(key) ? key.length + stored.get(key).length : 0;
if (value === undefined) {
stored.delete(key);
storedChars -= previous;
writes.set(key, undefined);
return;
}
let json;
try {
json = stringify(value);
} catch (error) {
throw new TypeError("store(" + stringify(key) + ") value is not JSON-serializable: " + format(error));
}
if (json === undefined) {
throw new TypeError("store(" + stringify(key) + ") value is not JSON-serializable");
}
if (json.length > ${MAX_STORE_VALUE_CHARS}) {
throw new RangeError("store(" + stringify(key) + ") value exceeds ${MAX_STORE_VALUE_CHARS} characters of JSON");
}
const next = storedChars - previous + key.length + json.length;
if (next > ${MAX_STORE_TOTAL_CHARS}) {
throw new RangeError("store is full: stored values would exceed ${MAX_STORE_TOTAL_CHARS} characters of JSON");
}
stored.set(key, json);
storedChars = next;
writes.set(key, json);
}
function load(key) {
checkKey("load", key);
const json = stored.get(key);
return json === undefined ? undefined : parse(json);
}
function serializeWrites() {
const entries = [];
for (const [key, json] of writes) entries.push(json === undefined ? [key] : [key, json]);
return stringify(entries);
}
Object.defineProperty(globalThis, "store", { value: store, enumerable: true });
Object.defineProperty(globalThis, "load", { value: load, enumerable: true });
// Primitives become their string form, everything else JSON.
function outputText(value) {
if (value === undefined || value === null || typeof value !== "object" && typeof value !== "function") {
return String(value);
}
const json = stringify(value);
return json === undefined ? String(value) : json;
}
function text(value) {
let rendered;
try {
rendered = outputText(value);
} catch (error) {
throw new TypeErrorCtor(error instanceof ErrorCtor ? error.message : String(error));
}
if (!finished) bridge("output", "text", rendered);
}
function imageUrl(value) {
if (typeof value === "string") return value;
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
}
if (value.image_url !== undefined) {
if (typeof value.image_url !== "string") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
return value.image_url;
}
if (typeof value.type !== "string") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
if (value.type !== "image") {
throw new TypeErrorCtor('image only accepts MCP image blocks, got "' + value.type + '"');
}
if (typeof value.data !== "string" || value.data === "") throw new TypeErrorCtor("image expected MCP image data");
if (value.data.toLowerCase().startsWith("data:")) return value.data;
const mimeType = typeof value.mimeType === "string" && value.mimeType ? value.mimeType : "application/octet-stream";
return "data:" + mimeType + ";base64," + value.data;
}
function image(value) {
const url = imageUrl(value);
if (url === "") throw new TypeErrorCtor(${JSON.stringify(IMAGE_HELPER_EXPECTS)});
const colon = url.indexOf(":");
const scheme = colon === -1 ? "" : url.slice(0, colon).toLowerCase();
if (scheme === "http" || scheme === "https") {
throw new TypeErrorCtor("remote image URLs are not supported in tool outputs. Pass a base64 data URI instead");
}
const comma = url.indexOf(",");
const header = comma === -1 ? [] : url.slice(colon + 1, comma).split(";");
if (scheme !== "data" || comma === -1 || header.slice(1).every((part) => part.toLowerCase() !== "base64")) {
throw new TypeErrorCtor("invalid image output. Pass a base64 data URI instead");
}
if (!finished) bridge("output", "image", url.slice(comma + 1), header[0] || "application/octet-stream");
}
function exit() {
let writesJson;
try {
writesJson = serializeWrites();
} catch (error) {
done(false, describeError(error));
throw EXIT;
}
done(true, undefined, writesJson);
throw EXIT;
}
const console = {};
for (const level of ["log", "info", "warn", "error", "debug"]) {
console[level] = (...args) => {
if (!finished) bridge("output", "text", args.map(format).join(" "));
};
}
Object.freeze(console);
Object.defineProperty(globalThis, "tools", { value: tools, enumerable: true });
Object.defineProperty(globalThis, "ALL_TOOLS", { value: allTools, enumerable: true });
Object.defineProperty(globalThis, "console", { value: console, enumerable: true });
Object.defineProperty(globalThis, "text", { value: text, enumerable: true });
Object.defineProperty(globalThis, "image", { value: image, enumerable: true });
Object.defineProperty(globalThis, "exit", { value: exit, enumerable: true });
return {
settle(id, ok, payload) {
const entry = pending.get(id);
if (!entry) return;
pending.delete(id);
if (!ok) {
entry.reject(new ErrorCtor(payload));
return;
}
let value;
try {
value = payload === undefined ? undefined : parse(payload);
} catch (error) {
entry.reject(error);
return;
}
entry.resolve(value);
},
run(fn) {
let promise;
try {
promise = fn(tools, console);
} catch (error) {
done(false, describeError(error));
return;
}
promiseThen.call(
promise,
(value) => {
let json;
try {
json = serialize(value);
} catch (error) {
done(false, describeError(error));
return;
}
done(true, json, serializeWrites());
},
(error) => {
done(false, describeError(error));
},
);
},
stalled() {
if (finished || pending.size > 0) return false;
done(
false,
stringify({
name: "Error",
message:
"The script is waiting on a promise that can never settle: no tool call is pending, and timers do not exist here.",
}),
);
return true;
},
};
})`;

View file

@ -0,0 +1,51 @@
import type { CodemodeOutputItem } from "../types.ts";
import type { CodemodeWasmModule } from "../wasm.ts";
/**
* Messages between the host (main thread) and the worker. Tool arguments,
* results, and values cross as JSON strings: the worker passes them into and
* out of the QuickJS VM as strings and never builds structured values itself.
*/
export interface WorkerData {
code: string;
/** `jsName` is the identifier the script uses; `description` is listed in `ALL_TOOLS`. */
tools: { name: string; jsName: string; description: string }[];
globals: { name: string; spread: boolean }[];
/** Compiled `quickjs-wasi` module. Structured clone shares the compiled code with the worker. */
wasm: CodemodeWasmModule;
memoryLimitBytes: number | undefined;
/** Snapshot for `load()`: key to JSON text. */
store: Record<string, string>;
/**
* One Int32 the host sets to non-zero before terminating the worker. The VM's interrupt handler
* polls it, because Bun's `worker.terminate()` cannot stop a thread that is spinning in wasm.
*/
interrupt: SharedArrayBuffer;
}
/** JSON-encoded `{ name?, message, stack? }` of an error thrown by the script. */
export type ScriptErrorJson = string;
export type WorkerToHostMessage =
| { type: "call"; id: number; target: "tool" | "global"; name: string; args: string | undefined }
| { type: "output"; item: CodemodeOutputItem }
/** `writes` is a JSON array of `[key, json]` for `store()` and `[key]` for deletions. */
| { type: "done"; ok: true; value: string | undefined; writes: string }
| { type: "done"; ok: false; error: ScriptErrorJson }
/** The VM failed outside the script's control, for example a wasm trap. */
| { type: "crash"; message: string };
export type HostToWorkerMessage =
/** `payload` is the JSON result when `ok`, otherwise the error message. */
{ type: "result"; id: number; ok: boolean; payload: string | undefined };
export function isWorkerToHostMessage(value: unknown): value is WorkerToHostMessage {
if (typeof value !== "object" || value === null) return false;
const type = (value as { type?: unknown }).type;
return type === "call" || type === "output" || type === "done" || type === "crash";
}
export function isHostToWorkerMessage(value: unknown): value is HostToWorkerMessage {
return typeof value === "object" && value !== null && (value as { type?: unknown }).type === "result";
}

View file

@ -0,0 +1,159 @@
/**
* Worker thread entry. One worker runs one script inside a fresh QuickJS VM
* (a separate wasm instance), relays tool calls and output to the host, and
* reports the result. The host terminates the worker when the script settles,
* times out, or is aborted; the worker exists so that a spinning script never
* blocks the host thread.
*
* Importing this module starts the worker. Hosts that bundle their code (for
* example a Bun compiled executable) add a file that imports
* `@earendil-works/pi-codemode/worker` as a separate entrypoint and pass its URL
* as `workerUrl`.
*/
import { parentPort, workerData } from "node:worker_threads";
import { JSException, type JSValueHandle, MAX_STACK_SIZE, QuickJS } from "quickjs-wasi";
import { PRELUDE_SOURCE } from "./prelude-source.ts";
import { isHostToWorkerMessage, type WorkerData, type WorkerToHostMessage } from "./protocol.ts";
function post(message: WorkerToHostMessage): void {
parentPort?.postMessage(message);
}
function crash(error: unknown): void {
post({ type: "crash", message: error instanceof Error ? `${error.name}: ${error.message}` : String(error) });
}
/**
* QuickJS writes engine diagnostics to fd 1 and 2, which the default shim
* forwards to the host's stdout and stderr. That output belongs to the host
* application (for example a TUI), so it is discarded. Reporting every byte as
* written keeps libc from retrying.
*/
function discardOutput(memory: { readonly buffer: ArrayBufferLike }) {
return {
fd_write(_fd: number, iovsPtr: number, iovsLen: number, nwrittenPtr: number): number {
const view = new DataView(memory.buffer);
let written = 0;
for (let i = 0; i < iovsLen; i++) {
written += view.getUint32(iovsPtr + i * 8 + 4, true);
}
view.setUint32(nwrittenPtr, written, true);
return 0;
},
};
}
function describeException(error: JSException): string {
const head = error.message ? `${error.name}: ${error.message}` : error.name;
const stack = error.stack?.trimEnd();
return JSON.stringify({ name: error.name, message: error.message, stack: stack ? `${head}\n${stack}` : head });
}
async function main(data: WorkerData): Promise<void> {
const interrupt = new Int32Array(data.interrupt);
const vm = await QuickJS.create({
wasm: data.wasm,
memoryLimit: data.memoryLimitBytes,
// Without a guard, deep recursion overflows the wasm stack and traps instead of throwing a
// catchable RangeError.
maxStackSize: MAX_STACK_SIZE,
interruptHandler: () => Atomics.load(interrupt, 0) !== 0,
wasi: discardOutput,
});
// Called from the prelude with primitives only.
const bridge = vm.newFunction("bridge", (kind, a, b, c) => {
switch (kind.toString()) {
case "call":
case "global":
post({
type: "call",
id: a.toNumber(),
target: kind.toString() === "call" ? "tool" : "global",
name: b.toString(),
args: c === undefined || c.isUndefined ? undefined : c.toString(),
});
break;
case "output":
post({
type: "output",
item:
a.toString() === "image"
? { type: "image", data: b.toString(), mimeType: c.toString() }
: { type: "text", text: b.toString() },
});
break;
case "done":
if (a.toBoolean()) {
post({
type: "done",
ok: true,
value: b === undefined || b.isUndefined ? undefined : b.toString(),
writes: c.toString(),
});
} else {
post({ type: "done", ok: false, error: b.toString() });
}
break;
}
return vm.undefined;
});
// The VM lives until the host terminates the worker, so these handles are never disposed.
const api = vm.withScope((scope) =>
scope.escape(
vm.callFunction(
vm.evalCode(PRELUDE_SOURCE, "codemode-prelude.js"),
vm.undefined,
bridge,
vm.newString(JSON.stringify(data.tools)),
vm.newString(JSON.stringify(data.globals)),
vm.newString(JSON.stringify(data.store)),
),
),
);
const settle = api.getProp("settle");
const run = api.getProp("run");
const stalled = api.getProp("stalled");
/** Run queued jobs, then fail a script that waits on nothing that can ever resume it. */
const drain = () => {
vm.executePendingJobs();
vm.callFunction(stalled, api).dispose();
};
parentPort?.on("message", (message: unknown) => {
if (!isHostToWorkerMessage(message)) return;
try {
vm.withScope(() => {
vm.callFunction(
settle,
api,
vm.newNumber(message.id),
message.ok ? vm.true : vm.false,
message.payload === undefined ? vm.undefined : vm.newString(message.payload),
);
});
drain();
} catch (error) {
crash(error);
}
});
// The prefix shares the first line with the script so reported line numbers
// match the script as written.
let fn: JSValueHandle;
try {
fn = vm.evalCode(`(async (tools, console) => {${data.code}\n})`, "codemode.js");
} catch (error) {
if (!(error instanceof JSException)) throw error;
post({ type: "done", ok: false, error: describeException(error) });
return;
}
vm.callFunction(run, api, fn).dispose();
fn.dispose();
drain();
}
if (parentPort) {
main(workerData as WorkerData).catch(crash);
}

View file

@ -0,0 +1,115 @@
/**
* Codemode source format: JavaScript, optionally preceded by one options line.
*
* ```js
* // @options: {"max_output_tokens": 2000, "timeout_ms": 30000}
* const text = await tools.read({ path: "package.json" });
* text(JSON.parse(text).name);
* ```
*/
export const CODEMODE_OPTIONS_PREFIX = "// @options:";
const SUPPORTED_FIELDS = ["max_output_tokens", "timeout_ms"] as const;
const SUPPORTED_FIELDS_TEXT = "`max_output_tokens` and `timeout_ms`";
/** Largest delay `setTimeout` supports, which bounds `timeout_ms`. */
const MAX_TIMEOUT_MS = 2_147_483_647;
/**
* Lark grammar for providers with grammar-constrained tool input. It only fixes the shape of the
* options line; the options JSON and the code are checked by {@link parseCodemodeSource}.
*/
export const CODEMODE_SOURCE_GRAMMAR = String.raw`
start: options_source | plain_source
options_source: OPTIONS_LINE NEWLINE SOURCE
plain_source: SOURCE
OPTIONS_LINE: /[ \t]*\/\/ @options:[^\r\n]*/
NEWLINE: /\r?\n/
SOURCE: /[\s\S]+/
`;
export interface CodemodeSourceOptions {
/** Token budget for the script's output. */
maxOutputTokens?: number;
/** Hard deadline for the whole script in milliseconds, including tool calls. */
timeoutMs?: number;
}
export interface ParsedCodemodeSource {
/** The script with the options line replaced by an empty line, so line numbers are unchanged. */
code: string;
options: CodemodeSourceOptions;
}
export class CodemodeSourceError extends Error {
constructor(message: string) {
super(message);
this.name = "CodemodeSourceError";
}
}
function isSafeInteger(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
}
function parseOptions(directive: string): CodemodeSourceOptions {
if (directive === "") {
throw new CodemodeSourceError(`@options must be a JSON object with supported fields ${SUPPORTED_FIELDS_TEXT}`);
}
let value: unknown;
try {
value = JSON.parse(directive);
} catch (error) {
throw new CodemodeSourceError(
`@options must be valid JSON with supported fields ${SUPPORTED_FIELDS_TEXT}: ${error instanceof Error ? error.message : String(error)}`,
);
}
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new CodemodeSourceError(`@options must be a JSON object with supported fields ${SUPPORTED_FIELDS_TEXT}`);
}
const fields = value as Record<string, unknown>;
for (const key of Object.keys(fields)) {
if (!(SUPPORTED_FIELDS as readonly string[]).includes(key)) {
throw new CodemodeSourceError(`@options only supports ${SUPPORTED_FIELDS_TEXT}; got \`${key}\``);
}
}
const options: CodemodeSourceOptions = {};
const { max_output_tokens, timeout_ms } = fields;
if (max_output_tokens !== undefined) {
if (!isSafeInteger(max_output_tokens)) {
throw new CodemodeSourceError("@options field `max_output_tokens` must be a non-negative safe integer");
}
options.maxOutputTokens = max_output_tokens;
}
if (timeout_ms !== undefined) {
if (!isSafeInteger(timeout_ms) || timeout_ms === 0 || timeout_ms > MAX_TIMEOUT_MS) {
throw new CodemodeSourceError(
`@options field \`timeout_ms\` must be a positive integer up to ${MAX_TIMEOUT_MS}`,
);
}
options.timeoutMs = timeout_ms;
}
return options;
}
/**
* Split an optional first-line `// @options: {...}` from the script. Throws
* {@link CodemodeSourceError} for empty input and invalid options.
*/
export function parseCodemodeSource(input: string): ParsedCodemodeSource {
if (input.trim() === "") {
throw new CodemodeSourceError(
'Expected JavaScript source text (non-empty). Provide JS only, optionally with a first line `// @options: {"max_output_tokens": 1000}`.',
);
}
const newline = input.indexOf("\n");
const firstLine = (newline === -1 ? input : input.slice(0, newline)).replace(/\r$/, "");
const trimmed = firstLine.trimStart();
if (!trimmed.startsWith(CODEMODE_OPTIONS_PREFIX)) return { code: input, options: {} };
const code = newline === -1 ? "" : input.slice(newline);
if (code.trim() === "") {
throw new CodemodeSourceError("The @options line must be followed by JavaScript source on subsequent lines");
}
return { code, options: parseOptions(trimmed.slice(CODEMODE_OPTIONS_PREFIX.length).trim()) };
}

View file

@ -0,0 +1,135 @@
import type { CodemodeWasmModule } from "./wasm.ts";
export interface CodemodeToolContext {
/**
* Aborted when the script finishes (including unawaited calls), the
* execution times out, the caller aborts, or the sandbox is closed.
*/
signal: AbortSignal;
}
/** A JSON Schema document. Only used to render declarations; values are not validated against it. */
export type CodemodeJsonSchema = { [key: string]: unknown } | boolean;
export interface CodemodeTool {
/**
* The script calls tools as `tools.<id>(args)`, where `<id>` is the name with characters that
* are not valid in identifiers replaced by `_` (see `toCodemodeIdentifier`), and also as
* `tools["<name>"](args)`. Globals are called as `<name>(args)` and must be identifiers, or
* `<namespace>.<member>`, which groups them into a frozen namespace object.
*/
name: string;
/** Shown as a doc comment in {@link renderDeclarations}, and listed in `ALL_TOOLS` for tools. */
description?: string;
/** Schema of the single argument. Rendered as the parameter type; `unknown` when omitted. */
inputSchema?: CodemodeJsonSchema;
/** Schema of the resolved value. Rendered as the promise type; `unknown` when omitted. */
outputSchema?: CodemodeJsonSchema;
/** Globals only: `execute` receives all call arguments as an array instead of the first one. */
spread?: boolean;
/**
* Globals only: TypeScript parameter list and return type for {@link renderDeclarations}, for
* example `(type: string, id?: string): Promise<Model[]>`. Replaces the rendering from the schemas.
*/
signature?: string;
/**
* `args` is whatever the script passed, after a JSON round trip. The return
* value must be JSON-serializable; a thrown error surfaces in the script as
* an `Error` with the same message.
*/
execute(args: unknown, context: CodemodeToolContext): Promise<unknown> | unknown;
}
/**
* One item of the script's output, in the order the script produced it: `text()` and `console.*`
* produce text items, `image()` image items. `data` is base64.
*/
export type CodemodeOutputItem = { type: "text"; text: string } | { type: "image"; data: string; mimeType: string };
export type CodemodeCallStatus = "ok" | "error" | "cancelled";
export interface CodemodeCall {
name: string;
status: CodemodeCallStatus;
durationMs: number;
}
export type CodemodeErrorKind =
/** The script threw or failed to parse. `name` and `stack` come from the script's error. */
| "script"
/** The overall deadline expired. The worker was terminated. */
| "timeout"
/** The caller's signal fired or the sandbox was closed. The worker was terminated. */
| "aborted"
/** The worker or VM failed outside the script's control (for example a wasm trap or a missing worker file). */
| "sandbox";
export interface CodemodeError {
kind: CodemodeErrorKind;
name?: string;
message: string;
stack?: string;
}
/** Keys the script changed with `store()`. Only successful executions report writes. */
export interface CodemodeStoreWrites {
set: Record<string, unknown>;
/** Keys stored as `undefined`. */
delete: string[];
}
/** `output` is kept for failed executions too, up to the failure. `exit()` completes with `value: undefined`. */
export type CodemodeResult =
| {
ok: true;
value: unknown;
output: CodemodeOutputItem[];
calls: CodemodeCall[];
storeWrites: CodemodeStoreWrites;
}
| { ok: false; error: CodemodeError; output: CodemodeOutputItem[]; calls: CodemodeCall[] };
export interface CodemodeSandboxOptions {
tools?: CodemodeTool[];
/**
* Functions exposed as top-level identifiers instead of on `tools`, for host helpers such as
* attaching an image to the result. They behave like tools (JSON round trip, promise result)
* but are not recorded in `result.calls`. Names must be identifiers and may not shadow the
* built-in globals (`tools`, `ALL_TOOLS`, `console`, `text`, `image`, `exit`, `store`, `load`).
*/
globals?: CodemodeTool[];
/**
* Overall deadline per execution, including time spent in tools. `Infinity` disables the
* deadline; the execution then only ends when the script settles or is aborted.
* Default: 300000.
*/
timeoutMs?: number;
/**
* Maximum memory the QuickJS VM may allocate. Allocations beyond it fail inside the script as
* `InternalError: out of memory`. Default: no limit beyond wasm32's 4 GiB address space.
*/
memoryLimitBytes?: number;
/**
* Compiled `quickjs-wasi/quickjs.wasm`, usually from {@link loadQuickJSWasm}. Default:
* `loadQuickJSWasm()`, the file in the installed `quickjs-wasi` package. Pass it when that file
* is not on disk, for example in a Bun compiled executable.
*/
wasm?: CodemodeWasmModule | Promise<CodemodeWasmModule>;
/**
* Worker entry that imports `@earendil-works/pi-codemode/worker`. Default: this package's own
* worker file. Pass it when this package is bundled, since the default is resolved relative to
* the module that creates the sandbox.
*/
workerUrl?: URL;
}
export interface CodemodeExecuteOptions {
signal?: AbortSignal;
/** Overrides the sandbox default for this execution. */
timeoutMs?: number;
/**
* Values the script reads with `load(key)`. Must be JSON-serializable. The script's own
* `store()` calls come back as `result.storeWrites`; persisting them is up to the caller.
*/
store?: Readonly<Record<string, unknown>>;
}

View file

@ -0,0 +1,35 @@
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
/**
* A compiled `WebAssembly.Module` of `quickjs-wasi/quickjs.wasm`. Typed opaquely because the Node
* type definitions do not declare the WebAssembly globals (they live in the DOM lib).
*/
export type CodemodeWasmModule = object;
interface WebAssemblyGlobal {
compile(bytes: Uint8Array): Promise<CodemodeWasmModule>;
}
const modules = new Map<string, Promise<CodemodeWasmModule>>();
/**
* Read and compile the QuickJS wasm once per path. `path` defaults to the file in the installed
* `quickjs-wasi` package; pass it when that file lives elsewhere, for example embedded in a Bun
* compiled executable. A failed load is retried on the next call.
*/
export function loadQuickJSWasm(path?: string): Promise<CodemodeWasmModule> {
const resolved = path ?? createRequire(import.meta.url).resolve("quickjs-wasi/quickjs.wasm");
let module = modules.get(resolved);
if (!module) {
const { WebAssembly } = globalThis as unknown as { WebAssembly: WebAssemblyGlobal };
module = readFile(resolved)
.then((bytes) => WebAssembly.compile(bytes))
.catch((error: unknown) => {
modules.delete(resolved);
throw error;
});
modules.set(resolved, module);
}
return module;
}