Dialect adapter
@supolka/cloudflare-codemode-executor does two jobs that meet only at the native Executor type. CodemodeExecutor implements the @cloudflare/codemode dialect over anything implementing that interface, and connect builds a native executor that opens a WebSocket to a daemon. Stacked one on the other, they let a host keep the dialect it already writes against while its code runs somewhere else entirely. All of it lives in packages/cloudflare-codemode/src. Moving a host onto this executor says what the swap costs a host running the dialect today, and the contract underneath is the executor protocol.
Conventions the dialect never declares
Section titled “Conventions the dialect never declares”Executor, the dialect’s own interface, declares execute(code, providersOrFns, options?) returning a promise for {result, error?, logs?}. A provider is a name with a record of functions and an optional prelude, and the options include connector bindings. But nothing in that shape says what a tool is named inside the sandbox, what a captured log line looks like, which provider names are refused, what a call to an undeclared tool returns, or what a timed-out run reports.
DynamicWorkerExecutor, the executor the dialect ships with, decides every one of them. It runs each execution in a Worker loaded through the Worker Loader binding. Hosts reading their own logs for [warn] prefixes, or parsing Execution timed out out of an error, depend on that behaviour as surely as on the interface. A replacement that differs on any of them breaks that host. But the type checker says nothing about it.
So almost all of those conventions live in this one package and nowhere else in the repository:
| File | What it owns |
|---|---|
tool-name.ts |
the identifier a tool is exposed under, and the message two colliding names produce |
execution-plan.ts |
the reserved provider names, the name rejection texts, the connector control markers |
normalize.ts |
the shapes of raw model output that become a runnable arrow |
codemode-executor.ts |
the log prefixes and the rendering of a timed-out run |
Two texts sit in the protocol package instead, because the native side composes them as well. ToolNotFoundError composes the text for a tool call the sandbox turns down on its own, and TIMEOUT_MESSAGE is the phrase ExecutionTimeoutError opens with. The adapter renders a recognized timeout back down to it.
The native contract states its own texts, written for the model that reads them and not for a host that parses them, and the adapter translates between the two wordings. Its dependency on the upstream package is a peer range declared in packages/cloudflare-codemode/package.json, and every import of it is import type, so no code from @cloudflare/codemode runs inside the adapter.
One call through CodemodeExecutor
Section titled “One call through CodemodeExecutor”CodemodeExecutor in codemode-executor.ts implements the dialect’s Executor over a native one and knows nothing about which native executor it wraps.
sequenceDiagram participant H as host participant A as CodemodeExecutor participant N as native executor participant S as sandbox H->>A: execute(code, providers, options) A->>A: validateExecution, where a text ends the call A->>A: planExecution, the descriptors and the handlers A->>N: normalizeCode(code), the descriptors, the dispatch, countHostWaits true N->>S: invoke the arrow S->>A: __dispatch(provider, tool, argsJson) A-->>S: the result envelope, or the error envelope S-->>N: the settled value and the captured lines N-->>A: the result or the error, and logs carrying levels A-->>H: the result, the rendered error, the prefixed logs
Arguments the call accepts
Section titled “Arguments the call accepts”The second argument is either an array of providers or a bare record of functions. That record is the dialect’s older shape, and execute wraps it as a single provider named codemode after writing one line to console.warn:
[@supolka/cloudflare-codemode-executor] Passing a bare functions record to execute() is deprecated. Pass an array of providers instead.
A provider is ExtendedProvider, declared in execution-plan.ts: the dialect’s ResolvedProvider with name, fns and the optional prelude, widened with positionalArgs and proxySource from the native ProviderDescriptor. describeProvider copies each optional member onto the descriptor when it is defined and leaves it off when it is not, so a plain provider crosses as {name, tools} alone. Options are ExtendedExecuteOptions: the dialect’s connectors plus modules, timeout and outbound, all three of them the extension.
Name rules and their texts
Section titled “Name rules and their texts”validateExecution returns a string or null. A string ends the call with {result: undefined, error} before the native executor is touched, so a refused request costs no worker and no isolate. It walks the providers first and the connectors after, putting each name through the rules below in that order. Then it finishes with detectSanitizeCollision over the providers. Wherever a text opens with a kind noun it reads Provider or Connector, according to the list the name came from.
| The rule | The text |
|---|---|
| The name is one the entry scope already binds | Provider name "Promise" is reserved |
The name is outside PROVIDER_NAME_PATTERN, an ASCII-only pattern |
Connector name "my-api" is not a valid JavaScript identifier |
The name is in JAVASCRIPT_RESERVED_WORDS, which adds async, eval and arguments to the words the language reserves |
Provider name "class" is a JavaScript reserved word |
| The name is taken | Duplicate provider name "dup", and Duplicate name "api" (connector clashes with provider) for a connector |
| Two tools of one provider sanitize alike | Tool names "list-issues" and "list.issues" both sanitize to "list_issues" in provider "gh" |
That reserved set is the union of the protocol’s RESERVED_PROVIDER_NAMES, described under providers and the sandbox scope, and the names the dialect reserves on top of it, replicated verbatim in execution-plan.ts. It is shorter than the dialect’s own list, because part of that list is names of the harness the dialect generates, Promise and console among them, and the protocol’s set already refuses those.
The reserved-word check is a deliberate superset. Upstream performs no such check and its generated worker fails to start with a syntax error instead, so the compatibility catalogue marks that case as one the reference cannot run. Inside the sandbox, the native executor validates the same names again, through validateProviders from the protocol package, with its own longer texts. This check buys the dialect’s wording and a rejection that costs no worker.
Execution plan
Section titled “Execution plan”planExecution returns the descriptors that cross to the executor and the handlers that stay here. Provider and connector functions never leave the process that owns them. Only the names cross, together with whatever source a provider chose to bring.
For a provider, every raw key of fns goes through sanitizeToolName, and the function is kept under the sanitized identifier. That is the identifier the dialect’s type generator showed the model. Sanitized keys are the descriptor’s tool list. Its handler looks a call up under that identifier and throws ToolNotFoundError when it finds nothing. A call spelled the raw way is turned down: async () => api['list-issues']({ repo: 'x' }) fails with Tool "list-issues" not found, and the differential suite pins that against the reference.
A connector is described with an empty tool list, because its tool names live behind callTool. The generated proxy’s get trap returns an async function for any name the provider’s own source has not bound, apart from symbols and then. The connector’s handler calls connector.binding.callTool(tool, args[0]). Connector calls take the first argument only. The dialect reports a connector’s control decisions as a marker object, not as a throw across RPC. readControlMarker turns the marker back into a local throw: {"__codemode_control__": "pause"} becomes a throw of __CODEMODE_PAUSE__, and {"__codemode_control__": "error"} throws the marker’s message. That throw then travels as the envelope error like any other tool failure.
Dispatch and the completion guard
Section titled “Dispatch and the completion guard”createDispatch wraps the plan’s handlers in createEnvelopeDispatch from the protocol package, which owns the {result} and {error} envelope and the completion guard. Arguments arrive as one JSON string, parseDispatchArguments turns it into an array, and the handler calls the tool function with that array spread, under the dispatch grammar. Dispatch rides every request, including one that declares no providers at all. A call naming a provider with no handler behind it fails with Tool "<tool>" not found, the same text as a call naming an unknown tool of a known provider. So an undeclared namespace and an undeclared tool read alike from inside the sandbox.
That guard is a single mutable object. execute builds {active: true}, hands it to the dispatch, and flips it to false in its own finally, after which every later call is refused with Execution has already completed. A program that starts a tool call and returns without awaiting it therefore gets its result while the abandoned call gets the completion text.
Logs and errors on the way back
Section titled “Logs and errors on the way back”Log lines cross the native wire with the level in a field of its own, as Logs states, and formatLogs renders them into the flat strings the dialect returns.
| Level | Prefix |
|---|---|
log |
none |
warn |
[warn] |
error |
[error] |
renderError handles one text and passes every other message through. The native timeout failure opens with the phrase the dialect pins, and goes on to name the bound and what remains possible:
Execution timed out at its <timeout> ms bound. Do less work per run and return the part that is already finished.
It renders that back down to what a host on the dialect read before the move:
Execution timed out
ExecutionTimeoutError.matches tests the whole first sentence, not the opening words. Programs that threw Execution timed out waiting for the vendor API keep their own message, and so does every other envelope error.
The adapter sets countHostWaits: true on every native request it issues. By default the native clock stops while a tool call or an outbound request the caller performs is in flight. But the dialect measures wall-clock time instead, so a tool slower than the timeout times the run out. Both semantics are specified under timeouts and the run clock.
dispose forwards to the native executor. The dialect’s Executor interface declares execute alone, so dispose sits outside it. But calling it is the only way to release the pool or the socket underneath.
Fields the extension adds
Section titled “Fields the extension adds”The dialect’s own executors take the module map, the timeout and the outbound fetcher as constructor options. So a host that bundles a fresh module graph per execution constructs an executor per call. The reference worker in the testbed does exactly that. However, an executor here owns a pool of child processes, and a pool built per call has no warm worker to offer.
ExtendedExecuteOptions puts those three on the call instead. They are the same fields the native ExecuteRequest declares, forwarded unchanged, so one long-lived executor works for a host that bundles per call. Options the caller left out leave the request field undefined. That reads the same as absent to the native contract, so the executor’s construction-time default applies. ExtendedProvider is the other half. positionalArgs and proxySource are native ProviderDescriptor members the dialect’s current ResolvedProvider has no field for. A host that sets them gets what providers and the sandbox scope specifies: a chosen argument convention, and generated proxy source of the host’s own.
Output normalization
Section titled “Output normalization”The native contract’s entry form is an arrow expression, optionally preceded by static imports, and nothing rewrites the code on the way in. But the dialect’s contract is different. Its MCP server hands the string from the model’s tool call straight to executor.execute, and every executor the package ships normalizes inside. An adapter that skipped this would run raw model output against a contract that expects a cleaned arrow.
normalizeCode in normalize.ts trims the input, strips one markdown fence when the whole string is one, and splits the leading static import declarations off the body with separateImports from the protocol package. Import declarations are legal at module scope only and the entry wraps the body in a function, so the import block stays ahead of the arrow, not inside it. That part is this executor’s extension; the dialect’s generated worker embeds the arrow in a method body, where a static import cannot appear at all.
That body then goes through acorn, parse(source, { ecmaVersion: 'latest', sourceType: 'module' }), and normalizeCode decides on the program’s statements. Each row below shows what the module returns, written as the JavaScript string literal it produces, where \n is a newline.
| The model wrote | What the executor runs | The branch |
|---|---|---|
async () => 42 |
async () => 42 |
a lone arrow expression passes through |
1 + 2 |
async () => {\nreturn (1 + 2)\n} |
the last statement is an expression, so its return is spliced in |
const a = 40\na + 2 |
async () => {\nconst a = 40\nreturn (a + 2)\n} |
the same branch, with the statements ahead of it |
let a = 1\nif (a) { a += 1 } |
async () => {\nlet a = 1\nif (a) { a += 1 }\n} |
nothing trailing to return, so the body is wrapped |
return 6 * 7 |
async () => {\nreturn 6 * 7\n} |
the parse failed, and the wrap makes it legal |
function run() { return 42 } |
async () => {\nfunction run() { return 42 }\nreturn run();\n} |
a single named declaration is declared and called |
export default async () => 42 |
async () => 42 |
the default export is normalized again |
export default function () { return 42 } |
async () => {\nreturn (function () { return 42 })();\n} |
an anonymous declaration parses only in that position |
export default class {} |
async () => {\nreturn (class {});\n} |
the same, returned instead of called |
import { helper } from 'lib.js'\nconst a = helper()\na |
import { helper } from 'lib.js'\nasync () => {\nconst a = helper()\nreturn (a)\n} |
the import block stays ahead of the arrow |
| the empty string | async () => {} |
there is nothing to run |
Fences are stripped only when they wrap the whole string, and the tag may be js, javascript, ts, typescript, tsx, jsx or absent:
```jsconst sum = 20 + 22sum```becomes async () => {\nconst sum = 20 + 22\nreturn (sum)\n}.
Parse failure is not proof of broken code, so the module wraps instead of refusing. return 6 * 7 does not parse at module scope and runs correctly once the arrow is around it. But code that is broken for real still fails, and the sandbox reports its syntax error inside the result envelope where the model can read it. Every shape in the table above is a fixed point: normalizing the output again returns it unchanged. Three shapes from above run end to end through the composition a codemode host ships:
import { CodemodeExecutor } from '@supolka/cloudflare-codemode-executor'import { IsolateExecutor } from '@supolka/codemode-executor'
const executor = new CodemodeExecutor(new IsolateExecutor({ pool: { maxWorkers: 1 } }))
console.log(await executor.execute('const answer = 6 * 7\nanswer', []))console.log(await executor.execute('return 6 * 7', []))console.log(await executor.execute('```js\nconsole.warn("late")\n```', []))
await executor.dispose(){ result: 42, logs: [] }{ result: 42, logs: [] }{ result: undefined, logs: [ '[warn] late' ] }return 6 * 7 arrives in the sandbox already wrapped, the third call’s fence is gone before the parse, and [warn] on the last line is the prefix formatLogs puts in front of a console.warn.
Tool names and the collision text
Section titled “Tool names and the collision text”Tool names arrive free-form. The Model Context Protocol puts no character constraint on a tool name, so a hyphen or a dot in one is ordinary. Generated sandbox code can only call valid JavaScript identifiers, and the dialect’s type generator shows the model the sanitized name, so the executor has to expose each tool under exactly the identifier the model was told about.
sanitizeToolName replaces hyphens, dots and whitespace with an underscore, drops every character outside [a-zA-Z0-9_$], returns _ when no character is left, prefixes an underscore to a leading digit, and appends one to a reserved word. That reserved set in tool-name.ts is every ECMAScript edition’s reserved words, including the future-reserved words of ES1 to ES3, plus the strict-mode restricted names arguments and eval. undefined is on it too, because generated source must not shadow it. That set matches the dialect’s own generator word for word, so the identifier the model was shown is the identifier the handler looks up.
| Raw name | Identifier |
|---|---|
list-issues |
list_issues |
search.code |
search_code |
send email |
send_email |
files:read! |
filesread |
2fa-verify |
_2fa_verify |
delete |
delete_ |
-delete |
_delete |
поиск🔍 |
_ |
Those last two rows are the edges of the transform. A name that stops being reserved once it gains its leading underscore takes no suffix. Names with no character an identifier accepts collapse to a lone underscore, and that is one way two names end up on the same identifier.
detectSanitizeCollision walks each provider’s raw keys and reports the first pair that sanitizes to one identifier:
Tool names "list-issues" and "list.issues" both sanitize to "list_issues" in provider "gh"
Hosts on the dialect surface that envelope error to their model and to their own operators, and the differential suite diffs it character for character against the real executor. So the string is contract. Only the raw names know about the collision, because the wire sends the sanitized ones. formatSanitizeCollision composes the text in one place, so a reword takes a deliberate edit. But the oldest line the peer range covers does not detect the collision at all. It lets the later tool overwrite the earlier one, so the catalogue skips that column with the reason written on the case.
Remote executor
Section titled “Remote executor”RemoteExecutor in remote-executor.ts implements the native Executor contract across a capnweb WebSocket session to an executor server. IsolateExecutor implements that same contract in the host’s own process. Neither RemoteExecutor nor session.ts touches a runtime-specific API, so a Worker hosts it as readily as a Node process. The testbed proves that by running it inside workerd without nodejs_compat, in apps/executor-testbed/src/workerd, so a Node import could not slip through unnoticed.
Opening a session
Section titled “Opening a session”connect(url, options) opens the session before it returns, so an unreachable server, a rejected connection or a version disagreement surfaces at the call that built the executor. Session.open passes a plain url straight to capnweb, which constructs the socket from the runtime’s own WebSocket. The standard constructor takes no headers, so headers without an injected websocket is refused up front with CONNECTION_REJECTED and a message naming options.websocket. Callers whose handshake needs headers pass an implementation such as Node’s ws. Constructors that throw become CONNECTION_REJECTED with the text Failed to open WebSocket to <url>.
Nothing else crosses the socket before the version handshake. protocolVersion() is read and compared by exact equality through assertProtocolCompatible. A disagreement disposes the session and throws PROTOCOL_MISMATCH with the text Executor protocol mismatch: client speaks v1, server speaks v2. Any other handshake failure becomes CONNECTION_REJECTED. Each session registers onRpcBroken on its remote as it is built, and that broken flag is the whole liveness signal the executor reads.
Rotation and reconnect
Section titled “Rotation and reconnect”stateDiagram-v2 [*] --> Live: Session.open, then the version handshake Live --> Live: start before a call, settle after it Live --> Retired: the execution count reached rotateAfterExecutions Live --> Broken: onRpcBroken fired Retired --> Disposed: the last running call settled Broken --> Disposed: reopenSession disposes it before opening the next Disposed --> [*]: the executor holds a fresh session from here
A session counts its running calls. start raises the count before a call and settle lowers it after, and retire marks the session and disposes it once the count is zero. Rotation is safe for that reason. A rotation triggered while another execution is still in flight leaves the retired session running it, and disposal waits for that last call to finish.
Rotation’s counter lives on the executor. Every execute raises it in its finally, whether the call succeeded or failed, and at rotateAfterExecutions the executor drops its reference to the session and retires it. That next call finds no session and opens one.
ensureSession reopens whenever there is no session or the current one is broken. That reopen is single-flight: concurrent calls after a break all await the same opening promise, because a race here would open one socket per caller and leak every one but the winner. reopenSession disposes what it had, then retries with a linear backoff of backoff * (attempt + 1), and reconnect: false leaves it a single attempt. A version mismatch never heals by retrying, so the loop ends on it. And a dispose that arrives mid-attempt ends it the same way, since the freshly opened session is disposed and the attempt throws CONNECTION_BROKEN. That policy applies to calls that have not started. A tool call is not idempotent, so a call broken in flight is never replayed.
Stubs cross, implementations stay
Section titled “Stubs cross, implementations stay”Both the dispatch function and the caller’s outbound function are arguments of the execute RPC call, so capnweb passes them as live stubs. The server calls back over the same socket. Provider implementations stay in the caller’s process. So CodemodeExecutor over connect still runs a host’s own functions for a program on another machine. Requests with no dispatch send null. The outbound tri-state becomes the explicit mode string, with the function attached for stub alone, because the transport cannot tell an absent function from an undefined one.
That same limitation shapes the call options. A defined request field wins over the connection-time default, and only the options the caller actually set are put on the wire object. Properties set to undefined cross capnweb as a present key, not as a dropped one. And null is a value of its own here, not an absence, since a null timeout asks the server for its maximum bound. retainHandles rides the request alone, since retention is a per-run decision tied to reading the granted executionId back.
invokeHandle and closeHandles share the session acquisition through callHandleSurface and skip the rotation counter. The server keys the handles of a retained execution on the pool and not on the connection, so any session may make the call. But the dialect never asks for handles.
How a break is reported
Section titled “How a break is reported”A failure raised under a call whose session is broken becomes an ExecutorError with the code CONNECTION_BROKEN, retryable true, and the original failure as its cause. Its texts name where it happened: Executor connection broke during execution for execute, and Executor connection broke during a call for the handle surface. Every other failure goes through rehydrateExecutorError, which rebuilds the typed error from the code on the wire, because class identity does not survive an RPC boundary. But a failure with no recognized code passes through untouched, so a provider’s own rejection keeps its shape. After dispose, every call rejects with CONNECTION_BROKEN and the text RemoteExecutor is closed.
How the compatibility claim is proven
Section titled “How the compatibility claim is proven”Compatibility is stated as a catalogue and measured against the real thing, under the rule Testing states. That suite lives in apps/executor-testbed/src.
compatibility/cases.ts declares one case per contact point with the dialect. A case is the code, an optional list of declaratively described providers, optional options, and an expect applied to the normalized outcome of every side that ran. Its catalogue covers the result shapes, tool dispatch and its argument conventions, the binary codec, console capture, timeouts, provider validation, the sanitize collision, the module map and the raw model output shapes. Beside those it adds the shapes assistant-written code takes in production: module graphs behind a dynamic import, diagnostic console calls, promises nobody awaits, error messages a host parses, provider accessors a host generates itself, instrumentation that replaces a global, and the platform surfaces such code uses, from AbortSignal to URLPattern. Both sides materialize their tool functions from the same descriptions through reference/tools.ts, so a declared behaviour cannot mean two different things across a diff.
This repository’s side is runOnOurs in compatibility/runner.ts, which builds new CodemodeExecutor(new IsolateExecutor({...})), the composition a real codemode host ships. The reference side is reference/worker.ts, a Worker that runs one execute per fetch through the real DynamicWorkerExecutor over a Worker Loader binding, started under wrangler dev by wrangler.ts and called over HTTP. A throw crossing execute is part of the surface, so it travels as a threw field and does not fail the request. normalizeResult puts both outcomes through a JSON round trip, so a Map or a Set collapses the same way on both sides, and the two normalized results are compared whole. Timing never takes part in a diff.
The matrix’s reference columns all run that same worker, each bundled against a release of its own. wrangler.reference.toml builds against whatever apps/executor-testbed/package.json installs, so the newest release is a first-class column and not a pin. Every older column pulls its release through an npm alias in the same manifest, and an [alias] block in that column’s own wrangler configuration rewrites the worker’s import onto it. Between them the columns cover the whole peer range the package declares.
A case the reference cannot run says so on the case itself. Its reason is written beside it. reference: false moves the case into a describe of its own in compatibility.spec.ts, whose reference callback throws the case name if anything calls it, so the standalone path cannot start calling the reference unnoticed. Reasons in the catalogue are a leading static import the upstream worker cannot express, an outbound mode the reference wire has no field for, a spinning execution that would wedge wrangler dev because it enforces no CPU limit on a loaded worker, and the places where this executor is a deliberate superset. describeRequest refuses an option the reference wire cannot represent while it builds the request, and names the case in the message, so a case that grows such an option fails loudly instead of crossing half-expressed.
Where upstream’s own behaviour changed between lines, skipOn names the versions the case does not apply to, and the comment beside it names the boundary. Argument conventions moved, the binary codec arrived, the reserved set grew to cover the harness globals, collision detection appeared, and ResolvedProvider gained the field for a provider’s own source. Each case that turns on one of those names the line it skips.
Beside the catalogue, compatibility/surface.spec.ts reads the global surface, the members of every capability, the brands, the console methods, the crypto members and the fetch members out of a live reference worker and a live sandbox, and diffs both against a recorded table. Every entry in that table is a measurement, so a capability arriving on either side breaks the test until the table records it. Runtime deviations says what that inventory is for, and which differences it has recorded.