Просмотр исходного кода

feat(codemode): sync v2 implementation (#35574)

Aiden Cline 1 месяц назад
Родитель
Сommit
d5aa79c73a
32 измененных файлов с 5538 добавлено и 5654 удалено
  1. 4 3
      packages/codemode/README.md
  2. 154 1204
      packages/codemode/codemode.md
  3. 8 3905
      packages/codemode/src/codemode.ts
  4. 1 1
      packages/codemode/src/index.ts
  5. 200 0
      packages/codemode/src/interpreter/model.ts
  6. 3465 0
      packages/codemode/src/interpreter/runtime.ts
  7. 2 2
      packages/codemode/src/openapi/index.ts
  8. 12 14
      packages/codemode/src/openapi/runtime.ts
  9. 3 7
      packages/codemode/src/openapi/spec.ts
  10. 51 0
      packages/codemode/src/stdlib/collections.ts
  11. 4 0
      packages/codemode/src/stdlib/console.ts
  12. 94 0
      packages/codemode/src/stdlib/date.ts
  13. 42 0
      packages/codemode/src/stdlib/json.ts
  14. 65 0
      packages/codemode/src/stdlib/math.ts
  15. 66 0
      packages/codemode/src/stdlib/number.ts
  16. 77 0
      packages/codemode/src/stdlib/object.ts
  17. 6 0
      packages/codemode/src/stdlib/promise.ts
  18. 74 0
      packages/codemode/src/stdlib/regexp.ts
  19. 52 0
      packages/codemode/src/stdlib/string.ts
  20. 90 0
      packages/codemode/src/stdlib/url.ts
  21. 90 0
      packages/codemode/src/stdlib/value.ts
  22. 0 2
      packages/codemode/src/tool-api.ts
  23. 91 76
      packages/codemode/src/tool-runtime.ts
  24. 301 0
      packages/codemode/src/tool-schema.ts
  25. 8 310
      packages/codemode/src/tool.ts
  26. 17 2
      packages/codemode/src/values.ts
  27. 3 1
      packages/codemode/test/codemode.test.ts
  28. 20 5
      packages/codemode/test/fixtures/openapi-happy-path.json
  29. 292 90
      packages/codemode/test/fixtures/opencode-v2-openapi.json
  30. 22 28
      packages/codemode/test/openapi.test.ts
  31. 2 2
      packages/codemode/test/signature.test.ts
  32. 222 2
      packages/codemode/test/stdlib.test.ts

+ 4 - 3
packages/codemode/README.md

@@ -243,12 +243,13 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports:
 - 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, including `$codemode`, and `Object.keys(tools.ns)` lists 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.
+- 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). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. 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.
 - `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
+- URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention.
 - 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.
+- `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`, `URL`, `URLSearchParams`, `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 `{}`.
+Inside a program, standard-library 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 they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams 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.
 

+ 154 - 1204
packages/codemode/codemode.md

@@ -1,1226 +1,176 @@
-# CodeMode - Status, Decisions, and Remaining Work
+# CodeMode Design and Status
 
-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.
+This is the living design and status document for `@opencode-ai/codemode` and its existing V2 OpenCode adapter.
+It records current behavior, intentional boundaries, durable rationale, and material remaining work.
 
-Tracking issue: https://github.com/anomalyco/opencode/issues/34787
-Working branch: `codemode-v2` (base: `dev`)
+Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove
+completed work instead of preserving checked-off chronology.
 
----
+Detailed package API documentation lives in [README.md](./README.md). OpenAPI-specific follow-ups live in
+[src/openapi/TODO.md](./src/openapi/TODO.md).
 
-## 1. What this is
+## How CodeMode Works
 
-CodeMode gives a model one `execute` tool that runs JavaScript/TypeScript programs against a
-tree of schema-described tools (`tools.<namespace>.<tool>(input)`), instead of exposing dozens
-of MCP tools individually. The point is **control flow**: sequencing, filtering, and composing
-tool calls in one program instead of round-tripping through the agent loop, plus not flooding
-the context window when users connect many MCP servers.
+### Purpose
 
-Architecture split (locked):
+CodeMode gives a model one `execute` tool backed by a confined JavaScript interpreter. Inside the program, the model
+can call an explicit tree of schema-described tools, sequence dependent work, run independent calls concurrently,
+and filter or aggregate results before returning them to the agent loop.
 
