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.
Tracking issue: anomalyco#34787
Working branch: codemode-v2 (base: dev)
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.
Architecture split (locked):
packages/codemode(@opencode-ai/codemode) - the generic, host-agnostic runtime: a hand-rolled, Effect-native, tree-walking interpreter over acorn ASTs (TypeScript stripped viatypescript'stranspileModule), the tool runtime/data boundary, discovery/search, andTool.make. It knows nothing about OpenCode, MCP, permissions, or rendering.packages/opencode- the OpenCode integration: an MCP adapter that converts MCP tool definitions intoTool.make(...)definitions, permission gating, host-side attachment collection, the agent-facingexecutetool, and TUI progress rendering.
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).
From issue #34787 and design discussion. Do not relitigate these casually.
- Generic CodeMode lives in its own package:
@opencode-ai/codemode(repo scope convention; the issue's@opencode/codemodename 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),$codemodereserved 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 istsgo --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.
- 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
runwraps 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_2suffixing, no cleverness. OpenCode groups flatserver_toolMCP names intotools.<server>.<tool>namespaces before handing them over.
- Search only - no separate
describe.tools.$codemode.search({ query?, namespace?, limit? })over the final tool tree, owned by this package. - Search result item shape:
{ path, description, signature }in an{ items, total }wrapper. Thesignaturestring embeds the full input/output TypeScript types - in search results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schemadescriptions and constraints (@default,@format,@deprecated,@minItems,@maxItems) ride along as field comments. The original spec's separateinput/outputraw-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. Resultpaths render a JavaScript expression rooted attools(for exampletools.github.list_issuesortools.context7["resolve-library-id"]) so each is directly usable as the call site; the internalToolDescription.pathstays unprefixed. - 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>, NOTPromise<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
unknownas their return type.
Tool.makecarries 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 (
runreturnsEffect). Promise normalization for plugin authors can come later.
- No
output.text/file/imageAPI 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
executetool result as ordinary tool-result attachments (OpenCode already hasTool.ExecuteResult.attachments-> vision plumbing inmessage-v2.ts). - No base64 in CodeMode values, ever. The model routes nothing; it can't accidentally dump image bytes into context or drop attachments.
- 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 tomaxOutputBytesin 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 setmaxOutputBytesexplicitly, 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@internalInternalExecutionLimitstype (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) andMAX_VALUE_DEPTH = 32(thecopyInboundary depth check, kept only because it beats a native stack-overflow RangeError as an error message; still reportsInvalidDataValue). - Truncation layering RESOLVED (user direction): CodeMode truncation is off in OpenCode.
executeis a normalTool.definetool, so OpenCode's native tool-output truncation (50KB / 2000 lines intool.ts+truncate.ts, full output dumped to a file) applies to it with no special-casing - verified by tracingwrap()intool.ts:130-144(themetadata.truncatedexemption never fires forexecute). One truncation layer, the host's.maxOutputBytesremains 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,typeofnever throws, NaN/Infinity flow in-sandbox, etc. console.*is captured intologson 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 })andonToolCallEnd({ index, name, input, durationMs, outcome, message? }). Interrupted calls fire no end event. NoCurrentToolCallcontext service (removed in Wave 2).
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).
packages/codemodecreated from the experiments implementation:src/{index,codemode,tool, tool-error,tool-runtime}.ts, README, AGENTS.md, tests.package.json: name@opencode-ai/codemode, depsacorn@8.15.0,typescript: catalog:,effect: catalog:(both repos pin effect4.0.0-beta.83; opencode's effect patch only touchesunstable/httpapi, which this package doesn't use).- Tests converted vitest ->
bun:test. Only src change from verbatim: theCurrentToolCallContext.Service key string renamed to@opencode-ai/codemode/CurrentToolCall.
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 (
copyInadmits them;NaN/Infinityare bindable globals;charCodeAtreturns real NaN). Normalized tonullonly at the data boundary (copyOut- single chokepoint for final results AND tool-call arguments), matchingJSON.stringify. Guards likeNumber.isNaN(x)/parseInt(x) || 0work. - H2/H3: unknown property reads on strings/numbers/arrays ->
undefined(incl. under?.), instead of throwing. This was the real-transcript failure: models writeresult?.login ?? resultagainst JSON-string tool results. - H4:
typeof undeclaredIdentifier->"undefined"(short-circuits before resolution). - H5:
Boolean/String/Numberaccepted 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).
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,containsOpaqueReferencefor operator guards; theruntimeValueBytesbyte-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 ->
{}.copyInalso converts hostDate/RegExp/Map/Setinstances 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;toStringis ISO for cross-host determinism. - RegExp: literals +
new RegExp,test/exec(statefullastIndexforg), stringmatch/matchAll/replace/replaceAll/split/search. Match results are plain arrays carryingindex/namedgroupsas own properties (enabled by a general array own-property read fix);inputomitted deliberately. Function replacers unsupported (clear error). Patterns run on the host engine - catastrophic backtracking is bounded only bytimeoutMs(accepted, in README). - Map/Set: full method sets;
keys/values/entriesreturn arrays (not iterators);for...of+ spread work;Object.fromEntries(map),Array.from(map|set); SameValueZero keys (NaN findable). (The incremental byte totals andmaxCollectionLength/maxDataBytesenforcement this wave added were deleted in Fix 5.) - Rode along, same spirit:
typeofnever throws for any value (typeof fn->"function"),!works on any value,for...ofover strings,{...sandboxValue}no-op, template interpolation renders/regex/and ISO dates directly.
The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this wave; both packages typecheck clean.
Tool.makeschema flexibility (src/tool.ts):input/outputeach 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).outputis optional -> signature rendersPromise<unknown>and the host result is exposed as-is. Discrimination viaSchema.isSchema. New helpers exported fromtool.ts:inputTypeScript/outputTypeScript/decodeInput/decodeOutput/jsonSchemaToTypeScript;tool-runtime.tsconsumes them (no directSchema.*use there anymore). TypesJsonSchema/ToolSchemaexported from the index. Note: an emptySchema.Struct({})renders as{ } | Array<unknown>(effect's JSON Schema emission) - cosmetic, fixed in Wave 4.
output.*API deleted:OutputItem(+Schema), resultoutputfields, theoutputglobal/namespace dispatch,invokeOutput/outputItem/helpers, interpreter output fields, instructions line, README section, seeded tests. AGENTS.md keeps a rephrased future-design note (channel name staysoutputif it ever returns).- Hooks:
CurrentToolCallremoved entirely (class, provideService,ServicesExclude special-casing, index export).onToolCall->onToolCallStart({ index, name, input })+onToolCallEnd({ index, name, input, durationMs, outcome: "success"|"failure", message? }). End fires symmetrically viaEffect.tap/tapErroraround the settling portion (host run + output decode + boundary copy; search too - its post-record body is wrapped inEffect.tryso failures are typed and observable).messageis the model-safe failure message (ToolError/ToolRuntimeErrormessage, else "Tool execution failed"). Interrupted calls fire no end event (timeout kills the whole execution anyway). - Limits collapse: public
ExecutionLimits={ timeoutMs?, maxToolCalls?, maxOutputBytes? }(defaults 10_000 / 100 / 32_000). This wave kept the other knobs as internal defaults reachable through an@internalInternalExecutionLimitstype; Fix 5 later deleted that type and the internal limit system entirely. maxOutputBytestruncation (CodeMode-owned, never fails): applied viaboundOutputin a finalEffect.mapover 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 gainstruncated: true(also added toExecuteResultSchema). UTF-8-safe truncation (no split code points). (The in-sandboxmaxDataBytescheck 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 (optionallytools.-prefixed) returns that tool alone (total: 1), bypassing ranking. Tokenization/ranking/shape unchanged.
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 flatserver_toolkeys intoCatalogEntrys carrying the raw MCPinputSchema/outputSchemaas render-only JSON Schema;toolTreeturns each intoTool.make({ description, input, output?, run })undertools.<server>.<tool>. The agent-facing description isCodeMode.make({ tools }).instructions()over a preview tree (placeholder runs, never invoked) - so signature rendering, the inline-vs-search switch, and$codemode.searchavailability all come from this package and stay consistent with execution. runpath: per-child permission ask first (ctx.ask({ permission: entry.key, patterns: ["*"], always: ["*"] }), exactly the old gating; approvingexecuteapproves no child). Denials and host failures are mapped totoolError(message)so they surface as safe, catchable in-program failures (MCPisErrortext propagates ase.message; without this they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper fromcatalog.convertTool(entry.tool.execute!), which owns callTool timeouts/progress-reset.- Result shaping (
toSandboxResult): preferstructuredContent; else joined text content; media (image/audio/resource blob/resource_link) NEVER enters the sandbox - blocks are stripped into a per-executionAttachment[]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 becomesnull; non-MCP values pass through. No handles, noResult<T>envelope, no base64 in the sandbox, no data-size tuning (themaxDataBytesbudget 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 existingTool.ExecuteResult.attachments->message-v2.tsvision plumbing; attachments ride on both success and error results. Diagnosticsuggestionsnot already contained in the message are appended to error output. Native outer truncation stays on (adapter never setsmetadata.truncated); CodeMode's ownmaxOutputBytes(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 TUIExecutecomponent (packages/tui/src/routes/session/index.tsx) already renders.$codemode.searchcalls stream through the same channel. - Deletions/deps:
src/session/rune/(all five files) andtest/session/rune-parity.test.ts(superseded by this package'stest/parity.test.ts) deleted;acornremoved from opencode deps,typescriptmoved back to devDependencies,"@opencode-ai/codemode": "workspace:*"added;bun installrun (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/withLogsunits) andcode-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/$runedescribe/renderType/rankToolstests died with the old design (58+17+24 -> 34+16).
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 (
discoveryPlanintool-runtime.ts): the all-or-nothing inline/search modes are gone -DiscoveryModedeleted,DiscoveryOptionsis just{ maxInlineCatalogBytes? }(default 16,000 UTF-8 bytes; later converted tomaxInlineCatalogTokens, default 4,000 estimated tokens - see Post-wave fixes). Port of the old opencodedescribe()PREVIEW_BUDGETalgorithm, adapted toToolDescription: 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.searchIndexis required and built unconditionally (new exportedToolRuntime.searchIndex(tools);SearchEntrytype exported);CodeMode.execute(one-shot) passes it too, preserving theexecute==make().executelaw. A speculativetools.$codemode.searchcall on a small catalog now succeeds instead ofUnknownTool, 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 (
renderSchemaintool.ts): an object schema with no properties renders{}(was{ }), and the emptySchema.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), andbun packages/opencode/src/index.ts run --dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "...". Confirmed: a singleexecutetool 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.searchcalls 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.
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 pobserves its settlement). Chosen over lazy because: (1) it's spec-faithful - JS promise work starts at call time, soconst 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 andFiber.awaitis idempotent, soawait ptwice orPromise.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.forkChildchildren are auto-supervised (interrupted when the parent fiber exits) andEffect.timeoutOrElseisraceFirst, 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 insidePromise.all). - Mechanics:
SandboxPromiseinvalues.ts(fiber-backed for tool calls; fiberlessimmediateeffect forPromise.resolve/reject). Forks runsemaphore.withPermit(invoke)withstartImmediately: true- a per-executionSemaphore.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) plusonToolCallStartfire at the call site before any await.awaitof 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/raceaccept any array (or spreadable collection) mixing promises and plain data - inline, built beforehand, spread, nested in variables.allSettledyields{ status: "fulfilled", value } | { status: "rejected", reason }with reasons produced by the samecaughtErrorValuehelper thecatchbinding uses (factored out ofevaluateTryStatement).raceresolves/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.resolveflattens promises;Promise.rejectrejects with the reason viaProgramThrow. - Opaqueness/boundaries: promises are runtime references -
typeof->"object"(real JS), operators reject them,copyInraises an await-hintingInvalidDataValue("contains an un-awaited Promise; await tool calls (...) before using their results") for results, tool arguments, andJSON.stringifyinstead of{}. Property access on a promise is a deliberate error (not the forgivingundefined):.then/.catch/.finally->UnsupportedSyntaxpointing atawait+ 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, andPromiseAllReferencedeleted (PromiseMethodReferenceoverall/allSettled/race/resolve/rejectreplaces it);supportedSyntaxMessage, the two instructions lines intool-runtime.ts, and README "Supported Programs" rewritten for the new surface. - Known divergences (deliberate):
p === qon promises throws the operators-need-data diagnostic instead of comparing identity;{...promise}errors instead of JS's silent{}; a per-iterationawaitinsideitems.map(async (i) => await tools.x(i))runs sequentially (interpreter callbacks compose synchronously) - the parallel idiom is mapping to un-awaited calls and awaitingPromise.all, which the instructions show.
-
Key enumeration:
Object.keys(tools)+for...in(done). Motivating transcript: a model tried to enumerate tool namespaces withObject.keys(tools)(failed with the generic "Object.keys input must contain plain objects only." -toolsis aToolReference, not plain data) and thenfor (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.makenow returns akeys(path)capability (namespaceKeysintool-runtime.ts) threaded into theInterpreteralongsideinvoke- the interpreter still never holds the host tool tree.Object.keys(tools)yields the top-level namespace names (never$codemode, which is virtual - butObject.keys(tools.$codemode)yields["search"]),Object.keys(tools.ns)the names at that node; a callable tool leaf enumerates as[](likeObject.keysof a JS function); an unknown path throws anUnknownTooldiagnostic suggestingObject.keys(tools)and$codemode.search(matching call-time unknown-tool behavior rather than silently returning[]).Object.values/Object.entries(and every otherObject.*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'senumerableKeyshelper with theObject.keystool path. const/let declarations and bare identifiers bind the key; break/continue work. Anything else (strings, Map/Set, numbers, null, ...) is a clear error suggestingfor...oforObject.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 intest/enumeration.test.ts(14, incl. the exact transcript program) plus one adapter-level assertion thatObject.keys(tools)returns MCP server 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/rankToolsalgorithm inpackages/opencode/src/session/code-mode.tsat git HEAD), replacing the word-set ranker intool-runtime.ts. Searchable text per tool = path + description + input-schema property names + theirdescriptionstrings - extracted by the newinputPropertieshelper intool.ts(Effect Schemas viaSchema.toJsonSchemaDocument, the same emission signature rendering uses; JSON Schemas readpropertiesdirectly, 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? })-namespace(validated as a string when provided) filtersSearchEntrys to one top-level namespace before ranking;{ query: "", namespace: "github" }lists that namespace alphabetically.searchSignatureupdated. - Callable result paths: search-result
paths are rendered as JavaScript expressions rooted attools(tools.github.list_issues, or bracket notation for non-identifier segments), directly usable as the call site. InternalToolDescription.pathstays unprefixed; only the search RESULT items are rendered this way. Exact-path queries accept canonical paths and rendered expressions. - Instructions (
discoveryPlan): an explicit calling-convention line and a browse hint on the search advertisement (both since absorbed into the## Rulessection 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).
- Ranking ported from the pre-rebuild implementation (the
-
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
discoveryPlan, ordered so the workflow sits at the top (the least likely part of a long description to be truncated or skimmed away) and the catalog at the bottom (the per-section content described here was later condensed by Fix 8 - Workflow/Rules deduped, Syntax inverted):- Intro (2 lines): "Write a CodeMode program... Return code only." + "Execute
JavaScript in a confined runtime with access to the tools listed below under
tools.*." (the second line drops the tools clause when the tree is empty). ## Workflow: numbered steps - find a tool viatools.$codemode.search-> read the{ path, description, signature }matches -> call by path ->typeof res === "string" ? JSON.parse(res) : res-> return only the needed fields. When the catalog is COMPLETE the search/read steps collapse into "Pick a tool from the list under## Available tools" and the steps renumber (4 instead of 5).## Rules: call-by-exact-path; TEXT-is-JSON -> JSON.parse; return small (never raw payloads); filter/aggregate large collections in code instead of per-item round-trips; console.log/warn/error/dir/table for intermediates;Promise.allparallelism (no .then/.catch - await + try/catch);Object.keys(tools)/for...inenumeration; 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'stoSandboxResult/mediaMarker).## Syntax: the dense syntax lines unchanged, minus the Promise.all and console lines (moved into Rules) and thefor (const ns in tools)fragment (redundant with the enumeration rule).## 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; JSON.parse + return-small rules present; no
total_count/list_issues/real-tool example lines; browse hint only when PARTIAL; zero-tool minimal sections) - 156 pass / 0 fail; adapter suites gain the same assertions on the built description (still 35 + 16, green).
- Intro (2 lines): "Write a CodeMode program... Return code only." + "Execute
JavaScript in a confined runtime with access to the tools listed below under
Fix 4 - token-budgeted catalog (was bytes) (user direction: signatures need a token budget; namespaces must always be present):
src/token.tsadded: copy of@opencode-ai/core/util/token(round(chars / 4)), so the package stays dependency-free; keep in sync if the core heuristic changes.DiscoveryOptions.maxInlineCatalogBytes->maxInlineCatalogTokens(default 4,000 estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size reduction).discoveryPlanchargesestimate(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 ofop_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):
ExecutionLimits(timeoutMs10_000 /maxToolCalls100 /maxOutputBytes32_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.ResolvedExecutionLimitsshrank to those three fields; the@internalInternalExecutionLimitstype is deleted.- Deleted outright:
maxOperationsand the whole operation-budget machinery (recordWork/recordOperation/budget.operations, plus theworkUnits/cheapArrayMethodscost helpers);maxSourceBytes(the pre-parse source-size check);maxDataBytes(every byte-accounting path:runtimeValueBytes,boundedProgramValue, the container-size caches (containerSizes/objectCounts), Map/Set incrementalbytesfields invalues.ts, string-growthlimitStringchecks, tool-argument/result byte checks intool-runtime.ts, and the final-result size check);maxAuditBytes(log and audit-trail byte accounting -toolCallsrecords 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). TheOperationLimitExceededandAuditLimitExceededdiagnostic kinds are gone from theDiagnosticKindunion andExecuteResultSchema(fine - the package is unreleased). - Fixed constants, not knobs:
TOOL_CALL_CONCURRENCY = 8(codemode.ts; the fork semaphore) andMAX_VALUE_DEPTH = 32(tool-runtime.ts; thecopyIndepth check - kept only because it produces a clearer error than a native stack-overflow RangeError; stillInvalidDataValue). TheDataLimitsplumbing throughtool-runtime.tsis gone -copyIn(value, label)needs no limits argument, andToolRuntime.maketakes 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
timeoutMsinterrupts even a purewhile (true) {}loop (empirically verified: a 200ms timeout fired at ~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression test incodemode.test.tsasserts exactly this (while(true){}+timeoutMs: 200->TimeoutExceeded, elapsed well under a few seconds). - Kept (correctness, not budgets): circular detection (
copyInwalks +rejectCircularInsertionon 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
InternalExecutionLimitsas a convenience to plainExecutionLimits(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 catalogLines 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 incodemode.ts) is untouched. - Workflow/Rules deduped: the call-by-exact-path, JSON.parse-string-results, and
return-small content now lives ONLY in the numbered Workflow steps (with their
compliance-driving justifications inline: "most tools return JSON as a string", "raw
payloads get truncated and waste context"); Rules keeps only bullets adding new
content - filter/aggregate collections in code, console.* intermediates (logs ride
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'spathas-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 (
discoveryPlan): the ported stop-on-first-miss behavior (alphabetically-late namespaces starved to "none shown" while an early namespace inlines everything) is replaced by round-robin fairness - in each round (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:
tokenized 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 totermForms- 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; new
syntax-section test (leads with "Standard modern JavaScript works", names the
verified not-supported list, 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 assertszeta_only_toolIS 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
maxInlineCatalogTokens4,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 singleunknown-treatment warning: "A result typedPromise<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. - Kept as-is per user: the JSON.parse workflow step (maps to the original motivating transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule (undecided), no no-fetch/ambient-authority rule added (proposed, not approved).
- Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged 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: theerrorConstructorsnames (Error,TypeError,RangeError,SyntaxError,ReferenceError,EvalError,URIError) are bound globals (ErrorConstructorReference, callable with or withoutnew;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 everyObject.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.caughtErrorValueproduces{ name, message }wrappers viacreateErrorValue, so caught interpreter AND tool failures areinstanceof Errorand carry thenamethe equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real JS"):InterpreterRuntimeErrorgained anerrorNamefield ("Error" default) set fluently at throw sites via.as(name)-JSON.parsefailures 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 badnormalizeform"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 specificinstanceof(e instanceof SyntaxError), matching JS. The operator is handled inevaluateBinaryExpressionBEFORE the data-only operand check (liketypeof, it observes any lhs - promises and functions included); recognized rhs: the error constructors (a specific type matches its own brand orError, never a sibling),Date/RegExp/Map/Set(sandbox classes),Array,Object(any object/function-ish value),Promise(SandboxPromise), andNumber/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 runrejectCircularInsertionlike push/unshift; one-arg form removes to the end, undefined delete count removes nothing),fill(circular-checked value) andcopyWithin(host-delegated), andkeys/values/entriesreturning arrays (the Map/Set convention - for...of and spread work either way). TheretryableArrayMethods"rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown array properties still readundefined. - 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/trimRightas trimStart/trimEnd aliases. - Actionable regex failures:
toHostRegexandconstructRegExpnow show the offending pattern (or flags) plus the engine reason (deduped "Invalid regular expression:" prefix viaregexFailureReason) and a shared escaping hint (escapeRegexHint); flags failures list the valid flag letters; the replaceAll/matchAll missing-gerrors spell out the exact/pattern/gto write and the single-match alternative. - copyIn split (the important one):
copyIn(value, label, preserveSandboxValues = false)- recursion moved to a privatecopyBounded;boundedData(every intra-sandbox checkpoint:Object.*helpers, coercion/Array.from/join inputs, template interpolation, expression-result checkpoints) is nowcopyIn(value, label, true), which passesSandboxDate/SandboxRegExp/SandboxMap/SandboxSetthrough 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: theObject.*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 intoboundedData. Object/array spread already preserved instances (reference copies, no checkpoint) - now tested. - Console formatting:
formatConsoleArgumentis total and deep (formatConsoleValue): numbers render viaString(NaN/Infinity/-Infinityliterally - never the JSONnull; 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 beyondMAX_CONSOLE_DEPTH = 32(fixed constant, not a knob) degrades to...- console can no longer fail a program.console.tableguards withcontainsOpaqueReference(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);supportedSyntaxMessageleft 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-failureinstanceof); adapter suites unchanged (34 + 16, green); both packagestsgo --noEmitclean.
Truncation layering - CodeMode truncation off in OpenCode (user direction; resolves the section 4 outer-truncation item the OPPOSITE way from "kill the outer one"):
maxOutputByteslost 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.maxOutputBytesisnumber | undefined;boundOutputonly 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.
executeis a normalTool.definetool, so OpenCode's native tool-output truncation applies with no special-casing - verified by tracingwrap()(tool.ts:130-144, 50KB/2000-line thresholds intruncate.ts, full output dumped to a file undertool-output/): themetadata.truncatedself-truncation exemption never fires forexecute(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
maxOutputBytesexplicitly; README table and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB value + 50KB log line pass through unbounded,truncatedundefined); the adapter test that relied on the old default now asserts the oversized result reaches the shared wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
Docs polish (post-API-review): stale DiscoveryOptions JSDoc fixed (claimed default
4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality)
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.tsand is now a registry-resident tool service on the TaskTool precedent:CodeModeTool = Tool.define(CODE_MODE_TOOL, ...)whose init depends onMCP.Service,Agent.Service, andSession.Service. It is yielded inToolRegistry.layer, gated intobuiltinbyflags.experimentalCodeMode(like the lsp/plan experiments), andMCP.nodejoined the registry'snode.deps(MCP.nodehas no ToolRegistry dependency, so no cycle). The session-level special-casing insession/tools.ts(ad-hocSessionCodeMode.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 iffflags.experimentalCodeMode&& count > 0. - Description split on the
describeTaskprecedent: the tool's static base description is a two-line summary;describeCodeMode(agent)inregistry.tools()appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog,catalogInstructionsin the adapter) at the same composition point as task - soplugin.trigger("tool.definition")sees the base description first. - Permission-aware catalog + dispatch (the bug fix): the visibility predicate from
llm/request.tsresolveToolsis hoisted toPermission.visibleTools(tools, ruleset)(a record filter overPermission.disabled- only a harddenywith pattern"*"hides a tool; ask-level rules stay fully visible and prompt at call time) andresolveToolsnow uses it, so the two paths cannot drift.describeCodeModefilters with the merged agent+session ruleset thatSessionTools.resolvepasses into the registry before building the catalog/search index;executerebuilds the runtime per execution from a fresh, filteredmcp.tools()snapshot using the same merged ruleset (Agent.get(ctx.agent)+Session.get(ctx.sessionID), matching the mergeSessionTools.contextwires intoctx.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-messageuser.tools[key] === falsearrives at request-prep after descriptions are built and has no child-call equivalent. - Preserved behavior: cancellation race + pre-aborted-signal guard,
toSandboxResultunwrap order, attachment accumulation,CODE_MODE_TOOLat all title sites, no execution limits (native truncation only),displayInput, per-childctx.askgating (now wired throughTool.Contextexactly 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(mockedMCP.Service/Agent.Service/Session.Servicereplacing the directdefine(...)construction; description assertions targetcatalogInstructions, 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.visibleToolssemantics.test/tool/ registry.test.tsgained four registry-level tests: registered with flag+MCP tools, excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering throughregistry.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.tsextracts the duplicated "invoke an MCP tool" middle into one sharedMcpInvoke.invoke(input): plugintool.execute.beforehook -> permission ask ({ permission: key, patterns: ["*"], always: ["*"] }via the caller'sctx.ask) -> dispatch through the ai-sdk tool's execute inside theTool.executetracing span (tool.name/tool.call_id/session.id/message.idattributes) -> plugintool.execute.afterhook. It returns the RAW result the ai-sdk execute resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop inSessionTools.resolveapplies its existing model-facing shaping/truncation, code mode appliestoSandboxResult. It lives undersrc/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.invokewith the raw MCP result - which is exactly what the legacy loop always passed (the rawCallToolResult, 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= theexecutecall'sctx.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-sdktoolCallId(options.toolCallId) stays each caller's existing value (ctx.callID ?? entry.keyfor code mode). - Child-scoped hook failures:
CodeModeTool(which now also yieldsPlugin.Service) wraps the whole child call - hooks, ask, dispatch - intoCatchable(the generalization of the oldaskPermissioncatchCause), so a plugin hook failure fails ONLY that child call as a catchable in-programtoolError; 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 andparent/1,parent/2ids, 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 aPlugin.Servicemock (pass-through trigger by default, overridable). Newtest/session/tools.test.ts(3 tests) pinsSessionTools.resolveat the real-registry seam (LayerNode.compile, fake MCP layer): flag on + MCP tools ->executepresent, raw MCP keys suppressed; flag off -> raw keys present,executeabsent; 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):renderSchemaemitted raw property names, so schema properties likefoo-bar/@type/x.y/123rendered invalid TypeScript ({ foo-bar?: string }). Fixed with arenderKeyhelper - bare identifiers stay bare, everything else isJSON.stringify-quoted - applied in the singlefieldclosure both the compact and pretty renderings share. TheidentifierSegmentregex now lives intool.ts(exported) andtool-runtime.ts's bracket-notationtoolExpressionimports 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 oldanyOf/oneOfrenderer collapsed any union containing{ type: "number" }to justnumber, 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):applyCompoundAssignmentdid raw JS ops on interpreter wrapper objects, sox += ydiverged fromx = x + y(sandbox Dated += 1produced"[object Object]1";d -= 400gaveNaNinstead of epoch arithmetic). The operator table + coercion moved verbatim out ofevaluateBinaryExpressioninto a sharedapplyBinaryOperator; compound assignment validates against acompoundOperatorsset (+=...>>>=) 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 inboundedData. 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.
Batch these together - per user direction: important, but deliberately deferred to one focused interpreter-surface pass rather than picked off piecemeal.
- Medium-tier JS parity items deferred from the original audit: caught errors are plain
{ name, message }objects, notinstanceof Error(andErrorisn't a value -x instanceof Erroris unsupported syntax);splice(still a "rewrite using map/filter" hint) and arrayentries()/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.) -
Date/Map/Set/RegExpvalues passing throughObject.*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 surviveObject.*/spread checkpoints instead of JSON-serializing them. -
console.log(NaN)prints"null"(goes through the boundary chokepoint) - could special-case number formatting informatConsoleArgument. - Sandbox values nested inside logged containers print
[CodeMode reference](console.log({ m: map })) - could deep-format instead.
- Revisit how MCP text results reach the program. Today:
structuredContentwhen the server sends it, else joined text as a plain string (the program JSON.parses it, guided by a workflow step). Considered and deferred: (a) conservative boundary auto-parse (text starting with{/[that parses cleanly becomes an object) - 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.
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
URLandURLSearchParams. 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
Mathmethods (sin/cos/tan, inverse/hyperbolic variants,atan2,log1p,expm1,imul,fround,clz32, etc.). Decide explicitly onMath.random: likely acceptable becauseDate.now()is already exposed, but document the nondeterminism if enabled. - P3: base64 helpers - add string-only
atob/btoaequivalents. Useful for API/tool payload cleanup and does not require opening the broader binary boundary. - P4: small crypto helper - consider
crypto.randomUUID()only, not fullcrypto. 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/TextDecoderfirst, thenArrayBuffer/typed arrays/DataView/Blob/Fileonly 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
Datesetters and common formatting helpers (toUTCString, maybeIntllater). Lower priority; most orchestration can use existing getters,Date.parse,Date.UTC, and ISO strings. - P7: environment/config access - do not expose raw
process.envas 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.
Pre-PR fixes (user-approved cut):
- Cancellation does not interrupt the interpreter - the no-limits rationale claimed
"user cancel interrupts the execution fiber," but
tools.tsruns tools viarun.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 holdctx.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)wherecancelledis anEffect.callbackabort-signal watcher (listener removed on interruption) resuming with anok: 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-abortcompleteToolCallbookkeeping 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 enterswhile(true){}- would hang if interruption broke; pre-aborted signal runs nothing). Adapter suite 34 -> 36. (Wiring abort->interrupt into the sharedtools.tsrunner for ALL tools remains a worthwhile separate change.) - 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 ofmcp.tools(), so the model is invited to call tools that can only fail at permission time, and per-messagetools[key]=falsedisabling 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 sharedPermission.visibleToolspredicate 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-messagetools[key] === falseremains a documented gap by design (it arrives at request-prep, after descriptions are built). - Style:
code-mode.tsis the onlysrc/sessionsibling without theexport * as ... from "./..."self-reexport footer, forcing a star import attools.ts:26(AGENTS.md violation). Add footer + import the projection. DONE: addedexport * as SessionCodeMode from "./code-mode"footer;tools.tsnow imports the namedSessionCodeModeprojection. - Trivial: latent
groupByServerfallback bug -key.slice(0, key.indexOf("_"))isslice(0, -1)when no underscore (unreachable today; guard or drop); deadCODE_MODE_TOOLexport (integration points hardcode"execute"- use it or inline it). DONE: no-underscore key now falls back to the whole key (test pins it); the fourtitle: "execute"sites incode-mode.tsnow referenceCODE_MODE_TOOL.
Post-MVP (logged, not blocking an experimental flag):
- Plugin
tool.execute.before/afterhooks skip child calls - legacy MCP registration fires them per tool (tools.ts:419-441); under code mode only the outerexecutefires 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 runMcpInvoke.invoke(src/mcp/invoke.ts) - hooks AND theTool.executespan fire for child calls with synthetic${parentCallID}/${n}callIDs; hook failures are child-scoped, catchable in-program errors. - Description/preview rebuilt every assistant turn -
registry.tools()re-runsgroupByServer+ a throwawayCodeMode.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 secondCodeMode.makeper execution is inherent (description precedes execution). - Child permission rejection round-trips through the defect channel -
ctx.askdefect (tools.ts:90orDie) recovered viacatchCause+Cause.squash(code-mode.ts:238-245). Works, interrupts preserved, but fragile coupling; exposing the typed rejection onTool.Context.askwould be cleaner. - No collision guard on the
executetool id (a plugin/custom tool namedexecuteis silently shadowed; a log line would do). - Style nits: triple-nested
yield*intools.ts:101-107argument position (bind first, like neighbors); single-use micro-helpers (toJsonSchemais a bare cast); comment density far above session-neighbor norm; adapter tests use rawEffect.runPromise+ hand-built layers withas anyinstead of thetestEffect/LayerNode.compilefixture pattern (test/tool/grep.test.ts:25-31) and star-importTruncate. - 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.
-
evaluateUpdateExpression(++/--) still uses rawNumber(current), sod++on a sandbox Date yieldsNaNwhered += 1now uses epoch semantics (and real JSd++would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment parity fix; route it throughapplyBinaryOperatorif it ever matters. - Media-only marker could name what it attached when MCP provides names:
image/audioblocks carry no filename (mime + data only) so the generic[N images attached to the result]stays, butresource/resource_linkblocks have URIs/names we could surface, e.g.[2 files attached: chart.png, data.csv]. Minor. - Truncation layering decided (user direction): the OPPOSITE of killing the outer layer -
CodeMode truncation off in OpenCode (
maxOutputByteslost its default; absent = no truncation, uniform with the other two limits), native tool-output truncation is the single active layer (verified:executeflows throughtool.tswrap()like any normal tool, no exemption). See the section 3 entry. - Flaky wall-clock assertion removed from
test/promise.test.ts: the parallelism test now relies solely on the deterministictrace.maxActive > 1counter (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 ->FileParts 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 viametadata.toolCalls. - Commit hygiene: all work committed and pushed on
codemode-v2as 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-verifyper repo convention. The scratch.opencode/opencode.jsoncstays 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.
- Motivating failure (why forgiving semantics + prompting matter): in a real transcript,
the model wrote
me.result?.login ?? me.resultwhere 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. copyInhas 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 invalues.ts, opaque-by-default viaisRuntimeReference, explicit carve-outs, JSON form incopyIn's boundary mode plus pass-through in its preserving mode, console formatting (formatConsoleValue), tests - and make sure theObject.*helpers treat it as an empty object so class fields never leak.- The interpreter throws synchronously inside
Effect.gen/Effect.syncfreely; everything is normalized bycatchCause->normalizeErrorintoDiagnosticdata. Program failures are data, never Effect failures; only interruption propagates. parseProgramwraps source inasync 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.definewrapper,truncate.output, 2000 lines / 50KB, saves full output to disk and appends a hint) unlessmetadata.truncatedis set. Theexecutetool currently rides that for free. - Effect version: both repos pin
effect@4.0.0-beta.83via 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 intest/{codemode,parity,stdlib}.test.ts. - OpenCode file map (integration points):
src/tool/code-mode.ts(the adapter, now a registry tool service -CodeModeTool+catalogInstructions; formerlysrc/session/code-mode.ts);src/tool/registry.ts(describeCodeMode, enablement intools(),MCP.nodedep);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 bysrc/session/llm/request.tsresolveTools);src/mcp/index.ts(MCP.tools()/MCP.defs());src/mcp/catalog.ts(convertTool,server_toolnaming);src/tool/tool.ts(ExecuteResult.attachments, truncation wrapper);src/session/message-v2.ts(attachments -> vision);packages/tui/src/routes/session/index.tsx(Executeprogress component);src/effect/runtime-flags.ts(feature flag).