diff --git a/packages/codemode/AGENTS.md b/packages/codemode/AGENTS.md index 5fefa97407..88dd9c81d9 100644 --- a/packages/codemode/AGENTS.md +++ b/packages/codemode/AGENTS.md @@ -10,6 +10,6 @@ - If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead. - Improve the sandbox failure taxonomy. Distinguish parse/compile mistakes, unsupported syntax, user-thrown errors, invalid returned data, tool refusal, tool internal failure, timeout, and genuine runtime defects so agents can recover accurately instead of treating everything as a generic execution failure. - Preserve the public/private error split. Tool authors should be able to return a safe model-visible message while retaining a private cause for host diagnostics. Unknown host failures must remain sanitized by default. -- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today’s JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization. +- Think deliberately about richer binary boundaries before allowing `Blob`, `File`, `ArrayBuffer`, streams, or typed arrays beyond today's JSON-like values. If CodeMode supports binary tool args/results, use explicit tagged data shapes and clear size limits rather than relying on ambient runtime serialization. - Keep host capabilities explicit. Globals such as `fetch`, `crypto`, filesystem handles, extra modules, or network clients should be opt-in runtime capabilities with obvious policy defaults, not ambient authority. Default to unavailable unless a host deliberately provides the capability. - If `fetch` is added, model it as a host-provided outbound capability with policy controls: allowed origins, methods, headers, response size, timeout, and whether response bodies may be returned, emitted, or only summarized through a tool. diff --git a/packages/codemode/README.md b/packages/codemode/README.md index 5ae401c5f0..5f1292f7e6 100644 --- a/packages/codemode/README.md +++ b/packages/codemode/README.md @@ -150,7 +150,7 @@ interface ExecuteFailure { ## Discovery -The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going — so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL — N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`). +The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`). The default budget is 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). Override it when constructing a runtime: @@ -163,7 +163,7 @@ const runtime = CodeMode.make({ The budget must be a non-negative safe integer. -The runtime search tool is always registered — including when the catalog is fully inlined — so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial: +The runtime search tool is always registered - including when the catalog is fully inlined - so a speculative `tools.$codemode.search` call never fails as an unknown tool. It is only advertised in the instructions when the inlined list is partial: ```ts const matches = await tools.$codemode.search({ @@ -173,9 +173,9 @@ const matches = await tools.$codemode.search({ }) ``` -`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches — so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10). +`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10). -Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. The result signature is the pretty, JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** … */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). The inline catalog in the instructions keeps the compact single-line form. +Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. The result signature is the pretty, JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). The inline catalog in the instructions keeps the compact single-line form. ```ts tools.github.list_issues(input: { @@ -193,7 +193,7 @@ tools.github.list_issues(input: { Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone. -The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result tools exist inside `tools`; filter and aggregate collections in code; treat `Promise` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Example call forms use explicit `.`/`` placeholders — never a real or fabricated tool name. +The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result tools exist inside `tools`; filter and aggregate collections in code; treat `Promise` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Example call forms use explicit `.`/`` placeholders - never a real or fabricated tool name. A host cannot define its own `$codemode` top-level namespace. @@ -202,19 +202,19 @@ A host cannot define its own `$codemode` top-level namespace. CodeMode executes a deliberately bounded JavaScript subset. It supports: - Plain data literals, property access, assignment, and destructuring. -- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references — anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. +- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`. - Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring. - Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`. - Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`. -- `Date` — `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). -- Regular expressions — `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported. -- `Map` and `Set` — construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). -- First-class promises — an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. -- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure — thrown errors, interpreter runtime errors, and tool failures — is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have — `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error. +- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones). +- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported. +- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators). +- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic. +- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error. Inside a program, Date/RegExp/Map/Set values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`. -It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` — `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available. +It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available. CodeMode is an orchestration language, not a general JavaScript runtime. @@ -224,11 +224,11 @@ The limits are exactly three knobs: | Limit | Default | Bounds | | --- | ---: | --- | -| `timeoutMs` | none — no timeout | Wall-clock execution time. | -| `maxToolCalls` | none — unlimited | Tool calls admitted during the execution. | -| `maxOutputBytes` | none — no truncation | Model-facing output: the serialized result value plus captured logs. | +| `timeoutMs` | none - no timeout | Wall-clock execution time. | +| `maxToolCalls` | none - unlimited | Tool calls admitted during the execution. | +| `maxOutputBytes` | none - no truncation | Model-facing output: the serialized result value plus captured logs. | -No limit has a default, on purpose: execution budgets are host policy, not library policy — a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context. +No limit has a default, on purpose: execution budgets are host policy, not library policy - a host that wants a bound sets one; a host that can interrupt the execution fiber (as OpenCode does on user cancel) may set no timeout, and a host with its own tool-output truncation (as OpenCode has) may leave `maxOutputBytes` unset. A host with neither should set `maxOutputBytes`, or oversized results silently flood model context. Pass only the overrides you need: @@ -246,7 +246,7 @@ Limits are safe integers. `timeoutMs` must be at least `1`; the others may be `0 Exceeding a configured `maxOutputBytes` never fails the execution. An oversized result value is replaced by its truncated serialized text plus an explanatory marker, logs are kept from the start until the remaining budget is exhausted (with a final marker line noting the cut), and the result carries `truncated: true`. -When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) — no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded. +When configured, the timeout interrupts in-flight tool Effects, including eagerly started calls the program has not awaited (their fibers are supervised by the execution). The interpreter yields cooperatively between steps, so the timeout also interrupts pure busy loops (`while (true) {}`) - no separate work budget exists. Tool implementations remain responsible for making their external operations interruptible or independently bounded. Two interpreter internals are fixed constants rather than knobs: at most 8 tool calls run concurrently, and values crossing a data boundary may nest at most 32 levels deep (deeper values fail as `InvalidDataValue`, which reads better than a native stack-overflow error). Neither is part of the public contract. diff --git a/packages/codemode/codemode.md b/packages/codemode/codemode.md index d824681f92..3b954f5b0d 100644 --- a/packages/codemode/codemode.md +++ b/packages/codemode/codemode.md @@ -1,8 +1,8 @@ -# CodeMode — Status, Decisions, and Remaining Work +# CodeMode - Status, Decisions, and Remaining Work This document is the working plan for `@opencode-ai/codemode` and its OpenCode integration. It captures every locked decision, everything already implemented, and a detailed TODO of what -remains — enough context that someone (human or agent) can pick up any item cold. +remains - enough context that someone (human or agent) can pick up any item cold. Tracking issue: https://github.com/anomalyco/opencode/issues/34787 Working branch: `codemode-v2` (base: `dev`) @@ -19,11 +19,11 @@ the context window when users connect many MCP servers. Architecture split (locked): -- **`packages/codemode` (`@opencode-ai/codemode`)** — the generic, host-agnostic runtime: +- **`packages/codemode` (`@opencode-ai/codemode`)** - the generic, host-agnostic runtime: a hand-rolled, Effect-native, tree-walking interpreter over acorn ASTs (TypeScript stripped via `typescript`'s `transpileModule`), the tool runtime/data boundary, discovery/search, and `Tool.make`. It knows nothing about OpenCode, MCP, permissions, or rendering. -- **`packages/opencode`** — the OpenCode integration: an MCP adapter that converts MCP tool +- **`packages/opencode`** - the OpenCode integration: an MCP adapter that converts MCP tool definitions into `Tool.make(...)` definitions, permission gating, host-side attachment collection, the agent-facing `execute` tool, and TUI progress rendering. @@ -44,7 +44,7 @@ From issue #34787 and design discussion. Do not relitigate these casually. - **Keep the hand-rolled interpreter.** No QuickJS/V8/sandbox-engine dependency. We own and test the whole surface; the model only needs orchestration syntax, not a full runtime. - Naming: `CodeMode`, `Tool`, `ToolError`, `UnknownTool` (diagnostic kind), `$codemode` - reserved discovery namespace. (Historical names — "rune", "capability" — are dead.) + reserved discovery namespace. (Historical names - "rune", "capability" - are dead.) - Existing OpenCode core tools (bash/edit/patch/...) stay registered normally for v1. CodeMode covers MCP tools, user-registered tools, and deferred tools only. - Test runner is `bun test`; typecheck is `tsgo --noEmit` (repo conventions). Not vitest. @@ -55,21 +55,21 @@ From issue #34787 and design discussion. Do not relitigate these casually. - The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary `Tool.make(...)` definitions and hands CodeMode a plain tool tree. - Permissions stay in the OpenCode adapter (each tool's `run` wraps the permission ask). - CodeMode stays dumb — no permission model in this package. + CodeMode stays dumb - no permission model in this package. - Namespace collisions: last write wins (plain JS object override). No `tools.mcp.*` prefix, no `_2` suffixing, no cleverness. OpenCode groups flat `server_tool` MCP names into `tools..` namespaces before handing them over. ### Discovery / search -- **Search only — no separate `describe`.** `tools.$codemode.search({ query?, namespace?, +- **Search only - no separate `describe`.** `tools.$codemode.search({ query?, namespace?, limit? })` over the final tool tree, owned by this package. - Search result item shape: `{ path, description, signature }` in an `{ items, total }` - wrapper. The `signature` string embeds the full input/output TypeScript types — in search + wrapper. The `signature` string embeds the full input/output TypeScript types - in search results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schema `description`s and constraints (`@default`, `@format`, `@deprecated`, `@minItems`, `@maxItems`) ride along as field comments. The original spec's separate `input`/`output` raw-schema fields are deliberately NOT added: shapes are already fully expressed in the - TypeScript signature and schema annotations now arrive as JSDoc — intent satisfied, letter + TypeScript signature and schema annotations now arrive as JSDoc - intent satisfied, letter deviated. Result `path`s render a JavaScript expression rooted at `tools` (for example `tools.github.list_issues` or `tools.context7["resolve-library-id"]`) so each is directly usable as the call site; the internal `ToolDescription.path` stays unprefixed. @@ -84,7 +84,7 @@ From issue #34787 and design discussion. Do not relitigate these casually. ### Schemas / Tool.make - `Tool.make` carries rich metadata so search can render real signatures. - Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially - render-only — used for TypeScript rendering; the adapter may validate on its own). Leave + render-only - used for TypeScript rendering; the adapter may validate on its own). Leave room for Standard Schema later. - Tool implementations are **Effect-based** for v1 (`run` returns `Effect`). Promise normalization for plugin authors can come later. @@ -92,21 +92,21 @@ From issue #34787 and design discussion. Do not relitigate these casually. ### Attachments / output - **No `output.text/file/image` API in v1.** (Deleted in Wave 2.) - Tool calls return native structured payloads into the sandbox. Files/images emitted by - child tools **never enter the sandbox** — the OpenCode adapter strips and accumulates them + child tools **never enter the sandbox** - the OpenCode adapter strips and accumulates them host-side as calls happen, then returns them on the outer `execute` tool result as ordinary - tool-result attachments (OpenCode already has `Tool.ExecuteResult.attachments` → vision + tool-result attachments (OpenCode already has `Tool.ExecuteResult.attachments` -> vision plumbing in `message-v2.ts`). - No base64 in CodeMode values, ever. The model routes nothing; it can't accidentally dump image bytes into context or drop attachments. ### Runtime behavior -- Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` — +- Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` - matching the original locked spec exactly. NO limit has a default (user direction, Fix 6 for the first two; extended to `maxOutputBytes` in the truncation-layering fix below): - absent = no timeout / unlimited calls / no output truncation — budgets are host policy. + absent = no timeout / unlimited calls / no output truncation - budgets are host policy. A host without its own output bounding should set `maxOutputBytes` explicitly, or oversized results silently flood model context. OpenCode's adapter policy (user - direction): NO limits at all — no timeout, unlimited tool calls (each child call is + direction): NO limits at all - no timeout, unlimited tool calls (each child call is permission-gated; user cancel interrupts the execution fiber and its children), and no CodeMode truncation (output bounding is OpenCode's native tool-output truncation). The internal limit system that Wave 2 kept behind @@ -119,12 +119,12 @@ From issue #34787 and design discussion. Do not relitigate these casually. - Truncation layering RESOLVED (user direction): CodeMode truncation is off in OpenCode. `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation (50KB / 2000 lines in `tool.ts` + `truncate.ts`, full output dumped to a file) applies to - it with no special-casing — verified by tracing `wrap()` in `tool.ts:130-144` (the + it with no special-casing - verified by tracing `wrap()` in `tool.ts:130-144` (the `metadata.truncated` exemption never fires for `execute`). One truncation layer, the host's. `maxOutputBytes` remains available for hosts without their own bounding. - Pure-JS built-ins only. **No ambient authority**: no fs, child processes, network/fetch, process/env, or timers in v1. The agent has the bash tool for that. -- Forgiving JS semantics are locked (see §3, Wave 1a/1b-i) — missing props read `undefined`, +- Forgiving JS semantics are locked (see section 3, Wave 1a/1b-i) - missing props read `undefined`, `typeof` never throws, NaN/Infinity flow in-sandbox, etc. - `console.*` is captured into `logs` on the result; the host appends them to model-facing output. Not a tool call; costs no tool budget. @@ -138,41 +138,41 @@ From issue #34787 and design discussion. Do not relitigate these casually. ## 3. Current status (what is already done on `codemode-v2`) Everything below is committed and pushed on `codemode-v2` (six commits, in pairs of -generic-package + OpenCode-integration: waves 0–5, Fixes 4–9, then the DSL-expansion pass / +generic-package + OpenCode-integration: waves 0-5, Fixes 4-9, then the DSL-expansion pass / real-JS error names / truncation layering). Verification: from `packages/codemode`, `bun test` (211 pass / 0 fail across `codemode/parity/stdlib/promise/enumeration/signature`) and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and -`bun test test/tool/` (all green — the adapter suites are `test/tool/code-mode.test.ts`, +`bun test test/tool/` (all green - the adapter suites are `test/tool/code-mode.test.ts`, 43 tests, and `test/tool/code-mode-integration.test.ts`, 16 tests, moved from `test/session/` by the registry promotion; registry coverage in `test/tool/registry.test.ts`). -### Wave 0 — scaffold (done) +### Wave 0 - scaffold (done) - `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool, tool-error,tool-runtime}.ts`, README, AGENTS.md, tests. - `package.json`: name `@opencode-ai/codemode`, deps `acorn@8.15.0`, `typescript: catalog:`, `effect: catalog:` (both repos pin effect `4.0.0-beta.83`; opencode's effect patch only touches `unstable/httpapi`, which this package doesn't use). -- Tests converted vitest → `bun:test`. Only src change from verbatim: the `CurrentToolCall` +- Tests converted vitest -> `bun:test`. Only src change from verbatim: the `CurrentToolCall` Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`. -### Wave 1a — forgiving JS semantics (done) +### Wave 1a - forgiving JS semantics (done) Ported from the old opencode rune work; `test/parity.test.ts` (24 tests) is the acceptance spec. The seeded interpreter was deliberately strict; these behaviors replaced that: - **H1**: NaN/Infinity flow as in-sandbox values (`copyIn` admits them; `NaN`/`Infinity` are bindable globals; `charCodeAt` returns real NaN). Normalized to `null` only at the data - boundary (`copyOut` — single chokepoint for final results AND tool-call arguments), matching + boundary (`copyOut` - single chokepoint for final results AND tool-call arguments), matching `JSON.stringify`. Guards like `Number.isNaN(x)` / `parseInt(x) || 0` work. -- **H2/H3**: unknown property reads on strings/numbers/arrays → `undefined` (incl. under +- **H2/H3**: unknown property reads on strings/numbers/arrays -> `undefined` (incl. under `?.`), instead of throwing. This was the real-transcript failure: models write `result?.login ?? result` against JSON-string tool results. -- **H4**: `typeof undeclaredIdentifier` → `"undefined"` (short-circuits before resolution). +- **H4**: `typeof undeclaredIdentifier` -> `"undefined"` (short-circuits before resolution). - **H5**: `Boolean`/`String`/`Number` accepted as array callbacks (`filter(Boolean)`). - **H6**: `{...null}` / `{...undefined}` object spread is a no-op. Array spread of null/undefined still throws (real JS throws too). -### Wave 1b-i — stdlib value types: Date, RegExp, Map, Set (done) +### Wave 1b-i - stdlib value types: Date, RegExp, Map, Set (done) `src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both `codemode.ts` and `tool-runtime.ts` import without a cycle). Design: @@ -180,8 +180,8 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t access allowlists, Date in binary/unary ops, Map/Set in spread/for...of, console formatting, `containsOpaqueReference` for operator guards; the `runtimeValueBytes` byte-accounting carve-out died with that machinery in Fix 5). -- **JSON semantics at every boundary and checkpoint**: Date → ISO string (invalid → null), - RegExp/Map/Set → `{}`. `copyIn` also converts host `Date`/`RegExp`/`Map`/`Set` instances the +- **JSON semantics at every boundary and checkpoint**: Date -> ISO string (invalid -> null), + RegExp/Map/Set -> `{}`. `copyIn` also converts host `Date`/`RegExp`/`Map`/`Set` instances the same way (a host tool may legitimately return them). (Narrowed by the DSL-expansion pass: intra-sandbox checkpoints now preserve the instances; JSON forms apply at the host boundary only.) @@ -191,39 +191,39 @@ spec. The seeded interpreter was deliberately strict; these behaviors replaced t `match/matchAll/replace/replaceAll/split/search`. Match results are plain arrays carrying `index`/named `groups` as own properties (enabled by a general array own-property read fix); `input` omitted deliberately. Function replacers unsupported (clear error). Patterns run on - the host engine — catastrophic backtracking is bounded only by `timeoutMs` (accepted, in + the host engine - catastrophic backtracking is bounded only by `timeoutMs` (accepted, in README). - Map/Set: full method sets; `keys/values/entries` return **arrays** (not iterators); `for...of` + spread work; `Object.fromEntries(map)`, `Array.from(map|set)`; SameValueZero keys (NaN findable). (The incremental byte totals and `maxCollectionLength`/`maxDataBytes` enforcement this wave added were deleted in Fix 5.) -- Rode along, same spirit: `typeof` never throws for any value (`typeof fn` → `"function"`), +- Rode along, same spirit: `typeof` never throws for any value (`typeof fn` -> `"function"`), `!` works on any value, `for...of` over strings, `{...sandboxValue}` no-op, template interpolation renders `/regex/` and ISO dates directly. -### Wave 2 — API layer (done) +### Wave 2 - API layer (done) The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this wave; both packages typecheck clean. - **`Tool.make` schema flexibility** (`src/tool.ts`): `input`/`output` each accept an Effect Schema (validating, decoded both directions as before) OR a raw JSON Schema document - (render-only — no validation, values pass through; rendering handles `$defs`/`definitions` - + `$ref`). `output` is **optional** → signature renders `Promise` and the host + (render-only - no validation, values pass through; rendering handles `$defs`/`definitions` + + `$ref`). `output` is **optional** -> signature renders `Promise` and the host result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from `tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/ `jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty - `Schema.Struct({})` renders as `{ } | Array` (effect's JSON Schema emission) — + `Schema.Struct({})` renders as `{ } | Array` (effect's JSON Schema emission) - cosmetic, fixed in Wave 4. - **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output` global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields, instructions line, README section, seeded tests. AGENTS.md keeps a rephrased future-design note (channel name stays `output` if it ever returns). - **Hooks**: `CurrentToolCall` removed entirely (class, provideService, `Services` Exclude - special-casing, index export). `onToolCall` → `onToolCallStart({ index, name, input })` + + special-casing, index export). `onToolCall` -> `onToolCallStart({ index, name, input })` + `onToolCallEnd({ index, name, input, durationMs, outcome: "success"|"failure", message? })`. End fires symmetrically via `Effect.tap`/`tapError` around the settling portion (host run + - output decode + boundary copy; search too — its post-record body is wrapped in `Effect.try` + output decode + boundary copy; search too - its post-record body is wrapped in `Effect.try` so failures are typed and observable). `message` is the model-safe failure message (`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls fire no end event (timeout kills the whole execution anyway). @@ -237,16 +237,16 @@ wave; both packages typecheck clean. output limit; return a smaller value]`; logs keep leading lines within the remaining budget + `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to `ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox - `maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 — + `maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 - truncation is now the only result-size mechanism.) -- **Search polish**: default limit 12 → **10** (`defaultSearchLimit`); exact-path lookup — a +- **Search polish**: default limit 12 -> **10** (`defaultSearchLimit`); exact-path lookup - a trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone (`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged. -### Wave 3 — OpenCode MCP adapter (done) +### Wave 3 - OpenCode MCP adapter (done) `packages/opencode/src/session/code-mode.ts` rewritten as a thin adapter over this package; the vendored rune interpreter is gone. Same `define(mcpTools, mcpDefs, servers)` signature, so -`tools.ts` gating (flag on + MCP tools exist → single `execute` tool, early-return suppresses +`tools.ts` gating (flag on + MCP tools exist -> single `execute` tool, early-return suppresses per-MCP registration; MCP resource tools unaffected) is unchanged. - **Tool tree**: `groupByServer` (longest-sanitized-prefix, ported) groups flat `server_tool` @@ -254,7 +254,7 @@ per-MCP registration; MCP resource tools unaffected) is unchanged. JSON Schema; `toolTree` turns each into `Tool.make({ description, input, output?, run })` under `tools..`. The agent-facing description is `CodeMode.make({ tools }).instructions()` over a preview tree (placeholder runs, never - invoked) — so signature rendering, the inline-vs-search switch, and `$codemode.search` + invoked) - so signature rendering, the inline-vs-search switch, and `$codemode.search` availability all come from this package and stay consistent with execution. - **`run` path**: per-child permission ask first (`ctx.ask({ permission: entry.key, patterns: ["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child). @@ -263,47 +263,47 @@ per-MCP registration; MCP resource tools unaffected) is unchanged. they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper from `catalog.convertTool` (`entry.tool.execute!`), which owns callTool timeouts/progress-reset. - **Result shaping** (`toSandboxResult`): prefer `structuredContent`; else joined text - content; media (image/audio/resource blob/resource_link) NEVER enters the sandbox — blocks + content; media (image/audio/resource blob/resource_link) NEVER enters the sandbox - blocks are stripped into a per-execution `Attachment[]` accumulator, and a media-only result becomes a marker payload (`"[1 image attached to the result]"`, noun/count adjusted). An MCP-shaped result with nothing extractable becomes `null`; non-MCP values pass through. No handles, no `Result` envelope, no base64 in the sandbox, no data-size tuning (the `maxDataBytes` budget that existed at the time was deleted in Fix 5). - **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND - error — logs are plain pre-formatted lines now), attachments: accumulated }` through the - existing `Tool.ExecuteResult.attachments` → `message-v2.ts` vision plumbing; attachments + error - logs are plain pre-formatted lines now), attachments: accumulated }` through the + existing `Tool.ExecuteResult.attachments` -> `message-v2.ts` vision plumbing; attachments ride on both success and error results. Diagnostic `suggestions` not already contained in the message are appended to error output. Native outer truncation stays on (adapter never sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default at the time) - cut first — since the truncation-layering fix, native truncation is the only layer. + cut first - since the truncation-layering fix, native truncation is the only layer. Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout); - killed in Fix 6 — the adapter now passes no limits at all. -- **Progress**: `onToolCallStart`/`onToolCallEnd` → `ctx.metadata({ toolCalls })` with - `{ tool, status: running|completed|error, input? }` per call index — the exact shape the + killed in Fix 6 - the adapter now passes no limits at all. +- **Progress**: `onToolCallStart`/`onToolCallEnd` -> `ctx.metadata({ toolCalls })` with + `{ tool, status: running|completed|error, input? }` per call index - the exact shape the TUI `Execute` component (`packages/tui/src/routes/session/index.tsx`) already renders. `$codemode.search` calls stream through the same channel. - **Deletions/deps**: `src/session/rune/` (all five files) and `test/session/rune-parity.test.ts` (superseded by this package's `test/parity.test.ts`) deleted; `acorn` removed from opencode deps, `typescript` moved back to devDependencies, `"@opencode-ai/codemode": "workspace:*"` added; `bun install` run (lockfile updated). -- **Tests**: both opencode suites rewritten against the adapter design — +- **Tests**: both opencode suites rewritten against the adapter design - `code-mode.test.ts` (34: grouping, description/signature rendering incl. the large-catalog search fallback, execution, permission flow + denial, metadata streaming, attachment accumulation + media-only marker, logs on success/error, truncation marker, `toSandboxResult`/`formatValue`/`withLogs` units) and `code-mode-integration.test.ts` (16: real in-memory MCP server; native structured results, attachment accumulation, isError propagation, logs, permissions, live metadata). Old envelope/attachment-handle/`$rune` - describe/`renderType`/`rankTools` tests died with the old design (58+17+24 → 34+16). + describe/`renderType`/`rankTools` tests died with the old design (58+17+24 -> 34+16). -### Wave 4 — instructions/prompting + polish (done) +### Wave 4 - instructions/prompting + polish (done) Instructions are now the budgeted-catalog + prompting-guidance form; verified e2e against a real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both packages typecheck clean. - **Budgeted catalog** (`discoveryPlan` in `tool-runtime.ts`): the all-or-nothing - inline/search modes are gone — `DiscoveryMode` deleted, `DiscoveryOptions` is just + inline/search modes are gone - `DiscoveryMode` deleted, `DiscoveryOptions` is just `{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes; later converted to - `maxInlineCatalogTokens`, default 4,000 estimated tokens — see Post-wave fixes). Port of + `maxInlineCatalogTokens`, default 4,000 estimated tokens - see Post-wave fixes). Port of the old opencode `describe()` `PREVIEW_BUDGET` algorithm, adapted to `ToolDescription`: every namespace is ALWAYS listed with its tool count; full signature lines @@ -312,21 +312,21 @@ packages typecheck clean. alphabetically; once one line does not fit, inlining stops for every remaining namespace (counts only), exactly like the ported algorithm (this stop-everything behavior was later replaced by round-robin fairness in Fix 8). The header states comprehensiveness - precisely: "Available tools (COMPLETE list — …)" vs "Available tools (PARTIAL — N of M + precisely: "Available tools (COMPLETE list - ...)" vs "Available tools (PARTIAL - N of M shown; find the rest with tools.$codemode.search)"; namespace labels are `(N tools)` / `(N tools, K shown)` / `(N tools, none shown)`. An empty tree renders "No tools are currently available." - **Search always registered** (documented decision): `DiscoveryPlan.searchIndex` is required and built unconditionally (new exported `ToolRuntime.searchIndex(tools)`; `SearchEntry` type exported); `CodeMode.execute` (one-shot) passes it too, preserving the - `execute`≡`make().execute` law. A speculative `tools.$codemode.search` call on a small + `execute`==`make().execute` law. A speculative `tools.$codemode.search` call on a small catalog now succeeds instead of `UnknownTool`, and unknown-tool suggestions always point at search. Search is *advertised* in the instructions only when the inlined list is PARTIAL, keeping small-catalog instructions tight. -- **Prompting content** in `instructions()`, mapping 1:1 to the §5 transcript failures: +- **Prompting content** in `instructions()`, mapping 1:1 to the section 5 transcript failures: parse-string-results-as-JSON, return-small, console-for-intermediates, and read-the-description-before-calling guidance. (The flat prose layout this wave produced - was later replaced wholesale by the markdown-section restructure — see Post-wave fixes — + was later replaced wholesale by the markdown-section restructure - see Post-wave fixes - which also deleted this wave's worked example.) - **Cosmetic renderer fixes** (`renderSchema` in `tool.ts`): an object schema with no properties renders `{}` (was `{ }`), and the empty `Schema.Struct({})` emission @@ -342,16 +342,16 @@ packages typecheck clean. sequential-thinking; left uncommitted/as-is), and `bun packages/opencode/src/index.ts run --dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single `execute` tool registered alongside core tools (per-MCP registration suppressed; MCP - resource tools unaffected); the live description read back as "Available tools (PARTIAL — + resource tools unaffected); the live description read back as "Available tools (PARTIAL - 56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace labels (context7/github/memory fully shown; playwright/sentry/sequential-thinking "none - shown" — the alphabetical-exhaustion starvation Fix 8 later replaced with round-robin + shown" - the alphabetical-exhaustion starvation Fix 8 later replaced with round-robin fairness); programs executed with in-program `$codemode.search` calls and returned the correct answer. NOT verified e2e (headless only; covered by unit/integration tests instead): TUI child-call rendering, attachments becoming visible images, output truncation. -### Wave 5 — Promise generalization (done) +### Wave 5 - Promise generalization (done) First-class promise values in the interpreter; the direct-tool-call-only `Promise.all` restriction (and its bespoke AST checks) is gone. Package suite is 136 tests / 0 fail (35 new in `test/promise.test.ts`); adapter suites and both typechecks unchanged/green; the opencode @@ -359,18 +359,18 @@ adapter needed **no changes**. - **Decision: eager fork** (`const p = tools.a.b(x)` starts the call immediately on a supervised child fiber; `await p` observes its settlement). Chosen over lazy because: - (1) it's spec-faithful — JS promise work starts at call time, so + (1) it's spec-faithful - JS promise work starts at call time, so `const a = t1(); const b = t2(); return [await a, await b]` gets real parallelism instead of - silently sequential awaits; (2) run-once is free — a fiber settles exactly once and + silently sequential awaits; (2) run-once is free - a fiber settles exactly once and `Fiber.await` is idempotent, so `await p` twice or `Promise.all([p, p])` can never re-invoke the tool (lazy needs a deferred/latch to match); (3) effect's structured concurrency does the - hard part — `Effect.forkChild` children are auto-supervised (interrupted when the parent + hard part - `Effect.forkChild` children are auto-supervised (interrupted when the parent fiber exits) and `Effect.timeoutOrElse` is `raceFirst`, which runs the program on its own raced fiber, so forked calls cannot escape the timeout (tested: in-flight forks are interrupted, awaited or abandoned, direct or inside `Promise.all`). - **Mechanics**: `SandboxPromise` in `values.ts` (fiber-backed for tool calls; fiberless `immediate` effect for `Promise.resolve`/`reject`). Forks run - `semaphore.withPermit(invoke)` with `startImmediately: true` — a per-execution + `semaphore.withPermit(invoke)` with `startImmediately: true` - a per-execution `Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)` (fixed 8, see Fix 5) caps live calls (the "Effect.all or equivalent" cap lives where the work is, so combinator joins can be sequential without losing parallelism), and the tool-call-count charge (`recordCall`) plus @@ -378,7 +378,7 @@ adapter needed **no changes**. top-level promise resolves like an async-function return (`return tools.a.b(x)` works without await). - **Promise combinators are normal functions over values**: `Promise.all`/`allSettled`/`race` - accept any array (or spreadable collection) mixing promises and plain data — inline, built + accept any array (or spreadable collection) mixing promises and plain data - inline, built beforehand, spread, nested in variables. `allSettled` yields `{ status: "fulfilled", value } | { status: "rejected", reason }` with reasons produced by the same `caughtErrorValue` helper the `catch` binding uses (factored out of @@ -388,20 +388,20 @@ adapter needed **no changes**. interrupt-only settlement keeps propagating as interruption (preserving the host-interruption law). `Promise.resolve` flattens promises; `Promise.reject` rejects with the reason via `ProgramThrow`. -- **Opaqueness/boundaries**: promises are runtime references — `typeof` → `"object"` (real JS), +- **Opaqueness/boundaries**: promises are runtime references - `typeof` -> `"object"` (real JS), operators reject them, `copyIn` raises an await-hinting `InvalidDataValue` ("contains an - un-awaited Promise; await tool calls (…) before using their results") for results, tool + un-awaited Promise; await tool calls (...) before using their results") for results, tool arguments, and `JSON.stringify` instead of `{}`. Property access on a promise is a - deliberate error (not the forgiving `undefined`): `.then/.catch/.finally` → - `UnsupportedSyntax` pointing at `await` + try/catch; anything else → "await it first". - `new Promise(...)` → UnsupportedSyntax ("tool calls already return promises"); + deliberate error (not the forgiving `undefined`): `.then/.catch/.finally` -> + `UnsupportedSyntax` pointing at `await` + try/catch; anything else -> "await it first". + `new Promise(...)` -> UnsupportedSyntax ("tool calls already return promises"); `Promise.` lists the five available statics. `console.log(p)` prints `[Promise (await it to get its value)]`. - **Program-end drain**: on successful completion the interpreter awaits still-running un-awaited fibers (like a runtime waiting on in-flight I/O at exit), so fire-and-forget calls complete deterministically; a failure nobody could have handled surfaces as an - "Unhandled rejection from an un-awaited tool call: …" diagnostic (kind preserved, - suggestion says to await) — keeping pre-wave failure visibility for un-awaited + "Unhandled rejection from an un-awaited tool call: ..." diagnostic (kind preserved, + suggestion says to await) - keeping pre-wave failure visibility for un-awaited statement-position calls. Settlement observation (await/all/allSettled/race) marks a promise handled; failed executions skip the drain and children are interrupted by supervision. @@ -413,35 +413,35 @@ adapter needed **no changes**. - **Known divergences (deliberate)**: `p === q` on promises throws the operators-need-data diagnostic instead of comparing identity; `{...promise}` errors instead of JS's silent `{}`; a per-iteration `await` inside `items.map(async (i) => await tools.x(i))` runs sequentially - (interpreter callbacks compose synchronously) — the parallel idiom is mapping to un-awaited + (interpreter callbacks compose synchronously) - the parallel idiom is mapping to un-awaited calls and awaiting `Promise.all`, which the instructions show. ### Post-wave fixes - **Key enumeration: `Object.keys(tools)` + `for...in` (done).** Motivating transcript: a model tried to enumerate tool namespaces with `Object.keys(tools)` (failed with the generic - "Object.keys input must contain plain objects only." — `tools` is a `ToolReference`, not + "Object.keys input must contain plain objects only." - `tools` is a `ToolReference`, not plain data) and then `for (const key in tools)` ("Syntax 'ForInStatement' is not - supported"), and had to fall back to guessing namespace names from the instructions — + supported"), and had to fall back to guessing namespace names from the instructions - defeating discovery. Fixes, all in this package: - `ToolRuntime.make` now returns a `keys(path)` capability (`namespaceKeys` in - `tool-runtime.ts`) threaded into the `Interpreter` alongside `invoke` — the interpreter + `tool-runtime.ts`) threaded into the `Interpreter` alongside `invoke` - the interpreter still never holds the host tool tree. `Object.keys(tools)` yields the top-level namespace - names (never `$codemode`, which is virtual — but `Object.keys(tools.$codemode)` yields + names (never `$codemode`, which is virtual - but `Object.keys(tools.$codemode)` yields `["search"]`), `Object.keys(tools.ns)` the names at that node; a callable tool leaf enumerates as `[]` (like `Object.keys` of a JS function); an unknown path throws an `UnknownTool` diagnostic suggesting `Object.keys(tools)` and `$codemode.search` (matching call-time unknown-tool behavior rather than silently returning `[]`). - `Object.values`/`Object.entries` (and every other `Object.*` helper) on a tool reference - now fail with "…not plain data. Use Object.keys(tools) for names, or + now fail with "...not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures." instead of the generic message. - `Object.keys(array)` returns index strings (`["0", "1", ...]`) like real JS (was a Backlog item). - `for...in` (ForInStatement) iterates own enumerable string keys of plain objects, index - strings of arrays, and namespace/tool names of tool references — sharing the interpreter's + strings of arrays, and namespace/tool names of tool references - sharing the interpreter's `enumerableKeys` helper with the `Object.keys` tool path. const/let declarations and bare identifiers bind the key; break/continue work. Anything else (strings, Map/Set, numbers, - null, ...) is a clear error suggesting `for...of` or `Object.keys` — deliberately smaller + null, ...) is a clear error suggesting `for...of` or `Object.keys` - deliberately smaller than real JS (which yields indices for strings and zero iterations for Maps/Sets/null). - `supportedSyntaxMessage`, the instructions loops line, and README "Supported Programs" mention the new surface; tests in `test/enumeration.test.ts` (14, incl. the exact @@ -455,7 +455,7 @@ adapter needed **no changes**. - **Ranking ported from the pre-rebuild implementation** (the `searchTextFor`/`tokenize`/ `rankTools` algorithm in `packages/opencode/src/session/code-mode.ts` at git HEAD), replacing the word-set ranker in `tool-runtime.ts`. Searchable text per tool = path + - description + input-schema property names + their `description` strings — extracted by + description + input-schema property names + their `description` strings - extracted by the new `inputProperties` helper in `tool.ts` (Effect Schemas via `Schema.toJsonSchemaDocument`, the same emission signature rendering uses; JSON Schemas read `properties` directly, resolving a trivial top-level `$ref`; try/catch falls back to @@ -467,7 +467,7 @@ adapter needed **no changes**. An empty query now browses ALPHABETICALLY by path (was declaration order). Kept: `{ path, description, signature }` result items, default limit 10, exact-path instant lookup, input validation errors. - - **Namespace scoping**: `tools.$codemode.search({ query?, namespace?, limit? })` — + - **Namespace scoping**: `tools.$codemode.search({ query?, namespace?, limit? })` - `namespace` (validated as a string when provided) filters `SearchEntry`s to one top-level namespace before ranking; `{ query: "", namespace: "github" }` lists that namespace alphabetically. `searchSignature` updated. @@ -489,21 +489,21 @@ adapter needed **no changes**. fields in the worked example) are replaced by structured markdown in `discoveryPlan`, ordered so the workflow sits at the top (the least likely part of a long description to be truncated or skimmed away) and the catalog at the bottom (the per-section content - described here was later condensed by Fix 8 — Workflow/Rules deduped, Syntax inverted): - - **Intro** (2 lines): "Write a CodeMode program… Return code only." + "Execute + described here was later condensed by Fix 8 - Workflow/Rules deduped, Syntax inverted): + - **Intro** (2 lines): "Write a CodeMode program... Return code only." + "Execute JavaScript in a confined runtime with access to the tools listed below under `tools.*`." (the second line drops the tools clause when the tree is empty). - - **`## Workflow`**: numbered steps — find a tool via `tools.$codemode.search` → read - the `{ path, description, signature }` matches → call by path → `typeof res === - "string" ? JSON.parse(res) : res` → return only the needed fields. When the catalog is + - **`## Workflow`**: numbered steps - find a tool via `tools.$codemode.search` -> read + the `{ path, description, signature }` matches -> call by path -> `typeof res === + "string" ? JSON.parse(res) : res` -> return only the needed fields. When the catalog is COMPLETE the search/read steps collapse into "Pick a tool from the list under `## Available tools`" and the steps renumber (4 instead of 5). - - **`## Rules`**: call-by-exact-path; TEXT-is-JSON → JSON.parse; return small (never raw + - **`## Rules`**: call-by-exact-path; TEXT-is-JSON -> JSON.parse; return small (never raw payloads); filter/aggregate large collections in code instead of per-item round-trips; console.log/warn/error/dir/table for intermediates; `Promise.all` parallelism (no - .then/.catch — await + try/catch); `Object.keys(tools)`/`for...in` enumeration; + .then/.catch - await + try/catch); `Object.keys(tools)`/`for...in` enumeration; browse-one-namespace via search (PARTIAL only); and host-side media handling (files/ - images never enter the program; a media-only call yields a small text marker — wording + images never enter the program; a media-only call yields a small text marker - wording verified against the adapter's `toSandboxResult`/`mediaMarker`). - **`## Syntax`**: the dense syntax lines unchanged, minus the Promise.all and console lines (moved into Rules) and the `for (const ns in tools)` fragment (redundant with @@ -513,7 +513,7 @@ adapter needed **no changes**. advertisement follows when PARTIAL (its description-reading and browse clauses moved to Workflow/Rules). - Every call form in Workflow/Rules uses explicit `.`/`` - placeholders — the example builder that derived a worked example from the first inlined + placeholders - the example builder that derived a worked example from the first inlined catalog tool (`exampleArguments` + the example-selection machinery) is DELETED, so no real catalog tool is cherry-picked into examples and no fabricated names or fields appear anywhere in the instructions. Zero tools keep "No tools are currently @@ -521,32 +521,32 @@ adapter needed **no changes**. - **Tests**: the package worked-example test replaced by section-structure/placeholder assertions (section order; JSON.parse + return-small rules present; no `total_count`/`list_issues`/real-tool example lines; browse hint only when PARTIAL; - zero-tool minimal sections) — 156 pass / 0 fail; adapter suites gain the same + zero-tool minimal sections) - 156 pass / 0 fail; adapter suites gain the same assertions on the built description (still 35 + 16, green). -**Fix 4 — token-budgeted catalog (was bytes)** (user direction: signatures need a token +**Fix 4 - token-budgeted catalog (was bytes)** (user direction: signatures need a token budget; namespaces must always be present): - `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so the package stays dependency-free; keep in sync if the core heuristic changes. - - `DiscoveryOptions.maxInlineCatalogBytes` → `maxInlineCatalogTokens` (default 4,000 - estimated tokens ≈ the old 16,000 bytes at 4 chars/token — behavior parity, not a size + - `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000 + estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first + stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in - Fix 8). Namespace stub lines were and remain unbudgeted — every + Fix 8). Namespace stub lines were and remain unbudgeted - every namespace always appears with its tool count, even at budget 0 (asserted in package and adapter tests). - Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements - (2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ≈ 1,100 tokens fixed; - worst-case net description ≈ fixed + 4,000 ≈ 5,100 estimated tokens. + (2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ~ 1,100 tokens fixed; + worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens. -**Fix 5 — internal limits removed** (user direction: only the three PUBLIC limits survive as +**Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as configurable knobs; the internal limit system dies): - `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at the time; Fix 6 later removed the first two defaults. Same validation: safe integers, timeoutMs >= 1, others >= 0, RangeError otherwise) is now - the ENTIRE limit surface — exactly the shape §2's original locked spec named. + the ENTIRE limit surface - exactly the shape section 2's original locked spec named. `ResolvedExecutionLimits` shrank to those three fields; the `@internal` `InternalExecutionLimits` type is deleted. - **Deleted outright**: `maxOperations` and the whole operation-budget machinery @@ -556,22 +556,22 @@ configurable knobs; the internal limit system dies): the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes` fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and - audit-trail byte accounting — `toolCalls` records and the start/end hooks are unchanged); - `maxCollectionLength` (every array-length/object-field-count check — this knob was + audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged); + `maxCollectionLength` (every array-length/object-field-count check - this knob was actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded` and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and - `ExecuteResultSchema` (fine — the package is unreleased). + `ExecuteResultSchema` (fine - the package is unreleased). - **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork - semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check — kept + semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept only because it produces a clearer error than a native stack-overflow RangeError; still - `InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone — + `InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone - `copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just `(tools, maxToolCalls, hooks?, searchIndex?)`. - - **Verified fact**: timeout interruption does NOT depend on the operation budget — the + - **Verified fact**: timeout interruption does NOT depend on the operation budget - the Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at ~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression - test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` → + test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` -> `TimeoutExceeded`, elapsed well under a few seconds). - **Kept (correctness, not budgets)**: circular detection (`copyIn` walks + `rejectCircularInsertion` on mutations), plain-objects-only, blocked properties @@ -582,75 +582,75 @@ configurable knobs; the internal limit system dies): assignment allows any non-negative integer index (holes permitted, message now "must be a non-negative integer"); interpreter-produced deep/hostile structures that overflow the native stack during a walk still normalize to the existing "Execution exceeded the - maximum nesting depth." data diagnostic — failures remain data everywhere. - - Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth ×2, + maximum nesting depth." data diagnostic - failures remain data everywhere. + - Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2, enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/ maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit - test — superseded by the package timeout regression test); rewrote the helpers that used + test - superseded by the package timeout regression test); rewrote the helpers that used `InternalExecutionLimits` as a convenience to plain `ExecutionLimits` (promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter suites: 34 + 16. -**Fix 6 — no default timeout / tool-call cap** (user direction): `timeoutMs` and -`maxToolCalls` lost their defaults (were 10_000 / 100) — absent now means no timeout / +**Fix 6 - no default timeout / tool-call cap** (user direction): `timeoutMs` and +`maxToolCalls` lost their defaults (were 10_000 / 100) - absent now means no timeout / unlimited calls. Budgets are host policy, not library policy; `maxOutputBytes` kept its -32,000 default at the time (removed later — see the truncation-layering entry: absent now +32,000 default at the time (removed later - see the truncation-layering entry: absent now means no truncation). `ResolvedExecutionLimits` carries `number | undefined` for both, the timeout wrapper is only applied when configured, and `ToolRuntime.make` treats undefined `maxToolCalls` as uncapped. Validation is unchanged when values ARE provided (safe integers, timeoutMs >= 1, others >= 0). The OpenCode adapter is unaffected in behavior it sets (explicit 30s timeout) but now runs with unlimited tool calls. Immediately after, per user direction, the adapter's 30s timeout was killed too: `CODE_LIMITS` is deleted and OpenCode -passes NO limits — no timeout, no tool-call cap. Rationale: user cancel interrupts the +passes NO limits - no timeout, no tool-call cap. Rationale: user cancel interrupts the execution fiber and structured concurrency takes the program and in-flight child calls down with it; every child call is permission-gated; output truncation (32KB default) is the only active bound. New regression test: 150 tool calls succeed with no limits configured (would have tripped the old default 100). Package suite: 155 pass / 0 fail. -**Fix 7 — JSDoc-annotated search signatures**: `tools.$codemode.search` result signatures are -now the pretty, indented multiline form with per-field JSDoc — ported from the pre-rebuild +**Fix 7 - JSDoc-annotated search signatures**: `tools.$codemode.search` result signatures are +now the pretty, indented multiline form with per-field JSDoc - ported from the pre-rebuild rune renderer in this repo's git history (`renderType(def, { pretty })`/`docTags`/`jsdoc`/ `renderObject`), adapted to the current renderer's conventions (`Array`, `unknown` fallback, existing `$defs`/`$ref` handling and empty-object `{}` collapse; the old -`Result`/`returnType` machinery was deliberately not ported — payloads stay native). +`Result`/`returnType` machinery was deliberately not ported - payloads stay native). Semantics: each described input/output field carries its schema `description` as a -`/** … */` comment at the right indent (nested objects recurse deeper); constraints TS can't -express surface as JSDoc tags — `@deprecated`, `@default ` (unserializable defaults +`/** ... */` comment at the right indent (nested objects recurse deeper); constraints TS can't +express surface as JSDoc tags - `@deprecated`, `@default ` (unserializable defaults skipped), `@format`, `@minItems`/`@maxItems`; `*/` inside text is neutralized to `* /`; multiline descriptions become `*`-prefixed blocks with blank edges trimmed; undescribed, untagged fields get no comment. Implementation: `renderSchema` in `tool.ts` grew a `RenderContext` (`{ definitions, pretty }`), a `MAX_RENDER_DEPTH = 8` recursion ceiling plus -a `$ref` `seen` guard (the renderer previously had neither — a cyclic `$defs` would have +a `$ref` `seen` guard (the renderer previously had neither - a cyclic `$defs` would have looped; it now degrades to the ref name/`unknown`), and try/catch totality on the public helpers (`toTypeScript`/`jsonSchemaToTypeScript`/`inputTypeScript`/`outputTypeScript` never -throw — pathological schemas render `unknown`); each helper takes an optional trailing +throw - pathological schemas render `unknown`); each helper takes an optional trailing `pretty = false` parameter, so existing callers are unchanged and compact output stays byte-identical (inline `catalogLine`s and the token budget depend on it). `SearchEntry` gained an eagerly-computed `signature` field (built once per tool at index-build time in -`toSearchEntry` — rendering is cheap and the search hot path stays allocation-free); both +`toSearchEntry` - rendering is cheap and the search hot path stays allocation-free); both ranked results and exact-path lookups serve it. Works for both tool kinds: Effect Schema annotations (`Schema.String.annotate({ description })`) flow through the emitted JSON -Schema, and raw JSON Schema (MCP) property metadata is read directly — both covered in +Schema, and raw JSON Schema (MCP) property metadata is read directly - both covered in `test/signature.test.ts` (12 tests) plus one strengthened adapter assertion (MCP property description appears as JSDoc in a live search result; the tool description/catalog contains no `/**`). README search section updated with an example. Package suite: 167 pass / 0 fail; adapter suites: 34 + 16. -**Fix 8 — condensed instructions + round-robin catalog fairness + plural-aware search** +**Fix 8 - condensed instructions + round-robin catalog fairness + plural-aware search** (user direction: the fixed instruction prose was too verbose; two discovery fixes ride along). All in `tool-runtime.ts`; no interpreter changes. - **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens) are replaced by four short lines (~188) built on "models already know JavaScript; name - only what is unusual or missing": (1) standard modern JS works — functions/closures, + only what is unusual or missing": (1) standard modern JS works - functions/closures, destructuring, template literals, loops, try/catch, spread, optional chaining, the usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are stripped before execution, decorators are not supported; (3) NOT supported (each fails with a message naming the alternative): classes, generators, for await...of, .then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors - are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates → - ISO strings; Map/Set/RegExp → `{}`). Every claim was verified against the interpreter - before writing: probed empirically — classes/generators/for-await/.then/.catch/ + are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates -> + ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter + before writing: probed empirically - classes/generators/for-await/.then/.catch/ .finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged templates/object getters all fail with clear diagnostics; TS annotations/`as`/ interfaces/type aliases are stripped and TS **enums actually work** (transpileModule @@ -660,23 +660,23 @@ along). All in `tool-runtime.ts`; no interpreter changes. return-small content now lives ONLY in the numbered Workflow steps (with their compliance-driving justifications inline: "most tools return JSON as a string", "raw payloads get truncated and waste context"); Rules keeps only bullets adding new - content — filter/aggregate collections in code, console.* intermediates (logs ride + content - filter/aggregate collections in code, console.* intermediates (logs ride back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace (PARTIAL only), and the media rule compressed to one line. The no-.then/.catch guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search - step gained query-style guidance (`— short phrases like "list issues" work best`; a + step gained query-style guidance (`- short phrases like "list issues" work best`; a clearly-a-query-string example, not a tool name), and the exact-path guidance is now "call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it as-is rather than guessing segments". - **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0, - bytes/3.7 — same method as Fix 4; chars/4 in parentheses): - preamble 44 → 44 (41 → 41), Workflow 146 → 187 (135 → 171), Rules 362 → 191 - (332 → 176), Syntax 453 → 188 (419 → 174); fixed prose total 1,005 → 610 (927 → 562), - ≈ 40% reduction with no behavioral content dropped. Workflow grew slightly because it + bytes/3.7 - same method as Fix 4; chars/4 in parentheses): + preamble 44 -> 44 (41 -> 41), Workflow 146 -> 187 (135 -> 171), Rules 362 -> 191 + (332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562), + ~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it absorbed the deduped parse/return-small justifications. - **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss behavior (alphabetically-late namespaces starved to "none shown" while an early - namespace inlines everything) is replaced by round-robin fairness — in each round + namespace inlines everything) is replaced by round-robin fairness - in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest line against the shared token budget; a namespace whose next line does not fit is done while the others keep going; stop when all are done. Every @@ -687,8 +687,8 @@ along). All in `tool-runtime.ts`; no interpreter changes. namespace's shown set. - **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must be substring of indexed text), so query "issues" missed a tool whose text only says - "issue". Now each term expands to `termForms` — the term plus naive singular variants - (trailing "es" stripped when length > 3, trailing "s" when length > 2) — and each of + "issue". Now each term expands to `termForms` - the term plus naive singular variants + (trailing "es" stripped when length > 3, trailing "s" when length > 2) - and each of the four field checks passes when ANY form matches. Weights, exact-path lookup, and namespace scoping untouched. A true plural path match still outranks a singular-only description match (path substring 8 + searchable 2 > description 4 + searchable 2). @@ -700,68 +700,68 @@ along). All in `tool-runtime.ts`; no interpreter changes. plural/singular test (query "issues" finds a singular-only tool; ranking still prefers the true "issues" path match). Adapter: description assertions updated; the large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` + - its inlined line) — it was "none shown" under starvation. README updated (budgeted - catalog paragraph → round-robin; search paragraph → singular variants; - instructions-structure paragraph → new section contents). Package suite: 169 pass / + its inlined line) - it was "none shown" under starvation. README updated (budgeted + catalog paragraph -> round-robin; search paragraph -> singular variants; + instructions-structure paragraph -> new section contents). Package suite: 169 pass / 0 fail; adapter suites: 34 + 16. -**Fix 9 — prompting trims per user review of Fix 8** (user reviewed the condensed +**Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed instructions and directed further cuts): - - Default `maxInlineCatalogTokens` 4,000 → **2,000** (user wants ~2k tokens of signatures + - Default `maxInlineCatalogTokens` 4,000 -> **2,000** (user wants ~2k tokens of signatures auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces). - Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single `unknown`-treatment warning: "A result typed `Promise` has no guaranteed - shape — verify what actually came back before relying on its fields." (Deliberately - does NOT suggest console.log — user review: naming it there nudges models to log AND + shape - verify what actually came back before relying on its fields." (Deliberately + does NOT suggest console.log - user review: naming it there nudges models to log AND return the same data; the prompt stays console-neutral, neither for nor against.) The media-stripping MECHANISM is unchanged and still tested; only the prose about it - is gone — the `[N images attached]` marker is self-explanatory in context. + is gone - the `[N images attached]` marker is self-explanatory in context. - Kept as-is per user: the JSON.parse workflow step (maps to the original motivating - transcript failure; NOT copied from prior art — see §5 note), the browse-namespace rule + transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule (undecided), no no-fetch/ambient-authority rule added (proposed, not approved). - Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter - boundary ("could get weird" — type flips, program-sees vs tool-sent divergence). Logged + boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged as a next-iteration follow-up below. -**DSL-expansion pass — interpreter-surface batch from §4** (the deferred medium-tier JS +**DSL-expansion pass - interpreter-surface batch from section 4** (the deferred medium-tier JS parity items, done as one focused pass; no public API or limit changes): - **`instanceof` + real Error values**: the `errorConstructors` names (`Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are - bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` → + bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` -> `"function"`). Error values stay the same plain `{ name, message }` null-prototype - objects as before — the constructor name additionally rides on a NON-ENUMERABLE symbol + objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread, JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the brand is lost on spread/boundary copies exactly like JS loses the prototype. `caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so caught interpreter AND tool failures are `instanceof Error` and carry the `name` the - equivalent real-JS failure would have (follow-up fix, user-directed — "closest to real + equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set - fluently at throw sites via `.as(name)` — `JSON.parse` failures are `"SyntaxError"` (and - now include the engine's position detail in the message; safe — derived from the + fluently at throw sites via `.as(name)` - `JSON.parse` failures are `"SyntaxError"` (and + now include the engine's position detail in the message; safe - derived from the program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`, a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly keeps its own name when it is one of the standard seven. Tool failures and everything - without a specific analogue stay `"Error"` — internal class names never leak. Specific + without a specific analogue stay `"Error"` - internal class names never leak. Specific names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS. The operator is handled in `evaluateBinaryExpression` - BEFORE the data-only operand check (like `typeof`, it observes any lhs — promises and + BEFORE the data-only operand check (like `typeof`, it observes any lhs - promises and functions included); recognized rhs: the error constructors (a specific type matches its own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes), `Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and - `Number`/`String`/`Boolean` (always false — no boxed values exist); anything else is a + `Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a catchable error naming the recognized constructors. - **Array methods**: `splice` (mutating, returns the removed elements; insertions run `rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined delete count removes nothing), `fill` (circular-checked value) and `copyWithin` (host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set - convention — for...of and spread work either way). The `retryableArrayMethods` + convention - for...of and spread work either way). The `retryableArrayMethods` "rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown array properties still read `undefined`. - - **String methods**: `localeCompare(that)` (locale/options arguments ignored — host + - **String methods**: `localeCompare(that)` (locale/options arguments ignored - host default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form - → catchable error naming the four valid forms), `trimLeft`/`trimRight` as + -> catchable error naming the four valid forms), `trimLeft`/`trimRight` as trimStart/trimEnd aliases. - **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the offending pattern (or flags) plus the engine reason (deduped "Invalid regular @@ -770,32 +770,32 @@ parity items, done as one focused pass; no public API or limit changes): replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and the single-match alternative. - **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues = - false)` — recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox + false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`, which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by - reference as leaves** (contents not walked — Map/Set members are validated at their + reference as leaves** (contents not walked - Map/Set members are validated at their mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity, plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep - the await-hinting rejection in BOTH modes (deliberate — JS-parity pass-through was + the await-hinting rejection in BOTH modes (deliberate - JS-parity pass-through was considered and skipped to preserve the nudge). The HOST boundary (final result, tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and - still serializes JSON forms (Date → ISO, RegExp/Map/Set → `{}`); host instances met on + still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on the preserving path are defensively wrapped into sandbox equivalents. Ripple: the - `Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` → `[]`, - assign sources contribute nothing, hasOwn → false — JS has no own enumerable props + `Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`, + assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread - already preserved instances (reference copies, no checkpoint) — now tested. + already preserved instances (reference copies, no checkpoint) - now tested. - **Console formatting**: `formatConsoleArgument` is total and deep (`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity` - literally — never the JSON `null`; finite numbers match their JSON form), nested + literally - never the JSON `null`; finite numbers match their JSON form), nested strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become in-place `[CodeMode reference]` markers instead of collapsing the whole argument, cycles render `[Circular]` (reachable via Map/Set members, which mutation never checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob) - degrades to `…` — console can no longer fail a program. `console.table` guards with + degrades to `...` - console can no longer fail a program. `console.table` guards with `containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell walkers treat sandbox values as scalar cells. - **Prose**: the instructions Syntax not-supported line dropped its `instanceof @@ -803,41 +803,41 @@ parity items, done as one focused pass; no public API or limit changes): preservation vs boundary serialization, error values/`instanceof`, new array/string methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists supported syntax, was already non-exhaustive, and stays accurate). - - **Tests**: package suite 169 → 209 (parity: Error/instanceof + real-JS error-name + - **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib `instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged (34 + 16, green); both packages `tsgo --noEmit` clean. -**Truncation layering — CodeMode truncation off in OpenCode** (user direction; resolves the -§4 outer-truncation item the OPPOSITE way from "kill the outer one"): +**Truncation layering - CodeMode truncation off in OpenCode** (user direction; resolves the +section 4 outer-truncation item the OPPOSITE way from "kill the outer one"): - `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two - limits: absent = no truncation. All three limits are uniformly no-default — budgets are + limits: absent = no truncation. All three limits are uniformly no-default - budgets are host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`; `boundOutput` only runs when the host set the limit. Explicit values validate as before - (safe integer ≥ 0). + (safe integer >= 0). - OpenCode continues to pass NO limits, which now also means no CodeMode truncation. `execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation - applies with no special-casing — verified by tracing `wrap()` (`tool.ts:130-144`, + applies with no special-casing - verified by tracing `wrap()` (`tool.ts:130-144`, 50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under `tool-output/`): the `metadata.truncated` self-truncation exemption never fires for - `execute` (its metadata never sets that key). One truncation layer, the host's — and it + `execute` (its metadata never sets that key). One truncation layer, the host's - and it is the richer one (file dump + explore/grep hint vs an inline marker). - Hosts without their own output bounding set `maxOutputBytes` explicitly; README table - and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit → 100KB + and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test that relied on the old default now asserts the oversized result reaches the shared wrapper un-truncated. Suites: 210 + 50, tsgo clean both. **Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default -4,000 and alphabetical cheapest-first — now 2,000 and round-robin, matching Fix 8/9 reality) +4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality) and the README's incorrect "`effect` as a peer dependency" line corrected (`effect` is a regular dependency; hosts depend on it themselves because the API surface is Effect-typed). **Registry promotion + permission-aware catalog** (the "promote to a proper tool service" -restructure; fixes the §4 permission-advertising bug): - - **The adapter moved** `src/session/code-mode.ts` → `src/tool/code-mode.ts` and is now a +restructure; fixes the section 4 permission-advertising bug): + - **The adapter moved** `src/session/code-mode.ts` -> `src/tool/code-mode.ts` and is now a registry-resident tool service on the TaskTool precedent: `CodeModeTool = Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`, and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by @@ -852,18 +852,18 @@ restructure; fixes the §4 permission-advertising bug): - **Description split on the `describeTask` precedent**: the tool's static base description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()` appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog, - `catalogInstructions` in the adapter) at the same composition point as task — so + `catalogInstructions` in the adapter) at the same composition point as task - so `plugin.trigger("tool.definition")` sees the base description first. - **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from `llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)` - (a record filter over `Permission.disabled` — only a hard `deny` with pattern `"*"` + (a record filter over `Permission.disabled` - only a hard `deny` with pattern `"*"` hides a tool; ask-level rules stay fully visible and prompt at call time) and `resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters with the merged agent+session ruleset that `SessionTools.resolve` passes into the registry before building the catalog/search index; `execute` rebuilds the runtime per execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset (`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge - `SessionTools.context` wires into `ctx.ask`) — a denied tool is not dispatchable + `SessionTools.context` wires into `ctx.ask`) - a denied tool is not dispatchable even if the model guesses its name and yields the normal unknown-tool diagnostic. Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives at request-prep after descriptions are built and has no child-call equivalent. @@ -872,7 +872,7 @@ restructure; fixes the §4 permission-advertising bug): limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired through `Tool.Context` exactly like every registry tool). - **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation, - permission ruleset) was considered and deliberately skipped — the per-turn rebuild is + permission ruleset) was considered and deliberately skipped - the per-turn rebuild is cheap (grouping + string rendering); revisit only if profiling shows it matters. - **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration} .test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct @@ -884,20 +884,20 @@ restructure; fixes the §4 permission-advertising bug): excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green. -**Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the §4 "plugin hooks skip +**Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the section 4 "plugin hooks skip child calls" gap): - `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool" - middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook → + middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook -> permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's - `ctx.ask`) → dispatch through the ai-sdk tool's execute inside the `Tool.execute` - tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) → + `ctx.ask`) -> dispatch through the ai-sdk tool's execute inside the `Tool.execute` + tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) -> plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute - resolved with; each caller keeps its own shaping edge — the legacy per-MCP loop in + resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop in `SessionTools.resolve` applies its existing model-facing shaping/truncation, code mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool, not about sessions or code mode. - - **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result — + - **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result - which is exactly what the legacy loop always passed (the raw `CallToolResult`, not the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit and the hook payload cannot drift between callers. No callback/edge-firing design @@ -905,11 +905,11 @@ child calls" gap): - **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to the entry key; `n` = per-execution counter starting at 1, shared across all child - calls in one program). callID is an opaque string — nothing parses it. The ai-sdk + calls in one program). callID is an opaque string - nothing parses it. The ai-sdk `toolCallId` (`options.toolCallId`) stays each caller's existing value (`ctx.callID ?? entry.key` for code mode). - **Child-scoped hook failures**: `CodeModeTool` (which now also yields - `Plugin.Service`) wraps the whole child call — hooks, ask, dispatch — in + `Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in `toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin hook failure fails ONLY that child call as a catchable in-program `toolError`; other calls in the same program keep running and interruption still propagates as @@ -917,10 +917,10 @@ child calls" gap): - **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a failing before hook is caught in-program, gates dispatch, and leaves the outer - execute ok) — both code-mode harnesses gained a `Plugin.Service` mock (pass-through + execute ok) - both code-mode harnesses gained a `Plugin.Service` mock (pass-through trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins `SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer): - flag on + MCP tools → `execute` present, raw MCP keys suppressed; flag off → raw + flag on + MCP tools -> `execute` present, raw MCP keys suppressed; flag off -> raw keys present, `execute` absent; and the legacy raw-MCP execute fires before/after hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter 45 + 16, session/tool/permission all green; this package untouched (211 pass). @@ -929,8 +929,8 @@ child calls" gap): verified real with failing tests before fixing): - **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema` emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123` - rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper — - bare identifiers stay bare, everything else is `JSON.stringify`-quoted — applied in the + rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper - + bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the single `field` closure both the compact and pretty renderings share. The `identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s bracket-notation `toolExpression` imports it: one source of truth for "is this a bare @@ -947,7 +947,7 @@ verified real with failing tests before fixing): diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`; `d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`; - compound assignment validates against a `compoundOperators` set (`+=` … `>>>=`) and + compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`) keep their separate short-circuit path (`evaluateLogicalAssignment`), and both assignment call sites still wrap results in `boundedData`. Deliberate side effect: @@ -960,34 +960,34 @@ verified real with failing tests before fixing): ## 4. Remaining work (detailed TODO) -### Next DSL-expansion pass (done — see the DSL-expansion pass entry in §3) -Batch these together — per user direction: important, but deliberately deferred to one +### Next DSL-expansion pass (done - see the DSL-expansion pass entry in section 3) +Batch these together - per user direction: important, but deliberately deferred to one focused interpreter-surface pass rather than picked off piecemeal. - [x] Medium-tier JS parity items deferred from the original audit: caught errors are plain - `{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value — + `{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value - `x instanceof Error` is unsupported syntax); `splice` (still a "rewrite using map/filter" hint) and array `entries()/keys()/values()`; `localeCompare`/`normalize`/`trimLeft`/`trimRight`; friendlier regex-y error messages. - (`fill`/`copyWithin` — which the hint set also covered — were implemented too since + (`fill`/`copyWithin` - which the hint set also covered - were implemented too since they are trivial host delegations, so the hint set is gone entirely.) - [x] `Date`/`Map`/`Set`/`RegExp` values passing through `Object.*` helpers and coercion checkpoints take their JSON forms (e.g. `Object.values({ d: date })` yields the ISO - string, not the Date — calling `.getTime()` on it then fails). Currently deliberate + string, not the Date - calling `.getTime()` on it then fails). Currently deliberate (documented in README) but flagged as important: fix in this pass by letting sandbox values survive `Object.*`/spread checkpoints instead of JSON-serializing them. -- [x] `console.log(NaN)` prints `"null"` (goes through the boundary chokepoint) — could +- [x] `console.log(NaN)` prints `"null"` (goes through the boundary chokepoint) - could special-case number formatting in `formatConsoleArgument`. - [x] Sandbox values nested inside logged containers print `[CodeMode reference]` - (`console.log({ m: map })`) — could deep-format instead. + (`console.log({ m: map })`) - could deep-format instead. ### Next iteration: text-result handling (deliberate follow-up, user-directed) - [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the server sends it, else joined text as a plain string (the program JSON.parses it, guided by a workflow step). Considered and deferred: (a) conservative boundary - auto-parse (text starting with `{`/`[` that parses cleanly becomes an object) — + auto-parse (text starting with `{`/`[` that parses cleanly becomes an object) - rejected for now as potentially confusing (type flips; program sees something other than what the tool sent); (b) raw-envelope passthrough with the envelope shape - stamped into every output schema — rejected (more digging per call, verbose + stamped into every output schema - rejected (more digging per call, verbose signatures). Result quality is dominated by whether servers declare output schemas; revisit once real usage shows which failure modes matter. @@ -996,29 +996,29 @@ Current instructions say "usual Array/String/Object/Math/JSON methods," but the intentionally a subset. Keep CodeMode focused on orchestration and data shaping, not a full host runtime, but close the high-friction gaps models are likely to reach for. -- [ ] **P0: tighten wording first** — change instructions/docs to say "common stdlib subset" +- [ ] **P0: tighten wording first** - change instructions/docs to say "common stdlib subset" until the surface is broader. This avoids misleading the model into assuming every JS helper exists. -- [ ] **P1: URL parsing helpers** — add `URL` and `URLSearchParams`. These are high-value for +- [ ] **P1: URL parsing helpers** - add `URL` and `URLSearchParams`. These are high-value for tool orchestration (query strings, ids in URLs, API links), deterministic, and do not add ambient host authority. -- [ ] **P2: Math completion** — add the missing standard deterministic `Math` methods +- [ ] **P2: Math completion** - add the missing standard deterministic `Math` methods (`sin`/`cos`/`tan`, inverse/hyperbolic variants, `atan2`, `log1p`, `expm1`, `imul`, `fround`, `clz32`, etc.). Decide explicitly on `Math.random`: likely acceptable because `Date.now()` is already exposed, but document the nondeterminism if enabled. -- [ ] **P3: base64 helpers** — add string-only `atob`/`btoa` equivalents. Useful for API/tool +- [ ] **P3: base64 helpers** - add string-only `atob`/`btoa` equivalents. Useful for API/tool payload cleanup and does not require opening the broader binary boundary. -- [ ] **P4: small crypto helper** — consider `crypto.randomUUID()` only, not full `crypto`. +- [ ] **P4: small crypto helper** - consider `crypto.randomUUID()` only, not full `crypto`. UUID generation is a common orchestration need; broader crypto can wait until there is a concrete use case and a clear capability boundary. -- [ ] **P5: text/binary primitives** — consider `TextEncoder`/`TextDecoder` first, then +- [ ] **P5: text/binary primitives** - consider `TextEncoder`/`TextDecoder` first, then `ArrayBuffer`/typed arrays/`DataView`/`Blob`/`File` only with an explicit boundary design (serialization, size limits, and how values cross tool args/results). This is reasonable but lower priority than URL/base64 because CodeMode is still plain-data oriented. -- [ ] **P6: date/formatting conveniences** — consider `Date` setters and common formatting +- [ ] **P6: date/formatting conveniences** - consider `Date` setters and common formatting helpers (`toUTCString`, maybe `Intl` later). Lower priority; most orchestration can use existing getters, `Date.parse`, `Date.UTC`, and ISO strings. -- [ ] **P7: environment/config access** — do not expose raw `process.env` as a global ambient +- [ ] **P7: environment/config access** - do not expose raw `process.env` as a global ambient authority. If this becomes useful, add an explicit host-provided/whitelisted capability (for example a small env/config tool or injected read-only object) so secrets are not accidentally exposed to arbitrary CodeMode programs. @@ -1029,34 +1029,34 @@ orchestration use case. ### Wiring-review findings (subagent code review of the OpenCode integration, triaged) Pre-PR fixes (user-approved cut): -- [x] **Cancellation does not interrupt the interpreter** — the no-limits rationale claimed +- [x] **Cancellation does not interrupt the interpreter** - the no-limits rationale claimed "user cancel interrupts the execution fiber," but `tools.ts` runs tools via - `run.promise` → `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring; + `run.promise` -> `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring; on cancel the ai-sdk abandons the promise, child MCP calls abort (they hold - `ctx.abort`) but the interpreter fiber spun on — `while(true){}` or a try/catch + `ctx.abort`) but the interpreter fiber spun on - `while(true){}` or a try/catch loop was uncancellable with no timeout backstop. Verified by hand, not just the reviewer. FIXED in the adapter: `Effect.raceFirst(runtime.execute(code), cancelled)` where `cancelled` is an `Effect.callback` abort-signal watcher (listener removed on - interruption) resuming with an `ok: false` "Execution cancelled." result — the abort + interruption) resuming with an `ok: false` "Execution cancelled." result - the abort winning the race interrupts the execution fiber (interpreter auto-yield makes busy loops preemptible, same mechanism as timeoutMs) and returning a value keeps the runner's post-abort `completeToolCall` bookkeeping on its normal path. A pre-aborted signal short-circuits at entry before the program starts (racing alone still lets the loser run its first steps). Tests: +2 adapter (child call triggers abort - deterministically then the program enters `while(true){}` — would hang if - interruption broke; pre-aborted signal runs nothing). Adapter suite 34 → 36. - (Wiring abort→interrupt into the shared `tools.ts` runner for ALL tools remains a + deterministically then the program enters `while(true){}` - would hang if + interruption broke; pre-aborted signal runs nothing). Adapter suite 34 -> 36. + (Wiring abort->interrupt into the shared `tools.ts` runner for ALL tools remains a worthwhile separate change.) -- [x] **Permission-denied/disabled MCP tools are still advertised in the catalog** — the +- [x] **Permission-denied/disabled MCP tools are still advertised in the catalog** - the non-code-mode path filters them from the model's view (`llm/request.ts:208-213`); code mode builds the catalog from all of `mcp.tools()`, so the model is invited to call tools that can only fail at permission time, and per-message `tools[key]=false` disabling has no child-call equivalent. Fix: filter the catalog with the same ruleset. - DONE (see the "Registry promotion + permission-aware catalog" entry in §3): the + DONE (see the "Registry promotion + permission-aware catalog" entry in section 3): the shared `Permission.visibleTools` predicate filters both the appended catalog/description (`describeCodeMode`, agent ruleset) and the execute-time tool - tree (merged agent+session ruleset) — hard-denied tools are neither advertised nor + tree (merged agent+session ruleset) - hard-denied tools are neither advertised nor dispatchable. Ask-level tools stay visible/callable. Per-message `tools[key] === false` remains a documented gap by design (it arrives at request-prep, after descriptions are built). @@ -1065,30 +1065,30 @@ Pre-PR fixes (user-approved cut): `tools.ts:26` (AGENTS.md violation). Add footer + import the projection. DONE: added `export * as SessionCodeMode from "./code-mode"` footer; `tools.ts` now imports the named `SessionCodeMode` projection. -- [x] Trivial: latent `groupByServer` fallback bug — `key.slice(0, key.indexOf("_"))` is +- [x] Trivial: latent `groupByServer` fallback bug - `key.slice(0, key.indexOf("_"))` is `slice(0, -1)` when no underscore (unreachable today; guard or drop); dead - `CODE_MODE_TOOL` export (integration points hardcode `"execute"` — use it or inline + `CODE_MODE_TOOL` export (integration points hardcode `"execute"` - use it or inline it). DONE: no-underscore key now falls back to the whole key (test pins it); the four `title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`. Post-MVP (logged, not blocking an experimental flag): -- [x] **Plugin `tool.execute.before/after` hooks skip child calls** — legacy MCP +- [x] **Plugin `tool.execute.before/after` hooks skip child calls** - legacy MCP registration fires them per tool (`tools.ts:419-441`); under code mode only the outer `execute` fires them, so auditing/intercepting plugins silently lose MCP coverage when the flag flips. - DONE (see the "Shared MCP invocation middle" entry in §3): both paths now run - `McpInvoke.invoke` (`src/mcp/invoke.ts`) — hooks AND the `Tool.execute` span fire + DONE (see the "Shared MCP invocation middle" entry in section 3): both paths now run + `McpInvoke.invoke` (`src/mcp/invoke.ts`) - hooks AND the `Tool.execute` span fire for child calls with synthetic `${parentCallID}/${n}` callIDs; hook failures are child-scoped, catchable in-program errors. -- [x] Description/preview rebuilt every assistant turn — `registry.tools()` re-runs +- [x] Description/preview rebuilt every assistant turn - `registry.tools()` re-runs `groupByServer` + a throwaway `CodeMode.make(...).instructions()` per turn (`describeCodeMode`). DECIDED as an explicit non-goal: memoizing the catalog builder keyed on (ToolsChanged generation, permission ruleset) was considered and - deliberately skipped — the per-turn rebuild is cheap (grouping + string + deliberately skipped - the per-turn rebuild is cheap (grouping + string rendering); revisit only if profiling shows it matters. A second `CodeMode.make` per execution is inherent (description precedes execution). -- [ ] Child permission rejection round-trips through the defect channel — `ctx.ask` +- [ ] Child permission rejection round-trips through the defect channel - `ctx.ask` defect (`tools.ts:90` orDie) recovered via `catchCause` + `Cause.squash` (`code-mode.ts:238-245`). Works, interrupts preserved, but fragile coupling; exposing the typed rejection on `Tool.Context.ask` would be cleaner. @@ -1113,26 +1113,26 @@ Post-MVP (logged, not blocking an experimental flag): blocks carry no filename (mime + data only) so the generic `[N images attached to the result]` stays, but `resource`/`resource_link` blocks have URIs/names we could surface, e.g. `[2 files attached: chart.png, data.csv]`. Minor. -- [x] Truncation layering decided (user direction): the OPPOSITE of killing the outer layer — +- [x] Truncation layering decided (user direction): the OPPOSITE of killing the outer layer - CodeMode truncation off in OpenCode (`maxOutputBytes` lost its default; absent = no truncation, uniform with the other two limits), native tool-output truncation is the single active layer (verified: `execute` flows through `tool.ts` `wrap()` like any - normal tool, no exemption). See the §3 entry. + normal tool, no exemption). See the section 3 entry. - [x] Flaky wall-clock assertion removed from `test/promise.test.ts`: the parallelism test now relies solely on the deterministic `trace.maxActive > 1` counter (which proves - true temporal overlap). The timeout tests were never flaky — 100ms timeout vs 60s + true temporal overlap). The timeout tests were never flaky - 100ms timeout vs 60s tool sleeps (600x margin) with counter-based assertions. - [ ] Attachment propagation believed correct but unverified end-to-end at the OpenCode - wiring layer (codemode strips → `Tool.ExecuteResult.attachments` → processor - normalizes → `FilePart`s visible to the model). Code-reviewed as sound; confirm with + wiring layer (codemode strips -> `Tool.ExecuteResult.attachments` -> processor + normalizes -> `FilePart`s visible to the model). Code-reviewed as sound; confirm with one interactive session (an image-returning MCP tool) when convenient. Same session can eyeball TUI child-call rendering via `metadata.toolCalls`. - [x] Commit hygiene: all work committed and pushed on `codemode-v2` as six commits, in - generic-package + OpenCode-integration pairs (waves 0–5; Fixes 4–9; DSL pass + + generic-package + OpenCode-integration pairs (waves 0-5; Fixes 4-9; DSL pass + error names + truncation layering). Future work: commit only when explicitly asked; push with `--no-verify` per repo convention. The scratch `.opencode/opencode.jsonc` stays uncommitted. -- [ ] MVP scope decided (user direction): the interactive e2e eyeball is NOT required — +- [ ] MVP scope decided (user direction): the interactive e2e eyeball is NOT required - remaining pre-PR work is essentially just opening the PR. Attachment-propagation verification (below) stays parked as post-MVP. @@ -1141,28 +1141,28 @@ Post-MVP (logged, not blocking an experimental flag): ## 5. Context and gotchas for whoever picks this up - **Motivating failure (why forgiving semantics + prompting matter):** in a real transcript, - the model wrote `me.result?.login ?? me.result` where the tool result was a JSON *string* — + the model wrote `me.result?.login ?? me.result` where the tool result was a JSON *string* - the old strict interpreter threw (`String property 'login' is not available`); then the model returned a raw 105KB payload, which native truncation dumped to a file, costing a subagent round-trip to extract one number. Interpreter forgiveness stops the crashes; Wave 4 prompting stops the payload dumping. Both are needed. - Realistically **all MCP tools render `Promise`** (no outputSchema), so the instructions prose is the only lever for result-shape behavior in the dominant case. -- **`copyIn` has two roles, split by a mode flag** (DSL-expansion pass): host↔sandbox - boundary (default mode — final result, tool arguments, `JSON.stringify`, tool-result +- **`copyIn` has two roles, split by a mode flag** (DSL-expansion pass): host<->sandbox + boundary (default mode - final result, tool arguments, `JSON.stringify`, tool-result intake; sandbox value types serialize to JSON forms) AND intra-sandbox data checkpoint - (`boundedData` = `copyIn(value, label, true)` — sandbox value instances pass through by + (`boundedData` = `copyIn(value, label, true)` - sandbox value instances pass through by reference as leaves, everything else keeps the same plain-data validation). If you add a new value type, follow the Wave 1b-i pattern: class in `values.ts`, opaque-by-default via `isRuntimeReference`, explicit carve-outs, JSON form in `copyIn`'s boundary mode plus - pass-through in its preserving mode, console formatting (`formatConsoleValue`), tests — + pass-through in its preserving mode, console formatting (`formatConsoleValue`), tests - and make sure the `Object.*` helpers treat it as an empty object so class fields never leak. - The interpreter throws synchronously inside `Effect.gen`/`Effect.sync` freely; everything is - normalized by `catchCause` → `normalizeError` into `Diagnostic` data. Program failures are + normalized by `catchCause` -> `normalizeError` into `Diagnostic` data. Program failures are **data, never Effect failures**; only interruption propagates. - `parseProgram` wraps source in `async function __codemode__() { ... }`, transpiles TS, then - slices between the first `{` and last `}` — line/col diagnostics are offset accordingly + slices between the first `{` and last `}` - line/col diagnostics are offset accordingly (`sourceLocation`). Don't inject prologue code; it breaks the offsets. - OpenCode wraps every tool's output with auto-truncation (`Tool.define` wrapper, `truncate.output`, 2000 lines / 50KB, saves full output to disk and appends a hint) unless @@ -1171,18 +1171,18 @@ Post-MVP (logged, not blocking an experimental flag): v4-only APIs (`Schema.Decoder`, `Schema.toJsonSchemaDocument`, `Context.Service`, `Cause.hasInterruptsOnly`, `Effect.timeoutOrElse`). The effect-smol checkout referenced in the workspace is the implementation source of truth for v4 behavior questions. -- File map (this package): `src/codemode.ts` — types/limits/parser/Interpreter/execute/make; - `src/tool-runtime.ts` — tool tree, `copyIn`/`copyOut`, search/discovery, invoke path; - `src/tool.ts` — `Tool.make` + JSON-Schema→TS rendering; `src/values.ts` — sandbox value - types; `src/tool-error.ts` — `ToolError`; tests in `test/{codemode,parity,stdlib}.test.ts`. +- File map (this package): `src/codemode.ts` - types/limits/parser/Interpreter/execute/make; + `src/tool-runtime.ts` - tool tree, `copyIn`/`copyOut`, search/discovery, invoke path; + `src/tool.ts` - `Tool.make` + JSON-Schema->TS rendering; `src/values.ts` - sandbox value + types; `src/tool-error.ts` - `ToolError`; tests in `test/{codemode,parity,stdlib}.test.ts`. - OpenCode file map (integration points): `src/tool/code-mode.ts` (the adapter, now a - registry tool service — `CodeModeTool` + `catalogInstructions`; formerly + registry tool service - `CodeModeTool` + `catalogInstructions`; formerly `src/session/code-mode.ts`); `src/tool/registry.ts` (`describeCodeMode`, enablement in `tools()`, `MCP.node` dep); `src/session/tools.ts` (raw-MCP-registration suppression when the flag is on); `src/permission/index.ts` (`Permission.visibleTools`, the shared visibility predicate, also used by `src/session/llm/request.ts` `resolveTools`); `src/mcp/index.ts` (`MCP.tools()`/`MCP.defs()`); `src/mcp/catalog.ts` (`convertTool`, `server_tool` naming); `src/tool/tool.ts` (`ExecuteResult.attachments`, truncation - wrapper); `src/session/message-v2.ts` (attachments → vision); + wrapper); `src/session/message-v2.ts` (attachments -> vision); `packages/tui/src/routes/session/index.tsx` (`Execute` progress component); `src/effect/runtime-flags.ts` (feature flag). diff --git a/packages/codemode/src/codemode.ts b/packages/codemode/src/codemode.ts index 5f879a294f..77478e7249 100644 --- a/packages/codemode/src/codemode.ts +++ b/packages/codemode/src/codemode.ts @@ -65,7 +65,7 @@ export type ExecuteOptions = {}> = { /** Source for one program in the supported JavaScript subset. */ code: string /** Explicit tool tree exposed to the program as `tools`. */ - tools?: Tools & ToolTree + tools?: Tools & ToolTree> /** Per-execution overrides for the default resource limits. */ limits?: ExecutionLimits /** Observes decoded tool input immediately before tool execution. */ @@ -224,7 +224,6 @@ class IntrinsicReference { ) {} } -// A read-only computed member (e.g. `str.length`, a character index) — not assignable. class ComputedValue { constructor(readonly value: unknown) {} } @@ -249,7 +248,6 @@ class GlobalMethodReference { constructor(readonly namespace: GlobalNamespaceName | "Number" | "String", readonly name: string) {} } -// A built-in callable global (`Number`, `String`, `Boolean`, `parseInt`, `parseFloat`). class CoercionFunction { constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {} } @@ -258,17 +256,11 @@ class ProgramThrow { constructor(readonly value: unknown) {} } -// A bound error constructor global (`Error`, `TypeError`, ...): callable with or without -// `new`, and the recognized right-hand side of `x instanceof Error`. class ErrorConstructorReference { constructor(readonly name: string) {} } -// Error values stay plain `{ name, message }` data objects — they stringify/serialize exactly -// as before — but carry their constructor name on a non-enumerable symbol key so `instanceof -// Error` can recognize them. Object.entries/JSON walks (copyIn/copyOut, spread, stringify) -// never see the brand, and losing it on spread/boundary copies matches JS, where a spread -// error loses its prototype too. +// Non-enumerable so spread/copyOut preserve the plain `{ name, message }` data shape. const ErrorBrand: unique symbol = Symbol("codemode.error") const brandError = (errorValue: SafeObject, name: string): SafeObject => { @@ -347,7 +339,7 @@ const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit") const supportedSyntaxMessage = - "Supported orchestration syntax: tools.* calls (they return promises — resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported — use await with try/catch)." + "Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)." const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError => new InterpreterRuntimeError(`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`, node, "UnsupportedSyntax", [supportedSyntaxMessage]) @@ -355,7 +347,7 @@ const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError /** How many eagerly forked tool calls may run at once. Fixed; not a configurable knob. */ const TOOL_CALL_CONCURRENCY = 8 -/** Console formatting recursion ceiling; deeper values render as "…". Fixed; not a knob. */ +/** Console formatting recursion ceiling; deeper values render as "...". Fixed; not a knob. */ const MAX_CONSOLE_DEPTH = 32 const validateLimit = (name: keyof ExecutionLimits, value: Value, minimum: number): Value => { @@ -365,7 +357,7 @@ const validateLimit = (name: keyof ExecutionLi return value } -// No limit has a default: absent means no timeout / unlimited calls / no output truncation — +// No limit has a default: absent means no timeout / unlimited calls / no output truncation - // budgets are host policy, not library policy. A host without its own output bounding should // pass maxOutputBytes explicitly, or oversized results flood model context. const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({ @@ -379,7 +371,7 @@ class InterpreterRuntimeError extends Error { /** * The constructor name a program observes when it catches this failure (`caught.name`, and * the brand behind `caught instanceof SyntaxError` etc.). "Error" unless the failing - * operation names a standard type in real JS — e.g. JSON.parse and invalid regex patterns + * operation names a standard type in real JS - e.g. JSON.parse and invalid regex patterns * throw SyntaxError, an unknown identifier is a ReferenceError, a bad normalize form is a * RangeError. */ @@ -569,21 +561,7 @@ const normalizeError = (error: unknown): Diagnostic => { } } -// The plain value a program observes for a settled failure — shared by `catch` bindings, -// `Promise.allSettled` rejection reasons, and race-loser diagnostics. A thrown program value -// passes through as-is (so `throw new Error(m)` yields its `{ name, message }` object); every -// other failure becomes a plain `{ name, message }` object, error-branded so `caught -// instanceof Error` is true for interpreter and tool failures too. When a host failure is a -// real Error whose constructor name is one of the standard seven (e.g. JSON.parse throwing a -// SyntaxError), that name is carried through — both as `caught.name` and as the brand, so -// `caught instanceof SyntaxError` matches real JS. Interpreter diagnostics carry the name the -// equivalent real-JS failure would have (`errorName`, "Error" unless the throw site says -// otherwise); tool failures and internal error classes are plain "Error" — internal class -// names never leak. -// Interpreter errors use their raw message so the program never sees the transpiled-source -// "(line N, col N)" coordinates that normalizeError appends — without disturbing a host/tool -// message that legitimately ends that way. Other error kinds carry no appended location, so -// normalizeError is used as-is. +// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers. const caughtErrorValue = (thrown: unknown): unknown => { if (thrown instanceof ProgramThrow) return thrown.value if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message) @@ -591,17 +569,6 @@ const caughtErrorValue = (thrown: unknown): unknown => { return createErrorValue(name, normalizeError(thrown).message) } -// ── Built-in method/global implementations ─────────────────────────────────── -// These mirror the corresponding JavaScript operations over Data Values. They are -// pure (string/Object/Math/JSON/coercion) and so live as free functions; array -// Methods that run CodeMode callbacks live on the interpreter (they need invokeFunction). - -// The intra-sandbox data checkpoint: copies a value through `copyIn` in preserving mode, -// which validates the plain-data contract (depth, circularity, plain objects only, blocked -// properties) while keeping sandbox value instances (Date/RegExp/Map/Set) alive — so values -// flowing through `Object.*` helpers, coercion inputs, and other in-sandbox checkpoints stay -// fully usable. Only the HOST boundary (final result, tool-call arguments, JSON.stringify) -// serializes them to JSON forms via the default `copyIn` mode. const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true) const isRuntimeReference = (value: unknown): boolean => @@ -675,7 +642,7 @@ const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => } if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise // Number/String/Boolean wrap primitives in JS; no boxed values exist in CodeMode, so - // `x instanceof Number` is always false — exactly what it is for primitives in JS. + // `x instanceof Number` is always false - exactly what it is for primitives in JS. if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) { return false } @@ -718,7 +685,7 @@ const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = " // A host match result as a sandbox value: a plain array of the full match and captures, with // `index` and named `groups` attached as own array properties (readable, and dropped at data -// boundaries exactly like JSON.stringify drops them in JS). `input` is omitted — it duplicates +// boundaries exactly like JSON.stringify drops them in JS). `input` is omitted - it duplicates // the whole subject string per match. const matchToValue = (match: RegExpMatchArray): Array => { const result: Array = Array.from(match, (group) => group) @@ -851,7 +818,7 @@ const invokeStringMethod = (value: string, name: string, args: Array, n case "substring": result = value.substring(optNum(0) ?? 0, optNum(1)); break case "substr": result = value.substr(optNum(0) ?? 0, optNum(1)); break // JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value - // (normalized to null only at the data boundary — see copyOut), so return it as-is. + // (normalized to null only at the data boundary - see copyOut), so return it as-is. case "charCodeAt": result = value.charCodeAt(optNum(0) ?? 0); break case "codePointAt": result = value.codePointAt(optNum(0) ?? 0); break case "toString": result = value; break @@ -947,7 +914,7 @@ const invokeObjectMethod = (name: string, args: Array, node: AstNode): const requireObject = (): Record => { const value = boundedData(args[0], `Object.${name} input`) // Sandbox values (Date/RegExp/Map/Set) have no own enumerable properties in JS, so the - // Object.* helpers see them as empty objects — never their interpreter internals. + // Object.* helpers see them as empty objects - never their interpreter internals. if (isSandboxValue(value)) return {} if (value === null || typeof value !== "object" || Array.isArray(value)) { throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node) @@ -961,7 +928,7 @@ const invokeObjectMethod = (name: string, args: Array, node: AstNode): switch (name) { case "keys": { // Object.keys(array) yields index strings (["0", "1", ...]) exactly as in JS; objects - // yield their own enumerable keys. (Tool references never reach here — the interpreter + // yield their own enumerable keys. (Tool references never reach here - the interpreter // resolves them against the host tool tree first.) const value = boundedData(args[0], "Object.keys input") if (isSandboxValue(value)) return [] @@ -1054,7 +1021,7 @@ const invokeJsonMethod = (name: string, args: Array, node: AstNode): un parsed = JSON.parse(text) } catch (error) { // The engine reason is derived from the program-supplied string (token/position), so - // it is safe to surface — and the position is exactly what a model needs to fix it. + // it is safe to surface - and the position is exactly what a model needs to fix it. throw new InterpreterRuntimeError( `JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`, node, @@ -1289,7 +1256,7 @@ class Interpreter { globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) }) } // NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data - // boundary — see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. + // boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`. globalScope.set("NaN", { mutable: false, value: NaN }) globalScope.set("Infinity", { mutable: false, value: Infinity }) } @@ -1324,7 +1291,7 @@ class Interpreter { if (!returned) value = self.lastValue // The program body runs inside an implicit async function, so a returned promise - // resolves before crossing the data boundary — `return tools.ns.tool(...)` works + // resolves before crossing the data boundary - `return tools.ns.tool(...)` works // without an explicit await, exactly as in JS. if (value instanceof SandboxPromise) value = yield* self.settlePromise(value) yield* self.drainPendingSettlements() @@ -1333,7 +1300,7 @@ class Interpreter { } // Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so - // their work completes before the execution ends — mirroring a JS runtime waiting on + // their work completes before the execution ends - mirroring a JS runtime waiting on // in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection // diagnostic (interrupted calls, e.g. Promise.race losers, are ignored). private drainPendingSettlements(): Effect.Effect { @@ -1347,7 +1314,7 @@ class Interpreter { `Unhandled rejection from an un-awaited tool call: ${failure.message}`, undefined, failure.kind, - ["Await tool calls — `const result = await tools.ns.tool(...)` — so failures can be caught and handled."], + ["Await tool calls - `const result = await tools.ns.tool(...)` - so failures can be caught and handled."], ) } }) @@ -1355,8 +1322,8 @@ class Interpreter { // Eagerly starts a tool call on a supervised child fiber (so the execution timeout and // scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a - // first-class promise value. `startImmediately` makes the runtime admit the call — charging - // the tool-call budget and firing onToolCallStart — at the call site, before any await. + // first-class promise value. `startImmediately` makes the runtime admit the call - charging + // the tool-call budget and firing onToolCallStart - at the call site, before any await. private createToolCallPromise(path: ReadonlyArray, args: Array): Effect.Effect { const self = this return Effect.map( @@ -1738,7 +1705,7 @@ class Interpreter { // Own enumerable string keys of a value, shared by `for...in` and `Object.keys` over tool // references: plain data objects enumerate their own keys, arrays their index strings (plus - // any own non-index properties, e.g. match results' index/groups — exactly Object.keys in + // any own non-index properties, e.g. match results' index/groups - exactly Object.keys in // JS), and a tool reference the namespace/tool names at its path in the host tool tree. // Returns undefined for everything else so callers can raise a contextual error. private enumerableKeys(value: unknown): Array | undefined { @@ -1763,7 +1730,7 @@ class Interpreter { // Keys are snapshotted up front (mutation during iteration is safe): plain objects // enumerate their own keys, arrays their index strings, and tool references the - // namespace/tool names at that node — the same enumeration Object.keys performs. + // namespace/tool names at that node - the same enumeration Object.keys performs. // Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather // than real JS's surprising behavior (indices for strings, zero iterations for // Maps/Sets/null): the hint points at the constructs that do what the program means. @@ -1864,7 +1831,7 @@ class Interpreter { return Effect.failCause(cause) } - // The program sees a plain { message } error (or the thrown value itself) — see + // The program sees a plain { message } error (or the thrown value itself) - see // caughtErrorValue, shared with Promise.allSettled rejection reasons. const caught = caughtErrorValue(Cause.squash(cause)) const parameter = getOptionalNode(handler, "param") @@ -1923,7 +1890,7 @@ class Interpreter { return } - // Default values: `x = expr` / `{ a = 1 }` — the default is evaluated only when the value is undefined. + // Default values: `x = expr` / `{ a = 1 }` - the default is evaluated only when the value is undefined. if (pattern.type === "AssignmentPattern") { const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node) @@ -1939,7 +1906,7 @@ class Interpreter { for (const propertyValue of getArray(pattern, "properties")) { const property = asNode(propertyValue, "properties") - // Object rest: `{ a, ...others }` — gather the not-yet-consumed own keys. + // Object rest: `{ a, ...others }` - gather the not-yet-consumed own keys. if (property.type === "RestElement") { const rest: SafeObject = Object.create(null) as SafeObject for (const [key, item] of Object.entries(value as SafeObject)) { @@ -1972,7 +1939,7 @@ class Interpreter { for (const [index, item] of getArray(pattern, "elements").entries()) { if (item === null) continue const element = asNode(item, `elements[${index}]`) - // Array rest: `[head, ...tail]` — binds the remaining elements (must be last). + // Array rest: `[head, ...tail]` - binds the remaining elements (must be last). if (element.type === "RestElement") { yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element) break @@ -2051,7 +2018,7 @@ class Interpreter { const self = this if (name === "Promise") { throw new InterpreterRuntimeError( - "new Promise(...) is not supported in CodeMode; tool calls already return promises — call the tool and await the result.", + "new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.", node, "UnsupportedSyntax", [supportedSyntaxMessage], @@ -2086,7 +2053,7 @@ class Interpreter { if (typeof arg === "string") return new SandboxDate(Date.parse(arg)) return new SandboxDate(Number.NaN) } - // new Date(year, month, day?, hours?, ...) — local-time component form. + // new Date(year, month, day?, hours?, ...) - local-time component form. const parts = args.map((arg) => coerceToNumber(arg)) return new SandboxDate(new Date(...(parts as [number, number])).getTime()) } @@ -2161,8 +2128,8 @@ class Interpreter { const operator = getString(node, "operator") const self = this return Effect.gen(function*() { - const lhs = (yield* self.evaluateExpression(getNode(node, "left"))) as any - const rhs = (yield* self.evaluateExpression(getNode(node, "right"))) as any + const lhs = yield* self.evaluateExpression(getNode(node, "left")) + const rhs = yield* self.evaluateExpression(getNode(node, "right")) // Like `typeof`, `instanceof` observes any value without coercing it (a promise or // function operand is a legitimate question, not an error), so it is handled before // the data-only operand check. @@ -2176,7 +2143,7 @@ class Interpreter { * semantics. Shared by binary expressions and compound assignment (`x op= y` must behave * exactly like `x = x op y`, coercion included). */ - private applyBinaryOperator(operator: string, lhs: any, rhs: any, node: AstNode): unknown { + private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown { if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) { throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue") } @@ -2184,37 +2151,37 @@ class Interpreter { // "No default value" TypeError when an operator coerces them. Coerce to their JS string // form first (as String(x) / template literals do) so operators behave like JavaScript. // A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value - // for arithmetic and ordering — so `end - start` and `a < b` work as in JS. + // for arithmetic and ordering - so `end - start` and `a < b` work as in JS. // Identity (=== / !==) and the right operand of `in` keep their raw object value. const coerceOperand = (operand: unknown): unknown => { if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand } const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object" - const l = coerceOperand(lhs) as any - const r = coerceOperand(rhs) as any + const l = coerceOperand(lhs) + const r = coerceOperand(rhs) switch (operator) { - case "+": return l + r - case "-": return l - r - case "*": return l * r - case "/": return l / r - case "%": return l % r - case "**": return l ** r + case "+": return (l as string) + (r as string) + case "-": return (l as number) - (r as number) + case "*": return (l as number) * (r as number) + case "/": return (l as number) / (r as number) + case "%": return (l as number) % (r as number) + case "**": return (l as number) ** (r as number) // Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces. case "==": return bothObjects ? lhs === rhs : l == r case "===": return lhs === rhs case "!=": return bothObjects ? lhs !== rhs : l != r case "!==": return lhs !== rhs - case "<": return l < r - case "<=": return l <= r - case ">": return l > r - case ">=": return l >= r - case "&": return l & r - case "|": return l | r - case "^": return l ^ r - case "<<": return l << r - case ">>": return l >> r - case ">>>": return l >>> r + case "<": return (l as string) < (r as string) + case "<=": return (l as string) <= (r as string) + case ">": return (l as string) > (r as string) + case ">=": return (l as string) >= (r as string) + case "&": return (l as number) & (r as number) + case "|": return (l as number) | (r as number) + case "^": return (l as number) ^ (r as number) + case "<<": return (l as number) << (r as number) + case ">>": return (l as number) >> (r as number) + case ">>>": return (l as number) >>> (r as number) case "in": if (rhs === null || typeof rhs !== "object") { throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node) @@ -2245,27 +2212,26 @@ class Interpreter { return Effect.succeed("undefined") } return Effect.map(this.evaluateExpression(argument), (value) => { - // `typeof` and `!` never throw in JS — they observe any value (functions and runtime + // `typeof` and `!` never throw in JS - they observe any value (functions and runtime // references included) without coercing it, so feature detection and negation work. if (operator === "typeof") return typeofValue(value) if (operator === "!") return !value if (containsOpaqueReference(value)) { throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue") } - const rhs = value as any // Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value // (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to // their JS string form first (see evaluateBinaryExpression). - const operand = rhs instanceof SandboxDate - ? (rhs.time as any) - : rhs !== null && typeof rhs === "object" - ? (coerceToString(rhs) as any) - : rhs + const operand = value instanceof SandboxDate + ? value.time + : value !== null && typeof value === "object" + ? coerceToString(value) + : value let result: unknown switch (operator) { - case "+": result = +operand; break - case "-": result = -operand; break - case "~": result = ~operand; break + case "+": result = +(operand as number); break + case "-": result = -(operand as number); break + case "~": result = ~(operand as number); break default: throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node) } return boundedData(result, "Unary expression result") @@ -2398,7 +2364,7 @@ class Interpreter { } // Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate - // namespace/tool names from the host tool tree — the discovery idiom a model reaches for + // namespace/tool names from the host tool tree - the discovery idiom a model reaches for // first. Every other Object helper cannot produce data from a tool reference, so it fails // with a pointer at the working idioms instead of the generic plain-objects-only message. private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown { @@ -2426,11 +2392,11 @@ class Interpreter { } // Console arguments format deeply and totally: values render as a debugger would show them - // rather than as boundary JSON — numbers keep NaN/Infinity (JSON would say null), sandbox + // rather than as boundary JSON - numbers keep NaN/Infinity (JSON would say null), sandbox // values keep their friendly forms at ANY depth (ISO date, /regex/flags, Map(n) [...], // Set(n) [...]), opaque runtime references become "[CodeMode reference]" markers in place, // and plain objects/arrays render JSON-style. Formatting never fails the program: cycles - // render "[Circular]" and extreme depth degrades to "…". + // render "[Circular]" and extreme depth degrades to "...". private formatConsoleArgument(value: unknown): string { if (value === undefined) return "undefined" // A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue). @@ -2448,7 +2414,7 @@ class Interpreter { if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]" if (value instanceof SandboxDate) return coerceToString(value) if (value instanceof SandboxRegExp) return coerceToString(value) - if (depth > MAX_CONSOLE_DEPTH) return "…" + if (depth > MAX_CONSOLE_DEPTH) return "..." if (seen.has(value)) return "[Circular]" if (value instanceof SandboxMap) { seen.add(value) @@ -2544,8 +2510,8 @@ class Interpreter { } // Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable - // collection) mixing promise values and plain data — built inline, beforehand, via spread, - // whatever — because tool calls already run eagerly on their own fibers; the combinators + // collection) mixing promise values and plain data - built inline, beforehand, via spread, + // whatever - because tool calls already run eagerly on their own fibers; the combinators // only observe settlements. Joining is therefore sequential (no extra fibers) without // costing parallelism, and the concurrency cap stays where the work is: the fork semaphore. private invokePromiseMethod(ref: PromiseMethodReference, args: Array, node: AstNode): Effect.Effect { @@ -2638,7 +2604,7 @@ class Interpreter { const run = Effect.gen(function*() { // Seed every parameter name into the scope as a TDZ slot first, so a default that // references another parameter resolves to that (uninitialized) param rather than - // silently falling through to an outer binding of the same name — matching JS. + // silently falling through to an outer binding of the same name - matching JS. const paramScope = self.currentScope() for (const parameter of fn.parameters) { for (const name of collectPatternNames(parameter)) { @@ -2858,7 +2824,7 @@ class Interpreter { : self.invokeFunction(callback, callbackArgs) return Effect.gen(function*() { // Iterate a snapshot taken at call time so a callback that mutates the array can't - // self-extend the loop — matching JS, where elements appended during iteration are not visited. + // self-extend the loop - matching JS, where elements appended during iteration are not visited. const items = target.slice() switch (name) { case "map": { @@ -2976,7 +2942,7 @@ class Interpreter { let rightIndex = 0 while (leftIndex < left.length && rightIndex < right.length) { // Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host - // crash) and treat NaN as 0 — the spec's "no consistent order" → keep the left element. + // crash) and treat NaN as 0 - the spec's "no consistent order" -> keep the left element. const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]])) if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++]) else merged.push(right[rightIndex++]) @@ -3174,7 +3140,7 @@ class Interpreter { if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)]) if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key) // Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`), - // instead of throwing — so defensive access like `result?.login ?? result` on a JSON-string + // instead of throwing - so defensive access like `result?.login ?? result` on a JSON-string // tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a // real string still reaches here.) Only the method allowlist above yields callables. return new ComputedValue(undefined) @@ -3225,14 +3191,14 @@ class Interpreter { if (objectValue instanceof SandboxPromise) { if (key === "then" || key === "catch" || key === "finally") { throw new InterpreterRuntimeError( - `Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) — e.g. \`const result = await tools.ns.tool(...)\`.`, + `Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`, propertyNode, "UnsupportedSyntax", [supportedSyntaxMessage], ) } throw new InterpreterRuntimeError( - "This value is an un-awaited Promise and has no readable properties; await it first — e.g. `const result = await tools.ns.tool(...)`.", + "This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.", objectNode, "InvalidDataValue", ) @@ -3258,7 +3224,7 @@ class Interpreter { return new ComputedValue((objectValue as Record & Array)[key]) } // Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`), - // instead of throwing — so defensive access under optional chaining behaves as expected. + // instead of throwing - so defensive access under optional chaining behaves as expected. return new ComputedValue(undefined) } return { target: objectValue, key } @@ -3294,7 +3260,7 @@ class Interpreter { } // Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression - // runs once), then lets `compute` decide whether to write — enabling compound assignment, + // runs once), then lets `compute` decide whether to write - enabling compound assignment, // updates, plain writes, and short-circuiting logical assignment to share one safe path. private modifyMember( node: AstNode, @@ -3329,7 +3295,7 @@ class Interpreter { } // Rejects inserting a value that (transitively) contains the container it is being inserted - // into — the mutation that would create a circular structure no later walk could survive. + // into - the mutation that would create a circular structure no later walk could survive. private rejectCircularInsertion(container: object, value: unknown, label: string, node: AstNode, seen = new Set()): void { if (value === container) throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue") if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return @@ -3384,7 +3350,7 @@ class Interpreter { throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError") } - // A parameter default that forward-references a later (not-yet-bound) parameter — JS TDZ. + // A parameter default that forward-references a later (not-yet-bound) parameter - JS TDZ. if (binding.initialized === false) { throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError") } @@ -3532,7 +3498,7 @@ const utf8Truncate = (value: string, maxBytes: number): string => { * Oversized values are replaced by their truncated serialized text with an explanatory marker, * and logs are kept from the start until the remaining budget is exhausted. Truncation never * fails the execution; `truncated: true` marks affected results. Only runs when the host set - * `maxOutputBytes` — with the limit absent, output passes through unbounded. + * `maxOutputBytes` - with the limit absent, output passes through unbounded. */ const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResult => { let truncated = false diff --git a/packages/codemode/src/tool-runtime.ts b/packages/codemode/src/tool-runtime.ts index 10e54dcb2c..21741e3146 100644 --- a/packages/codemode/src/tool-runtime.ts +++ b/packages/codemode/src/tool-runtime.ts @@ -118,9 +118,9 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has * objects only, blocked properties, data-only leaves). * * Two modes share the walk: - * - **Boundary** (`preserveSandboxValues` false, the default): the host↔sandbox boundary — + * - **Boundary** (`preserveSandboxValues` false, the default): the host<->sandbox boundary - * final results, tool-call arguments, `JSON.stringify`. Sandbox value types serialize - * exactly as JSON.stringify would: Date → ISO string (invalid → null), RegExp/Map/Set → {}. + * exactly as JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}. * - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in * codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves, * contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and @@ -142,7 +142,7 @@ const copyBounded = (value: unknown, label: string, depth: number, seen: Set { if (value === undefined && undefinedAsNull) return null // Normalize non-finite numbers to null as the value crosses out of the sandbox (final return - // and tool-call arguments both funnel through here), matching JSON semantics — NaN/Infinity + // and tool-call arguments both funnel through here), matching JSON semantics - NaN/Infinity // have no JSON representation, so JSON.stringify would produce null anyway. if (typeof value === "number" && !Number.isFinite(value)) { return null @@ -311,7 +311,7 @@ const tokenize = (query: string): Array => * A term plus its naive singular variants (trailing "s"/"es" stripped), so a plural * query term ("issues") still matches indexed text that only carries the singular * ("issue"). Matching is one-directional substring containment, so the variants are - * needed only on the query side; scoring weights are unchanged — each field check + * needed only on the query side; scoring weights are unchanged - each field check * passes when ANY form matches. */ const termForms = (term: string): Array => { @@ -326,7 +326,7 @@ const firstLine = (text: string) => text.split("\n", 1)[0]!.trim() /** One-line description used on inline catalog lines; the full text stays in search results. */ const brief = (text: string, max = 120) => { const line = firstLine(text) - return line.length > max ? line.slice(0, max - 1) + "…" : line + return line.length > max ? line.slice(0, max - 1) + "..." : line } const catalogLine = (tool: ToolDescription) => { @@ -359,11 +359,11 @@ export const assertValidTools = (tools: HostTools): void => { /** * Budgeted catalog: every namespace is always listed with its tool count; full call * signatures are inlined against the `maxInlineCatalogTokens` budget (estimated tokens, - * chars/4) round-robin across namespaces — in each round (namespaces alphabetical), every + * chars/4) round-robin across namespaces - in each round (namespaces alphabetical), every * namespace still holding un-inlined tools attempts to place its next-cheapest line, and - * a namespace whose next line does not fit is done while the others keep going — so every + * a namespace whose next line does not fit is done while the others keep going - so every * namespace gets some representation before any namespace gets everything. The section - * states exactly how comprehensive it is — overall (COMPLETE vs PARTIAL) and per + * states exactly how comprehensive it is - overall (COMPLETE vs PARTIAL) and per * namespace. Namespace stub lines are never budgeted: every namespace appears with its * tool count even at budget 0. */ @@ -390,7 +390,7 @@ export const discoveryPlan = ( // exactly how comprehensive it is. Round-robin fairness: in each round (namespaces // alphabetical), every namespace still holding un-inlined tools tries to place its // next-cheapest line against the shared budget; a namespace whose next line does not - // fit is done — the others keep going — so every namespace gets some representation + // fit is done - the others keep going - so every namespace gets some representation // before any namespace gets everything. const selections = ordered.map(([namespace, group]) => ({ namespace, @@ -424,7 +424,7 @@ export const discoveryPlan = ( // Section order is deliberate: workflow first (the top is the least likely part of a long // description to be truncated or skimmed away), then rules, then syntax, with the budgeted - // catalog at the bottom. Example call forms use explicit `.` placeholders — + // catalog at the bottom. Example call forms use explicit `.` placeholders - // never a real or fabricated tool name. const intro = [ "Write a CodeMode program to answer the request. Return code only.", @@ -445,17 +445,17 @@ export const discoveryPlan = ( "", ...(complete ? [ - "1. Pick a tool from the list under `## Available tools` — each line is the exact call signature; use it as-is rather than guessing segments.", - "2. Call it using the exact signature shown: `const res = await tools..(input)` — bracket notation may appear for names that are not JavaScript identifiers.", - '3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string.', - "4. Return only the fields you need: `return { : data. }` — raw payloads get truncated and waste context.", + "1. Pick a tool from the list under `## Available tools` - each line is the exact call signature; use it as-is rather than guessing segments.", + "2. Call it using the exact signature shown: `const res = await tools..(input)` - bracket notation may appear for names that are not JavaScript identifiers.", + '3. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.', + "4. Return only the fields you need: `return { : data. }` - raw payloads get truncated and waste context.", ] : [ - '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` — short phrases like "list issues" work best.', - "2. Read the matches: each item is `{ path, description, signature }` — read the description before using an unfamiliar tool.", - "3. Call it with the result's `path` as-is (never guess segments): `const res = await tools..(input)` — bracket notation may appear for names that are not JavaScript identifiers.", - '4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string.', - "5. Return only the fields you need: `return { : data. }` — raw payloads get truncated and waste context.", + '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` - short phrases like "list issues" work best.', + "2. Read the matches: each item is `{ path, description, signature }` - read the description before using an unfamiliar tool.", + "3. Call it with the result's `path` as-is (never guess segments): `const res = await tools..(input)` - bracket notation may appear for names that are not JavaScript identifiers.", + '4. Parse text results: `const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string.', + "5. Return only the fields you need: `return { : data. }` - raw payloads get truncated and waste context.", ]), ] @@ -468,8 +468,8 @@ export const discoveryPlan = ( complete ? "- Only tools listed here are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed." : "- Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`; tools from the surrounding agent/runtime are not implicitly exposed.", - "- Filter, aggregate, and transform collections in code — never return them raw or call a tool per item across messages.", - "- A result typed `Promise` has no guaranteed shape — verify what actually came back before relying on its fields.", + "- Filter, aggregate, and transform collections in code - never return them raw or call a tool per item across messages.", + "- A result typed `Promise` has no guaranteed shape - verify what actually came back before relying on its fields.", "- Run independent calls in parallel: `await Promise.all(items.map((item) => tools..(item)))`.", "- `Object.keys(tools)` lists namespaces; `Object.keys(tools.)` lists its tools; `for...in` works on both.", ...(complete ? [] : ['- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.']), @@ -491,8 +491,8 @@ export const discoveryPlan = ( } else { toolSection.push( complete - ? "## Available tools (COMPLETE list — every tool is shown below with its full call signature)" - : `## Available tools (PARTIAL — ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`, + ? "## Available tools (COMPLETE list - every tool is shown below with its full call signature)" + : `## Available tools (PARTIAL - ${totalShown} of ${described.length} shown; find the rest with tools.$codemode.search)`, "", ) for (const [namespace, group] of ordered) { @@ -528,8 +528,8 @@ export const discoveryPlan = ( } /** - * The enumerable names at one node of the host tool tree — namespace names at the root, - * tool/namespace names below — powering `Object.keys(tools)` and `for...in` over tool + * The enumerable names at one node of the host tool tree - namespace names at the root, + * tool/namespace names below - powering `Object.keys(tools)` and `for...in` over tool * references. A callable tool is a leaf and enumerates as `[]` (like `Object.keys` of a * function in JS). An unknown path is an `UnknownTool` error pointing at the working * discovery idioms, mirroring how calling an unknown tool fails. diff --git a/packages/codemode/src/tool.ts b/packages/codemode/src/tool.ts index 95d9f75c8b..20ebbaf9f0 100644 --- a/packages/codemode/src/tool.ts +++ b/packages/codemode/src/tool.ts @@ -4,7 +4,7 @@ import { Effect, Schema } from "effect" * JSON Schema subset accepted for render-only tool schemas. * * A JSON-Schema-described side of a tool is used to generate the model-visible TypeScript - * signature only — CodeMode performs no validation against it. This is the natural shape for + * signature only - CodeMode performs no validation against it. This is the natural shape for * adapter-provided tools (e.g. MCP definitions) whose schemas arrive as JSON Schema documents. */ export type JsonSchema = { @@ -63,7 +63,7 @@ const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder & const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown" /** - * Bare TypeScript identifier — usable unquoted as an object key (and, in the tool runtime, + * Bare TypeScript identifier - usable unquoted as an object key (and, in the tool runtime, * with dot access as a tool-path segment). Anything else must be quoted/bracketed. */ export const identifierSegment = /^[A-Za-z_$][A-Za-z0-9_$]*$/ @@ -77,7 +77,7 @@ const effectNumberSentinel = (schema: JsonSchema) => /** * Recursion ceiling for schema rendering. Object, array, and union recursion all increment - * depth, so this bounds every recursion path — pathological or structurally cyclic schemas + * depth, so this bounds every recursion path - pathological or structurally cyclic schemas * degrade to `unknown` instead of overflowing the stack (rendering must never throw). */ const MAX_RENDER_DEPTH = 8 @@ -111,7 +111,7 @@ const docTags = (schema: JsonSchema): Array => { /** * Format a schema `description` plus `tags` as a JSDoc comment at the given indent, - * preserving multi-line text (a single line stays `/** … *\/`; multiple lines become a + * preserving multi-line text (a single line stays `/** ... *\/`; multiple lines become a * `*`-prefixed block). `*\/` is neutralized so nothing can close the comment early, and * blank leading/trailing lines are trimmed. Returns "" (else a trailing newline) so * callers can prepend it directly to the field line. @@ -220,7 +220,7 @@ export type InputProperty = { } /** - * The property names, descriptions, and required flags of a tool's input schema — the raw + * The property names, descriptions, and required flags of a tool's input schema - the raw * material for search text. Best-effort: Effect Schemas go through their * JSON Schema document (the same emission signature rendering uses); JSON Schemas are read * directly, resolving a trivial top-level `$ref` into `$defs`/`definitions` when present. @@ -299,7 +299,7 @@ export const decodeOutput = (definition: Definition, value: unknown): unkn * document. Effect Schema input is decoded before `run` is invoked, and `run` returns the * encoded representation of an Effect Schema `output`, which CodeMode decodes before returning * it to the program. JSON Schemas only shape the model-visible signature; values pass through - * unvalidated. `output` is optional — without it the signature advertises `unknown` and the + * unvalidated. `output` is optional - without it the signature advertises `unknown` and the * host result is exposed as-is. The host tool remains responsible for authorization and * durable side-effect handling. * diff --git a/packages/codemode/src/values.ts b/packages/codemode/src/values.ts index e7ea42634a..ed92c62756 100644 --- a/packages/codemode/src/values.ts +++ b/packages/codemode/src/values.ts @@ -1,36 +1,17 @@ -// Sandbox value types backed by host primitives. They live in their own module so both the -// interpreter (codemode.ts) and the data boundary (tool-runtime.ts) can reference them without -// a circular import. All four are opaque runtime values inside a program; when a value crosses -// the sandbox boundary (final result, tool arguments, JSON.stringify) they serialize exactly as -// JSON.stringify would: Date -> ISO string (invalid -> null), RegExp/Map/Set -> {}. - import type { Effect, Fiber } from "effect" -/** - * A first-class promise value produced by an un-awaited tool call (or by - * `Promise.resolve`/`Promise.reject`). Tool-call promises are eager: the call runs on a fiber - * forked at call time, and `await` observes that fiber's settlement. Promises are opaque - * runtime references — `typeof` reports `"object"` (as in real JS), operators reject them, and - * they cannot cross a data boundary un-awaited (the boundary raises an await-hinting - * diagnostic instead of serializing `{}`). - */ export class SandboxPromise { - /** Set when Promise.race interrupts this promise's in-flight call after another entry wins. */ interrupted = false constructor( - /** Backing fiber for an eagerly started tool call; undefined for resolve/reject promises. */ readonly fiber: Fiber.Fiber | undefined, - /** Immediate settlement for fiberless promises (Promise.resolve / Promise.reject). */ readonly immediate?: Effect.Effect, ) {} } -/** An immutable instant, backed by an epoch-milliseconds time value (NaN = Invalid Date). */ export class SandboxDate { constructor(readonly time: number) {} } -/** A regular expression backed by the host engine; `lastIndex` state lives on the host regex. */ export class SandboxRegExp { readonly regex: RegExp constructor(pattern: string, flags: string) { @@ -38,12 +19,10 @@ export class SandboxRegExp { } } -/** A keyed collection with SameValueZero keys. */ export class SandboxMap { readonly map = new Map() } -/** A unique-value collection. */ export class SandboxSet { readonly set = new Set() } diff --git a/packages/codemode/test/codemode.test.ts b/packages/codemode/test/codemode.test.ts index ac04e9208a..2648e14de5 100644 --- a/packages/codemode/test/codemode.test.ts +++ b/packages/codemode/test/codemode.test.ts @@ -561,14 +561,14 @@ describe("CodeMode public contract", () => { expect(instructions.indexOf("## Rules")).toBeLessThan(instructions.indexOf("## Syntax")) expect(instructions.indexOf("## Syntax")).toBeLessThan(instructions.indexOf("\n## Available tools (COMPLETE list")) // The workflow carries the result-shape guidance; Rules only add content beyond it. - expect(instructions).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` — most tools return JSON as a string') + expect(instructions).toContain('`const data = typeof res === "string" ? JSON.parse(res) : res` - most tools return JSON as a string') expect(instructions).toContain("Return only the fields you need") expect(instructions).toContain("raw payloads get truncated and waste context") expect(instructions).toContain("`const res = await tools..(input)`") expect(instructions).toContain("surrounding agent tools are not available unless listed here") expect(instructions).toContain("Only tools listed here are available inside `tools`") expect(instructions).toContain("bracket notation may appear for names that are not JavaScript identifiers") - // Placeholders use the ./ style ONLY — no fabricated tool + // Placeholders use the ./ style ONLY - no fabricated tool // names, and no real catalog tools cherry-picked into example lines. expect(instructions).toContain("`return { : data. }`") expect(instructions).not.toContain("total_count") @@ -582,7 +582,7 @@ describe("CodeMode public contract", () => { // PARTIAL: the workflow starts with search (with query-style guidance that is clearly // a query string, never a tool name) and the browse-namespace rule appears. expect(partial).toContain( - '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` — short phrases like "list issues" work best.', + '1. Find a tool (skip when it is already listed below): `const { items } = await tools.$codemode.search({ query: "" })` - short phrases like "list issues" work best.', ) expect(partial).toContain("Only tools listed here or returned by `tools.$codemode.search` are available inside `tools`") expect(partial).toContain('- Browse one namespace: `await tools.$codemode.search({ query: "", namespace: "" })`.') @@ -635,7 +635,7 @@ describe("CodeMode public contract", () => { tools: { thread: { uploadFile: upload, generateImage: generate }, orders: { lookup } }, discovery: { maxInlineCatalogTokens: 0 }, }) - expect(runtime.instructions()).toContain("Available tools (PARTIAL — 0 of 3 shown; find the rest with tools.$codemode.search)") + expect(runtime.instructions()).toContain("Available tools (PARTIAL - 0 of 3 shown; find the rest with tools.$codemode.search)") expect(runtime.instructions()).toContain("- thread (2 tools, none shown)") expect(runtime.instructions()).toContain("- orders (1 tool, none shown)") expect(runtime.instructions()).toMatch(/\$codemode\.search/) @@ -783,7 +783,7 @@ describe("CodeMode public contract", () => { }) const runtime = CodeMode.make({ tools: { files: { upload, other } } }) - // "attachment" appears in neither path nor description — only in the input schema's + // "attachment" appears in neither path nor description - only in the input schema's // property names, which the searchable text includes. const byParameter = await Effect.runPromise(runtime.execute( `return await tools.$codemode.search({ query: "attachment" })`, @@ -817,7 +817,7 @@ describe("CodeMode public contract", () => { }) const runtime = CodeMode.make({ tools: { - // Neither path nor description contains "issues" — only the singular "issue". + // Neither path nor description contains "issues" - only the singular "issue". tracker: { fetch_all: simple("Fetch every open issue in the project") }, github: { list_issues: simple("List issues") }, misc: { rename: simple("Rename the workspace") }, @@ -888,7 +888,7 @@ describe("CodeMode public contract", () => { run: () => Effect.succeed("ok"), }) // Round 1 places alpha.cheap (~17 estimated tokens) and beta.cheap (~17); in round 2 - // alpha.expensive does not fit, which marks only alpha done — it must NOT prevent + // alpha.expensive does not fit, which marks only alpha done - it must NOT prevent // other namespaces from inlining (beta already got its line in the same round). const runtime = CodeMode.make({ tools: { alpha: { cheap, expensive }, beta: { cheap } }, @@ -896,7 +896,7 @@ describe("CodeMode public contract", () => { }) const instructions = runtime.instructions() - expect(instructions).toContain("Available tools (PARTIAL — 2 of 3 shown; find the rest with tools.$codemode.search)") + expect(instructions).toContain("Available tools (PARTIAL - 2 of 3 shown; find the rest with tools.$codemode.search)") expect(instructions).toContain("- alpha (2 tools, 1 shown)") expect(instructions).toContain(" - tools.alpha.cheap(input: { q: string }): Promise // Cheap") expect(instructions).not.toContain("tools.alpha.expensive(") @@ -972,7 +972,7 @@ describe("CodeMode public contract", () => { test("timeoutMs and maxToolCalls have no defaults: absent means unlimited", async () => { // 150 tool calls would have exceeded the old default cap of 100; with no limits - // provided, there is no cap and no timeout — budgets are host policy. + // provided, there is no cap and no timeout - budgets are host policy. const counter = Tool.make({ description: "Count invocations", input: Schema.Struct({}), diff --git a/packages/codemode/test/parity.test.ts b/packages/codemode/test/parity.test.ts index 36b6883cd0..ec931c2ee7 100644 --- a/packages/codemode/test/parity.test.ts +++ b/packages/codemode/test/parity.test.ts @@ -110,7 +110,7 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo expect(await value(`return parseInt("abc") || 0`)).toBe(0) expect(await value(`const x = Number("abc"); return Number.isNaN(x) ? 0 : x`)).toBe(0) expect(await value(`const o = {}; o.count = (o.count || 0) + 1; return o.count`)).toBe(1) - // average of an empty list, guarded — the classic divide-by-zero that used to throw pre-guard + // average of an empty list, guarded - the classic divide-by-zero that used to throw pre-guard expect(await value(`const a = []; return a.length ? a.reduce((s,x)=>s+x,0)/a.length : 0`)).toBe(0) }) @@ -118,7 +118,7 @@ describe("H1: NaN/Infinity flow as intermediates and normalize to null at the bo expect(await value(`return 5/0`)).toBeNull() expect(await value(`return 0/0`)).toBeNull() expect(await value(`return Math.max()`)).toBeNull() - // nested, too — normalization walks the returned structure + // nested, too - normalization walks the returned structure expect(await value(`return { a: Number("x"), b: 2, c: [1/0] }`)).toEqual({ a: null, b: 2, c: [null] }) }) diff --git a/packages/codemode/test/promise.test.ts b/packages/codemode/test/promise.test.ts index ed6b3bb8c8..b324ca2713 100644 --- a/packages/codemode/test/promise.test.ts +++ b/packages/codemode/test/promise.test.ts @@ -213,7 +213,7 @@ describe("Promise.all over arbitrary arrays", () => { ) expect(result).toEqual([1, 2, 3, 4]) // maxActive counts truly-overlapping live executions, so > 1 proves real - // parallelism deterministically — no wall-clock assertion needed. + // parallelism deterministically - no wall-clock assertion needed. expect(trace.maxActive).toBeGreaterThan(1) }) @@ -375,7 +375,7 @@ describe("timeout interruption of forked calls", () => { expect(result.ok).toBe(false) if (result.ok) return expect(result.error.kind).toBe("TimeoutExceeded") - // Both calls started; neither escaped the timeout — the awaited one AND the abandoned one. + // Both calls started; neither escaped the timeout - the awaited one AND the abandoned one. expect(trace.starts).toEqual([1, 2]) expect(trace.interrupted).toBe(2) expect(trace.completed).toBe(0)