-- **`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
-  definitions into `Tool.make(...)` definitions, permission gating, host-side attachment
-  collection, the agent-facing `execute` tool, and TUI progress rendering.
+The goals are:
 
-This package was seeded from the experiments workspace implementation
-(`experiments/agents/packages/codemode`, package `@agents/codemode`) and then modified here.
-The older vendored interpreter in `packages/opencode/src/session/rune/` was superseded by this
-package and was **deleted** in Wave 3 (done, see below).
+- Reduce model context consumed by large tool catalogs.
+- Avoid an agent round-trip between every dependent tool call.
+- Keep large intermediate results inside the program instead of sending them through model context.
+- Give generated code only the authority explicitly supplied by the host.
 
----
+CodeMode is an orchestration language, not a general JavaScript runtime or an application authorization system.
 
-## 2. Locked decisions
+### Runtime
 
-From issue #34787 and design discussion. Do not relitigate these casually.
+The generic runtime lives in `packages/codemode` and is host-neutral:
 
-### Core direction
+1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
+2. CodeMode generates model instructions, a budgeted inline catalog, and the internal `$codemode.search` tool.
+3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
+   executes it without `eval`.
+4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
+5. Execution returns `CodeMode.Result`. Expected program and tool failures are diagnostic data; host interruption
+   remains Effect interruption.
 
-- Generic CodeMode lives in its own package: `@opencode-ai/codemode` (repo scope convention;
-  the issue's `@opencode/codemode` name was normalized to the `@opencode-ai/*` convention).
-- **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.)
-- 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.
-- **Never reference external prior-art implementations** (other companies' code-execution
-  products/blog posts) in code, comments, commit messages, or docs in this repo.
+Effect Schemas validate and transform tool inputs and outputs. JSON Schemas render model-facing signatures but do not
+validate values; adapter-provided values still cross the plain-data boundary. A tool without an output schema is
+advertised as `Promise<unknown>`.
 
-### MCP / tools
+### Discovery and model workflow
 
-- 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.
-- 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.<server>.<tool>` namespaces before handing them over.
+The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
+round-robin across namespaces so one large namespace cannot starve the others. `$codemode.search` is always callable
+and is advertised when the inline catalog is partial.
 
-### Discovery / search
+The intended workflow is:
 
-- **Search only - no separate `describe`.** `tools.$codemode.search({ query?, namespace?,
-limit?, offset? })` over the final tool tree, owned by this package.
-- Search result item shape: `{ path, description, signature }` in an
-  `{ items, remaining, next }`
-  wrapper. The `signature` string embeds the full input/output TypeScript types and uses the
-  same pretty, JSDoc-annotated multiline form in inline catalogs and search results, 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
-  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.
-- `offset` is zero-based and defaults to 0. `remaining` counts matches after the current page;
-  `next` is `{ offset }` when another page exists and `null` on the final page.
-- Search is an internal `Tool.make` definition backed by Effect input/output schemas. Its
-  validation, output checking, call observation, and TypeScript signature use the same path as
-  host-provided schema tools. Host-only catalog preparation keeps internal tools out of their
-  own search index; only conditional advertisement remains special.
-- Default limit: **10** (done). Exact-path lookup goes through search too: a query equal to a
-  canonical tool path, `tools.`-prefixed path, or rendered JavaScript expression returns that
-  tool alone (done).
-- Signatures render **native payloads**: `Promise<Issue>`, NOT `Promise<Result<Issue>>`.
-  There is no result envelope; attachments never appear in return types (they are collected
-  host-side, see below).
-- Tools without an output schema render `unknown` as their return type.
+1. Pick an exact signature from the inline catalog, or return `$codemode.search(...)` results and use a selected path
+   in the next execution.
+2. Call the exact returned path without guessing or normalizing segments.
+3. Narrow `Promise<unknown>` results before reading fields.
+4. Start independent calls together and await them with `Promise.all`.
+5. Filter and aggregate inside the program, then return only the data needed by the model.
 
-### Schemas / Tool.make
+Search returns directly usable JavaScript paths, descriptions, and complete TypeScript signatures. It supports exact
+path lookup, namespace browsing, deterministic ranking, and pagination.
 
-- `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
-  room for Standard Schema later.
-- Tool implementations are **Effect-based** for v1 (`run` returns `Effect`). Promise
-  normalization for plugin authors can come later.
+### Tool execution
 
-### Attachments / output
+Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be
+awaited directly or through the supported `Promise` combinators. At most eight tool calls execute concurrently.
+Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic.
+
+The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
+defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
+concurrency and data nesting depth.
 
-- **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
-  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
-  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.
+### Data, files, and failures
 
-### Runtime behavior
-
-- 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.
-  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
-  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
-  an `@internal` `InternalExecutionLimits` type (maxOperations, maxDataBytes, maxValueDepth,
-  maxCollectionLength, maxSourceBytes, maxAuditBytes, maxConcurrency) was deleted outright in
-  Fix 5 (see Post-wave fixes). Two internals survive as fixed constants, not knobs:
-  `TOOL_CALL_CONCURRENCY = 8` (the fork semaphore) and `MAX_VALUE_DEPTH = 32` (the `copyIn`
-  boundary depth check, kept only because it beats a native stack-overflow RangeError as an
-  error message; still reports `InvalidDataValue`).
-- 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
-  `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 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.
-- Simple tool-call **start/end hooks** for nested progress: `onToolCallStart({ index, name,
-input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`.
-  Interrupted calls fire no end event. No `CurrentToolCall` context service (removed in
-  Wave 2).
-
----
-
-## 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 /
-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`,
-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)
-
-- `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`
-  Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`.
-
-### 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
-  `JSON.stringify`. Guards like `Number.isNaN(x)` / `parseInt(x) || 0` work.
-- **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).
-- **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)
-
-`src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both
-`codemode.ts` and `tool-runtime.ts` import without a cycle). Design:
-
-- Opaque-by-default: all four join `isRuntimeReference`, with explicit carve-outs (member
-  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
-  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.)
-- Date: `Date.now/parse/UTC`, `new Date(epoch|string|components)`, getters + UTC variants,
-  `end - start`, `a < b`, `+date`; `toString` is ISO for cross-host determinism.
-- RegExp: literals + `new RegExp`, `test`/`exec` (stateful `lastIndex` for `g`), string
-  `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
-  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"`),
-  `!` works on any value, `for...of` over strings, `{...sandboxValue}` no-op, template
-  interpolation renders `/regex/` and ISO dates directly.
-
-### 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<unknown>` 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 `Tool.JsonSchema`/`Tool.SchemaType` exported from the index. Note: an empty
-    `Schema.Struct({})` renders as `{  } | Array<unknown>` (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 })` +
-  `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`
-  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).
-- **Limits collapse**: public `CodeMode.ExecutionLimits` = `{ timeoutMs?, maxToolCalls?,
-maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as
-  internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5
-  later deleted that type and the internal limit system entirely.
-- **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in
-  a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized
-  serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte
-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
-    `CodeMode.Result`). 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 -
-    truncation is now the only result-size mechanism.)
-- **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
-  (`remaining: 0`, `next: null`), bypassing ranking. Tokenization/ranking/shape unchanged.
-
-### 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
-per-MCP registration; MCP resource tools unaffected) is unchanged.
-
-- **Tool tree**: `groupByServer` (longest-sanitized-prefix, ported) groups flat `server_tool`
-  keys into `CatalogEntry`s carrying the raw MCP `inputSchema`/`outputSchema` as render-only
-  JSON Schema; `toolTree` turns each into `Tool.make({ description, input, output?, run })`
-  under `tools.<server>.<tool>`. 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`
-  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).
-  Denials and host failures are mapped to `toolError(message)` so they surface as safe,
-  catchable in-program failures (MCP `isError` text propagates as `e.message`; without this
-  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
-  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<T>` 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
-  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.
-  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
-  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 -
-  `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).
-
-### 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** (`prepare` in `tool-runtime.ts`): the all-or-nothing
-  inline/search modes are gone - `DiscoveryMode` deleted, `CodeMode.DiscoveryOptions` is just
-  `{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes; later converted to
-  `catalogBudget`, 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
-  (`  - <signature> // <first line of description, capped at 120 chars>`) are inlined
-  cheapest-first (line byte length, path tiebreak) within each namespace, namespaces processed
-  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
-  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
-  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 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 -
-  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
-  (`anyOf: [{ type: "object" }, { type: "array" }]`, no properties/items) collapses to `{}`
-  (was `{  } | Array<unknown>`).
-- **Tests**: 4 package discovery tests rewritten for the budgeted behavior (COMPLETE small
-  catalog + search-still-registered; PARTIAL at budget 0; cheapest-first selection +
-  per-namespace labels + budget-exhaustion stopping later namespaces; mode-validation
-  assertion dropped); 3 opencode description assertions updated (COMPLETE/PARTIAL headers,
-  namespace labels, `(input: {})` rendering, cheapest-first op_0 shown / op_149 not).
-- **E2E (verified, headless)**: from the repo root with `OPENCODE_EXPERIMENTAL_CODE_MODE=1`,
-  the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory,
-  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 -
-  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
-  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)
-
-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
-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
-  `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
-  `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
-  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.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
-  `onToolCallStart` fire at the call site before any await. `await` of a non-promise is a passthrough no-op; a returned
-  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
-  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
-  `evaluateTryStatement`). `race` resolves/rejects with the first settlement and interrupts
-  losing in-flight calls; awaiting an interrupted loser afterwards is a catchable program
-  failure ("interrupted because another value settled a Promise.race first"), while any other
-  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),
-  operators reject them, `copyIn` raises an await-hinting `InvalidDataValue` ("contains an
-  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");
-  `Promise.<unknown>` 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
-  statement-position calls. Settlement observation (await/all/allSettled/race) marks a
-  promise handled; failed executions skip the drain and children are interrupted by
-  supervision.
-- **Deletions/updates**: `evaluatePromiseAll`, `evaluateParallelMap`, `isToolCallExpression`,
-  `isToolPath`, `forkForParallelCallback`, and `PromiseAllReference` deleted
-  (`PromiseMethodReference` over `all/allSettled/race/resolve/reject` replaces it);
-  `supportedSyntaxMessage`, the two instructions lines in `tool-runtime.ts`, and README
-  "Supported Programs" rewritten for the new surface.
-- **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
-  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
-  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 -
-  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
-    still never holds the callable tool tree. `Object.keys(tools)` yields the top-level namespace
-    names, including the internally registered `$codemode`; `Object.keys(tools.$codemode)` yields
-    `["search"]`, and `Object.keys(tools.ns)` yields 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
-    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
-    `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
-    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
-    transcript program) plus one adapter-level assertion that `Object.keys(tools)` returns
-    MCP server and CodeMode namespace names.
-
-- **Search ranking, namespace scoping, prefixed result paths (done).**
-  Motivation: the Wave 4 e2e run showed a model retrying calls because search-result paths
-  lacked the `tools.` prefix (a Backlog item), and the word-set ranker missed
-  parameter-name and partial-word queries. Fixes:
-  - **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
-    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
-    path + description). Queries tokenize on camelCase boundaries + non-alphanumeric
-    separators (empties and `*` dropped). Additive per-term scoring: exact path or
-    path-segment match 20, path substring 8, description substring 4, searchable-text
-    substring 2; summed across terms, filtered to score > 0, sorted score desc then path asc
-    (Fix 8 later made each field check accept the term OR a naive singular variant).
-    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?, offset? })` -
-    `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.
-  - **Callable result paths**: search-result `path`s are rendered as JavaScript expressions
-    rooted at `tools` (`tools.github.list_issues`, or bracket notation for non-identifier
-    segments), directly usable as the call site. Internal `ToolDescription.path` stays
-    unprefixed; only the search RESULT items are rendered this way. Exact-path queries accept
-    canonical paths and rendered expressions.
-  - **Instructions** (`prepare`): an explicit calling-convention line and a browse
-    hint on the search advertisement (both since absorbed into the `## Rules` section by
-    the instructions restructure below).
-  - **Tests**: package search/discovery tests updated (prefixed paths, alphabetical browse)
-    plus new coverage for namespace scoping, parameter-name matching, partial-word substring
-    matching, alphabetical empty-query order, and prefixed exact-path lookup; one adapter
-    assertion updated to the prefixed path (suites stay 35 + 16, green).
-
-- **Instructions restructure: markdown sections, placeholder-only call forms (done).**
-  The flat prose instructions (which mixed a real catalog tool with fabricated result
-  fields in the worked example) are replaced by structured markdown in `prepare`,
-  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 and the language-accuracy pass):
-  - **Intro**: identifies the language as restricted JavaScript for calling tools rather than
-    a general-purpose runtime.
-  - **`## Workflow`**: with a partial catalog, return search results from one execution, then
-    copy a selected path into the next execution. With a complete catalog, pick and call an
-    inlined signature, then return only the needed fields.
-  - **`## Rules`**: narrow unknown results at runtime; 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;
-    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
-    verified against the adapter's `toSandboxResult`/`mediaMarker`).
-  - **`## Language`**: a concise positive capability summary plus the major unavailable
-    runtime capabilities and the data-boundary serialization note.
-  - **`## Available tools`**: the budgeted catalog unchanged, with the COMPLETE/PARTIAL
-    header merged into the section heading (no trailing colon); the search-signature
-    advertisement follows when PARTIAL (its description-reading and browse clauses moved
-    to Workflow/Rules).
-  - Every call form in Workflow/Rules uses explicit `<namespace>.<tool>`/`<field>`
-    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
-    available." under minimal sections (intro + Syntax + Available tools).
-  - **Tests**: the package worked-example test replaced by section-structure/placeholder
-    assertions (section order; unknown-result + 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
-    assertions on the built description (still 35 + 16, green).
-
-**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.
-- `CodeMode.DiscoveryOptions.maxInlineCatalogBytes` -> `catalogBudget` (default 4,000
-  estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size
-  reduction). `prepare` 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
-    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.
-
-**Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as
-configurable knobs; the internal limit system dies):
-
-- `CodeMode.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 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
-  (`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/
-  `cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check);
-  `maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`,
-  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
-  actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
-  and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
-  `CodeMode.Result` (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
-  only because it produces a clearer error than a native stack-overflow RangeError; still
-  `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
-  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` ->
-  `TimeoutExceeded`, elapsed well under a few seconds).
-- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks +
-  `rejectCircularInsertion` on mutations), plain-objects-only, blocked properties
-  (`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit
-  behaviors unchanged.
-- Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels
-  now fail at the data boundary (`copyIn`) instead of at construction; array index
-  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 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
-  `InternalExecutionLimits` as a convenience to plain `CodeMode.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 /
-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
-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
-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
-rune renderer in this repo's git history (`renderType(def, { pretty })`/`docTags`/`jsdoc`/
-`renderObject`), adapted to the current renderer's conventions (`Array<T>`, `unknown`
-fallback, existing `$defs`/`$ref` handling and empty-object `{}` collapse; the old
-`Result<T>`/`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 <json>` (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
-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
-`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
-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
-`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**
-(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,
-  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/
-  .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
-  compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned.
-  `supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched.
-- **Workflow/Rules deduped**: the call-by-exact-path and return-small content now lives ONLY
-  in the numbered Workflow steps; Rules keeps only bullets adding new
-  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
-  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
-  absorbed the deduped parse/return-small justifications.
-- **Round-robin namespace inlining** (`prepare`): 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
-  (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
-  namespace gets some representation before any namespace gets everything. Kept:
-  `estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace
-  `(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL
-  header, alphabetical namespace order in the output, cheapest-first within each
-  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
-  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).
-- **Tests**: package instruction/structure assertions updated to the new text; the
-  language-section test rejects full-runtime wording, names major unavailable capabilities,
-  and keeps the data-boundary note; the budget-exhaustion
-  test rewritten to assert the new fairness (alpha.expensive not fitting must NOT
-  prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new
-  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 /
-  0 fail; adapter suites: 34 + 16.
-
-**Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed
-instructions and directed further cuts):
-
-- Default `catalogBudget` 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<unknown>` 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
-  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.
-- Later revised: unconditional JSON parsing was removed because text results are not
-  necessarily JSON. The browse-namespace rule remains; the language section now states that
-  ambient `fetch` is unavailable and external operations go through Code Mode tools.
-- 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
-  as a next-iteration follow-up below.
-
-**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` ->
-  `"function"`). Error values stay the same plain `{ name, message }` null-prototype
-  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
-  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
-  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
-  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
-  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
-  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`
-  "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
-  default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form
-  -> 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
-  expression:" prefix via `regexFailureReason`) and a shared escaping hint
-  (`escapeRegexHint`); flags failures list the valid flag letters; the
-  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
-  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
-  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
-  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
-  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
-  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.
-- **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
-  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
-  `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
-Error`/splice mentions (nothing else reworded); README updated (checkpoint
-  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
-  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
-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
-  host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`;
-  `boundOutput` only runs when the host set the limit. Explicit values validate as before
-  (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`,
-  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
-  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
-  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 `CodeMode.DiscoveryOptions` JSDoc fixed (claimed default
-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 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
-  `flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the
-  registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The
-  session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` +
-  append) is deleted; the early return that suppresses raw per-MCP registration when the
-  flag is on stays session-side, keyed on the same flag+tool-count condition.
-- **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP
-  tool count is consulted once (an Effect) before the synchronous filter, and code mode
-  passes the predicate iff `flags.experimentalCodeMode` && count > 0.
-- **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
-  `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 `"*"`
-  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
-  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.
-- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult`
-  unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution
-  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
-  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
-  `define(...)` construction; description assertions target `catalogInstructions`, the
-  registry's composition input) and gained permission coverage: deny excluded from
-  catalog/search, ask-level stays visible and callable, denied tool undispatchable
-  (unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/
-registry.test.ts` gained four registry-level tests: registered with flag+MCP tools,
-  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 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 ->
-  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) ->
-  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
-  `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 -
-  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
-  was needed.
-- **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
-  `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
-  `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
-  interruption. Legacy semantics unchanged: a hook failure fails the tool call.
-- **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
-  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
-  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).
-
-**Signature rendering + compound-assignment parity fixes** (externally reported, both
-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
-  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
-  identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact,
-  pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct).
-- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old
-  `anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
-  `number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
-  etc.). The collapse is now restricted to Effect's number-schema artifact
-  (`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums),
-  while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3.
-- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`):
-  `applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y`
-  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
-  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:
-  compound assignment now rejects opaque references, consistent with binary operators.
-  Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity,
-  string `+=` object/array, member-target compound, 13-case operator sweep vs real JS).
-  Package suite: 220 pass.
-
----
-
-## 4. Remaining work (detailed TODO)
-
-### 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 -
-      `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
-      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
-      (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
-      special-case number formatting in `formatConsoleArgument`.
-- [x] Sandbox values nested inside logged containers print `[CodeMode reference]`
-      (`console.log({ m: map })`) - could deep-format instead.
-
-### Next iteration: optional search input boundary
-
-- [ ] `SearchInput` uses Effect's exact `optionalKey`, so an omitted field is accepted but an
-      explicitly present `undefined` field is rejected. The previous handwritten validator
-      treated explicit `undefined` as omission. Decide whether search should preserve that
-      convenience locally or whether all tool arguments should adopt JSON-style undefined
-      normalization; do not broaden `copyOut` semantics solely to fix search.
-
-### 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. Programs narrow unknown results
-      before use; the prompt no longer recommends unconditional JSON parsing. Considered and deferred: (a) conservative boundary
-      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
-      signatures). Result quality is dominated by whether servers declare output schemas;
-      revisit once real usage shows which failure modes matter.
-
-### Next iteration: stdlib surface (prioritized)
-
-Current instructions say "usual Array/String/Object/Math/JSON methods," but the interpreter is
-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"
-      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
-      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
-      (`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
-      payload cleanup and does not require opening the broader binary boundary.
-- [ ] **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
-      `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
-      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
-      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.
-
-Explicit non-goals for now: `structuredClone`, `WeakMap`/`WeakSet`, and timers
-(`setTimeout`/`setInterval`/`queueMicrotask`). They do not materially improve the current tool
-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
-      "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;
-      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
-      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
-      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
-      worthwhile separate change.)
-- [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 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
-      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).
-- [x] Style: `code-mode.ts` is the only `src/session` sibling without the
-      `export * as ... from "./..."` self-reexport footer, forcing a star import at
-      `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
-      `slice(0, -1)` when no underscore (unreachable today; guard or drop); dead
-      `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
-      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 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
-      `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
-      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`
-      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.
-- [ ] No collision guard on the `execute` tool id (a plugin/custom tool named `execute`
-      is silently shadowed; a log line would do).
-- [ ] Style nits: triple-nested `yield*` in `tools.ts:101-107` argument position (bind
-      first, like neighbors); single-use micro-helpers (`toJsonSchema` is a bare cast);
-      comment density far above session-neighbor norm; adapter tests use raw
-      `Effect.runPromise` + hand-built layers with `as any` instead of the
-      `testEffect`/`LayerNode.compile` fixture pattern (`test/tool/grep.test.ts:25-31`)
-      and star-import `Truncate`.
-- [ ] Reviewer observation worth keeping: MCP server instructions (`sys.mcp`,
-      `session/system.ts:110-126`) still inject prose referencing server-native tool
-      names that are no longer directly callable under code mode.
-
-### Backlog / loose ends (non-blocking, any order)
-
-- [ ] `evaluateUpdateExpression` (`++`/`--`) still uses raw `Number(current)`, so `d++` on a
-      sandbox Date yields `NaN` where `d += 1` now uses epoch semantics (and real JS `d++`
-      would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment
-      parity fix; route it through `applyBinaryOperator` if it ever matters.
-- [ ] Media-only marker could name what it attached when MCP provides names: `image`/`audio`
-      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 -
-      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 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
-      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
-      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 +
-      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 -
-      remaining pre-PR work is essentially just opening the PR. Attachment-propagation
-      verification (below) stays parked as post-MVP.
-
----
-
-## 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 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<unknown>`** (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
-  intake; sandbox value types serialize to JSON forms) AND intra-sandbox data checkpoint
-  (`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 -
-  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
-  **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
-  (`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
-  `metadata.truncated` is set. The `execute` tool currently rides that for free.
-- Effect version: both repos pin `effect@4.0.0-beta.83` via bun catalogs. This package uses
-  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`.
-- OpenCode file map (integration points): `src/tool/code-mode.ts` (the adapter, now a
-  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);
-  `packages/tui/src/routes/session/index.tsx` (`Execute` progress component);
-  `src/effect/runtime-flags.ts` (feature flag).
+Program results and tool arguments are JSON-like data. Dates become ISO strings at host boundaries; RegExp, Map, and
+Set values become `{}` as they do under JSON serialization. Promise and runtime reference values cannot cross the
+boundary.
+
+Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a
+tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid
+data, tool failures, limits, timeouts, and execution failures.
+
+Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and
+attach them to the outer result, but the program receives only the structured tool output.
+
+### V2 OpenCode adapter
+
+This section describes the `v2` branch integration. On `dev`, CodeMode is integrated through
+`packages/opencode/src/tool/code-mode.ts`, where nested MCP calls run the `tool.execute.before` and
+`tool.execute.after` plugin hooks.
+
+CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and
+`packages/core/src/tool/execute.ts`:
+
+- Core has one canonical `Tool` representation. Location-scoped producers register direct or deferred tools through
+  `Tools.Service`.
+- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes direct tools
+  normally.
+- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become
+  CodeMode namespaces instead of flattened model-facing names.
+- Each nested call checks that its captured registration is still current before dispatching it.
+- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution
+  authorization.
+- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result.
+- Nested call statuses are returned as final `execute` metadata for the TUI.
+- `execute` is the one model-facing tool invocation. Nested calls reuse its invocation context and do not independently
+  run registry hooks or model-output bounding; this keeps complete intermediate structured values available for
+  in-program filtering. The outer `execute` settlement is the single model-output bounding boundary.
+- Core supplies no CodeMode timeout or tool-call limit. User cancellation interrupts the outer invocation and its
+  supervised children; the outer settlement applies Core's normal output-retention policy.
+
+MCP tools use this canonical path: they register as grouped tools and are deferred while CodeMode is enabled. Existing
+output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals
+inside CodeMode.
+
+## Intentionally Unsupported
+
+These are product boundaries rather than DSL backlog:
+
+- Ambient filesystem, process, environment, network, credential, or application access. External work must go through
+  supplied tools.
+- Modules, imports, dynamic imports, `eval`, arbitrary host globals, npm packages, and prototype mutation.
+- Generic permission prompts, authorization policy, durable pause/resume, replay, storage, or exactly-once external
+  side effects. Hosts and tools own those concerns.
+- Heuristic parsing of text tool results as JSON. A result should not silently change type based on its contents.
+
+The OpenAPI adapter may gain more transports and encodings, but it must continue skipping operations it cannot
+represent accurately rather than guessing semantics.
+
+## Decisions and Rationale
+
+| Decision | Rationale |
+| --- | --- |
+| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. |
+| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
+| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
+| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
+| Start tool promises eagerly and supervise them. | This preserves normal call-time parallelism while giving each call run-once settlement and interruption safety. |
+| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
+| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
+| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. |
+| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. |
+| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. |
+
+## Remaining Work
+
+Keep only material unresolved work here. Small isolated defects should be GitHub issues; adapter-only work belongs in
+the adapter TODO. Delete entries when completed.
+
+### DSL expansion
+
+The supported JavaScript subset should grow when common model-generated code improves tool orchestration. These are
+current omissions to implement, not intentional product boundaries.
+
+- [ ] Design proper multi-stage promise pipelines. Supporting `.then`, `.catch`, and `.finally` should preserve promise
+      assimilation, cancellation, failure handling, and concurrent per-item pipelines rather than adding syntax-only
+      shims. Consider `Promise.any` in the same pass.
+- [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and
+      collection values, then extend it to bounded host streams when a stream boundary exists.
+- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to
+      `Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed.
+- [ ] Close basic `Object` parity gaps: let `Object.values`/`Object.entries` accept arrays, make `Object.assign` validate
+      and mutate its target, add `Object.is`, and let `Object.fromEntries` consume every supported iterable.
+- [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set
+      composition methods, and `Array.prototype.toSpliced`.
+- [ ] Complete the deterministic `Math` surface beyond the current arithmetic, rounding, root, power, and logarithm
+      helpers. Decide separately whether nondeterministic `Math.random` belongs in the runtime.
+- [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter
+      defects are distinguishable without leaking private causes.
+
+### Tool and result contracts
+
+- [ ] Design explicit tagged representations and size rules before allowing Blob, File, ArrayBuffer, typed arrays, or
+      host streams to cross the sandbox boundary.
+- [ ] Define one consistent policy for tool path segments named `__proto__`, `constructor`, or `prototype`. They must
+      either be safely callable, rejected before catalog generation, or use one documented escaping rule.

Разница между файлами не показана из-за своего большого размера
+ 8 - 3905
packages/codemode/src/codemode.ts


+ 1 - 1
packages/codemode/src/index.ts

@@ -1,4 +1,4 @@
 export * as CodeMode from "./codemode.js"
-export * as Tool from "./tool-api.js"
+export * as Tool from "./tool.js"
 export * as OpenAPI from "./openapi/index.js"
 export { ToolError, toolError } from "./tool-error.js"

+ 200 - 0
packages/codemode/src/interpreter/model.ts

@@ -0,0 +1,200 @@
+import type { SafeObject } from "../tool-runtime.js"
+import type { SandboxURL } from "../values.js"
+
+export type SourcePosition = {
+  line: number
+  column: number
+}
+
+export type SourceLocation = {
+  start: SourcePosition
+  end: SourcePosition
+}
+
+export type AstNode = {
+  type: string
+  loc?: SourceLocation
+  [key: string]: unknown
+}
+
+export type ProgramNode = AstNode & {
+  type: "Program"
+  body: Array<AstNode>
+}
+
+export type Binding = {
+  mutable: boolean
+  value: unknown
+  initialized?: boolean
+}
+
+export type StatementResult =
+  | { kind: "none" }
+  | { kind: "value"; value: unknown }
+  | { kind: "return"; value: unknown }
+  | { kind: "break" }
+  | { kind: "continue" }
+
+export type MemberReference = {
+  target: SafeObject | Array<unknown> | SandboxURL
+  key: string | number
+}
+
+export class CodeModeFunction {
+  constructor(
+    readonly parameters: ReadonlyArray<AstNode>,
+    readonly body: AstNode,
+    readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
+  ) {}
+}
+
+export class IntrinsicReference {
+  constructor(
+    readonly receiver: unknown,
+    readonly name: string,
+  ) {}
+}
+
+export class ComputedValue {
+  constructor(readonly value: unknown) {}
+}
+
+export class PromiseNamespace {}
+
+export type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject"
+
+export class PromiseMethodReference {
+  constructor(readonly name: PromiseMethodName) {}
+}
+
+export type GlobalNamespaceName =
+  | "Object"
+  | "Math"
+  | "JSON"
+  | "Array"
+  | "console"
+  | "Date"
+  | "RegExp"
+  | "Map"
+  | "Set"
+  | "URL"
+  | "URLSearchParams"
+
+export class GlobalNamespace {
+  constructor(readonly name: GlobalNamespaceName) {}
+}
+
+export class GlobalMethodReference {
+  constructor(
+    readonly namespace: GlobalNamespaceName | "Number" | "String",
+    readonly name: string,
+  ) {}
+}
+
+export class CoercionFunction {
+  constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
+}
+
+export class UriFunction {
+  constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
+}
+
+export class ProgramThrow {
+  constructor(readonly value: unknown) {}
+}
+
+export class ErrorConstructorReference {
+  constructor(readonly name: string) {}
+}
+
+export type DiagnosticKind =
+  | "ParseError"
+  | "UnsupportedSyntax"
+  | "UnknownTool"
+  | "InvalidToolInput"
+  | "InvalidToolOutput"
+  | "InvalidDataValue"
+  | "ToolCallLimitExceeded"
+  | "TimeoutExceeded"
+  | "ToolFailure"
+  | "ExecutionFailure"
+
+export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
+
+export 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/URL/URLSearchParams, URI encoding helpers, 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)."
+
+export class InterpreterRuntimeError extends Error {
+  readonly node?: AstNode
+  errorName: string = "Error"
+
+  constructor(
+    message: string,
+    node?: AstNode,
+    readonly kind: DiagnosticKind = "ExecutionFailure",
+    readonly suggestions?: ReadonlyArray<string>,
+  ) {
+    super(message)
+    this.name = "InterpreterRuntimeError"
+    if (node) this.node = node
+  }
+
+  as(errorName: string): this {
+    this.errorName = errorName
+    return this
+  }
+}
+
+export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
+  new InterpreterRuntimeError(
+    `Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`,
+    node,
+    "UnsupportedSyntax",
+    [supportedSyntaxMessage],
+  )
+
+export const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null
+
+export const asNode = (value: unknown, context: string): AstNode => {
+  if (!isRecord(value) || typeof value.type !== "string") {
+    throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`)
+  }
+  return value as AstNode
+}
+
+export const getArray = (node: AstNode, key: string): Array<unknown> => {
+  const value = node[key]
+  if (!Array.isArray(value)) throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node)
+  return value
+}
+
+export const getString = (node: AstNode, key: string): string => {
+  const value = node[key]
+  if (typeof value !== "string") throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node)
+  return value
+}
+
+export const getBoolean = (node: AstNode, key: string): boolean => {
+  const value = node[key]
+  if (typeof value !== "boolean") throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node)
+  return value
+}
+
+export const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => {
+  const value = node[key]
+  if (value === undefined || value === null) return undefined
+  return asNode(value, key)
+}
+
+export const getNode = (node: AstNode, key: string): AstNode => asNode(node[key], key)
+
+export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({
+  line: Math.max(1, (node.loc?.start.line ?? 2) - 1),
+  column: Math.max(1, (node.loc?.start.column ?? 4) - 3),
+})
+
+export const formatLocation = (node?: AstNode): string => {
+  if (!node?.loc) return ""
+  const location = sourceLocation(node)
+  return ` (line ${location.line}, col ${location.column})`
+}

+ 3465 - 0
packages/codemode/src/interpreter/runtime.ts

@@ -0,0 +1,3465 @@
+import { parse } from "acorn"
+import { Cause, Effect, Exit, Fiber, Semaphore } from "effect"
+import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
+import {
+  copyIn,
+  copyOut,
+  isBlockedMember,
+  ToolReference,
+  ToolRuntime,
+  ToolRuntimeError,
+  type HostTools,
+  type SafeObject,
+  type Services,
+} from "../tool-runtime.js"
+import { ToolError } from "../tool-error.js"
+import type {
+  DataValue,
+  Diagnostic,
+  DiagnosticKind,
+  ExecuteOptions,
+  ResolvedExecutionLimits,
+  Result,
+} from "../codemode.js"
+import {
+  type AstNode,
+  asNode,
+  type Binding,
+  CodeModeFunction,
+  CoercionFunction,
+  ComputedValue,
+  ErrorConstructorReference,
+  GlobalMethodReference,
+  GlobalNamespace,
+  type GlobalNamespaceName,
+  formatLocation,
+  getArray,
+  getBoolean,
+  getNode,
+  getOptionalNode,
+  getString,
+  IntrinsicReference,
+  InterpreterRuntimeError,
+  isRecord,
+  type MemberReference,
+  OptionalShortCircuit,
+  PromiseMethodReference,
+  type PromiseMethodName,
+  PromiseNamespace,
+  ProgramThrow,
+  type ProgramNode,
+  type StatementResult,
+  sourceLocation,
+  supportedSyntaxMessage,
+  unsupportedSyntax,
+  UriFunction,
+} from "./model.js"
+import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
+import { consoleMethods, MAX_CONSOLE_DEPTH } from "../stdlib/console.js"
+import { dateMethods, dateStatics, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
+import { invokeJsonMethod } from "../stdlib/json.js"
+import { invokeMathMethod, mathConstants } from "../stdlib/math.js"
+import {
+  invokeNumberMethod,
+  invokeNumberStatic,
+  numberConstants,
+  numberMethods,
+  numberStatics,
+} from "../stdlib/number.js"
+import { invokeObjectMethod } from "../stdlib/object.js"
+import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js"
+import {
+  escapeRegexHint,
+  invokeRegExpMethod,
+  matchToValue,
+  regexpMethods,
+  regexpProperties,
+  regexFailureReason,
+  toHostRegex,
+} from "../stdlib/regexp.js"
+import { invokeStringStatic, stringMethods, stringStatics } from "../stdlib/string.js"
+import {
+  urlMethods,
+  urlProperties,
+  urlSearchParamsMethods,
+  urlStatics,
+  urlWritableProperties,
+  invokeUriFunction,
+  invokeURLMethod,
+  invokeURLStatic,
+  uriArgument,
+  urlArgument,
+} from "../stdlib/url.js"
+import {
+  boundedData,
+  coerceToNumber,
+  coerceToString,
+  compoundOperators,
+  createErrorValue,
+  errorBrandName,
+  errorConstructors,
+  invokeCoercion,
+  valueConstructors,
+} from "../stdlib/value.js"
+import {
+  isSandboxValue,
+  SandboxDate,
+  SandboxMap,
+  SandboxPromise,
+  SandboxRegExp,
+  SandboxSet,
+  SandboxURL,
+  SandboxURLSearchParams,
+} from "../values.js"
+
+const parseProgram = (code: string): ProgramNode => {
+  const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, {
+    reportDiagnostics: true,
+    compilerOptions: {
+      target: ScriptTarget.ESNext,
+      module: ModuleKind.ESNext,
+    },
+  })
+  const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
+
+  if (diagnostic) {
+    throw new InterpreterRuntimeError(
+      `Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
+      undefined,
+      "ParseError",
+    )
+  }
+
+  const bodyStart = transpiled.outputText.indexOf("{") + 1
+  const bodyEnd = transpiled.outputText.lastIndexOf("}")
+  const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
+  const parsed = parse(executableCode, {
+    ecmaVersion: "latest",
+    sourceType: "script",
+    allowReturnOutsideFunction: true,
+    allowAwaitOutsideFunction: true,
+    locations: true,
+  }) as unknown
+
+  if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) {
+    throw new InterpreterRuntimeError("Failed to parse script as a Program node.")
+  }
+
+  return parsed as ProgramNode
+}
+
+const publicErrorMessage = (message: string): string =>
+  message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "<redacted-path>")
+
+const normalizeError = (error: unknown): Diagnostic => {
+  if (error instanceof InterpreterRuntimeError) {
+    return {
+      kind: error.kind,
+      message: `${error.message}${formatLocation(error.node)}`,
+      ...(error.node?.loc ? { location: sourceLocation(error.node) } : {}),
+      ...(error.suggestions ? { suggestions: error.suggestions } : {}),
+    }
+  }
+
+  if (error instanceof ToolRuntimeError) {
+    return {
+      kind: error.kind,
+      message: error.message,
+      ...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
+    }
+  }
+
+  if (error instanceof ToolError) {
+    return { kind: "ToolFailure", message: publicErrorMessage(error.message) }
+  }
+
+  if (error instanceof ProgramThrow) {
+    const value = error.value
+    let message: string
+    if (containsRuntimeReference(value)) {
+      // A thrown tool/function reference must not leak its internal structure.
+      message = "a non-data value"
+    } else if (typeof value === "string") {
+      message = value
+    } else if (
+      value !== null &&
+      typeof value === "object" &&
+      typeof (value as { message?: unknown }).message === "string"
+    ) {
+      message = (value as { message: string }).message
+    } else {
+      try {
+        message = JSON.stringify(copyOut(value)) ?? String(value)
+      } catch {
+        message = String(value)
+      }
+    }
+    return { kind: "ExecutionFailure", message: `Uncaught: ${message}` }
+  }
+
+  if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) {
+    return {
+      kind: "ExecutionFailure",
+      message: "Execution exceeded the maximum nesting depth.",
+    }
+  }
+
+  if (error instanceof Error) {
+    return {
+      kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure",
+      message: publicErrorMessage(error.message),
+    }
+  }
+
+  // A non-Error thrown by a host tool (raw string / number / Symbol) still routes through
+  // path redaction so filesystem paths can never leak through the catch-all branch.
+  return {
+    kind: "ExecutionFailure",
+    message: publicErrorMessage(String(error)),
+  }
+}
+
+// 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)
+  const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error"
+  return createErrorValue(name, normalizeError(thrown).message)
+}
+
+const isRuntimeReference = (value: unknown): boolean =>
+  value instanceof CodeModeFunction ||
+  value instanceof ToolReference ||
+  value instanceof IntrinsicReference ||
+  value instanceof GlobalNamespace ||
+  value instanceof GlobalMethodReference ||
+  value instanceof PromiseNamespace ||
+  value instanceof PromiseMethodReference ||
+  value instanceof SandboxPromise ||
+  value instanceof CoercionFunction ||
+  value instanceof UriFunction ||
+  value instanceof ErrorConstructorReference ||
+  isSandboxValue(value)
+
+const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
+  if (isRuntimeReference(value)) return true
+  if (value === null || typeof value !== "object") return false
+  if (seen.has(value)) return false
+  seen.add(value)
+  const contains = Array.isArray(value)
+    ? value.some((item) => containsRuntimeReference(item, seen))
+    : Object.values(value).some((item) => containsRuntimeReference(item, seen))
+  seen.delete(value)
+  return contains
+}
+
+// Like containsRuntimeReference, but sandbox standard-library values count as data:
+// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive
+// coercion) rather than rejecting them as opaque interpreter machinery.
+const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
+  if (isSandboxValue(value)) return false
+  if (isRuntimeReference(value)) return true
+  if (value === null || typeof value !== "object") return false
+  if (seen.has(value)) return false
+  seen.add(value)
+  const contains = Array.isArray(value)
+    ? value.some((item) => containsOpaqueReference(item, seen))
+    : Object.values(value).some((item) => containsOpaqueReference(item, seen))
+  seen.delete(value)
+  return contains
+}
+
+// `typeof` never throws in JS; map every interpreter value to its JS-visible category.
+// A SandboxPromise falls through to the final `typeof value` and reports "object", exactly
+// like a real JS promise.
+const typeofValue = (value: unknown): string => {
+  if (
+    value instanceof CodeModeFunction ||
+    value instanceof CoercionFunction ||
+    value instanceof IntrinsicReference ||
+    value instanceof GlobalMethodReference ||
+    value instanceof PromiseMethodReference ||
+    value instanceof PromiseNamespace ||
+    value instanceof ErrorConstructorReference
+  )
+    return "function"
+  if (value instanceof UriFunction) return "function"
+  if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
+  if (value instanceof GlobalNamespace) {
+    return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
+  }
+  return typeof value
+}
+
+// `x instanceof C` against the constructors CodeMode knows. Like `typeof`, it observes any
+// left-hand value (opaque references included) without coercing it. Error checks use the
+// error brand: `instanceof Error` accepts every branded error; a specific error type matches
+// its own brand only (as in JS, where TypeError instances are also Error instances).
+const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
+  if (rhs instanceof ErrorConstructorReference) {
+    const brand = errorBrandName(lhs)
+    return brand !== undefined && (rhs.name === "Error" || brand === rhs.name)
+  }
+  if (rhs instanceof GlobalNamespace) {
+    switch (rhs.name) {
+      case "Date":
+        return lhs instanceof SandboxDate
+      case "RegExp":
+        return lhs instanceof SandboxRegExp
+      case "Map":
+        return lhs instanceof SandboxMap
+      case "Set":
+        return lhs instanceof SandboxSet
+      case "URL":
+        return lhs instanceof SandboxURL
+      case "URLSearchParams":
+        return lhs instanceof SandboxURLSearchParams
+      case "Array":
+        return Array.isArray(lhs)
+      case "Object":
+        return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
+    }
+  }
+  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.
+  if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
+    return false
+  }
+  throw new InterpreterRuntimeError(
+    "The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, or Promise.",
+    node,
+  )
+}
+
+const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
+  const str = (index: number): string => {
+    const arg = args[index]
+    if (typeof arg !== "string")
+      throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
+    return arg
+  }
+  const num = (index: number): number => {
+    const arg = args[index]
+    if (typeof arg !== "number")
+      throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
+    return arg
+  }
+  const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
+  const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
+
+  let result: unknown
+  switch (name) {
+    case "toLowerCase":
+      result = value.toLowerCase()
+      break
+    case "toUpperCase":
+      result = value.toUpperCase()
+      break
+    case "trim":
+      result = value.trim()
+      break
+    // trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them.
+    case "trimStart":
+    case "trimLeft":
+      result = value.trimStart()
+      break
+    case "trimEnd":
+    case "trimRight":
+      result = value.trimEnd()
+      break
+    // Locale/options arguments are ignored: comparison runs with the host default locale, and
+    // the common use is a sort comparator where any consistent order works.
+    case "localeCompare":
+      result = value.localeCompare(str(0))
+      break
+    case "normalize": {
+      const form = optStr(0)
+      try {
+        result = value.normalize(form)
+      } catch {
+        throw new InterpreterRuntimeError(
+          `String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
+          node,
+        ).as("RangeError")
+      }
+      break
+    }
+    case "split": {
+      if (args.length === 0) {
+        result = [value]
+        break
+      }
+      if (args[0] instanceof SandboxRegExp) {
+        result = value.split((args[0] as SandboxRegExp).regex, optNum(1))
+        break
+      }
+      const requestedLimit = optNum(1)
+      result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
+      break
+    }
+    case "slice":
+      result = value.slice(optNum(0), optNum(1))
+      break
+    case "includes":
+      result = value.includes(str(0), optNum(1))
+      break
+    case "startsWith":
+      result = value.startsWith(str(0), optNum(1))
+      break
+    case "endsWith":
+      result = value.endsWith(str(0), optNum(1))
+      break
+    case "indexOf":
+      result = value.indexOf(str(0), optNum(1))
+      break
+    case "lastIndexOf":
+      result = value.lastIndexOf(str(0), optNum(1))
+      break
+    case "replace":
+    case "replaceAll": {
+      if (args[0] instanceof SandboxRegExp) {
+        const pattern = (args[0] as SandboxRegExp).regex
+        const replacement = str(1)
+        if (name === "replaceAll" && !pattern.global) {
+          throw new InterpreterRuntimeError(
+            `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
+            node,
+          )
+        }
+        result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
+        break
+      }
+      if (name === "replace") {
+        result = value.replace(str(0), str(1))
+        break
+      }
+      result = value.replaceAll(str(0), str(1))
+      break
+    }
+    case "match": {
+      const pattern = toHostRegex(args[0], name, node)
+      const matched = value.match(pattern)
+      if (matched === null) return null
+      // A global match is a plain array of matched strings; a non-global match carries
+      // index/groups own properties, so bypass the copying data checkpoint to keep them.
+      if (pattern.global) return boundedData(matched, "String.match result")
+      return matchToValue(matched)
+    }
+    case "matchAll": {
+      const pattern = toHostRegex(args[0], name, node, "g")
+      if (!pattern.global) {
+        throw new InterpreterRuntimeError(
+          `String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
+          node,
+        )
+      }
+      // Materialized as an array (not an iterator); each entry is a match array with
+      // index/groups own properties. Match count is bounded by the subject length.
+      return Array.from(value.matchAll(pattern), matchToValue)
+    }
+    case "search": {
+      result = value.search(toHostRegex(args[0], name, node))
+      break
+    }
+    case "repeat": {
+      const count = num(0)
+      if (!Number.isFinite(count) || count < 0)
+        throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
+      result = value.repeat(count)
+      break
+    }
+    case "padStart":
+      result = value.padStart(num(0), optStr(1))
+      break
+    case "padEnd":
+      result = value.padEnd(num(0), optStr(1))
+      break
+    case "charAt":
+      result = value.charAt(optNum(0) ?? 0)
+      break
+    case "at":
+      result = value.at(optNum(0) ?? 0)
+      break
+    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.
+    case "charCodeAt":
+      result = value.charCodeAt(optNum(0) ?? 0)
+      break
+    case "codePointAt":
+      result = value.codePointAt(optNum(0) ?? 0)
+      break
+    case "toString":
+      result = value
+      break
+    case "concat": {
+      result = value.concat(...args.map((_, index) => str(index)))
+      break
+    }
+    default:
+      throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
+  }
+  return boundedData(result, `String.${name} result`)
+}
+
+const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
+  switch (name) {
+    case "isArray":
+      return Array.isArray(args[0])
+    case "of":
+      return [...args]
+    case "from": {
+      if (args.length > 1) {
+        throw new InterpreterRuntimeError(
+          "Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.",
+          node,
+          "UnsupportedSyntax",
+          [supportedSyntaxMessage],
+        )
+      }
+      // Map/Set materialize directly (the data checkpoint would serialize them to {}).
+      if (args[0] instanceof SandboxMap)
+        return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item])
+      if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values())
+      if (args[0] instanceof SandboxURLSearchParams) {
+        return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
+      }
+      const source = boundedData(args[0], "Array.from input")
+      if (typeof source === "string") return Array.from(source)
+      if (Array.isArray(source)) return [...source]
+      if (
+        source !== null &&
+        typeof source === "object" &&
+        typeof (source as { length?: unknown }).length === "number"
+      ) {
+        return Array.from(source as ArrayLike<unknown>)
+      }
+      throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node)
+    }
+    default:
+      throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
+  }
+}
+
+const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
+  if (ref.namespace === "console")
+    throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
+  if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
+  if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
+  if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
+  if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
+  if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
+  if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
+  if (ref.namespace === "Date") {
+    if (!dateStatics.has(ref.name))
+      throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node)
+    return invokeDateStatic(ref.name, args, node)
+  }
+  if (
+    ref.namespace === "RegExp" ||
+    ref.namespace === "Map" ||
+    ref.namespace === "Set" ||
+    ref.namespace === "URLSearchParams"
+  ) {
+    throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
+  }
+  return invokeJsonMethod(ref.name, args, node)
+}
+
+// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run.
+const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<string> => {
+  switch (pattern.type) {
+    case "Identifier":
+      out.push(getString(pattern, "name"))
+      break
+    case "AssignmentPattern":
+      collectPatternNames(getNode(pattern, "left"), out)
+      break
+    case "RestElement":
+      collectPatternNames(getNode(pattern, "argument"), out)
+      break
+    case "ArrayPattern":
+      for (const element of getArray(pattern, "elements")) {
+        if (element !== null) collectPatternNames(asNode(element, "elements"), out)
+      }
+      break
+    case "ObjectPattern":
+      for (const property of getArray(pattern, "properties")) {
+        const prop = asNode(property, "properties")
+        collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out)
+      }
+      break
+  }
+  return out
+}
+
+class Interpreter<R> {
+  private scopes: Array<Map<string, Binding>>
+  private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
+  // Enumerable namespace/tool names at a node of the host tool tree, threaded from
+  // ToolRuntime.make like invokeTool: the interpreter never holds the tree itself.
+  private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
+  private readonly logs: Array<string>
+  private lastValue: unknown
+  // Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap).
+  private readonly callPermits: Semaphore.Semaphore
+  // Fiber-backed promises whose settlement no program construct has observed yet. Successful
+  // program completion drains these (like a runtime waiting on in-flight work at exit) and
+  // surfaces a never-awaited failure as an unhandled-rejection diagnostic.
+  private readonly pendingSettlements = new Set<SandboxPromise>()
+
+  constructor(
+    invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
+    toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
+    logs: Array<string> = [],
+  ) {
+    const globalScope = new Map<string, Binding>()
+    this.scopes = [globalScope]
+    this.invokeTool = invokeTool
+    this.toolKeys = toolKeys
+    this.logs = logs
+    this.lastValue = undefined
+    this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
+    globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
+    globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
+    globalScope.set("undefined", { mutable: false, value: undefined })
+    globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
+    globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
+    globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") })
+    globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") })
+    globalScope.set("String", { mutable: false, value: new CoercionFunction("String") })
+    globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") })
+    globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") })
+    globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
+    globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
+    globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
+    globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
+    globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
+    globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
+    globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") })
+    globalScope.set("URL", { mutable: false, value: new GlobalNamespace("URL") })
+    globalScope.set("URLSearchParams", { mutable: false, value: new GlobalNamespace("URLSearchParams") })
+    globalScope.set("encodeURI", { mutable: false, value: new UriFunction("encodeURI") })
+    globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") })
+    globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") })
+    globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") })
+    // Error constructors are real values, so `x instanceof Error` works and `Error("msg")`
+    // (with or without `new`) constructs a branded { name, message } error object.
+    for (const name of errorConstructors) {
+      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)`.
+    globalScope.set("NaN", { mutable: false, value: NaN })
+    globalScope.set("Infinity", { mutable: false, value: Infinity })
+  }
+
+  run(program: ProgramNode): Effect.Effect<unknown, unknown, R> {
+    const self = this
+    // Run the program body in its own module scope on top of the builtin global scope, so
+    // top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like
+    // JS module scope, instead of colliding with the seeded globals.
+    this.pushScope()
+    return Effect.gen(function* () {
+      self.hoistFunctions(program.body)
+      let value: unknown = undefined
+      let returned = false
+      for (const statement of program.body) {
+        const result = yield* self.evaluateStatement(statement)
+
+        if (result.kind === "return") {
+          value = result.value
+          returned = true
+          break
+        }
+
+        if (result.kind === "break" || result.kind === "continue") {
+          throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
+        }
+
+        if (result.kind === "value") {
+          self.lastValue = result.value
+        }
+      }
+      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
+      // without an explicit await, exactly as in JS.
+      if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
+      yield* self.drainPendingSettlements()
+      return value
+    }).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
+  }
+
+  // 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
+  // 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<void, unknown, never> {
+    const self = this
+    return Effect.gen(function* () {
+      for (const promise of [...self.pendingSettlements]) {
+        const exit = yield* self.observePromise(promise)
+        if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue
+        const failure = normalizeError(Cause.squash(exit.cause))
+        throw new InterpreterRuntimeError(
+          `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."],
+        )
+      }
+    })
+  }
+
+  // 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.
+  private createToolCallPromise(
+    path: ReadonlyArray<string>,
+    args: Array<unknown>,
+  ): Effect.Effect<SandboxPromise, never, R> {
+    const self = this
+    return Effect.map(
+      Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), {
+        startImmediately: true,
+      }),
+      (fiber) => {
+        const promise = new SandboxPromise(fiber)
+        self.pendingSettlements.add(promise)
+        return promise
+      },
+    )
+  }
+
+  // The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking.
+  // Fiber settlement is idempotent, so observing the same promise repeatedly (await twice,
+  // Promise.all([p, p])) never re-runs the underlying call.
+  private observePromise(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
+    this.pendingSettlements.delete(promise)
+    return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void)
+  }
+
+  // `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch
+  // observes it exactly like a synchronous throw at the await site.
+  private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect<unknown, unknown, never> {
+    const self = this
+    return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node))
+  }
+
+  private unwrapPromiseExit(
+    promise: SandboxPromise | undefined,
+    exit: Exit.Exit<unknown, unknown>,
+    node?: AstNode,
+  ): Effect.Effect<unknown, unknown> {
+    if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
+    // A call Promise.race interrupted after losing settles as a catchable program failure;
+    // any other interruption is execution teardown (timeout/host) and must keep propagating
+    // as interruption rather than becoming program-visible data.
+    if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) {
+      return Effect.fail(
+        new InterpreterRuntimeError(
+          "This tool call was interrupted because another value settled a Promise.race first.",
+          node,
+        ),
+      )
+    }
+    return Effect.failCause(exit.cause)
+  }
+
+  private evaluateStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    switch (node.type) {
+      case "ExpressionStatement":
+        return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value }))
+      case "VariableDeclaration":
+        return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" }))
+      case "ReturnStatement": {
+        const argumentNode = getOptionalNode(node, "argument")
+        return argumentNode
+          ? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value }))
+          : Effect.succeed({ kind: "return", value: undefined })
+      }
+      case "BlockStatement":
+        return this.evaluateBlock(node)
+      case "IfStatement":
+        return this.evaluateIfStatement(node)
+      case "SwitchStatement":
+        return this.evaluateSwitchStatement(node)
+      case "WhileStatement":
+        return this.evaluateWhileStatement(node)
+      case "DoWhileStatement":
+        return this.evaluateDoWhileStatement(node)
+      case "ForStatement":
+        return this.evaluateForStatement(node)
+      case "ForOfStatement":
+        return this.evaluateForOfStatement(node)
+      case "ForInStatement":
+        return this.evaluateForInStatement(node)
+      case "BreakStatement":
+        return Effect.succeed(this.evaluateBreakStatement(node))
+      case "ContinueStatement":
+        return Effect.succeed(this.evaluateContinueStatement(node))
+      case "ThrowStatement":
+        return this.evaluateThrowStatement(node)
+      case "TryStatement":
+        return this.evaluateTryStatement(node)
+      case "EmptyStatement":
+        return Effect.succeed({ kind: "none" })
+      case "FunctionDeclaration":
+        return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions
+      default:
+        throw unsupportedSyntax(node.type, node)
+    }
+  }
+
+  private evaluateBlock(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    this.pushScope()
+    const self = this
+    return Effect.gen(function* () {
+      const body = getArray(node, "body")
+      self.hoistFunctions(body)
+
+      for (const statementValue of body) {
+        const statement = asNode(statementValue, "body")
+        const result = yield* self.evaluateStatement(statement)
+
+        if (result.kind === "value") {
+          self.lastValue = result.value
+          continue
+        }
+
+        if (result.kind !== "none") {
+          return result
+        }
+      }
+
+      return { kind: "none" } satisfies StatementResult
+    }).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
+  }
+
+  private createFunction(node: AstNode): CodeModeFunction {
+    if (node.generator === true) {
+      throw new InterpreterRuntimeError(
+        "Generator functions are not supported in CodeMode.",
+        node,
+        "UnsupportedSyntax",
+        [supportedSyntaxMessage],
+      )
+    }
+    return new CodeModeFunction(
+      getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
+      getNode(node, "body"),
+      this.scopes.slice(),
+    )
+  }
+
+  // Function declarations are hoisted: bound in their scope before the body runs, so a
+  // program can call a helper defined further down (matching JavaScript).
+  private hoistFunctions(statements: Array<unknown>): void {
+    for (const statementValue of statements) {
+      if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue
+      const node = statementValue as AstNode
+      this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node)
+    }
+  }
+
+  private evaluateIfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    const testNode = getNode(node, "test")
+    const consequentNode = getNode(node, "consequent")
+    const alternateNode = getOptionalNode(node, "alternate")
+
+    return Effect.flatMap(this.evaluateExpression(testNode), (test) =>
+      test
+        ? this.evaluateStatement(consequentNode)
+        : alternateNode
+          ? this.evaluateStatement(alternateNode)
+          : Effect.succeed({ kind: "none" }),
+    )
+  }
+
+  private evaluateSwitchStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    const self = this
+    this.pushScope()
+    return Effect.gen(function* () {
+      const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
+      if (containsOpaqueReference(discriminant)) {
+        throw new InterpreterRuntimeError(
+          "Switch discriminants must be data values in CodeMode.",
+          node,
+          "InvalidDataValue",
+        )
+      }
+      const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`))
+      let defaultIndex: number | undefined
+      let selected: number | undefined
+      for (const [index, branch] of cases.entries()) {
+        const test = getOptionalNode(branch, "test")
+        if (!test) {
+          defaultIndex = index
+          continue
+        }
+        const candidate = yield* self.evaluateExpression(test)
+        if (containsOpaqueReference(candidate)) {
+          throw new InterpreterRuntimeError(
+            "Switch case values must be data values in CodeMode.",
+            test,
+            "InvalidDataValue",
+          )
+        }
+        if (candidate === discriminant) {
+          selected = index
+          break
+        }
+      }
+      const start = selected ?? defaultIndex
+      if (start === undefined) return { kind: "none" } satisfies StatementResult
+      for (let index = start; index < cases.length; index += 1) {
+        for (const statementValue of getArray(cases[index]!, "consequent")) {
+          const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
+          if (result.kind === "break") return { kind: "none" } satisfies StatementResult
+          if (result.kind === "return" || result.kind === "continue") return result
+          if (result.kind === "value") self.lastValue = result.value
+        }
+      }
+      return { kind: "none" } satisfies StatementResult
+    }).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
+  }
+
+  private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    const testNode = getNode(node, "test")
+    const bodyNode = getNode(node, "body")
+
+    const self = this
+    return Effect.gen(function* () {
+      while (yield* self.evaluateExpression(testNode)) {
+        const result = yield* self.evaluateStatement(bodyNode)
+
+        if (result.kind === "continue") {
+          continue
+        }
+
+        if (result.kind === "break") {
+          return { kind: "none" } satisfies StatementResult
+        }
+
+        if (result.kind === "return") {
+          return result
+        }
+
+        if (result.kind === "value") {
+          self.lastValue = result.value
+        }
+      }
+
+      return { kind: "none" } satisfies StatementResult
+    })
+  }
+
+  private evaluateDoWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    const bodyNode = getNode(node, "body")
+    const testNode = getNode(node, "test")
+
+    const self = this
+    return Effect.gen(function* () {
+      do {
+        const result = yield* self.evaluateStatement(bodyNode)
+
+        if (result.kind === "continue") {
+          continue
+        }
+
+        if (result.kind === "break") {
+          return { kind: "none" } satisfies StatementResult
+        }
+
+        if (result.kind === "return") {
+          return result
+        }
+
+        if (result.kind === "value") {
+          self.lastValue = result.value
+        }
+      } while (yield* self.evaluateExpression(testNode))
+
+      return { kind: "none" } satisfies StatementResult
+    })
+  }
+
+  private evaluateForStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    this.pushScope()
+    const self = this
+    return Effect.gen(function* () {
+      const initNode = getOptionalNode(node, "init")
+      const testNode = getOptionalNode(node, "test")
+      const updateNode = getOptionalNode(node, "update")
+      const bodyNode = getNode(node, "body")
+
+      if (initNode) {
+        if (initNode.type === "VariableDeclaration") {
+          yield* self.evaluateVariableDeclaration(initNode)
+        } else {
+          yield* self.evaluateExpression(initNode)
+        }
+      }
+
+      const perIterationBindings =
+        initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var"
+          ? Array.from(self.currentScope().keys())
+          : []
+
+      while (testNode ? yield* self.evaluateExpression(testNode) : true) {
+        let iterationScope: Map<string, Binding> | undefined
+        if (perIterationBindings.length > 0) {
+          iterationScope = new Map(
+            perIterationBindings.map((name) => {
+              const binding = self.currentScope().get(name)!
+              return [name, { ...binding }]
+            }),
+          )
+          self.scopes.push(iterationScope)
+        }
+        const result = yield* self.evaluateStatement(bodyNode).pipe(
+          Effect.ensuring(
+            Effect.sync(() => {
+              if (iterationScope) self.popScope()
+            }),
+          ),
+        )
+
+        if (result.kind === "return") {
+          return result
+        }
+
+        if (result.kind === "break") {
+          return { kind: "none" } satisfies StatementResult
+        }
+
+        if (result.kind === "value") {
+          self.lastValue = result.value
+        }
+
+        if (iterationScope) {
+          const loopScope = self.currentScope()
+          for (const name of perIterationBindings) {
+            loopScope.set(name, { ...iterationScope.get(name)! })
+          }
+        }
+
+        if (updateNode) {
+          yield* self.evaluateExpression(updateNode)
+        }
+
+        if (result.kind === "continue") {
+          continue
+        }
+      }
+
+      return { kind: "none" } satisfies StatementResult
+    }).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
+  }
+
+  private evaluateForOfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    if (getBoolean(node, "await")) {
+      throw new InterpreterRuntimeError("for await...of is not supported.", node)
+    }
+
+    const self = this
+    return Effect.gen(function* () {
+      const left = getNode(node, "left")
+      const right = yield* self.evaluateExpression(getNode(node, "right"))
+      const body = getNode(node, "body")
+
+      // Arrays iterate in place; strings iterate code points; Maps iterate [key, value]
+      // pairs and Sets iterate values over a snapshot (mutation during iteration is safe).
+      const iterable = Array.isArray(right) ? right : spreadItems(right)
+      if (iterable === undefined) {
+        throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
+      }
+
+      let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
+      let assignmentName: string | undefined
+
+      if (left.type === "VariableDeclaration") {
+        const declarations = getArray(left, "declarations")
+        if (declarations.length !== 1) {
+          throw new InterpreterRuntimeError("for...of supports one declared binding.", left)
+        }
+
+        const declarator = asNode(declarations[0], "declarations[0]")
+        declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
+      } else if (left.type === "Identifier") {
+        assignmentName = getString(left, "name")
+      } else {
+        throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
+      }
+
+      for (const value of iterable) {
+        if (declaration) {
+          self.pushScope()
+          yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left)
+        } else if (assignmentName) {
+          self.setIdentifierValue(assignmentName, value, left)
+        }
+
+        const result = yield* self.evaluateStatement(body).pipe(
+          Effect.ensuring(
+            Effect.sync(() => {
+              if (declaration) self.popScope()
+            }),
+          ),
+        )
+
+        if (result.kind === "return") {
+          return result
+        }
+
+        if (result.kind === "break") {
+          return { kind: "none" }
+        }
+
+        if (result.kind === "value") {
+          self.lastValue = result.value
+        }
+
+        if (result.kind === "continue") {
+          continue
+        }
+      }
+
+      return { kind: "none" }
+    })
+  }
+
+  // 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
+  // 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<string> | undefined {
+    if (value instanceof ToolReference) {
+      return [...this.toolKeys(value.path)]
+    }
+    if (Array.isArray(value)) {
+      return Object.keys(value)
+    }
+    if (value !== null && typeof value === "object" && !isRuntimeReference(value)) {
+      return Object.keys(value)
+    }
+    return undefined
+  }
+
+  private evaluateForInStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    const self = this
+    return Effect.gen(function* () {
+      const left = getNode(node, "left")
+      const right = yield* self.evaluateExpression(getNode(node, "right"))
+      const body = getNode(node, "body")
+
+      // 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.
+      // 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.
+      const keys = self.enumerableKeys(right)
+      if (keys === undefined) {
+        throw new InterpreterRuntimeError(
+          "for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.",
+          node,
+        )
+      }
+
+      let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
+      let assignmentName: string | undefined
+
+      if (left.type === "VariableDeclaration") {
+        const declarations = getArray(left, "declarations")
+        if (declarations.length !== 1) {
+          throw new InterpreterRuntimeError("for...in supports one declared binding.", left)
+        }
+
+        const declarator = asNode(declarations[0], "declarations[0]")
+        declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
+      } else if (left.type === "Identifier") {
+        assignmentName = getString(left, "name")
+      } else {
+        throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
+      }
+
+      for (const key of keys) {
+        if (declaration) {
+          self.pushScope()
+          yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left)
+        } else if (assignmentName) {
+          self.setIdentifierValue(assignmentName, key, left)
+        }
+
+        const result = yield* self.evaluateStatement(body).pipe(
+          Effect.ensuring(
+            Effect.sync(() => {
+              if (declaration) self.popScope()
+            }),
+          ),
+        )
+
+        if (result.kind === "return") {
+          return result
+        }
+
+        if (result.kind === "break") {
+          return { kind: "none" }
+        }
+
+        if (result.kind === "value") {
+          self.lastValue = result.value
+        }
+
+        if (result.kind === "continue") {
+          continue
+        }
+      }
+
+      return { kind: "none" }
+    })
+  }
+
+  private evaluateBreakStatement(node: AstNode): StatementResult {
+    const labelNode = getOptionalNode(node, "label")
+
+    if (labelNode) {
+      throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node)
+    }
+
+    return { kind: "break" }
+  }
+
+  private evaluateContinueStatement(node: AstNode): StatementResult {
+    const labelNode = getOptionalNode(node, "label")
+
+    if (labelNode) {
+      throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node)
+    }
+
+    return { kind: "continue" }
+  }
+
+  private evaluateThrowStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    const argument = getNode(node, "argument")
+    return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value)))
+  }
+
+  private evaluateTryStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
+    const body = getNode(node, "block")
+    const handler = getOptionalNode(node, "handler")
+    const finalizer = getOptionalNode(node, "finalizer")
+    const self = this
+
+    const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), {
+      onFailure: (cause) => {
+        if (cause.reasons.some(Cause.isInterruptReason) || !handler) {
+          return Effect.failCause(cause)
+        }
+
+        // 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")
+        self.pushScope()
+        return Effect.gen(function* () {
+          if (parameter) yield* self.declarePattern(parameter, caught, true, handler)
+          return yield* self.evaluateStatement(getNode(handler, "body"))
+        }).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
+      },
+      onSuccess: Effect.succeed,
+    })
+
+    if (!finalizer) return attempted
+
+    const isAbrupt = (result: StatementResult): boolean =>
+      result.kind === "return" || result.kind === "break" || result.kind === "continue"
+
+    return Effect.matchCauseEffect(attempted, {
+      onFailure: (cause) =>
+        cause.reasons.some(Cause.isInterruptReason)
+          ? Effect.failCause(cause)
+          : Effect.flatMap(this.evaluateStatement(finalizer), (final) =>
+              isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause),
+            ),
+      onSuccess: (result) =>
+        Effect.flatMap(this.evaluateStatement(finalizer), (final) =>
+          isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result),
+        ),
+    })
+  }
+
+  private evaluateVariableDeclaration(node: AstNode): Effect.Effect<void, unknown, R> {
+    const kind = getString(node, "kind")
+    const declarations = getArray(node, "declarations")
+    const self = this
+    return Effect.gen(function* () {
+      for (const declarationValue of declarations) {
+        const declaration = asNode(declarationValue, "declarations")
+
+        if (declaration.type !== "VariableDeclarator") {
+          throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration)
+        }
+
+        const init = getOptionalNode(declaration, "init")
+        const value = init ? yield* self.evaluateExpression(init) : undefined
+        yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration)
+      }
+    })
+  }
+
+  private declarePattern(
+    pattern: AstNode,
+    value: unknown,
+    mutable: boolean,
+    node: AstNode,
+  ): Effect.Effect<void, unknown, R> {
+    const self = this
+    return Effect.gen(function* () {
+      if (pattern.type === "Identifier") {
+        self.declare(getString(pattern, "name"), value, mutable, node)
+        return
+      }
+
+      // 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)
+        return
+      }
+
+      if (pattern.type === "ObjectPattern") {
+        if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
+          throw new InterpreterRuntimeError(
+            "Object destructuring requires a data object value.",
+            pattern,
+            "InvalidDataValue",
+          )
+        }
+
+        const consumed = new Set<string>()
+        for (const propertyValue of getArray(pattern, "properties")) {
+          const property = asNode(propertyValue, "properties")
+
+          // 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)) {
+              if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
+            }
+            yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property)
+            continue
+          }
+
+          if (
+            property.type !== "Property" ||
+            getBoolean(property, "computed") ||
+            getString(property, "kind") !== "init"
+          ) {
+            throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
+          }
+
+          const keyNode = getNode(property, "key")
+          const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
+          if (isBlockedMember(key)) {
+            throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
+          }
+          consumed.add(key)
+          yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property)
+        }
+        return
+      }
+
+      if (pattern.type === "ArrayPattern") {
+        if (!Array.isArray(value)) {
+          throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
+        }
+
+        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).
+          if (element.type === "RestElement") {
+            yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element)
+            break
+          }
+          yield* self.declarePattern(element, value[index], mutable, pattern)
+        }
+        return
+      }
+
+      throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
+    })
+  }
+
+  private evaluateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
+    switch (node.type) {
+      case "Literal": {
+        // A regex literal parses as a Literal node carrying { pattern, flags }; construct the
+        // sandbox regex from those (the host `value` instance is never exposed).
+        const regex = node.regex
+        if (isRecord(regex) && typeof regex.pattern === "string") {
+          return Effect.sync(() =>
+            this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node),
+          )
+        }
+        return Effect.sync(() => boundedData(node.value, "Literal"))
+      }
+      case "Identifier":
+        return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node))
+      case "BinaryExpression":
+        return this.evaluateBinaryExpression(node)
+      case "LogicalExpression":
+        return this.evaluateLogicalExpression(node)
+      case "UnaryExpression":
+        return this.evaluateUnaryExpression(node)
+      case "AssignmentExpression":
+        return this.evaluateAssignmentExpression(node)
+      case "CallExpression":
+        return this.evaluateCallExpression(node)
+      case "ArrowFunctionExpression":
+      case "FunctionExpression":
+        return Effect.sync(() => this.createFunction(node))
+      case "MemberExpression":
+        return this.readMember(node)
+      case "ChainExpression":
+        return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) =>
+          value === OptionalShortCircuit ? undefined : value,
+        )
+      case "ObjectExpression":
+        return this.evaluateObjectExpression(node)
+      case "ArrayExpression":
+        return this.evaluateArrayExpression(node)
+      case "TemplateLiteral":
+        return this.evaluateTemplateLiteral(node)
+      case "ConditionalExpression":
+        return this.evaluateConditionalExpression(node)
+      case "UpdateExpression":
+        return this.evaluateUpdateExpression(node)
+      case "AwaitExpression": {
+        // `await` resolves a promise value; awaiting anything else is a passthrough no-op,
+        // matching real JS semantics for non-thenables.
+        const self = this
+        return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
+          value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value),
+        )
+      }
+      case "NewExpression":
+        return this.evaluateNewExpression(node)
+      default:
+        throw unsupportedSyntax(node.type, node)
+    }
+  }
+
+  private evaluateNewExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
+    const callee = getNode(node, "callee")
+    if (callee.type !== "Identifier") {
+      throw unsupportedSyntax("NewExpression", node)
+    }
+    const name = getString(callee, "name")
+    const argNodes = getArray(node, "arguments")
+    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.",
+        node,
+        "UnsupportedSyntax",
+        [supportedSyntaxMessage],
+      )
+    }
+    if (errorConstructors.has(name)) {
+      return Effect.gen(function* () {
+        const arg =
+          argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined
+        return createErrorValue(name, arg === undefined ? "" : coerceToString(arg))
+      })
+    }
+    if (valueConstructors.has(name)) {
+      return Effect.gen(function* () {
+        const args = yield* self.evaluateCallArguments(argNodes)
+        switch (name) {
+          case "Date":
+            return self.constructDate(args)
+          case "RegExp":
+            return self.constructRegExp(args, node)
+          case "Map":
+            return self.constructMap(args[0], node)
+          case "Set":
+            return self.constructSet(args[0], node)
+          case "URL":
+            return self.constructURL(args, node)
+          default:
+            return self.constructURLSearchParams(args[0], node)
+        }
+      })
+    }
+    throw unsupportedSyntax("NewExpression", node)
+  }
+
+  private constructDate(args: Array<unknown>): SandboxDate {
+    if (args.length === 0) return new SandboxDate(Date.now())
+    if (args.length === 1) {
+      const arg = args[0]
+      if (arg instanceof SandboxDate) return new SandboxDate(arg.time)
+      if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime())
+      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.
+    const parts = args.map((arg) => coerceToNumber(arg))
+    return new SandboxDate(new Date(...(parts as [number, number])).getTime())
+  }
+
+  private constructRegExp(args: Array<unknown>, node: AstNode): SandboxRegExp {
+    const first = args[0]
+    const pattern =
+      first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
+    const flagsArg = args[1]
+    if (flagsArg !== undefined && typeof flagsArg !== "string") {
+      throw new InterpreterRuntimeError(
+        `RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`,
+        node,
+      )
+    }
+    const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "")
+    try {
+      return new SandboxRegExp(pattern, flags)
+    } catch (error) {
+      // Say which part was rejected and how to fix it, instead of passing the engine
+      // message through bare. A flags failure names the flags; a pattern failure gets the
+      // escaping hint (the usual cause is an unescaped metacharacter in a built-up string).
+      const reason = regexFailureReason(error)
+      throw new InterpreterRuntimeError(
+        /flag/i.test(reason)
+          ? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.`
+          : `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`,
+        node,
+      ).as("SyntaxError")
+    }
+  }
+
+  private constructMap(init: unknown, node: AstNode): SandboxMap {
+    const target = new SandboxMap()
+    if (init === undefined || init === null) return target
+    const entries = Array.isArray(init)
+      ? init
+      : init instanceof SandboxMap
+        ? Array.from(init.map.entries(), ([key, item]): Array<unknown> => [key, item])
+        : undefined
+    if (entries === undefined) {
+      throw new InterpreterRuntimeError(
+        "new Map(...) expects an array of [key, value] pairs, a Map, or no argument.",
+        node,
+      )
+    }
+    for (const pair of entries) {
+      if (!Array.isArray(pair)) {
+        throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node)
+      }
+      target.map.set(pair[0], pair[1])
+    }
+    return target
+  }
+
+  private constructSet(init: unknown, node: AstNode): SandboxSet {
+    const target = new SandboxSet()
+    if (init === undefined || init === null) return target
+    const items = Array.isArray(init)
+      ? init
+      : init instanceof SandboxSet
+        ? Array.from(init.set.values())
+        : typeof init === "string"
+          ? Array.from(init)
+          : undefined
+    if (items === undefined) {
+      throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node)
+    }
+    for (const item of items) target.set.add(item)
+    return target
+  }
+
+  private constructURL(args: Array<unknown>, node: AstNode): SandboxURL {
+    if (args.length === 0) {
+      throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
+        "TypeError",
+      )
+    }
+    const input = urlArgument(args[0], "new URL input")
+    const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
+    try {
+      return new SandboxURL(new URL(input, base))
+    } catch {
+      throw new InterpreterRuntimeError(
+        `new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
+        node,
+      ).as("TypeError")
+    }
+  }
+
+  private constructURLSearchParams(init: unknown, node: AstNode): SandboxURLSearchParams {
+    if (init === undefined) return new SandboxURLSearchParams(new URLSearchParams())
+    if (init instanceof SandboxURLSearchParams) {
+      return new SandboxURLSearchParams(new URLSearchParams(init.params))
+    }
+    if (typeof init === "string") return new SandboxURLSearchParams(new URLSearchParams(init))
+    if (init === null || typeof init === "number" || typeof init === "boolean") {
+      return new SandboxURLSearchParams(new URLSearchParams(coerceToString(init)))
+    }
+    if (init instanceof SandboxMap) {
+      return this.constructURLSearchParams(
+        Array.from(init.map.entries(), ([key, value]) => [key, value]),
+        node,
+      )
+    }
+    if (Array.isArray(init)) {
+      const entries = init.map((pair) => {
+        if (!Array.isArray(pair) || pair.length !== 2) {
+          throw new InterpreterRuntimeError(
+            "new URLSearchParams(...) expects an array of [name, value] pairs.",
+            node,
+          ).as("TypeError")
+        }
+        return [uriArgument(pair[0], "URLSearchParams name"), uriArgument(pair[1], "URLSearchParams value")] as [
+          string,
+          string,
+        ]
+      })
+      return new SandboxURLSearchParams(new URLSearchParams(entries))
+    }
+    if (isSandboxValue(init)) return new SandboxURLSearchParams(new URLSearchParams())
+    const data = boundedData(init, "new URLSearchParams input")
+    if (data === null || typeof data !== "object") {
+      throw new InterpreterRuntimeError(
+        "new URLSearchParams(...) expects a query string, data object, array of pairs, or URLSearchParams.",
+        node,
+      ).as("TypeError")
+    }
+    return new SandboxURLSearchParams(
+      new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))),
+    )
+  }
+
+  private evaluateBinaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
+    const operator = getString(node, "operator")
+    const self = this
+    return Effect.gen(function* () {
+      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.
+      if (operator === "instanceof") return instanceofValue(lhs, rhs, node)
+      return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result")
+    })
+  }
+
+  /**
+   * Applies a binary operator to two already-evaluated operands with CodeMode's coercion
+   * 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: unknown, rhs: unknown, node: AstNode): unknown {
+    if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
+      throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue")
+    }
+    // Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host
+    // "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.
+    // 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)
+    const r = coerceOperand(rhs)
+    switch (operator) {
+      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 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)
+        }
+        // Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...).
+        return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey)
+      default:
+        throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
+    }
+  }
+
+  private evaluateLogicalExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
+    const operator = getString(node, "operator")
+    return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => {
+      if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left)
+      if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right"))
+      if (operator === "??")
+        return left !== null && left !== undefined
+          ? Effect.succeed(left)
+          : this.evaluateExpression(getNode(node, "right"))
+      throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node)
+    })
+  }
+
+  private evaluateUnaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
+    const operator = getString(node, "operator")
+    const argument = getNode(node, "argument")
+    // `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so
+    // feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before
+    // evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw.
+    if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) {
+      return Effect.succeed("undefined")
+    }
+    return Effect.map(this.evaluateExpression(argument), (value) => {
+      // `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")
+      }
+      // 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 =
+        value instanceof SandboxDate
+          ? value.time
+          : value !== null && typeof value === "object"
+            ? coerceToString(value)
+            : value
+      let result: unknown
+      switch (operator) {
+        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")
+    })
+  }
+
+  private evaluateAssignmentExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
+    const left = getNode(node, "left")
+    const operator = getString(node, "operator")
+    const self = this
+    return Effect.gen(function* () {
+      if (operator === "??=" || operator === "||=" || operator === "&&=") {
+        return yield* self.evaluateLogicalAssignment(node, left, operator)
+      }
+      const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
+      if (left.type === "Identifier") {
+        const name = getString(left, "name")
+        if (operator === "=") return self.setIdentifierValue(name, rightValue, left)
+        const next = boundedData(
+          self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node),
+          "Assignment result",
+        )
+        return self.setIdentifierValue(name, next, left)
+      }
+      if (left.type === "MemberExpression") {
+        if (operator === "=") return yield* self.writeMember(left, rightValue)
+        return yield* self.modifyMember(left, (current) => {
+          const next = boundedData(
+            self.applyCompoundAssignment(operator, current, rightValue, node),
+            "Assignment result",
+          )
+          return Effect.succeed({ write: true, next, result: next })
+        })
+      }
+      throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
+    })
+  }
+
+  private evaluateLogicalAssignment(
+    node: AstNode,
+    left: AstNode,
+    operator: string,
+  ): Effect.Effect<unknown, unknown, R> {
+    const self = this
+    const shouldAssign = (current: unknown): boolean =>
+      operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current)
+    if (left.type === "Identifier") {
+      const name = getString(left, "name")
+      return Effect.gen(function* () {
+        const current = self.getIdentifierValue(name, left)
+        if (!shouldAssign(current)) return current
+        const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
+        return self.setIdentifierValue(name, rightValue, left)
+      })
+    }
+    if (left.type === "MemberExpression") {
+      // Resolve the member exactly once; evaluate the RHS only if we actually assign.
+      return self.modifyMember(left, (current) =>
+        shouldAssign(current)
+          ? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({
+              write: true,
+              next: rightValue,
+              result: rightValue,
+            }))
+          : Effect.succeed({ write: false, next: current, result: current }),
+      )
+    }
+    throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
+  }
+
+  private evaluateUpdateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
+    const operator = getString(node, "operator")
+    const argument = getNode(node, "argument")
+    const prefix = getBoolean(node, "prefix")
+
+    const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined
+
+    if (increment === undefined) {
+      throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
+    }
+
+    if (argument.type === "Identifier") {
+      return Effect.sync(() => {
+        const name = getString(argument, "name")
+        const current = Number(this.getIdentifierValue(name, argument))
+        const next = current + increment
+        this.setIdentifierValue(name, next, argument)
+        return prefix ? next : current
+      })
+    }
+
+    if (argument.type === "MemberExpression") {
+      return this.modifyMember(argument, (current) => {
+        const value = Number(current)
+        const next = value + increment
+        return Effect.succeed({ write: true, next, result: prefix ? next : value })
+      })
+    }
+
+    throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument)
+  }
+
+  private evaluateCallExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
+    const callee = getNode(node, "callee")
+    const argNodes = getArray(node, "arguments")
+
+    const self = this
+    return Effect.gen(function* () {
+      const callable = yield* self.evaluateExpression(callee)
+      if (callable === OptionalShortCircuit) return OptionalShortCircuit
+      if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit
+
+      const args = yield* self.evaluateCallArguments(argNodes)
+
+      if (callable instanceof ToolReference) {
+        if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee)
+        // An un-awaited tool call is a first-class promise value; the call itself starts now.
+        return yield* self.createToolCallPromise(callable.path, args)
+      }
+      if (callable instanceof PromiseMethodReference) {
+        return yield* self.invokePromiseMethod(callable, args, node)
+      }
+      if (callable instanceof CodeModeFunction) {
+        return yield* self.invokeFunction(callable, args)
+      }
+      if (callable instanceof IntrinsicReference) {
+        return yield* self.invokeIntrinsic(callable, args, node)
+      }
+      if (callable instanceof GlobalMethodReference) {
+        if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node)
+        if (callable.namespace === "Object" && args[0] instanceof ToolReference) {
+          return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node)
+        }
+        return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
+      }
+      if (callable instanceof CoercionFunction) {
+        return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`)
+      }
+      if (callable instanceof UriFunction) {
+        return invokeUriFunction(callable, args, node)
+      }
+      // `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS.
+      if (callable instanceof ErrorConstructorReference) {
+        return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0]))
+      }
+      throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
+    })
+  }
+
+  // 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
+  // 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 {
+    if (name === "keys") {
+      return boundedData(this.enumerableKeys(ref)!, "Object.keys result")
+    }
+    throw new InterpreterRuntimeError(
+      `Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
+      node,
+      "InvalidDataValue",
+    )
+  }
+
+  private invokeConsole(name: string, args: Array<unknown>, node: AstNode): undefined {
+    if (!consoleMethods.has(name))
+      throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node)
+    this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node)))
+    return undefined
+  }
+
+  private formatConsoleMessage(name: string, args: Array<unknown>, node: AstNode): string {
+    if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0])
+    if (name === "table") return this.formatConsoleTable(args[0], args[1], node)
+    const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : ""
+    return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg)).join(" ")}`
+  }
+
+  // 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
+  // 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 "...".
+  private formatConsoleArgument(value: unknown): string {
+    if (value === undefined) return "undefined"
+    // A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue).
+    if (typeof value === "string") return value
+    return this.formatConsoleValue(value, new Set(), 0)
+  }
+
+  private formatConsoleValue(value: unknown, seen: Set<object>, depth: number): string {
+    // Nested undefined renders as null, matching what JSON boundary output would show.
+    if (value === null || value === undefined) return "null"
+    if (typeof value === "string") return JSON.stringify(value)
+    // String(value) keeps NaN/Infinity/-Infinity readable; finite numbers match their JSON form.
+    if (typeof value === "number" || typeof value === "boolean") return String(value)
+    if (typeof value !== "object") return String(value)
+    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 (value instanceof SandboxURL) return coerceToString(value)
+    if (value instanceof SandboxURLSearchParams) return coerceToString(value)
+    if (depth > MAX_CONSOLE_DEPTH) return "..."
+    if (seen.has(value)) return "[Circular]"
+    if (value instanceof SandboxMap) {
+      seen.add(value)
+      try {
+        const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
+        return `Map(${value.map.size}) ${this.formatConsoleValue(entries, seen, depth + 1)}`
+      } finally {
+        seen.delete(value)
+      }
+    }
+    if (value instanceof SandboxSet) {
+      seen.add(value)
+      try {
+        return `Set(${value.set.size}) ${this.formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
+      } finally {
+        seen.delete(value)
+      }
+    }
+    if (isRuntimeReference(value)) return "[CodeMode reference]"
+    seen.add(value)
+    try {
+      if (Array.isArray(value)) {
+        return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]`
+      }
+      return `{${Object.entries(value)
+        .map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`)
+        .join(",")}}`
+    } finally {
+      seen.delete(value)
+    }
+  }
+
+  private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string {
+    if (value === undefined) return "undefined"
+    // Sandbox values are legitimate table data (cells render their friendly forms); only
+    // truly opaque references (functions, tools, promises) collapse to the marker.
+    if (containsOpaqueReference(value)) return "[CodeMode reference]"
+    const data = boundedData(value, "console.table argument")
+    const columns = this.consoleTableColumns(columnsArgument, node)
+    const rows = this.consoleTableRows(data, columns)
+    const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values))))
+    const header = ["(index)", ...keys].join("\t")
+    return [
+      header,
+      ...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")),
+    ].join("\n")
+  }
+
+  private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray<string> | undefined {
+    if (value === undefined) return undefined
+    if (containsRuntimeReference(value)) return undefined
+    const columns = copyOut(copyIn(value, "console.table columns"), true)
+    return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
+  }
+
+  private consoleTableRows(
+    data: unknown,
+    columns: ReadonlyArray<string> | undefined,
+  ): Array<{ readonly index: string; readonly values: Record<string, unknown> }> {
+    if (Array.isArray(data)) {
+      return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) }))
+    }
+    if (data !== null && typeof data === "object" && !isSandboxValue(data)) {
+      return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) }))
+    }
+    return [{ index: "0", values: { Value: data } }]
+  }
+
+  private consoleTableValues(value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> {
+    if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) {
+      const source = value as Record<string, unknown>
+      if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
+      return Object.fromEntries(Object.entries(source))
+    }
+    return { Value: value }
+  }
+
+  private formatConsoleTableCell(value: unknown): string {
+    if (value === undefined) return ""
+    if (typeof value === "string") return value
+    return this.formatConsoleValue(value, new Set(), 0)
+  }
+
+  private evaluateCallArguments(argNodes: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> {
+    const self = this
+    return Effect.gen(function* () {
+      const args: Array<unknown> = []
+      for (const [index, arg] of argNodes.entries()) {
+        const argNode = asNode(arg, `arguments[${index}]`)
+        if (argNode.type === "SpreadElement") {
+          const spread = yield* self.evaluateExpression(getNode(argNode, "argument"))
+          const items = spreadItems(spread)
+          if (items === undefined)
+            throw new InterpreterRuntimeError(
+              "Spread arguments require an array, string, Map, or Set in CodeMode.",
+              argNode,
+            )
+          args.push(...items)
+        } else {
+          args.push(yield* self.evaluateExpression(argNode))
+        }
+      }
+      return args
+    })
+  }
+
+  // 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
+  // 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<unknown>,
+    node: AstNode,
+  ): Effect.Effect<unknown, unknown, R> {
+    const self = this
+    if (ref.name === "resolve") {
+      // Promise.resolve of a promise is that promise (JS flattens); anything else is a
+      // promise already fulfilled with the value.
+      const value = args[0]
+      return Effect.succeed(
+        value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)),
+      )
+    }
+    if (ref.name === "reject") {
+      return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0]))))
+    }
+
+    const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
+    if (items === undefined) {
+      throw new InterpreterRuntimeError(
+        `Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
+        node,
+      )
+    }
+
+    switch (ref.name) {
+      case "all": {
+        // Mark every promise element observed up-front (Promise.all handles all of its
+        // members' failures, as in JS), then join in index order; the first failure rejects
+        // the whole call while unrelated in-flight members keep running.
+        const settles = items.map((item) =>
+          item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item),
+        )
+        return Effect.gen(function* () {
+          const values: Array<unknown> = []
+          for (const settle of settles) values.push(yield* settle)
+          return values
+        })
+      }
+      case "allSettled": {
+        const observations = items.map((item) =>
+          item instanceof SandboxPromise
+            ? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit }))
+            : Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }),
+        )
+        return Effect.gen(function* () {
+          const outcomes: Array<unknown> = []
+          for (const observation of observations) {
+            const { exit, promise } = yield* observation
+            if (Exit.isSuccess(exit)) {
+              outcomes.push(
+                Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
+              )
+              continue
+            }
+            const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)
+            if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) {
+              // Execution teardown (timeout/host interruption), not a program-level rejection.
+              return yield* Effect.failCause(exit.cause)
+            }
+            const thrown = raceInterrupted
+              ? new InterpreterRuntimeError(
+                  "This tool call was interrupted because another value settled a Promise.race first.",
+                  node,
+                )
+              : Cause.squash(exit.cause)
+            outcomes.push(
+              Object.assign(Object.create(null) as SafeObject, {
+                status: "rejected",
+                reason: caughtErrorValue(thrown),
+              }),
+            )
+          }
+          return outcomes
+        })
+      }
+      case "race": {
+        if (items.length === 0) {
+          throw new InterpreterRuntimeError(
+            "Promise.race([]) would never settle; provide at least one promise or value.",
+            node,
+          )
+        }
+        const observations = items.map((item, index) =>
+          item instanceof SandboxPromise
+            ? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
+            : Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
+        )
+        return Effect.gen(function* () {
+          // First settlement (fulfilled OR rejected) wins; the observations never fail, so
+          // racing them yields exactly that. Losing in-flight calls are then interrupted.
+          const winner = yield* Effect.raceAll(observations)
+          for (const [index, item] of items.entries()) {
+            if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue
+            item.interrupted = true
+            yield* Fiber.interrupt(item.fiber)
+          }
+          const winningItem = items[winner.index]
+          return yield* self.unwrapPromiseExit(
+            winningItem instanceof SandboxPromise ? winningItem : undefined,
+            winner.exit,
+            node,
+          )
+        })
+      }
+    }
+  }
+
+  private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
+    const self = this
+    return Effect.suspend(() => {
+      const savedScopes = self.scopes
+      self.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
+      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.
+        const paramScope = self.currentScope()
+        for (const parameter of fn.parameters) {
+          for (const name of collectPatternNames(parameter)) {
+            paramScope.set(name, { mutable: true, value: undefined, initialized: false })
+          }
+        }
+        for (const [index, parameter] of fn.parameters.entries()) {
+          if (parameter.type === "RestElement") {
+            yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter)
+            break
+          }
+          yield* self.declarePattern(parameter, args[index], true, parameter)
+        }
+
+        if (fn.body.type === "BlockStatement") {
+          const result = yield* self.evaluateStatement(fn.body)
+          return result.kind === "return" || result.kind === "value" ? result.value : undefined
+        }
+
+        return yield* self.evaluateExpression(fn.body)
+      })
+      return run.pipe(
+        Effect.ensuring(
+          Effect.sync(() => {
+            self.scopes = savedScopes
+          }),
+        ),
+      )
+    })
+  }
+
+  private invokeIntrinsic(
+    ref: IntrinsicReference,
+    args: Array<unknown>,
+    node: AstNode,
+  ): Effect.Effect<unknown, unknown, R> {
+    if (typeof ref.receiver === "string") {
+      if (
+        (ref.name === "replace" || ref.name === "replaceAll") &&
+        (args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction)
+      ) {
+        return this.invokeStringReplacer(ref.receiver, ref.name, args, node)
+      }
+      return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
+    }
+    if (typeof ref.receiver === "number") {
+      return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node))
+    }
+    if (Array.isArray(ref.receiver)) {
+      return this.invokeArrayMethod(ref.receiver, ref.name, args, node)
+    }
+    if (ref.receiver instanceof SandboxDate) {
+      return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
+    }
+    if (ref.receiver instanceof SandboxRegExp) {
+      return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
+    }
+    if (ref.receiver instanceof SandboxMap) {
+      return this.invokeMapMethod(ref.receiver, ref.name, args, node)
+    }
+    if (ref.receiver instanceof SandboxSet) {
+      return this.invokeSetMethod(ref.receiver, ref.name, args, node)
+    }
+    if (ref.receiver instanceof SandboxURL) {
+      return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
+    }
+    if (ref.receiver instanceof SandboxURLSearchParams) {
+      return this.invokeURLSearchParamsMethod(ref.receiver, ref.name, args, node)
+    }
+    throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
+  }
+
+  private invokeStringReplacer(
+    value: string,
+    name: "replace" | "replaceAll",
+    args: Array<unknown>,
+    node: AstNode,
+  ): Effect.Effect<unknown, unknown, R> {
+    const apply = this.applyCollectionCallback(args[1], `String.${name}`, node)
+    const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
+    const collect = (...callbackArgs: Array<unknown>): string => {
+      const match = callbackArgs[0]
+      const groups = callbackArgs[callbackArgs.length - 1]
+      const hasGroups = groups !== null && typeof groups === "object"
+      const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
+      if (typeof match !== "string" || typeof offset !== "number") {
+        throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
+      }
+      if (hasGroups) {
+        const safeGroups: SafeObject = Object.create(null) as SafeObject
+        for (const [key, group] of Object.entries(groups)) {
+          if (!isBlockedMember(key)) safeGroups[key] = group
+        }
+        callbackArgs[callbackArgs.length - 1] = safeGroups
+      }
+      matches.push({ match, offset, args: callbackArgs })
+      return match
+    }
+
+    const pattern = args[0]
+    if (pattern instanceof SandboxRegExp) {
+      if (name === "replaceAll" && !pattern.regex.global) {
+        throw new InterpreterRuntimeError(
+          `String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
+          node,
+        )
+      }
+      if (name === "replace") value.replace(pattern.regex, collect)
+      else value.replaceAll(pattern.regex, collect)
+    } else {
+      if (typeof pattern !== "string") {
+        throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
+      }
+      if (name === "replace") value.replace(pattern, collect)
+      else value.replaceAll(pattern, collect)
+    }
+
+    return Effect.gen(function* () {
+      const output: Array<string> = []
+      let end = 0
+      for (const match of matches) {
+        output.push(
+          value.slice(end, match.offset),
+          coerceToString(boundedData(yield* apply(match.args), `String.${name} replacer result`)),
+        )
+        end = match.offset + match.match.length
+      }
+      output.push(value.slice(end))
+      return boundedData(output.join(""), `String.${name} result`)
+    })
+  }
+
+  // Runs a collection callback accepting a user function or supported builtin callable,
+  // mirroring the array-method callback contract.
+  private applyCollectionCallback(
+    callback: unknown,
+    name: string,
+    node: AstNode,
+  ): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> {
+    if (
+      !(callback instanceof CodeModeFunction) &&
+      !(callback instanceof CoercionFunction) &&
+      !(callback instanceof UriFunction)
+    ) {
+      throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
+    }
+    return (callbackArgs) =>
+      callback instanceof CoercionFunction
+        ? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
+        : callback instanceof UriFunction
+          ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
+          : this.invokeFunction(callback, callbackArgs)
+  }
+
+  private invokeMapMethod(
+    target: SandboxMap,
+    name: string,
+    args: Array<unknown>,
+    node: AstNode,
+  ): Effect.Effect<unknown, unknown, R> {
+    switch (name) {
+      case "get":
+        return Effect.succeed(target.map.get(args[0]))
+      case "has":
+        return Effect.succeed(target.map.has(args[0]))
+      case "set":
+        return Effect.sync(() => {
+          target.map.set(args[0], args[1])
+          return target
+        })
+      case "delete":
+        return Effect.sync(() => target.map.delete(args[0]))
+      case "clear":
+        return Effect.sync(() => {
+          target.map.clear()
+          return undefined
+        })
+      case "keys":
+        return Effect.sync(() => Array.from(target.map.keys()))
+      case "values":
+        return Effect.sync(() => Array.from(target.map.values()))
+      case "entries":
+        return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
+      case "forEach": {
+        const apply = this.applyCollectionCallback(args[0], "Map.forEach", node)
+        return Effect.gen(function* () {
+          // Snapshot iteration, matching the array-method callback contract.
+          for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
+          return undefined
+        })
+      }
+      default:
+        throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
+    }
+  }
+
+  private invokeSetMethod(
+    target: SandboxSet,
+    name: string,
+    args: Array<unknown>,
+    node: AstNode,
+  ): Effect.Effect<unknown, unknown, R> {
+    switch (name) {
+      case "has":
+        return Effect.succeed(target.set.has(args[0]))
+      case "add":
+        return Effect.sync(() => {
+          target.set.add(args[0])
+          return target
+        })
+      case "delete":
+        return Effect.sync(() => target.set.delete(args[0]))
+      case "clear":
+        return Effect.sync(() => {
+          target.set.clear()
+          return undefined
+        })
+      case "keys":
+      case "values":
+        return Effect.sync(() => Array.from(target.set.values()))
+      case "entries":
+        return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
+      case "forEach": {
+        const apply = this.applyCollectionCallback(args[0], "Set.forEach", node)
+        return Effect.gen(function* () {
+          for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
+          return undefined
+        })
+      }
+      default:
+        throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
+    }
+  }
+
+  private invokeURLSearchParamsMethod(
+    target: SandboxURLSearchParams,
+    name: string,
+    args: Array<unknown>,
+    node: AstNode,
+  ): Effect.Effect<unknown, unknown, R> {
+    const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`)
+    const requireArgs = (count: number): void => {
+      if (args.length < count) {
+        throw new InterpreterRuntimeError(
+          `URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
+          node,
+        ).as("TypeError")
+      }
+    }
+    switch (name) {
+      case "append": {
+        requireArgs(2)
+        return Effect.sync(() => {
+          target.params.append(arg(0), arg(1))
+          return undefined
+        })
+      }
+      case "delete": {
+        requireArgs(1)
+        return Effect.sync(() => {
+          if (args[1] !== undefined) target.params.delete(arg(0), arg(1))
+          else target.params.delete(arg(0))
+          return undefined
+        })
+      }
+      case "get":
+        requireArgs(1)
+        return Effect.sync(() => target.params.get(arg(0)))
+      case "getAll":
+        requireArgs(1)
+        return Effect.sync(() => target.params.getAll(arg(0)))
+      case "has":
+        requireArgs(1)
+        return Effect.sync(() =>
+          args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0)),
+        )
+      case "set": {
+        requireArgs(2)
+        return Effect.sync(() => {
+          target.params.set(arg(0), arg(1))
+          return undefined
+        })
+      }
+      case "sort":
+        return Effect.sync(() => {
+          target.params.sort()
+          return undefined
+        })
+      case "keys":
+        return Effect.sync(() => Array.from(target.params.keys()))
+      case "values":
+        return Effect.sync(() => Array.from(target.params.values()))
+      case "entries":
+        return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array<unknown> => [key, value]))
+      case "toString":
+        return Effect.sync(() => target.params.toString())
+      case "forEach": {
+        requireArgs(1)
+        const apply = this.applyCollectionCallback(args[0], "URLSearchParams.forEach", node)
+        return Effect.gen(function* () {
+          for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
+          return undefined
+        })
+      }
+      default:
+        throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
+    }
+  }
+
+  private invokeArrayMethod(
+    target: Array<unknown>,
+    name: string,
+    args: Array<unknown>,
+    node: AstNode,
+  ): Effect.Effect<unknown, unknown, R> {
+    const optNumber = (value: unknown, label: string): number | undefined => {
+      if (value === undefined) return undefined
+      if (typeof value !== "number")
+        throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
+      return value
+    }
+    switch (name) {
+      case "join": {
+        if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
+          throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
+        }
+        const input = boundedData(target, "Array.join input") as Array<unknown>
+        return Effect.succeed(
+          input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
+        )
+      }
+      case "includes":
+        if (args.length === 0 || args.length > 2)
+          throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
+        return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index")))
+      case "indexOf":
+        return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index")))
+      case "lastIndexOf":
+        return Effect.succeed(
+          args[1] === undefined
+            ? target.lastIndexOf(args[0])
+            : target.lastIndexOf(args[0], optNumber(args[1], "start index")),
+        )
+      case "at":
+        return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0))
+      case "slice":
+        return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))
+      case "concat":
+        return Effect.succeed(target.concat(...args))
+      case "flat":
+        return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
+      case "reverse":
+        return Effect.succeed([...target].reverse())
+      case "sort":
+      case "toSorted":
+        return this.sortArray(target, args[0], node)
+      case "toReversed":
+        return Effect.succeed([...target].reverse())
+      case "with": {
+        const index = optNumber(args[0], "index") ?? 0
+        const resolved = index < 0 ? target.length + index : index
+        if (resolved < 0 || resolved >= target.length) {
+          throw new InterpreterRuntimeError("Array.with index is out of range.", node)
+        }
+        const copied = [...target]
+        copied[resolved] = args[1]
+        return Effect.succeed(copied)
+      }
+      case "push": {
+        // Validate before mutating (so no rollback is needed): inserting a container into
+        // itself would create a cycle no later walk could survive.
+        for (const item of args) this.rejectCircularInsertion(target, item, "Array.push result", node)
+        target.push(...args)
+        return Effect.succeed(target.length)
+      }
+      case "unshift": {
+        for (const item of args) this.rejectCircularInsertion(target, item, "Array.unshift result", node)
+        target.unshift(...args)
+        return Effect.succeed(target.length)
+      }
+      case "pop":
+        return Effect.succeed(target.pop())
+      case "shift":
+        return Effect.succeed(target.shift())
+      case "splice": {
+        // Mutates in place and returns the removed elements, exactly like JS: one argument
+        // removes to the end, an undefined delete count removes nothing.
+        if (args.length === 0) return Effect.succeed(target.splice(0, 0))
+        const start = optNumber(args[0], "start") ?? 0
+        if (args.length === 1) return Effect.succeed(target.splice(start))
+        const deleteCount = optNumber(args[1], "delete count") ?? 0
+        const inserted = args.slice(2)
+        for (const item of inserted) this.rejectCircularInsertion(target, item, "Array.splice result", node)
+        return Effect.succeed(target.splice(start, deleteCount, ...inserted))
+      }
+      case "fill": {
+        this.rejectCircularInsertion(target, args[0], "Array.fill result", node)
+        return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
+      }
+      case "copyWithin":
+        return Effect.succeed(
+          target.copyWithin(
+            optNumber(args[0], "target index") ?? 0,
+            optNumber(args[1], "start") ?? 0,
+            optNumber(args[2], "end"),
+          ),
+        )
+      // keys/values/entries return arrays (not iterators), matching the Map/Set convention;
+      // they work with for...of and spread either way.
+      case "keys":
+        return Effect.succeed(Array.from(target.keys()))
+      case "values":
+        return Effect.succeed([...target])
+      case "entries":
+        return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
+    }
+
+    const callback = args[0]
+    if (
+      !(callback instanceof CodeModeFunction) &&
+      !(callback instanceof CoercionFunction) &&
+      !(callback instanceof UriFunction)
+    ) {
+      throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node)
+    }
+    const self = this
+    // Accept a user function or supported builtin callable, so idioms such as
+    // `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins
+    // are synchronous; only CodeModeFunctions can await tool calls.
+    const apply = (callbackArgs: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
+      callback instanceof CoercionFunction
+        ? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
+        : callback instanceof UriFunction
+          ? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
+          : 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.
+      const items = target.slice()
+      switch (name) {
+        case "map": {
+          const values: Array<unknown> = []
+          for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items]))
+          return values
+        }
+        case "flatMap": {
+          const values: Array<unknown> = []
+          for (const [index, item] of items.entries()) {
+            const mapped = yield* apply([item, index, items])
+            if (Array.isArray(mapped)) values.push(...mapped)
+            else values.push(mapped)
+          }
+          return values
+        }
+        case "filter": {
+          const values: Array<unknown> = []
+          for (const [index, item] of items.entries()) {
+            if (yield* apply([item, index, items])) values.push(item)
+          }
+          return values
+        }
+        case "find":
+          for (const [index, item] of items.entries()) {
+            if (yield* apply([item, index, items])) return item
+          }
+          return undefined
+        case "findIndex":
+          for (const [index, item] of items.entries()) {
+            if (yield* apply([item, index, items])) return index
+          }
+          return -1
+        case "some":
+          for (const [index, item] of items.entries()) {
+            if (yield* apply([item, index, items])) return true
+          }
+          return false
+        case "every":
+          for (const [index, item] of items.entries()) {
+            if (!(yield* apply([item, index, items]))) return false
+          }
+          return true
+        case "forEach":
+          for (const [index, item] of items.entries()) yield* apply([item, index, items])
+          return undefined
+        case "reduce": {
+          let accumulator: unknown
+          let start: number
+          if (args.length >= 2) {
+            accumulator = args[1]
+            start = 0
+          } else {
+            if (items.length === 0)
+              throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
+            accumulator = items[0]
+            start = 1
+          }
+          for (let index = start; index < items.length; index += 1) {
+            accumulator = yield* apply([accumulator, items[index], index, items])
+          }
+          return accumulator
+        }
+        case "reduceRight": {
+          let accumulator: unknown
+          let start: number
+          if (args.length >= 2) {
+            accumulator = args[1]
+            start = items.length - 1
+          } else {
+            if (items.length === 0)
+              throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
+            accumulator = items[items.length - 1]
+            start = items.length - 2
+          }
+          for (let index = start; index >= 0; index -= 1) {
+            accumulator = yield* apply([accumulator, items[index], index, items])
+          }
+          return accumulator
+        }
+        case "findLast":
+          for (let index = items.length - 1; index >= 0; index -= 1) {
+            if (yield* apply([items[index], index, items])) return items[index]
+          }
+          return undefined
+        case "findLastIndex":
+          for (let index = items.length - 1; index >= 0; index -= 1) {
+            if (yield* apply([items[index], index, items])) return index
+          }
+          return -1
+      }
+      throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node)
+    })
+  }
+
+  private sortArray(
+    target: Array<unknown>,
+    comparator: unknown,
+    node: AstNode,
+  ): Effect.Effect<Array<unknown>, unknown, R> {
+    if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) {
+      throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node)
+    }
+    if (!(comparator instanceof CodeModeFunction)) {
+      return Effect.sync(() =>
+        [...target].sort((a, b) => {
+          const left = coerceToString(a)
+          const right = coerceToString(b)
+          return left < right ? -1 : left > right ? 1 : 0
+        }),
+      )
+    }
+    const self = this
+    const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
+      if (items.length <= 1) return Effect.succeed(items)
+      const midpoint = Math.floor(items.length / 2)
+      return Effect.gen(function* () {
+        const left = yield* mergeSort(items.slice(0, midpoint))
+        const right = yield* mergeSort(items.slice(midpoint))
+        const merged: Array<unknown> = []
+        let leftIndex = 0
+        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.
+          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++])
+        }
+        return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)]
+      })
+    }
+    // Per spec, undefined elements sort to the end and the comparator is never called on them.
+    const defined = target.filter((item) => item !== undefined)
+    const undefinedCount = target.length - defined.length
+    return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)])
+  }
+
+  private evaluateObjectExpression(node: AstNode): Effect.Effect<Record<string, unknown>, unknown, R> {
+    const objectValue: Record<string, unknown> = Object.create(null) as Record<string, unknown>
+    const properties = getArray(node, "properties")
+    const self = this
+    return Effect.gen(function* () {
+      for (const propertyValue of properties) {
+        const property = asNode(propertyValue, "properties")
+
+        if (property.type === "SpreadElement") {
+          const spread = yield* self.evaluateExpression(getNode(property, "argument"))
+          // JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common
+          // `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values
+          // have no own enumerable properties in JS, so they are no-ops too.
+          if (spread === null || spread === undefined || isSandboxValue(spread)) continue
+          if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
+            throw new InterpreterRuntimeError(
+              "Object spread requires a data object in CodeMode.",
+              property,
+              "InvalidDataValue",
+            )
+          }
+          for (const [key, value] of Object.entries(spread)) {
+            if (isBlockedMember(key))
+              throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property)
+            objectValue[key] = value
+          }
+          continue
+        }
+
+        if (property.type !== "Property") {
+          throw new InterpreterRuntimeError("Only standard object properties are supported.", property)
+        }
+
+        if (getString(property, "kind") !== "init") {
+          throw new InterpreterRuntimeError("Only init object properties are supported.", property)
+        }
+
+        const keyNode = getNode(property, "key")
+        const valueNode = getNode(property, "value")
+        const computed = getBoolean(property, "computed")
+
+        let key: PropertyKey
+
+        if (computed) {
+          key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
+        } else if (keyNode.type === "Identifier") {
+          key = getString(keyNode, "name")
+        } else if (keyNode.type === "Literal") {
+          key = self.toPropertyKey(keyNode.value, keyNode)
+        } else {
+          throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
+        }
+
+        if (isBlockedMember(String(key))) {
+          throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode)
+        }
+        objectValue[String(key)] = yield* self.evaluateExpression(valueNode)
+      }
+
+      return objectValue
+    })
+  }
+
+  private evaluateArrayExpression(node: AstNode): Effect.Effect<Array<unknown>, unknown, R> {
+    const elements = getArray(node, "elements")
+    const values: Array<unknown> = []
+
+    const self = this
+    return Effect.gen(function* () {
+      for (const elementValue of elements) {
+        if (elementValue === null) {
+          values.push(undefined)
+          continue
+        }
+        const element = asNode(elementValue, "elements")
+        if (element.type === "SpreadElement") {
+          const spread = yield* self.evaluateExpression(getNode(element, "argument"))
+          const items = spreadItems(spread)
+          if (items === undefined)
+            throw new InterpreterRuntimeError(
+              "Array spread requires an array, string, Map, or Set in CodeMode.",
+              element,
+            )
+          values.push(...items)
+        } else {
+          values.push(yield* self.evaluateExpression(element))
+        }
+      }
+      return values
+    })
+  }
+
+  private evaluateTemplateLiteral(node: AstNode): Effect.Effect<string, unknown, R> {
+    const quasis = getArray(node, "quasis")
+    const expressions = getArray(node, "expressions")
+
+    let output = ""
+
+    const self = this
+    return Effect.gen(function* () {
+      for (let index = 0; index < quasis.length; index += 1) {
+        const quasi = asNode(quasis[index], "quasis")
+        const rawValue = quasi.value
+
+        if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") {
+          throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi)
+        }
+
+        output += rawValue.cooked
+
+        if (index < expressions.length) {
+          const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions"))
+          // The preserving checkpoint keeps sandbox values intact, so coerceToString renders
+          // them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk.
+          output += coerceToString(boundedData(raw, "Template interpolation"))
+        }
+      }
+
+      return output
+    })
+  }
+
+  private evaluateConditionalExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
+    return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) =>
+      this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")),
+    )
+  }
+
+  private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
+    // `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation
+    // so compound assignment inherits the same coercion semantics (Dates, data objects, ...).
+    // Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=)
+    // short-circuit and are handled by evaluateLogicalAssignment before reaching here.
+    if (!compoundOperators.has(operator)) {
+      throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node)
+    }
+    return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
+  }
+
+  private getMemberReference(
+    node: AstNode,
+  ): Effect.Effect<
+    | MemberReference
+    | ToolReference
+    | PromiseMethodReference
+    | IntrinsicReference
+    | GlobalMethodReference
+    | ComputedValue
+    | typeof OptionalShortCircuit
+    | undefined,
+    unknown,
+    R
+  > {
+    const objectNode = getNode(node, "object")
+    const propertyNode = getNode(node, "property")
+    const computed = getBoolean(node, "computed")
+    const optional = node.optional === true
+    const self = this
+    return Effect.gen(function* () {
+      const objectValue = yield* self.evaluateExpression(objectNode)
+      if (objectValue === OptionalShortCircuit) return OptionalShortCircuit
+      if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit
+
+      const key = computed
+        ? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
+        : propertyNode.type === "Identifier"
+          ? getString(propertyNode, "name")
+          : self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
+
+      if (objectValue instanceof ToolReference) {
+        if (typeof key !== "string" || isBlockedMember(key)) {
+          throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode)
+        }
+        return new ToolReference([...objectValue.path, key])
+      }
+
+      if (objectValue instanceof PromiseNamespace) {
+        if (typeof key === "string" && promiseStatics.has(key as PromiseMethodName)) {
+          return new PromiseMethodReference(key as PromiseMethodName)
+        }
+        throw new InterpreterRuntimeError(
+          `Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`,
+          propertyNode,
+        )
+      }
+
+      if (objectValue instanceof GlobalNamespace) {
+        if (typeof key !== "string" || isBlockedMember(key)) {
+          throw new InterpreterRuntimeError(
+            `${objectValue.name}.${String(key)} is not available in CodeMode.`,
+            propertyNode,
+          )
+        }
+        if (objectValue.name === "Math" && mathConstants.has(key)) {
+          return new ComputedValue((Math as unknown as Record<string, number>)[key])
+        }
+        return new GlobalMethodReference(objectValue.name, key)
+      }
+
+      if (typeof objectValue === "string") {
+        if (key === "length") return new ComputedValue(objectValue.length)
+        if (typeof key === "number") return new ComputedValue(objectValue[key])
+        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
+        // 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)
+      }
+
+      if (typeof objectValue === "number") {
+        if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key)
+        // Unknown property on a number reads as `undefined`, matching JS, rather than throwing.
+        return new ComputedValue(undefined)
+      }
+
+      // Number / String expose a small allowlist of statics; everything else stays opaque.
+      if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) {
+        if (objectValue.name === "Number" && numberConstants.has(key)) {
+          return new ComputedValue((Number as unknown as Record<string, number>)[key])
+        }
+        if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key)
+        if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
+      }
+
+      // Sandbox value types expose their method/property allowlists; any other key reads as
+      // `undefined`, consistent with unknown-property reads on strings/numbers/arrays.
+      if (objectValue instanceof SandboxDate) {
+        if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
+        return new ComputedValue(undefined)
+      }
+      if (objectValue instanceof SandboxRegExp) {
+        if (typeof key === "string" && regexpProperties.has(key)) {
+          return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
+        }
+        if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
+        return new ComputedValue(undefined)
+      }
+      if (objectValue instanceof SandboxMap) {
+        if (key === "size") return new ComputedValue(objectValue.map.size)
+        if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
+        return new ComputedValue(undefined)
+      }
+      if (objectValue instanceof SandboxSet) {
+        if (key === "size") return new ComputedValue(objectValue.set.size)
+        if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
+        return new ComputedValue(undefined)
+      }
+      if (objectValue instanceof SandboxURL) {
+        if (key === "searchParams") {
+          return new ComputedValue(objectValue.searchParams)
+        }
+        if (typeof key === "string" && urlMethods.has(key)) return new IntrinsicReference(objectValue, key)
+        if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
+        return new ComputedValue(undefined)
+      }
+      if (objectValue instanceof SandboxURLSearchParams) {
+        if (key === "size") return new ComputedValue(objectValue.params.size)
+        if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
+          return new IntrinsicReference(objectValue, key)
+        }
+        return new ComputedValue(undefined)
+      }
+
+      // Any property access on a promise is a confused program (`p.then(...)`, `p.value`);
+      // reading `undefined` here would hide the missing await, so both paths get an explicit,
+      // await-hinting error instead of the forgiving unknown-property fallthrough.
+      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(...)\`.`,
+            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(...)`.",
+          objectNode,
+          "InvalidDataValue",
+        )
+      }
+
+      if (isRuntimeReference(objectValue)) {
+        throw new InterpreterRuntimeError(
+          "CodeMode runtime references are opaque and do not expose properties.",
+          objectNode,
+          "InvalidDataValue",
+        )
+      }
+
+      if (typeof objectValue !== "object" || objectValue === null) {
+        throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
+      }
+
+      if (typeof key === "string" && isBlockedMember(key)) {
+        throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, propertyNode)
+      }
+
+      if (Array.isArray(objectValue)) {
+        if (
+          key !== "length" &&
+          !(typeof key === "string" && arrayMethods.has(key)) &&
+          typeof key !== "number" &&
+          !/^\d+$/.test(key)
+        ) {
+          // Own non-index properties read through (match results carry index/groups); like JS,
+          // they are readable in place and dropped by JSON at data boundaries.
+          if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
+            return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[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.
+          return new ComputedValue(undefined)
+        }
+        return { target: objectValue, key }
+      }
+
+      return { target: objectValue as SafeObject, key }
+    })
+  }
+
+  private readMember(node: AstNode): Effect.Effect<unknown, unknown, R> {
+    return Effect.map(this.getMemberReference(node), (reference) => {
+      if (reference === OptionalShortCircuit) return OptionalShortCircuit
+      if (reference instanceof ComputedValue) return reference.value
+      if (
+        reference === undefined ||
+        reference instanceof ToolReference ||
+        reference instanceof PromiseMethodReference ||
+        reference instanceof IntrinsicReference ||
+        reference instanceof GlobalMethodReference
+      )
+        return reference
+      if (Array.isArray(reference.target)) {
+        if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
+          return new IntrinsicReference(reference.target, reference.key)
+        }
+        return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
+      }
+      if (reference.target instanceof SandboxURL) {
+        return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
+      }
+      return reference.target[String(reference.key)]
+    })
+  }
+
+  private writeMember(node: AstNode, value: unknown): Effect.Effect<unknown, unknown, R> {
+    return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
+  }
+
+  // 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,
+  // updates, plain writes, and short-circuiting logical assignment to share one safe path.
+  private modifyMember(
+    node: AstNode,
+    compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>,
+  ): Effect.Effect<unknown, unknown, R> {
+    const self = this
+    return Effect.gen(function* () {
+      const reference = yield* self.getMemberReference(node)
+      if (
+        reference === OptionalShortCircuit ||
+        reference instanceof ComputedValue ||
+        reference === undefined ||
+        reference instanceof ToolReference ||
+        reference instanceof PromiseMethodReference ||
+        reference instanceof IntrinsicReference ||
+        reference instanceof GlobalMethodReference
+      ) {
+        throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node)
+      }
+      if (Array.isArray(reference.target)) {
+        if (reference.key === "length")
+          throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node)
+        if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
+          throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node)
+        }
+      }
+      const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
+      const current =
+        reference.target instanceof SandboxURL
+          ? (reference.target.url as unknown as Record<string, unknown>)[key]
+          : (reference.target as Record<PropertyKey, unknown>)[key]
+      const { write, next, result } = yield* compute(current)
+      if (write) self.assignToReference(reference, key, next, node)
+      return result
+    })
+  }
+
+  // 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.
+  private rejectCircularInsertion(
+    container: object,
+    value: unknown,
+    label: string,
+    node: AstNode,
+    seen = new Set<object>(),
+  ): 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
+    seen.add(value)
+    const items = Array.isArray(value) ? value : Object.values(value)
+    for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen)
+    seen.delete(value)
+  }
+
+  private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
+    if (Array.isArray(reference.target)) {
+      const target = reference.target
+      const index = key as number
+      if (!Number.isInteger(index) || index < 0) {
+        throw new InterpreterRuntimeError(
+          "Array assignment index must be a non-negative integer.",
+          node,
+          "InvalidDataValue",
+        )
+      }
+      this.rejectCircularInsertion(target, next, "Array assignment result", node)
+      target[index] = next
+      return
+    }
+    if (reference.target instanceof SandboxURL) {
+      const property = key as string
+      if (!urlWritableProperties.has(property)) {
+        throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
+      }
+      try {
+        const url = reference.target.url as unknown as Record<string, string>
+        url[property] = uriArgument(next, `URL.${property} value`)
+        return
+      } catch (error) {
+        if (error instanceof InterpreterRuntimeError || error instanceof ToolRuntimeError) throw error
+        throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
+      }
+    }
+    const target = reference.target as SafeObject
+    const objectKey = key as string
+    this.rejectCircularInsertion(target, next, "Object assignment result", node)
+    target[objectKey] = next
+  }
+
+  private toPropertyKey(value: unknown, node: AstNode): string | number {
+    if (typeof value === "string" || typeof value === "number") {
+      return value
+    }
+
+    throw new InterpreterRuntimeError("Property key must be a string or number.", node)
+  }
+
+  private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
+    const scope = this.currentScope()
+
+    // A pre-seeded parameter slot (initialized === false) is being bound for the first time;
+    // anything else already present is a genuine duplicate declaration.
+    const existing = scope.get(name)
+    if (existing && existing.initialized !== false) {
+      throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
+    }
+
+    scope.set(name, { mutable, value, initialized: true })
+  }
+
+  private getIdentifierValue(name: string, node: AstNode): unknown {
+    const binding = this.resolveBinding(name)
+
+    if (!binding) {
+      throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
+    }
+
+    // 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")
+    }
+
+    return binding.value
+  }
+
+  private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown {
+    const binding = this.resolveBinding(name)
+
+    if (!binding) {
+      throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
+    }
+
+    if (!binding.mutable) {
+      throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
+    }
+
+    binding.value = value
+    return value
+  }
+
+  private resolveBinding(name: string): Binding | undefined {
+    for (let index = this.scopes.length - 1; index >= 0; index -= 1) {
+      const scope = this.scopes[index]
+      const binding = scope?.get(name)
+
+      if (binding) {
+        return binding
+      }
+    }
+
+    return undefined
+  }
+
+  private currentScope(): Map<string, Binding> {
+    const scope = this.scopes[this.scopes.length - 1]
+
+    if (!scope) {
+      throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
+    }
+
+    return scope
+  }
+
+  private pushScope(): void {
+    this.scopes.push(new Map())
+  }
+
+  private popScope(): void {
+    this.scopes.pop()
+  }
+}
+
+/**
+ * Executes one Effect-native CodeMode program without constructing a reusable runtime.
+ *
+ * @example
+ * ```ts
+ * const result = yield* CodeMode.execute({
+ *   tools: { lookup },
+ *   code: `return await tools.lookup({ id: "order_42" })`,
+ * })
+ * ```
+ */
+export const executeWithLimits = <const Tools extends Record<string, unknown>>(
+  options: ExecuteOptions<Tools>,
+  limits: ResolvedExecutionLimits,
+  searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
+): Effect.Effect<Result, never, Services<Tools>> => {
+  const hooks = {
+    ...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
+    ...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
+  }
+  const tools = ToolRuntime.make(
+    (options.tools ?? {}) as HostTools<Services<Tools>>,
+    limits.maxToolCalls,
+    searchIndex,
+    hooks,
+  )
+  const logs: Array<string> = []
+  const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
+
+  if (options.code.trim().length === 0) {
+    return Effect.succeed({
+      ok: false,
+      error: { kind: "ParseError", message: "Code cannot be empty." },
+      toolCalls: tools.calls,
+    })
+  }
+
+  const operation = Effect.gen(function* () {
+    const program = parseProgram(options.code)
+    const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs)
+    const value = yield* interpreter.run(program)
+    const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
+    return {
+      ok: true,
+      value: result,
+      ...logged(),
+      toolCalls: tools.calls,
+    } satisfies Result
+  }).pipe((program) => {
+    const timeoutMs = limits.timeoutMs
+    if (timeoutMs === undefined) return program
+    return program.pipe(
+      Effect.timeoutOrElse({
+        duration: timeoutMs,
+        orElse: () =>
+          Effect.succeed({
+            ok: false,
+            error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
+            ...logged(),
+            toolCalls: tools.calls,
+          } satisfies Result),
+      }),
+    )
+  })
+
+  return operation.pipe(
+    Effect.catchCause((cause) =>
+      Cause.hasInterruptsOnly(cause)
+        ? Effect.interrupt
+        : Effect.succeed({
+            ok: false,
+            error: normalizeError(Cause.squash(cause)),
+            ...logged(),
+            toolCalls: tools.calls,
+          } satisfies Result),
+    ),
+    Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))),
+  )
+}
+
+const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
+
+// Truncates to a UTF-8 byte budget without splitting a code point (a split multi-byte
+// sequence decodes to a replacement character, which is dropped).
+const utf8Truncate = (value: string, maxBytes: number): string => {
+  const bytes = new TextEncoder().encode(value)
+  if (bytes.byteLength <= maxBytes) return value
+  const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes)))
+  return text.endsWith("\uFFFD") ? text.slice(0, -1) : text
+}
+
+/**
+ * Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`.
+ * 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.
+ */
+const boundOutput = (result: Result, maxOutputBytes: number): Result => {
+  let truncated = false
+
+  let value: DataValue = null
+  let valueBytes = 0
+  if (result.ok) {
+    const serialized = JSON.stringify(result.value) ?? "null"
+    const bytes = utf8ByteLength(serialized)
+    if (bytes > maxOutputBytes) {
+      truncated = true
+      value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]`
+      valueBytes = maxOutputBytes
+    } else {
+      value = result.value
+      valueBytes = bytes
+    }
+  }
+
+  const logs = result.logs ?? []
+  const kept: Array<string> = []
+  const logBudget = Math.max(0, maxOutputBytes - valueBytes)
+  let logBytes = 0
+  for (const line of logs) {
+    const lineBytes = utf8ByteLength(line) + 1
+    if (logBytes + lineBytes > logBudget) break
+    logBytes += lineBytes
+    kept.push(line)
+  }
+  if (kept.length < logs.length) {
+    truncated = true
+    kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`)
+  }
+
+  if (!truncated) return result
+  const logsPart = kept.length > 0 ? { logs: kept } : {}
+  return result.ok
+    ? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls }
+    : { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
+}

+ 2 - 2
packages/codemode/src/openapi/index.ts

@@ -1,5 +1,5 @@
 import { HttpClient } from "effect/unstable/http"
-import { Tool, type Definition } from "../tool.js"
+import { make, type Definition } from "../tool.js"
 import { invoke } from "./runtime.js"
 import {
   componentDefinitions,
@@ -102,7 +102,7 @@ export const fromSpec = (options: Options): Result => {
       setTool(
         tools,
         segments,
-        Tool.make({
+        make({
           description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
           input: inputSchema(input.fields, definitions),
           output: output.value,

+ 12 - 14
packages/codemode/src/openapi/runtime.ts

@@ -46,7 +46,9 @@ export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unkno
       )
     }
     if (json && Option.isNone(decoded)) {
-      return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`))
+      return yield* Effect.fail(
+        toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`),
+      )
     }
     return parsed
   })
@@ -206,9 +208,8 @@ const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string
       return toolError(`Missing required path parameter '${field.inputName}'.`)
     }
     const fieldValue = serializeSimple(field, item, (value) =>
-      encodeURIComponent(value).replace(
-        /[!'()*]/g,
-        (character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
+      encodeURIComponent(value).replace(/[!'()*]/g, (character) =>
+        `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
       ),
     )
     if (fieldValue instanceof ToolError) return fieldValue
@@ -270,7 +271,10 @@ const serializeQuery = (
     if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
       return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
     }
-    return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request)
+    return value.reduce(
+      (current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)),
+      request,
+    )
   }
   if (isRecord(value) && field.explode) {
     return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
@@ -285,15 +289,11 @@ const serializeQuery = (
   return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
 }
 
-const readResponseBody = (
-  response: HttpClientResponse.HttpClientResponse,
-  plan: Plan,
-): Effect.Effect<string, ToolError> =>
+const readResponseBody = (response: HttpClientResponse.HttpClientResponse, plan: Plan): Effect.Effect<string, ToolError> =>
   Effect.gen(function* () {
     const contentLength = response.headers["content-length"]
     const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10)
-    const declaredSize =
-      parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
+    const declaredSize = parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
     if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) {
       return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
     }
@@ -304,9 +304,7 @@ const readResponseBody = (
         return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
       }
       if (size + chunk.byteLength > body.byteLength) {
-        const grown = Buffer.allocUnsafe(
-          Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)),
-        )
+        const grown = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)))
         body.copy(grown, 0, 0, size)
         body = grown
       }

+ 3 - 7
packages/codemode/src/openapi/spec.ts

@@ -78,9 +78,7 @@ const isBinaryMediaType = (document: Document, mediaType: string, value: unknown
   return isRecord(schema) && schema.format === "binary"
 }
 
-const jsonContent = (
-  content: Record<string, unknown>,
-): { readonly mediaType: string; readonly schema: unknown } | undefined => {
+const jsonContent = (content: Record<string, unknown>): { readonly mediaType: string; readonly schema: unknown } | undefined => {
   const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
   return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined
 }
@@ -346,7 +344,7 @@ export const operationOutput = (
   if (outcomes.length === 0) return { ok: true, value: undefined }
   return {
     ok: true,
-    value: withDefinitions(outcomes.length === 1 ? (outcomes[0] ?? {}) : { anyOf: outcomes }, definitions),
+    value: withDefinitions(outcomes.length === 1 ? outcomes[0] ?? {} : { anyOf: outcomes }, definitions),
   }
 }
 
@@ -382,9 +380,7 @@ export const operationPath = (
   namespaces: ReadonlySet<string>,
 ): ReadonlyArray<string> => {
   const raw = nonEmptyString(operation.operationId)
-  const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(
-    sanitizeOperationSegment,
-  )
+  const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(sanitizeOperationSegment)
   if (isOperationPathAvailable(segments, used, namespaces)) return segments
   const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
   if (conflict >= 0 && conflict + 1 < segments.length) {

+ 51 - 0
packages/codemode/src/stdlib/collections.ts

@@ -0,0 +1,51 @@
+export const arrayMethods = new Set([
+  "map",
+  "filter",
+  "find",
+  "findIndex",
+  "findLast",
+  "findLastIndex",
+  "some",
+  "every",
+  "includes",
+  "join",
+  "reduce",
+  "reduceRight",
+  "flatMap",
+  "forEach",
+  "sort",
+  "toSorted",
+  "slice",
+  "concat",
+  "indexOf",
+  "lastIndexOf",
+  "at",
+  "flat",
+  "reverse",
+  "toReversed",
+  "with",
+  "push",
+  "pop",
+  "shift",
+  "unshift",
+  "splice",
+  "fill",
+  "copyWithin",
+  "keys",
+  "values",
+  "entries",
+])
+
+export const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
+
+export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
+
+export const spreadItems = (value: unknown): Array<unknown> | undefined => {
+  if (Array.isArray(value)) return value
+  if (typeof value === "string") return Array.from(value)
+  if (value instanceof SandboxMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
+  if (value instanceof SandboxSet) return Array.from(value.set.values())
+  if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
+  return undefined
+}
+import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js"

+ 4 - 0
packages/codemode/src/stdlib/console.ts

@@ -0,0 +1,4 @@
+export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
+
+/** Console formatting recursion ceiling; deeper values render as "...". */
+export const MAX_CONSOLE_DEPTH = 32

+ 94 - 0
packages/codemode/src/stdlib/date.ts

@@ -0,0 +1,94 @@
+export const dateMethods = new Set([
+  "getTime",
+  "valueOf",
+  "toISOString",
+  "toJSON",
+  "toString",
+  "getFullYear",
+  "getMonth",
+  "getDate",
+  "getDay",
+  "getHours",
+  "getMinutes",
+  "getSeconds",
+  "getMilliseconds",
+  "getUTCFullYear",
+  "getUTCMonth",
+  "getUTCDate",
+  "getUTCDay",
+  "getUTCHours",
+  "getUTCMinutes",
+  "getUTCSeconds",
+  "getUTCMilliseconds",
+  "getTimezoneOffset",
+])
+
+export const dateStatics = new Set(["now", "parse", "UTC"])
+
+export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
+  switch (name) {
+    case "now":
+      return Date.now()
+    case "parse":
+      return Date.parse(coerceToString(args[0]))
+    case "UTC":
+      return Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))
+    default:
+      throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node)
+  }
+}
+
+export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => {
+  const hosted = new Date(value.time)
+  switch (name) {
+    case "getTime":
+    case "valueOf":
+      return value.time
+    case "toISOString":
+      if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node)
+      return hosted.toISOString()
+    case "toJSON":
+      return Number.isFinite(value.time) ? hosted.toISOString() : null
+    case "toString":
+      return coerceToString(value)
+    case "getFullYear":
+      return hosted.getFullYear()
+    case "getMonth":
+      return hosted.getMonth()
+    case "getDate":
+      return hosted.getDate()
+    case "getDay":
+      return hosted.getDay()
+    case "getHours":
+      return hosted.getHours()
+    case "getMinutes":
+      return hosted.getMinutes()
+    case "getSeconds":
+      return hosted.getSeconds()
+    case "getMilliseconds":
+      return hosted.getMilliseconds()
+    case "getUTCFullYear":
+      return hosted.getUTCFullYear()
+    case "getUTCMonth":
+      return hosted.getUTCMonth()
+    case "getUTCDate":
+      return hosted.getUTCDate()
+    case "getUTCDay":
+      return hosted.getUTCDay()
+    case "getUTCHours":
+      return hosted.getUTCHours()
+    case "getUTCMinutes":
+      return hosted.getUTCMinutes()
+    case "getUTCSeconds":
+      return hosted.getUTCSeconds()
+    case "getUTCMilliseconds":
+      return hosted.getUTCMilliseconds()
+    case "getTimezoneOffset":
+      return hosted.getTimezoneOffset()
+    default:
+      throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node)
+  }
+}
+import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+import { SandboxDate } from "../values.js"
+import { coerceToNumber, coerceToString } from "./value.js"

+ 42 - 0
packages/codemode/src/stdlib/json.ts

@@ -0,0 +1,42 @@
+import {
+  type AstNode,
+  CodeModeFunction,
+  InterpreterRuntimeError,
+  supportedSyntaxMessage,
+} from "../interpreter/model.js"
+import { copyIn, copyOut } from "../tool-runtime.js"
+
+export const jsonStatics = new Set(["stringify", "parse"])
+
+export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
+  if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
+  switch (name) {
+    case "stringify": {
+      const replacer = args[1]
+      if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) {
+        throw new InterpreterRuntimeError(
+          "JSON.stringify replacers are not supported in CodeMode.",
+          node,
+          "UnsupportedSyntax",
+          [supportedSyntaxMessage],
+        )
+      }
+      const space = args[2]
+      const indent = typeof space === "number" || typeof space === "string" ? space : undefined
+      return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent)
+    }
+    case "parse": {
+      const text = args[0]
+      if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
+      try {
+        return copyIn(JSON.parse(text), "JSON.parse result")
+      } catch (error) {
+        throw new InterpreterRuntimeError(
+          `JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
+          node,
+        ).as("SyntaxError")
+      }
+    }
+  }
+  throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
+}

+ 65 - 0
packages/codemode/src/stdlib/math.ts

@@ -0,0 +1,65 @@
+export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"])
+
+export const mathMethods = new Set([
+  "max",
+  "min",
+  "abs",
+  "floor",
+  "ceil",
+  "round",
+  "trunc",
+  "sign",
+  "sqrt",
+  "cbrt",
+  "pow",
+  "hypot",
+  "log",
+  "log2",
+  "log10",
+  "exp",
+])
+
+export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
+  if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
+  const nums = args.map((arg) => {
+    if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
+    return arg
+  })
+  const [a = Number.NaN, b = Number.NaN] = nums
+  switch (name) {
+    case "max":
+      return Math.max(...nums)
+    case "min":
+      return Math.min(...nums)
+    case "abs":
+      return Math.abs(a)
+    case "floor":
+      return Math.floor(a)
+    case "ceil":
+      return Math.ceil(a)
+    case "round":
+      return Math.round(a)
+    case "trunc":
+      return Math.trunc(a)
+    case "sign":
+      return Math.sign(a)
+    case "sqrt":
+      return Math.sqrt(a)
+    case "cbrt":
+      return Math.cbrt(a)
+    case "pow":
+      return Math.pow(a, b)
+    case "hypot":
+      return Math.hypot(...nums)
+    case "log":
+      return Math.log(a)
+    case "log2":
+      return Math.log2(a)
+    case "log10":
+      return Math.log10(a)
+    case "exp":
+      return Math.exp(a)
+  }
+  throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
+}
+import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"

+ 66 - 0
packages/codemode/src/stdlib/number.ts

@@ -0,0 +1,66 @@
+export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"])
+
+export const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"])
+
+export const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"])
+
+export const invokeNumberMethod = (value: number, name: string, args: Array<unknown>, node: AstNode): unknown => {
+  const optNum = (index: number): number | undefined => {
+    const arg = args[index]
+    if (arg === undefined) return undefined
+    if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node)
+    return arg
+  }
+  let result: unknown
+  switch (name) {
+    case "toFixed":
+      result = value.toFixed(optNum(0))
+      break
+    case "toExponential":
+      result = value.toExponential(optNum(0))
+      break
+    case "toPrecision": {
+      const digits = optNum(0)
+      result = digits === undefined ? value.toString() : value.toPrecision(digits)
+      break
+    }
+    case "toString": {
+      const radix = optNum(0)
+      if (radix !== undefined && (radix < 2 || radix > 36)) {
+        throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node)
+      }
+      result = value.toString(radix)
+      break
+    }
+    default:
+      throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node)
+  }
+  return boundedData(result, `Number.${name} result`)
+}
+
+export const invokeNumberStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
+  const value = args[0]
+  switch (name) {
+    case "isInteger":
+      return Number.isInteger(value)
+    case "isFinite":
+      return Number.isFinite(value)
+    case "isNaN":
+      return Number.isNaN(value)
+    case "isSafeInteger":
+      return Number.isSafeInteger(value)
+    case "parseInt": {
+      const radix = args[1]
+      if (radix !== undefined && typeof radix !== "number") {
+        throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
+      }
+      return parseInt(coerceToString(value), radix)
+    }
+    case "parseFloat":
+      return parseFloat(coerceToString(value))
+    default:
+      throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node)
+  }
+}
+import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+import { boundedData, coerceToString } from "./value.js"

+ 77 - 0
packages/codemode/src/stdlib/object.ts

@@ -0,0 +1,77 @@
+import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+import { isBlockedMember } from "../tool-runtime.js"
+import { isSandboxValue, SandboxMap, SandboxURLSearchParams } from "../values.js"
+import { boundedData, coerceToString } from "./value.js"
+
+export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"])
+
+export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
+  if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
+  const requireObject = (): Record<string, unknown> => {
+    const value = boundedData(args[0], `Object.${name} input`)
+    if (isSandboxValue(value)) return {}
+    if (value === null || typeof value !== "object" || Array.isArray(value)) {
+      throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node)
+    }
+    return value as Record<string, unknown>
+  }
+  const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
+    if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
+    out[key] = item
+  }
+  switch (name) {
+    case "keys": {
+      const value = boundedData(args[0], "Object.keys input")
+      if (isSandboxValue(value)) return []
+      if (Array.isArray(value)) return Object.keys(value)
+      if (value === null || typeof value !== "object") {
+        throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node)
+      }
+      return Object.keys(value)
+    }
+    case "values":
+      return Object.values(requireObject())
+    case "entries":
+      return Object.entries(requireObject()).map(([key, item]) => [key, item])
+    case "hasOwn":
+      return Object.hasOwn(requireObject(), String(args[1]))
+    case "assign": {
+      const out: Record<string, unknown> = Object.create(null)
+      for (const source of args) {
+        if (source === null || source === undefined) continue
+        const value = boundedData(source, "Object.assign input")
+        if (isSandboxValue(value)) continue
+        if (value === null || typeof value !== "object" || Array.isArray(value)) {
+          throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
+        }
+        for (const [key, item] of Object.entries(value)) guardedSet(out, key, item)
+      }
+      return out
+    }
+    case "fromEntries": {
+      if (args[0] instanceof SandboxMap) {
+        const out: Record<string, unknown> = Object.create(null)
+        for (const [key, item] of args[0].map.entries()) guardedSet(out, coerceToString(key), item)
+        return out
+      }
+      if (args[0] instanceof SandboxURLSearchParams) {
+        const out: Record<string, unknown> = Object.create(null)
+        for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
+        return out
+      }
+      const pairs = boundedData(args[0], "Object.fromEntries input")
+      if (!Array.isArray(pairs)) {
+        throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
+      }
+      const out: Record<string, unknown> = Object.create(null)
+      for (const pair of pairs) {
+        if (!Array.isArray(pair)) {
+          throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node)
+        }
+        guardedSet(out, String(pair[0]), pair[1])
+      }
+      return out
+    }
+  }
+  throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
+}

+ 6 - 0
packages/codemode/src/stdlib/promise.ts

@@ -0,0 +1,6 @@
+import type { PromiseMethodName } from "../interpreter/model.js"
+
+export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "resolve", "reject"])
+
+/** Maximum number of eagerly forked tool calls that may run concurrently. */
+export const TOOL_CALL_CONCURRENCY = 8

+ 74 - 0
packages/codemode/src/stdlib/regexp.ts

@@ -0,0 +1,74 @@
+export const regexpMethods = new Set(["test", "exec", "toString"])
+
+export const regexpProperties = new Set([
+  "source",
+  "flags",
+  "lastIndex",
+  "global",
+  "ignoreCase",
+  "multiline",
+  "sticky",
+  "unicode",
+  "dotAll",
+])
+
+export const regexFailureReason = (error: unknown): string =>
+  (error instanceof Error ? error.message : String(error)).replace(/^Invalid regular expression:\s*/i, "")
+
+export const escapeRegexHint =
+  'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
+
+export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
+  if (arg instanceof SandboxRegExp) return arg.regex
+  if (typeof arg === "string") {
+    try {
+      return new RegExp(arg, extraFlags)
+    } catch (error) {
+      throw new InterpreterRuntimeError(
+        `String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
+        node,
+      ).as("SyntaxError")
+    }
+  }
+  throw new InterpreterRuntimeError(
+    `String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`,
+    node,
+  )
+}
+
+export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
+  const result: Array<unknown> = Array.from(match, (group) => group)
+  if (match.index !== undefined) (result as Record<string, unknown> & Array<unknown>).index = match.index
+  if (match.groups) {
+    const groups: SafeObject = Object.create(null) as SafeObject
+    for (const [key, group] of Object.entries(match.groups)) {
+      if (!isBlockedMember(key)) groups[key] = group
+    }
+    ;(result as Record<string, unknown> & Array<unknown>).groups = groups
+  }
+  return result
+}
+
+export const invokeRegExpMethod = (
+  value: SandboxRegExp,
+  name: string,
+  args: Array<unknown>,
+  node: AstNode,
+): unknown => {
+  switch (name) {
+    case "test":
+      return value.regex.test(coerceToString(args[0]))
+    case "exec": {
+      const matched = value.regex.exec(coerceToString(args[0]))
+      return matched === null ? null : matchToValue(matched)
+    }
+    case "toString":
+      return coerceToString(value)
+    default:
+      throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
+  }
+}
+import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
+import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
+import { SandboxRegExp } from "../values.js"
+import { coerceToString } from "./value.js"

+ 52 - 0
packages/codemode/src/stdlib/string.ts

@@ -0,0 +1,52 @@
+export const stringMethods = new Set([
+  "toLowerCase",
+  "toUpperCase",
+  "trim",
+  "trimStart",
+  "trimEnd",
+  "trimLeft",
+  "trimRight",
+  "split",
+  "slice",
+  "substring",
+  "substr",
+  "includes",
+  "startsWith",
+  "endsWith",
+  "indexOf",
+  "lastIndexOf",
+  "replace",
+  "replaceAll",
+  "repeat",
+  "padStart",
+  "padEnd",
+  "charAt",
+  "charCodeAt",
+  "codePointAt",
+  "at",
+  "concat",
+  "toString",
+  "match",
+  "matchAll",
+  "search",
+  "localeCompare",
+  "normalize",
+])
+
+export const stringStatics = new Set(["fromCharCode", "fromCodePoint"])
+
+export const invokeStringStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
+  const codes = args.map((arg) => {
+    if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node)
+    return arg
+  })
+  switch (name) {
+    case "fromCharCode":
+      return String.fromCharCode(...codes)
+    case "fromCodePoint":
+      return String.fromCodePoint(...codes)
+    default:
+      throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node)
+  }
+}
+import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"

+ 90 - 0
packages/codemode/src/stdlib/url.ts

@@ -0,0 +1,90 @@
+export const urlProperties = new Set([
+  "href",
+  "origin",
+  "protocol",
+  "username",
+  "password",
+  "host",
+  "hostname",
+  "port",
+  "pathname",
+  "search",
+  "hash",
+])
+
+export const urlWritableProperties = new Set([
+  "href",
+  "protocol",
+  "username",
+  "password",
+  "host",
+  "hostname",
+  "port",
+  "pathname",
+  "search",
+  "hash",
+])
+
+export const urlMethods = new Set(["toString", "toJSON"])
+export const urlStatics = new Set(["canParse", "parse"])
+export const urlSearchParamsMethods = new Set([
+  "append",
+  "delete",
+  "get",
+  "getAll",
+  "has",
+  "set",
+  "sort",
+  "forEach",
+  "keys",
+  "values",
+  "entries",
+  "toString",
+])
+
+export const uriArgument = (value: unknown, label: string): string => coerceToString(boundedData(value, label))
+
+export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node: AstNode): string => {
+  const value = uriArgument(args[0], `${ref.name} input`)
+  try {
+    switch (ref.name) {
+      case "encodeURI":
+        return encodeURI(value)
+      case "encodeURIComponent":
+        return encodeURIComponent(value)
+      case "decodeURI":
+        return decodeURI(value)
+      case "decodeURIComponent":
+        return decodeURIComponent(value)
+    }
+  } catch (error) {
+    throw new InterpreterRuntimeError(
+      `${ref.name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`,
+      node,
+    ).as("URIError")
+  }
+}
+
+export const urlArgument = (value: unknown, label: string): string =>
+  value instanceof SandboxURL ? value.url.href : uriArgument(value, label)
+
+export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
+  if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
+  if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node).as("TypeError")
+  const input = urlArgument(args[0], `URL.${name} input`)
+  const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
+  try {
+    const url = new URL(input, base)
+    return name === "canParse" ? true : new SandboxURL(url)
+  } catch {
+    return name === "canParse" ? false : null
+  }
+}
+
+export const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => {
+  if (name === "toString" || name === "toJSON") return value.url.href
+  throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
+}
+import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
+import { SandboxURL } from "../values.js"
+import { boundedData, coerceToString } from "./value.js"

+ 90 - 0
packages/codemode/src/stdlib/value.ts

@@ -0,0 +1,90 @@
+export const errorConstructors = new Set([
+  "Error",
+  "TypeError",
+  "RangeError",
+  "SyntaxError",
+  "ReferenceError",
+  "EvalError",
+  "URIError",
+])
+
+export const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"])
+
+export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
+
+const ErrorBrand: unique symbol = Symbol("codemode.error")
+
+export const createErrorValue = (name: string, message: string): SafeObject => {
+  const value = Object.assign(Object.create(null) as SafeObject, { name, message })
+  Object.defineProperty(value, ErrorBrand, { value: name })
+  return value
+}
+
+export const errorBrandName = (value: unknown): string | undefined =>
+  value !== null && typeof value === "object"
+    ? ((value as Record<PropertyKey, unknown>)[ErrorBrand] as string | undefined)
+    : undefined
+
+export const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true)
+
+export const coerceToString = (value: unknown): string => {
+  if (value === null) return "null"
+  if (value === undefined) return "undefined"
+  if (value instanceof SandboxDate)
+    return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
+  if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
+  if (value instanceof SandboxMap) return "[object Map]"
+  if (value instanceof SandboxSet) return "[object Set]"
+  if (value instanceof SandboxURL) return value.url.href
+  if (value instanceof SandboxURLSearchParams) return value.params.toString()
+  if (typeof value === "object") {
+    return Array.isArray(value)
+      ? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
+      : "[object Object]"
+  }
+  return String(value)
+}
+
+export const coerceToNumber = (value: unknown): number => {
+  if (value instanceof SandboxDate) return value.time
+  if (isSandboxValue(value)) return Number.NaN
+  return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
+}
+
+export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
+  const raw = args[0]
+  if (isSandboxValue(raw)) {
+    if (ref.name === "Boolean") return true
+    if (ref.name === "Number") return coerceToNumber(raw)
+    if (ref.name === "String") return coerceToString(raw)
+    if (ref.name === "parseInt") return parseInt(coerceToString(raw))
+    return parseFloat(coerceToString(raw))
+  }
+  const value = boundedData(args[0], `${ref.name} input`)
+  if (ref.name === "Number") return coerceToNumber(value)
+  if (ref.name === "Boolean") return Boolean(value)
+  if (ref.name === "parseInt") {
+    const radix = args[1]
+    if (radix !== undefined && typeof radix !== "number") {
+      throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
+    }
+    return parseInt(coerceToString(value), radix)
+  }
+  if (ref.name === "parseFloat") return parseFloat(coerceToString(value))
+  return coerceToString(value)
+}
+import {
+  type AstNode,
+  CoercionFunction,
+  InterpreterRuntimeError,
+} from "../interpreter/model.js"
+import { copyIn, type SafeObject } from "../tool-runtime.js"
+import {
+  isSandboxValue,
+  SandboxDate,
+  SandboxMap,
+  SandboxRegExp,
+  SandboxSet,
+  SandboxURL,
+  SandboxURLSearchParams,
+} from "../values.js"

+ 0 - 2
packages/codemode/src/tool-api.ts

@@ -1,2 +0,0 @@
-export { isDefinition, make } from "./tool.js"
-export type { Definition, JsonSchema, Options, ToolSchema as SchemaType } from "./tool.js"

+ 91 - 76
packages/codemode/src/tool-runtime.ts

@@ -6,12 +6,18 @@ import {
   identifierSegment,
   inputProperties,
   inputTypeScript,
-  isDefinition as isToolDefinition,
   outputTypeScript,
-  Tool,
-  type Definition,
-} from "./tool.js"
-import { SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
+} from "./tool-schema.js"
+import { isDefinition as isToolDefinition, type Definition } from "./tool.js"
+import {
+  SandboxDate,
+  SandboxMap,
+  SandboxPromise,
+  SandboxRegExp,
+  SandboxSet,
+  SandboxURL,
+  SandboxURLSearchParams,
+} from "./values.js"
 
 const estimateTokens = (input: string) => Math.max(0, Math.round(input.length / 4))
 
@@ -154,9 +160,9 @@ export const isBlockedMember = (name: string): boolean => blockedMemberNames.has
  * Two modes share the walk:
  * - **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/URL -> strings, the remaining value types -> {}.
  * - **Intra-sandbox checkpoint** (`preserveSandboxValues` true; see `boundedData` in
- *   codemode.ts): Date/RegExp/Map/Set instances pass through untouched (treated as leaves,
+ *   codemode.ts): standard-library value instances pass through untouched (treated as leaves,
  *   contents not walked), so values flowing through `Object.*` helpers, coercion inputs, and
  *   other in-sandbox checkpoints stay fully usable (`.getTime()`, `.has()`, ...).
  *
@@ -210,7 +216,9 @@ const copyBounded = (
       value instanceof SandboxDate ||
       value instanceof SandboxRegExp ||
       value instanceof SandboxMap ||
-      value instanceof SandboxSet
+      value instanceof SandboxSet ||
+      value instanceof SandboxURL ||
+      value instanceof SandboxURLSearchParams
     ) {
       return value
     }
@@ -230,24 +238,30 @@ const copyBounded = (
       for (const item of value.values()) wrapped.set.add(copyBounded(item, label, depth + 1, seen, true))
       return wrapped
     }
+    if (value instanceof URL) return new SandboxURL(new URL(value.href))
+    if (value instanceof URLSearchParams) return new SandboxURLSearchParams(new URLSearchParams(value))
   }
 
   // Sandbox value types (and their host counterparts, which a host tool may legitimately
-  // return) serialize exactly as JSON.stringify would at the data boundary: a Date is its
-  // toJSON() ISO string (invalid -> null), and RegExp/Map/Set have no JSON form beyond {}.
+  // return) serialize exactly as JSON.stringify would at the data boundary: Date/URL use
+  // toJSON(), while RegExp/Map/Set/URLSearchParams have no JSON form beyond {}.
   if (value instanceof SandboxDate) {
     return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
   }
   if (value instanceof Date) {
     return Number.isFinite(value.getTime()) ? value.toISOString() : null
   }
+  if (value instanceof SandboxURL) return value.url.href
+  if (value instanceof URL) return value.href
   if (
     value instanceof SandboxRegExp ||
     value instanceof SandboxMap ||
     value instanceof SandboxSet ||
+    value instanceof SandboxURLSearchParams ||
     value instanceof RegExp ||
     value instanceof Map ||
-    value instanceof Set
+    value instanceof Set ||
+    value instanceof URLSearchParams
   ) {
     return Object.create(null) as SafeObject
   }
@@ -369,69 +383,70 @@ const termForms = (term: string): Array<string> => {
   return forms
 }
 
-const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>) =>
-  Tool.make({
-    description: "Search available Code Mode tools",
-    input: SearchInput,
-    output: SearchOutput,
-    run: (request) =>
-      Effect.sync(() => {
-        const query = request.query ?? ""
-        const offset = request.offset ?? 0
-        const scoped =
-          request.namespace === undefined
-            ? searchIndex
-            : searchIndex.filter((entry) => entry.namespace === request.namespace)
-        // A query that names one tool path exactly (canonical path or rendered JavaScript
-        // expression) is a lookup, not a search: return that tool alone.
-        const trimmed = query.trim()
-        const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
-        const exact =
-          pathQuery === ""
-            ? undefined
-            : scoped.find(
-                (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
-              )
-        const terms = tokenize(query).map(termForms)
-        // Additive field-weighted scoring, summed across terms: exact path or path segment
-        // (20) > path substring (8) > description substring (4) > any searchable text,
-        // including input parameter names and descriptions (2).
-        const ranked =
-          exact !== undefined
-            ? [exact]
-            : scoped
-                .map((entry) => {
-                  const path = entry.description.path.toLowerCase()
-                  const description = entry.description.description.toLowerCase()
-                  const score = terms.reduce(
-                    (total, forms) =>
-                      total +
-                      (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
-                      (forms.some((form) => path.includes(form)) ? 8 : 0) +
-                      (forms.some((form) => description.includes(form)) ? 4 : 0) +
-                      (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
-                    0,
-                  )
-                  return { entry, score }
-                })
-                .filter(({ score }) => terms.length === 0 || score > 0)
-                .sort(
-                  (left, right) =>
-                    right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path),
+const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Definition => ({
+  _tag: "CodeModeTool",
+  description: "Search available Code Mode tools",
+  input: SearchInput,
+  output: SearchOutput,
+  run: (input) =>
+    Effect.sync(() => {
+      const request = input as typeof SearchInput.Type
+      const query = request.query ?? ""
+      const offset = request.offset ?? 0
+      const scoped =
+        request.namespace === undefined
+          ? searchIndex
+          : searchIndex.filter((entry) => entry.namespace === request.namespace)
+      // A query that names one tool path exactly (canonical path or rendered JavaScript
+      // expression) is a lookup, not a search: return that tool alone.
+      const trimmed = query.trim()
+      const pathQuery = trimmed.startsWith("tools.") ? trimmed.slice("tools.".length) : trimmed
+      const exact =
+        pathQuery === ""
+          ? undefined
+          : scoped.find(
+              (entry) => entry.description.path === pathQuery || toolExpression(entry.description.path) === trimmed,
+            )
+      const terms = tokenize(query).map(termForms)
+      // Additive field-weighted scoring, summed across terms: exact path or path segment
+      // (20) > path substring (8) > description substring (4) > any searchable text,
+      // including input parameter names and descriptions (2).
+      const ranked =
+        exact !== undefined
+          ? [exact]
+          : scoped
+              .map((entry) => {
+                const path = entry.description.path.toLowerCase()
+                const description = entry.description.description.toLowerCase()
+                const score = terms.reduce(
+                  (total, forms) =>
+                    total +
+                    (forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
+                    (forms.some((form) => path.includes(form)) ? 8 : 0) +
+                    (forms.some((form) => description.includes(form)) ? 4 : 0) +
+                    (forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
+                  0,
                 )
-                .map(({ entry }) => entry)
-        const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
-          ...description,
-          path: toolExpression(description.path),
-        }))
-        const remaining = Math.max(0, ranked.length - offset - items.length)
-        return {
-          items,
-          remaining,
-          next: remaining > 0 ? { offset: offset + items.length } : null,
-        }
-      }),
-  })
+                return { entry, score }
+              })
+              .filter(({ score }) => terms.length === 0 || score > 0)
+              .sort(
+                (left, right) =>
+                  right.score - left.score || left.entry.description.path.localeCompare(right.entry.description.path),
+              )
+              .map(({ entry }) => entry)
+      const items = ranked.slice(offset, offset + (request.limit ?? defaultSearchLimit)).map(({ description }) => ({
+        ...description,
+        path: toolExpression(description.path),
+      }))
+      const remaining = Math.max(0, ranked.length - offset - items.length)
+      return {
+        items,
+        remaining,
+        next: remaining > 0 ? { offset: offset + items.length } : null,
+      }
+    }),
+})
 
 const searchDescription = describeDefinition(`${reservedNamespace}.search`, makeSearchTool([]))
 
@@ -590,9 +605,9 @@ export const prepare = <R>(tools: HostTools<R>, catalogBudget = defaultCatalogBu
     "",
     "## Language",
     "",
-    "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls.",
-    "Modules/imports, classes, generators, timers, fetch, eval, prototype access, arbitrary methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
-    "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
+    "Use common JavaScript data operations, functions, control flow, selected standard-library methods, and awaited tool calls. Built-ins include Date, RegExp, Map, Set, URL, URLSearchParams, and URI encoding helpers.",
+    "Modules/imports, classes, generators, timers, fetch, eval, prototype access, unlisted methods, and promise chaining are unavailable. Use Code Mode tools for external operations. Use await with try/catch.",
+    "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
   ]
 
   const toolSection: Array<string> = [""]

+ 301 - 0
packages/codemode/src/tool-schema.ts

@@ -0,0 +1,301 @@
+import { JsonPointer, Schema } from "effect"
+import type { Definition, JsonSchema, SchemaType } from "./tool.js"
+
+const isEffectSchema = (schema: SchemaType): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
+
+const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
+
+/**
+ * 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_$]*$/
+
+/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
+const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
+
+const effectNumberSentinel = (schema: JsonSchema) =>
+  schema.type === "string" &&
+  Array.isArray(schema.enum) &&
+  schema.enum.length === 1 &&
+  (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
+
+const intersection = (members: ReadonlyArray<string>): string => {
+  const concrete = members.filter((member) => member !== "unknown")
+  if (concrete.length === 0) return "unknown"
+  if (concrete.length === 1) return concrete[0] ?? "unknown"
+  return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
+}
+
+/**
+ * Recursion ceiling for schema rendering. Object, array, and union recursion all increment
+ * 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
+
+type RenderContext = {
+  readonly definitions: Readonly<Record<string, JsonSchema>>
+  /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
+  readonly pretty: boolean
+}
+
+const hasUnresolvedRef = (
+  schema: JsonSchema,
+  definitions: Readonly<Record<string, JsonSchema>>,
+  seen: ReadonlySet<string> = new Set(),
+  visited: ReadonlySet<JsonSchema> = new Set(),
+): boolean => {
+  if (visited.has(schema)) return false
+  const nextVisited = new Set([...visited, schema])
+  if (schema.$ref !== undefined) {
+    const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
+    const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
+    if (name === undefined || definitions[name] === undefined || seen.has(name)) return true
+    if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true
+  }
+  return [
+    ...(schema.anyOf ?? []),
+    ...(schema.oneOf ?? []),
+    ...(schema.allOf ?? []),
+    ...Object.values(schema.properties ?? {}),
+    ...(schema.items === undefined ? [] : [schema.items]),
+    ...(typeof schema.additionalProperties === "object" ? [schema.additionalProperties] : []),
+  ].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited))
+}
+
+/**
+ * Schema constraints a TypeScript type cannot express natively but a model benefits from,
+ * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
+ */
+const docTags = (schema: JsonSchema): Array<string> => {
+  const tags: Array<string> = []
+  if (schema.deprecated === true) tags.push("@deprecated")
+  if (schema.default !== undefined) {
+    try {
+      const rendered = JSON.stringify(schema.default)
+      if (rendered !== undefined) tags.push(`@default ${rendered}`)
+    } catch {
+      // unserializable default: skip rather than emit a broken tag
+    }
+  }
+  if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
+  if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
+  if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
+  return tags
+}
+
+/**
+ * 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
+ * `*`-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.
+ */
+const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
+  const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
+    line.replaceAll("*/", "* /").replace(/\s+$/, ""),
+  )
+  while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
+  while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
+  if (lines.length === 0) return ""
+  if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
+  const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
+  return `${pad}/**\n${body}\n${pad} */\n`
+}
+
+const renderSchema = (
+  schema: JsonSchema,
+  ctx: RenderContext,
+  depth = 0,
+  seen: ReadonlySet<string> = new Set(),
+): string => {
+  if (depth > MAX_RENDER_DEPTH) return "unknown"
+  const nested =
+    schema.definitions === undefined && schema.$defs === undefined
+      ? ctx
+      : { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } }
+  if (schema.$ref) {
+    const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
+    const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
+    if (!name || !nested.definitions[name] || seen.has(name)) return "unknown"
+    return intersection([
+      renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])),
+      renderSchema({ ...schema, $ref: undefined }, nested, depth + 1, seen),
+    ])
+  }
+  if (schema.const !== undefined) return renderLiteral(schema.const)
+  if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
+  const alternatives = schema.anyOf ?? schema.oneOf
+  if (alternatives) {
+    // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
+    // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
+    // real JSON Schema unions such as `string | number` or `number | null` must keep
+    // every branch.
+    if (
+      alternatives.some((item) => item.type === "number") &&
+      alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
+    )
+      return "number"
+    // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
+    // (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
+    if (
+      alternatives.length === 2 &&
+      alternatives[0]?.type === "object" &&
+      alternatives[0].properties === undefined &&
+      alternatives[1]?.type === "array" &&
+      alternatives[1].items === undefined
+    ) {
+      return "{}"
+    }
+    const members = alternatives.map((item) => renderSchema(item, nested, depth + 1, seen))
+    if (members.some((member) => member === "unknown")) return "unknown"
+    return intersection([
+      members.join(" | "),
+      renderSchema({ ...schema, anyOf: undefined, oneOf: undefined }, nested, depth + 1, seen),
+    ])
+  }
+  if (schema.allOf) {
+    const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
+    if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
+    return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
+  }
+  if (Array.isArray(schema.type)) {
+    return schema.type.map((item) => renderSchema({ ...schema, type: item }, nested, depth + 1, seen)).join(" | ")
+  }
+  if (schema.type === "string") return "string"
+  if (schema.type === "number" || schema.type === "integer") return "number"
+  if (schema.type === "boolean") return "boolean"
+  if (schema.type === "null") return "null"
+  if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, nested, depth + 1, seen)}>`
+  if (schema.type === "object" || schema.properties) {
+    const required = new Set(schema.required ?? [])
+    const properties = Object.entries(schema.properties ?? {})
+    const additional = schema.additionalProperties
+    const indexType =
+      additional && typeof additional === "object" ? renderSchema(additional, nested, depth + 1, seen) : undefined
+    const field = ([name, value]: readonly [string, JsonSchema]) =>
+      `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, nested, depth + 1, seen)}`
+
+    if (!ctx.pretty) {
+      const fields = properties.map(field)
+      if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`)
+      return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
+    }
+
+    // Pretty: an indented block, each described field preceded by its JSDoc comment.
+    if (properties.length === 0 && indexType === undefined) return "{}"
+    const pad = "  ".repeat(depth + 1)
+    const lines = properties.map(
+      (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
+    )
+    if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
+    return `{\n${lines.join("\n")}\n${"  ".repeat(depth)}}`
+  }
+  return "unknown"
+}
+
+export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
+  try {
+    const visible = decoded ? Schema.toType(schema) : schema
+    const document = Schema.toJsonSchemaDocument(visible) as {
+      readonly schema: JsonSchema
+      readonly definitions?: Readonly<Record<string, JsonSchema>>
+    }
+    return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty })
+  } catch {
+    return "unknown"
+  }
+}
+
+/** Renders a raw JSON Schema document as a TypeScript type string. */
+export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
+  try {
+    return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
+  } catch {
+    return "unknown"
+  }
+}
+
+/** One input property of a tool, extracted best-effort from its input schema. */
+export type InputProperty = {
+  readonly name: string
+  readonly description: string | undefined
+  readonly required: boolean
+}
+
+/**
+ * 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.
+ * Anything unresolvable yields `[]` (search falls back to path + description).
+ */
+export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
+  try {
+    const document = isEffectSchema(definition.input)
+      ? (Schema.toJsonSchemaDocument(definition.input) as {
+          readonly schema: JsonSchema
+          readonly definitions?: Readonly<Record<string, JsonSchema>>
+        })
+      : {
+          schema: definition.input,
+          definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
+        }
+    const definitions = document.definitions ?? {}
+    let schema = document.schema
+    if (schema.$ref !== undefined) {
+      const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
+      const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
+      const resolved = name === undefined ? undefined : definitions[name]
+      if (resolved === undefined) return []
+      schema = resolved
+    }
+    const required = new Set(schema.required ?? [])
+    return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
+      name,
+      description: typeof value.description === "string" ? value.description : undefined,
+      required: required.has(name),
+    }))
+  } catch {
+    return []
+  }
+}
+
+/**
+ * The model-visible TypeScript type of a tool's input. `pretty` renders an indented
+ * multiline block with schema descriptions and constraints as JSDoc comments on the
+ * fields; the default stays the compact single-line form.
+ */
+export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
+  isEffectSchema(definition.input)
+    ? toTypeScript(definition.input, false, pretty)
+    : jsonSchemaToTypeScript(definition.input, pretty)
+
+/**
+ * The model-visible TypeScript type of a tool's result; tools without an output schema
+ * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
+ */
+export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
+  definition.output === undefined
+    ? "unknown"
+    : isEffectSchema(definition.output)
+      ? toTypeScript(definition.output, true, pretty)
+      : jsonSchemaToTypeScript(definition.output, pretty)
+
+/**
+ * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
+ * JSON-Schema-described inputs pass through unvalidated (render-only).
+ */
+export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
+  isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
+
+/**
+ * Decodes a tool result before it is exposed to the program. Effect Schemas validate and
+ * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
+ * the host value through unchanged.
+ */
+export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
+  definition.output !== undefined && isEffectSchema(definition.output)
+    ? Schema.decodeUnknownSync(definition.output)(value)
+    : value

+ 8 - 310
packages/codemode/src/tool.ts

@@ -1,4 +1,4 @@
-import { Effect, JsonPointer, Schema } from "effect"
+import { Effect, Schema } from "effect"
 
 /**
  * JSON Schema subset accepted for render-only tool schemas.
@@ -30,25 +30,25 @@ export type JsonSchema = {
 }
 
 /** Either a validating Effect Schema or a render-only JSON Schema document. */
-export type ToolSchema = Schema.Decoder<unknown> | JsonSchema
+export type SchemaType = Schema.Decoder<unknown> | JsonSchema
 
 /** Schema-backed tool definition consumed by a CodeMode tool tree. */
 export type Definition<R = never> = {
   readonly _tag: "CodeModeTool"
   readonly description: string
-  readonly input: ToolSchema
-  readonly output: ToolSchema | undefined
+  readonly input: SchemaType
+  readonly output: SchemaType | undefined
   readonly run: (input: unknown) => Effect.Effect<unknown, unknown, R>
 }
 
 /** The value `run` receives: the decoded type for Effect Schemas, `unknown` for JSON Schemas. */
-export type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
+type InputType<S> = S extends Schema.Decoder<unknown> ? S["Type"] : unknown
 
 /** The value `run` returns: the encoded type for Effect Schemas, `unknown` otherwise. */
-export type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
+type ResultType<S> = S extends Schema.Decoder<unknown> ? S["Encoded"] : unknown
 
 /** Options for defining one CodeMode tool. */
-export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R = never> = {
+export type Options<I extends SchemaType, O extends SchemaType | undefined, R = never> = {
   readonly description: string
   readonly input: I
   readonly output?: O
@@ -58,305 +58,6 @@ export type Options<I extends ToolSchema, O extends ToolSchema | undefined, R =
 export const isDefinition = <R = never>(value: unknown): value is Definition<R> =>
   typeof value === "object" && value !== null && "_tag" in value && value._tag === "CodeModeTool"
 
-const isEffectSchema = (schema: ToolSchema): schema is Schema.Decoder<unknown> & Schema.Top => Schema.isSchema(schema)
-
-const renderLiteral = (value: unknown): string => JSON.stringify(value) ?? "unknown"
-
-/**
- * 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_$]*$/
-
-/** Renders a property name as a valid TS object key: bare when an identifier, quoted otherwise. */
-const renderKey = (name: string): string => (identifierSegment.test(name) ? name : JSON.stringify(name))
-
-const effectNumberSentinel = (schema: JsonSchema) =>
-  schema.type === "string" &&
-  Array.isArray(schema.enum) &&
-  schema.enum.length === 1 &&
-  (schema.enum[0] === "NaN" || schema.enum[0] === "Infinity" || schema.enum[0] === "-Infinity")
-
-const intersection = (members: ReadonlyArray<string>): string => {
-  const concrete = members.filter((member) => member !== "unknown")
-  if (concrete.length === 0) return "unknown"
-  if (concrete.length === 1) return concrete[0] ?? "unknown"
-  return concrete.map((member) => (member.includes(" | ") ? `(${member})` : member)).join(" & ")
-}
-
-/**
- * Recursion ceiling for schema rendering. Object, array, and union recursion all increment
- * 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
-
-type RenderContext = {
-  readonly definitions: Readonly<Record<string, JsonSchema>>
-  /** Indented, JSDoc-annotated multiline rendering (search results); compact single line otherwise. */
-  readonly pretty: boolean
-}
-
-const hasUnresolvedRef = (
-  schema: JsonSchema,
-  definitions: Readonly<Record<string, JsonSchema>>,
-  seen: ReadonlySet<string> = new Set(),
-  visited: ReadonlySet<JsonSchema> = new Set(),
-): boolean => {
-  if (visited.has(schema)) return false
-  const nextVisited = new Set([...visited, schema])
-  if (schema.$ref !== undefined) {
-    const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
-    const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
-    if (name === undefined || definitions[name] === undefined || seen.has(name)) return true
-    if (hasUnresolvedRef(definitions[name], definitions, new Set([...seen, name]), nextVisited)) return true
-  }
-  return [
-    ...(schema.anyOf ?? []),
-    ...(schema.oneOf ?? []),
-    ...(schema.allOf ?? []),
-    ...Object.values(schema.properties ?? {}),
-    ...(schema.items === undefined ? [] : [schema.items]),
-    ...(typeof schema.additionalProperties === "object" ? [schema.additionalProperties] : []),
-  ].some((item) => hasUnresolvedRef(item, definitions, seen, nextVisited))
-}
-
-/**
- * Schema constraints a TypeScript type cannot express natively but a model benefits from,
- * surfaced as JSDoc tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
- */
-const docTags = (schema: JsonSchema): Array<string> => {
-  const tags: Array<string> = []
-  if (schema.deprecated === true) tags.push("@deprecated")
-  if (schema.default !== undefined) {
-    try {
-      const rendered = JSON.stringify(schema.default)
-      if (rendered !== undefined) tags.push(`@default ${rendered}`)
-    } catch {
-      // unserializable default: skip rather than emit a broken tag
-    }
-  }
-  if (typeof schema.format === "string") tags.push(`@format ${schema.format}`)
-  if (typeof schema.minItems === "number") tags.push(`@minItems ${schema.minItems}`)
-  if (typeof schema.maxItems === "number") tags.push(`@maxItems ${schema.maxItems}`)
-  return tags
-}
-
-/**
- * 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
- * `*`-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.
- */
-const jsdoc = (description: string | undefined, tags: ReadonlyArray<string>, pad: string): string => {
-  const lines = [...(description === undefined ? [] : description.split("\n")), ...tags].map((line) =>
-    line.replaceAll("*/", "* /").replace(/\s+$/, ""),
-  )
-  while (lines.length > 0 && lines[0]!.trim() === "") lines.shift()
-  while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop()
-  if (lines.length === 0) return ""
-  if (lines.length === 1) return `${pad}/** ${lines[0]} */\n`
-  const body = lines.map((line) => `${pad} *${line === "" ? "" : ` ${line}`}`).join("\n")
-  return `${pad}/**\n${body}\n${pad} */\n`
-}
-
-const renderSchema = (
-  schema: JsonSchema,
-  ctx: RenderContext,
-  depth = 0,
-  seen: ReadonlySet<string> = new Set(),
-): string => {
-  if (depth > MAX_RENDER_DEPTH) return "unknown"
-  const nested =
-    schema.definitions === undefined && schema.$defs === undefined
-      ? ctx
-      : { ...ctx, definitions: { ...ctx.definitions, ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) } }
-  if (schema.$ref) {
-    const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
-    const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
-    if (!name || !nested.definitions[name] || seen.has(name)) return "unknown"
-    return intersection([
-      renderSchema(nested.definitions[name], nested, depth, new Set([...seen, name])),
-      renderSchema({ ...schema, $ref: undefined }, nested, depth + 1, seen),
-    ])
-  }
-  if (schema.const !== undefined) return renderLiteral(schema.const)
-  if (schema.enum) return schema.enum.map(renderLiteral).join(" | ")
-  const alternatives = schema.anyOf ?? schema.oneOf
-  if (alternatives) {
-    // Effect's number schema emits `anyOf: [{ type: "number" }, { const: "NaN" },
-    // { const: "Infinity" }, { const: "-Infinity" }]`. Collapse only that artifact;
-    // real JSON Schema unions such as `string | number` or `number | null` must keep
-    // every branch.
-    if (
-      alternatives.some((item) => item.type === "number") &&
-      alternatives.every((item) => item.type === "number" || effectNumberSentinel(item))
-    )
-      return "number"
-    // An empty Schema.Struct({}) emits `anyOf: [{ type: "object" }, { type: "array" }]`
-    // (no properties/items); render the bare shape as {} instead of `{} | Array<unknown>`.
-    if (
-      alternatives.length === 2 &&
-      alternatives[0]?.type === "object" &&
-      alternatives[0].properties === undefined &&
-      alternatives[1]?.type === "array" &&
-      alternatives[1].items === undefined
-    ) {
-      return "{}"
-    }
-    const members = alternatives.map((item) => renderSchema(item, nested, depth + 1, seen))
-    if (members.some((member) => member === "unknown")) return "unknown"
-    return intersection([
-      members.join(" | "),
-      renderSchema({ ...schema, anyOf: undefined, oneOf: undefined }, nested, depth + 1, seen),
-    ])
-  }
-  if (schema.allOf) {
-    const members = schema.allOf.map((item) => renderSchema(item, nested, depth + 1, seen))
-    if (schema.allOf.some((item) => hasUnresolvedRef(item, nested.definitions))) return "unknown"
-    return intersection([renderSchema({ ...schema, allOf: undefined }, nested, depth + 1, seen), ...members])
-  }
-  if (Array.isArray(schema.type)) {
-    return schema.type.map((item) => renderSchema({ ...schema, type: item }, nested, depth + 1, seen)).join(" | ")
-  }
-  if (schema.type === "string") return "string"
-  if (schema.type === "number" || schema.type === "integer") return "number"
-  if (schema.type === "boolean") return "boolean"
-  if (schema.type === "null") return "null"
-  if (schema.type === "array") return `Array<${renderSchema(schema.items ?? {}, nested, depth + 1, seen)}>`
-  if (schema.type === "object" || schema.properties) {
-    const required = new Set(schema.required ?? [])
-    const properties = Object.entries(schema.properties ?? {})
-    const additional = schema.additionalProperties
-    const indexType =
-      additional && typeof additional === "object" ? renderSchema(additional, nested, depth + 1, seen) : undefined
-    const field = ([name, value]: readonly [string, JsonSchema]) =>
-      `${renderKey(name)}${required.has(name) ? "" : "?"}: ${renderSchema(value, nested, depth + 1, seen)}`
-
-    if (!ctx.pretty) {
-      const fields = properties.map(field)
-      if (indexType !== undefined) fields.push(`[key: string]: ${indexType}`)
-      return fields.length === 0 ? "{}" : `{ ${fields.join("; ")} }`
-    }
-
-    // Pretty: an indented block, each described field preceded by its JSDoc comment.
-    if (properties.length === 0 && indexType === undefined) return "{}"
-    const pad = "  ".repeat(depth + 1)
-    const lines = properties.map(
-      (entry) => `${jsdoc(entry[1].description, docTags(entry[1]), pad)}${pad}${field(entry)},`,
-    )
-    if (indexType !== undefined) lines.push(`${pad}[key: string]: ${indexType},`)
-    return `{\n${lines.join("\n")}\n${"  ".repeat(depth)}}`
-  }
-  return "unknown"
-}
-
-export const toTypeScript = (schema: Schema.Top, decoded = false, pretty = false): string => {
-  try {
-    const visible = decoded ? Schema.toType(schema) : schema
-    const document = Schema.toJsonSchemaDocument(visible) as {
-      readonly schema: JsonSchema
-      readonly definitions?: Readonly<Record<string, JsonSchema>>
-    }
-    return renderSchema(document.schema, { definitions: document.definitions ?? {}, pretty })
-  } catch {
-    return "unknown"
-  }
-}
-
-/** Renders a raw JSON Schema document as a TypeScript type string. */
-export const jsonSchemaToTypeScript = (schema: JsonSchema, pretty = false): string => {
-  try {
-    return renderSchema(schema, { definitions: { ...(schema.definitions ?? {}), ...(schema.$defs ?? {}) }, pretty })
-  } catch {
-    return "unknown"
-  }
-}
-
-/** One input property of a tool, extracted best-effort from its input schema. */
-export type InputProperty = {
-  readonly name: string
-  readonly description: string | undefined
-  readonly required: boolean
-}
-
-/**
- * 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.
- * Anything unresolvable yields `[]` (search falls back to path + description).
- */
-export const inputProperties = <R>(definition: Definition<R>): Array<InputProperty> => {
-  try {
-    const document = isEffectSchema(definition.input)
-      ? (Schema.toJsonSchemaDocument(definition.input) as {
-          readonly schema: JsonSchema
-          readonly definitions?: Readonly<Record<string, JsonSchema>>
-        })
-      : {
-          schema: definition.input,
-          definitions: { ...(definition.input.definitions ?? {}), ...(definition.input.$defs ?? {}) },
-        }
-    const definitions = document.definitions ?? {}
-    let schema = document.schema
-    if (schema.$ref !== undefined) {
-      const segment = schema.$ref.match(/^#\/(?:\$defs|definitions)\/([^/]+)$/)?.[1]
-      const name = segment === undefined ? undefined : JsonPointer.unescapeToken(segment)
-      const resolved = name === undefined ? undefined : definitions[name]
-      if (resolved === undefined) return []
-      schema = resolved
-    }
-    const required = new Set(schema.required ?? [])
-    return Object.entries(schema.properties ?? {}).map(([name, value]) => ({
-      name,
-      description: typeof value.description === "string" ? value.description : undefined,
-      required: required.has(name),
-    }))
-  } catch {
-    return []
-  }
-}
-
-/**
- * The model-visible TypeScript type of a tool's input. `pretty` renders an indented
- * multiline block with schema descriptions and constraints as JSDoc comments on the
- * fields; the default stays the compact single-line form.
- */
-export const inputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
-  isEffectSchema(definition.input)
-    ? toTypeScript(definition.input, false, pretty)
-    : jsonSchemaToTypeScript(definition.input, pretty)
-
-/**
- * The model-visible TypeScript type of a tool's result; tools without an output schema
- * return `unknown`. `pretty` renders the JSDoc-annotated multiline form, as for inputs.
- */
-export const outputTypeScript = <R>(definition: Definition<R>, pretty = false): string =>
-  definition.output === undefined
-    ? "unknown"
-    : isEffectSchema(definition.output)
-      ? toTypeScript(definition.output, true, pretty)
-      : jsonSchemaToTypeScript(definition.output, pretty)
-
-/**
- * Decodes tool input before `run` is invoked. Effect Schemas validate (throwing on failure);
- * JSON-Schema-described inputs pass through unvalidated (render-only).
- */
-export const decodeInput = <R>(definition: Definition<R>, value: unknown): unknown =>
-  isEffectSchema(definition.input) ? Schema.decodeUnknownSync(definition.input)(value) : value
-
-/**
- * Decodes a tool result before it is exposed to the program. Effect Schemas validate and
- * transform (throwing on failure); JSON Schema outputs and tools without an output schema pass
- * the host value through unchanged.
- */
-export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unknown =>
-  definition.output !== undefined && isEffectSchema(definition.output)
-    ? Schema.decodeUnknownSync(definition.output)(value)
-    : value
-
 /**
  * Defines one schema-described tool available to a CodeMode program through `tools.*`.
  *
@@ -384,7 +85,7 @@ export const decodeOutput = <R>(definition: Definition<R>, value: unknown): unkn
  * })
  * ```
  */
-export const make = <I extends ToolSchema, const O extends ToolSchema | undefined = undefined, R = never>(
+export const make = <I extends SchemaType, const O extends SchemaType | undefined = undefined, R = never>(
   options: Options<I, O, R>,
 ): Definition<R> => ({
   _tag: "CodeModeTool",
@@ -393,6 +94,3 @@ export const make = <I extends ToolSchema, const O extends ToolSchema | undefine
   output: options.output,
   run: (input) => options.run(input as InputType<I>),
 })
-
-/** Constructors for schema-backed tools exposed inside CodeMode programs. */
-export const Tool = { make, isDefinition }

+ 17 - 2
packages/codemode/src/values.ts

@@ -27,8 +27,23 @@ export class SandboxSet {
   readonly set = new Set<unknown>()
 }
 
-export const isSandboxValue = (value: unknown): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet =>
+export class SandboxURLSearchParams {
+  constructor(readonly params: URLSearchParams) {}
+}
+
+export class SandboxURL {
+  readonly searchParams: SandboxURLSearchParams
+  constructor(readonly url: URL) {
+    this.searchParams = new SandboxURLSearchParams(url.searchParams)
+  }
+}
+
+export const isSandboxValue = (
+  value: unknown,
+): value is SandboxDate | SandboxRegExp | SandboxMap | SandboxSet | SandboxURL | SandboxURLSearchParams =>
   value instanceof SandboxDate ||
   value instanceof SandboxRegExp ||
   value instanceof SandboxMap ||
-  value instanceof SandboxSet
+  value instanceof SandboxSet ||
+  value instanceof SandboxURL ||
+  value instanceof SandboxURLSearchParams

+ 3 - 1
packages/codemode/test/codemode.test.ts

@@ -655,9 +655,11 @@ describe("CodeMode public contract", () => {
     for (const missing of ["Modules/imports", "classes", "generators", "fetch", "promise chaining"]) {
       expect(instructions).toContain(missing)
     }
+    expect(instructions).toContain("URL, URLSearchParams, and URI encoding helpers")
+    expect(instructions).not.toContain("host globals")
     expect(instructions).toContain("Use Code Mode tools for external operations")
     expect(instructions).toContain(
-      "Dates serialize to ISO strings at data boundaries; Map/Set/RegExp serialize to `{}`.",
+      "Dates and URLs serialize to strings at data boundaries; Map/Set/RegExp/URLSearchParams serialize to `{}`.",
     )
   })
 

+ 20 - 5
packages/codemode/test/fixtures/openapi-happy-path.json

@@ -93,10 +93,16 @@
                   },
                   "role": {
                     "type": "string",
-                    "enum": ["admin", "member"]
+                    "enum": [
+                      "admin",
+                      "member"
+                    ]
                   }
                 },
-                "required": ["name", "email"],
+                "required": [
+                  "name",
+                  "email"
+                ],
                 "additionalProperties": false
               }
             }
@@ -137,7 +143,9 @@
                   "type": "integer"
                 }
               },
-              "required": ["query"],
+              "required": [
+                "query"
+              ],
               "additionalProperties": false
             }
           },
@@ -208,10 +216,17 @@
           },
           "role": {
             "type": "string",
-            "enum": ["admin", "member"]
+            "enum": [
+              "admin",
+              "member"
+            ]
           }
         },
-        "required": ["id", "name", "email"],
+        "required": [
+          "id",
+          "name",
+          "email"
+        ],
         "additionalProperties": false
       }
     },

Разница между файлами не показана из-за своего большого размера
+ 292 - 90
packages/codemode/test/fixtures/opencode-v2-openapi.json


+ 22 - 28
packages/codemode/test/openapi.test.ts

@@ -1,8 +1,8 @@
 import { describe, expect, test } from "bun:test"
 import { Effect, Layer, Option } from "effect"
 import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
-import { CodeMode, OpenAPI } from "../src/index.js"
-import { inputTypeScript, outputTypeScript, Tool } from "../src/tool.js"
+import { CodeMode, OpenAPI, Tool } from "../src/index.js"
+import { inputTypeScript, outputTypeScript } from "../src/tool-schema.js"
 
 const baseUrl = "http://localhost:4096"
 type Document = OpenAPI.Document
@@ -54,9 +54,7 @@ const json = (value: unknown, status = 200) =>
 
 const singleOperation = (operation: Record<string, unknown>, method = "get"): Document => ({
   openapi: "3.1.0",
-  paths: {
-    "/test": { [method]: { operationId: "test", responses: { 200: { description: "Success" } }, ...operation } },
-  },
+  paths: { "/test": { [method]: { operationId: "test", responses: { 200: { description: "Success" } }, ...operation } } },
 })
 
 describe("OpenAPI.fromSpec", () => {
@@ -96,12 +94,7 @@ describe("OpenAPI.fromSpec", () => {
     const remove = toolAt(api.tools, "users.remove")
 
     expect(api.skipped).toEqual([])
-    if (
-      !Tool.isDefinition(get) ||
-      !Tool.isDefinition(create) ||
-      !Tool.isDefinition(search) ||
-      !Tool.isDefinition(remove)
-    ) {
+    if (!Tool.isDefinition(get) || !Tool.isDefinition(create) || !Tool.isDefinition(search) || !Tool.isDefinition(remove)) {
       throw new Error("happy-path fixture did not generate every operation")
     }
     expect(inputTypeScript(get)).toBe(
@@ -117,8 +110,7 @@ describe("OpenAPI.fromSpec", () => {
 
     const result = await Effect.runPromise(
       CodeMode.make({ tools: { api: api.tools } })
-        .execute(
-          `
+        .execute(`
           const user = await tools.api.users.get({
             userId: "user-1",
             include: ["profile", "permissions"],
@@ -136,8 +128,7 @@ describe("OpenAPI.fromSpec", () => {
           })
           const removed = await tools.api.users.remove({ userId: "user-1" })
           return { user, created, summary, removed }
-        `,
-        )
+        `)
         .pipe(Effect.provide(client.layer)),
     )
 
@@ -491,9 +482,9 @@ describe("OpenAPI.fromSpec", () => {
     expect(url.searchParams.get("nullable")).toBe("null")
     expect(url.searchParams.get("constructor")).toBe("safe")
     expect(client.requests[0]!.headers.meta).toBe("a=b,c=d")
-    await expect(Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer)))).rejects.toThrow(
-      "unsupported nested value",
-    )
+    await expect(
+      Effect.runPromise(tool.run({ keys: [undefined] }).pipe(Effect.provide(client.layer))),
+    ).rejects.toThrow("unsupported nested value")
   })
 
   test("skips unsupported parameter encodings and malformed security", () => {
@@ -595,10 +586,7 @@ describe("OpenAPI.fromSpec", () => {
 
   test("applies authentication carriers without prototype or collision loss", async () => {
     const client = recordingClient(() => json({ ok: true }))
-    const authenticated = (
-      security: ReadonlyArray<Record<string, ReadonlyArray<string>>>,
-      schemes: Record<string, unknown>,
-    ) =>
+    const authenticated = (security: ReadonlyArray<Record<string, ReadonlyArray<string>>>, schemes: Record<string, unknown>) =>
       OpenAPI.fromSpec({
         baseUrl,
         spec: { ...singleOperation({}), security, components: { securitySchemes: schemes } },
@@ -614,10 +602,13 @@ describe("OpenAPI.fromSpec", () => {
     expect(new URL(client.requests[0]!.url).searchParams.get("__proto__")).toBe("secret")
 
     const duplicate = toolAt(
-      authenticated([{ first: [], second: [] }], {
-        first: { type: "apiKey", in: "header", name: "x-key" },
-        second: { type: "apiKey", in: "header", name: "x-key" },
-      }).tools,
+      authenticated(
+        [{ first: [], second: [] }],
+        {
+          first: { type: "apiKey", in: "header", name: "x-key" },
+          second: { type: "apiKey", in: "header", name: "x-key" },
+        },
+      ).tools,
       "test",
     )
     if (!Tool.isDefinition(duplicate)) throw new Error("duplicate auth tool was not generated")
@@ -642,7 +633,8 @@ describe("OpenAPI.fromSpec", () => {
         },
       },
       auth: {
-        resolve: ({ name }) => Effect.succeed(name === "bearer" ? { type: "bearer", token: "secret" } : undefined),
+        resolve: ({ name }) =>
+          Effect.succeed(name === "bearer" ? { type: "bearer", token: "secret" } : undefined),
       },
     })
     const alternativeTool = toolAt(alternative.tools, "test")
@@ -774,7 +766,9 @@ describe("OpenAPI.fromSpec", () => {
     const oversized = recordingClient(
       () => new Response(null, { headers: { "content-length": String(50 * 1024 * 1024 + 1) } }),
     )
-    const malformed = recordingClient(() => new Response("{", { headers: { "content-type": "application/json" } }))
+    const malformed = recordingClient(
+      () => new Response("{", { headers: { "content-type": "application/json" } }),
+    )
     const chunked = recordingClient(() => new Response(new Uint8Array(50 * 1024 * 1024 + 1)))
 
     await expect(Effect.runPromise(tool.run({}).pipe(Effect.provide(oversized.layer)))).rejects.toThrow(

+ 2 - 2
packages/codemode/test/signature.test.ts

@@ -1,7 +1,7 @@
 import { describe, expect, test } from "bun:test"
 import { Effect, Schema } from "effect"
-import { CodeMode } from "../src/index.js"
-import { Tool, inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool.js"
+import { CodeMode, Tool } from "../src/index.js"
+import { inputTypeScript, jsonSchemaToTypeScript, outputTypeScript } from "../src/tool-schema.js"
 
 // A raw JSON Schema tool in the shape an MCP adapter produces: render-only input schema
 // whose property descriptions and constraints must surface as JSDoc in pretty signatures.

+ 222 - 2
packages/codemode/test/stdlib.test.ts

@@ -1,12 +1,12 @@
 import { describe, expect, test } from "bun:test"
-import { Effect } from "effect"
+import { Effect, Schema } from "effect"
 import { CodeMode, Tool } from "../src/index.js"
 
 // Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
 // intra-sandbox checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
 // values, while at the host boundary (final result, tool arguments, JSON.stringify) they
 // serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
-// RegExp/Map/Set -> {}.
+// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
 const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
 const value = async (code: string) => {
   const result = await run(code)
@@ -149,6 +149,67 @@ describe("RegExp", () => {
     expect(await value(`return "hi bob".replace(/b(o)b/, "[$1]")`)).toBe("hi [o]")
   })
 
+  test("function replacers receive captures, offsets, input, and named groups", async () => {
+    expect(
+      await value(`
+        const seen = []
+        const output = "a1b22".replace(/(\\d)(\\d)?/g, (match, first, second, offset, input) => {
+          seen.push([match, first, second === undefined, offset, input])
+          return Number(match) * 2
+        })
+        return { output, seen }
+      `),
+    ).toEqual({
+      output: "a2b44",
+      seen: [
+        ["1", "1", true, 1, "a1b22"],
+        ["22", "2", false, 3, "a1b22"],
+      ],
+    })
+    expect(
+      await value(`
+        return "red-blue".replace(
+          /(?<left>[a-z]+)-(?<right>[a-z]+)/,
+          (match, left, right, offset, input, groups) => groups.right + ":" + groups.left,
+        )
+      `),
+    ).toBe("blue:red")
+  })
+
+  test("function replacers support string searches, zero-length matches, and result coercion", async () => {
+    expect(await value(`return "banana".replace("na", (match, offset, input) => "[" + offset + "]")`)).toBe("ba[2]na")
+    expect(await value(`return "ab".replaceAll("", (match, offset) => offset)`)).toBe("0a1b2")
+    expect(await value(`return "😀".replaceAll(/(?:)/gu, (match, offset) => "[" + offset + "]")`)).toBe("[0]😀[2]")
+    expect(
+      await value(`return "123".replace(/\\d/g, (match) => match === "1" ? 7 : match === "2" ? null : { n: 3 })`),
+    ).toBe("7null[object Object]")
+  })
+
+  test("function replacers can await effectful tool calls", async () => {
+    const decorate = Tool.make({
+      description: "Decorate a string",
+      input: Schema.String,
+      output: Schema.String,
+      run: (input) => Effect.succeed(`[${input}]`),
+    })
+    const result = await Effect.runPromise(
+      CodeMode.execute({
+        tools: { host: { decorate } },
+        code: `return "a1b22".replace(/\\d+/g, async (match) => await tools.host.decorate(match))`,
+      }),
+    )
+    expect(result.ok && result.value).toBe("a[1]b[22]")
+
+    const missingAwait = await Effect.runPromise(
+      CodeMode.execute({
+        tools: { host: { decorate } },
+        code: `return "a1".replace(/\\d/, (match) => tools.host.decorate(match))`,
+      }),
+    )
+    expect(!missingAwait.ok && missingAwait.error.kind).toBe("InvalidDataValue")
+    expect(!missingAwait.ok && missingAwait.error.message).toContain("un-awaited Promise")
+  })
+
   test("replaceAll without the g flag is a catchable error", async () => {
     expect(await value(`try { "a".replaceAll(/a/, "b"); return "no" } catch { return "caught" }`)).toBe("caught")
   })
@@ -208,6 +269,165 @@ describe("RegExp", () => {
   })
 })
 
+describe("URL and URI helpers", () => {
+  test("encodes and decodes complete URIs and URI components", async () => {
+    expect(
+      await value(`
+        return [
+          encodeURI("https://example.test/a b?q=a/b"),
+          encodeURIComponent("a b/c?"),
+          decodeURI("https://example.test/a%20b?q=a/b"),
+          decodeURIComponent("a%20b%2Fc%3F"),
+          ["a b", "c/d"].map(encodeURIComponent),
+        ]
+      `),
+    ).toEqual([
+      "https://example.test/a%20b?q=a/b",
+      "a%20b%2Fc%3F",
+      "https://example.test/a b?q=a/b",
+      "a b/c?",
+      ["a%20b", "c%2Fd"],
+    ])
+    expect(
+      await value(`try { decodeURIComponent("%zz"); return false } catch (error) { return error instanceof URIError }`),
+    ).toBe(true)
+  })
+
+  test("resolves and mutates URLs with linked search parameters", async () => {
+    expect(
+      await value(`
+        const url = new URL("../users?id=old#top", "https://user:pass@example.com:8443/api/v1/")
+        url.pathname = "/items/a b"
+        url.searchParams.set("id", "a b")
+        url.searchParams.append("tag", "x/y")
+        url.hash = "part 1"
+        return {
+          href: url.href,
+          origin: url.origin,
+          host: url.host,
+          pathname: url.pathname,
+          search: url.search,
+          id: url.searchParams.get("id"),
+          string: String(url),
+          json: url.toJSON(),
+          instances: [
+            url instanceof URL,
+            url.searchParams instanceof URLSearchParams,
+            url.searchParams === url.searchParams,
+          ],
+        }
+      `),
+    ).toEqual({
+      href: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
+      origin: "https://example.com:8443",
+      host: "example.com:8443",
+      pathname: "/items/a%20b",
+      search: "?id=a+b&tag=x%2Fy",
+      id: "a b",
+      string: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
+      json: "https://user:pass@example.com:8443/items/a%20b?id=a+b&tag=x%2Fy#part%201",
+      instances: [true, true, true],
+    })
+  })
+
+  test("URLSearchParams supports records, pairs, mutation, callbacks, and materialization", async () => {
+    expect(
+      await value(`
+        const params = new URLSearchParams([["tag", "b"], ["tag", "a"], ["q", "a b"]])
+        const seen = []
+        params.forEach((value, key) => seen.push(key + "=" + value))
+        params.delete("tag", "b")
+        params.append("tag", "c")
+        params.sort()
+        return {
+          text: params.toString(),
+          size: params.size,
+          tags: params.getAll("tag"),
+          has: params.has("tag", "c"),
+          entries: Array.from(params),
+          object: Object.fromEntries(params),
+          record: new URLSearchParams({ page: 2, filter: "open" }).toString(),
+          seen,
+        }
+      `),
+    ).toEqual({
+      text: "q=a+b&tag=a&tag=c",
+      size: 3,
+      tags: ["a", "c"],
+      has: true,
+      entries: [
+        ["q", "a b"],
+        ["tag", "a"],
+        ["tag", "c"],
+      ],
+      object: { q: "a b", tag: "c" },
+      record: "page=2&filter=open",
+      seen: ["tag=b", "tag=a", "q=a b"],
+    })
+  })
+
+  test("URL parsing failures are catchable and values use native JSON forms", async () => {
+    expect(
+      await value(`
+        const parsed = URL.parse("/users", "https://example.test/api/")
+        let invalidIsTypeError = false
+        try { new URL("not relative without a base") } catch (error) { invalidIsTypeError = error instanceof TypeError }
+        return {
+          canParse: URL.canParse("/users", "https://example.test/api/"),
+          cannotParse: URL.canParse("not relative without a base"),
+          parsed: parsed.href,
+          invalidIsTypeError,
+          boundary: [new URL("https://example.test/a"), new URLSearchParams("q=one")],
+          json: JSON.stringify({ url: new URL("https://example.test/a"), params: new URLSearchParams("q=one") }),
+        }
+      `),
+    ).toEqual({
+      canParse: true,
+      cannotParse: false,
+      parsed: "https://example.test/users",
+      invalidIsTypeError: true,
+      boundary: ["https://example.test/a", {}],
+      json: '{"url":"https://example.test/a","params":{}}',
+    })
+  })
+
+  test("distinguishes omitted URL arguments from explicit undefined", async () => {
+    expect(
+      await value(`
+        function throwsTypeError(run) {
+          try { run(); return false } catch (error) { return error instanceof TypeError }
+        }
+        const params = new URLSearchParams()
+        const required = [
+          () => params.append(),
+          () => params.delete(),
+          () => params.get(),
+          () => params.getAll(),
+          () => params.has(),
+          () => params.set(),
+          () => params.forEach(),
+        ].map(throwsTypeError)
+        params.append(undefined, undefined)
+        return {
+          construct: throwsTypeError(() => new URL()),
+          canParse: throwsTypeError(() => URL.canParse()),
+          parse: throwsTypeError(() => URL.parse()),
+          explicitUndefined: new URL(undefined, "https://example.test/base/").href,
+          params: params.toString(),
+          required,
+        }
+      `),
+    ).toEqual({
+      construct: true,
+      canParse: true,
+      parse: true,
+      explicitUndefined: "https://example.test/base/undefined",
+      params: "undefined=undefined",
+      required: [true, true, true, true, true, true, true],
+    })
+  })
+})
+
 describe("Map", () => {
   test("get/set/has/size with chaining", async () => {
     expect(

Некоторые файлы не были показаны из-за большого количества измененных файлов