The dialect adapter
@supolka/cloudflare-codemode-executor does two jobs that meet only at a type. CodemodeExecutor serves the @cloudflare/codemode dialect over anything implementing the native Executor interface, and connect builds a native executor that reaches a daemon over a WebSocket. 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”The dialect declares little in types. Its Executor interface carries execute(code, providersOrFns, options?) answering {result, error?, logs?}, a provider is a name with a record of functions and an optional prelude, and the options carry 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 answers, or what a timed-out run reports.
Those answers are behaviour of DynamicWorkerExecutor, the executor the dialect ships with. 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. Replacements that answer any of them differently break 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. tool-name.ts owns the identifier a tool is exposed under and the message two colliding names produce. execution-plan.ts owns the reserved provider names, the name rejection texts and the connector control markers. normalize.ts owns the shapes of raw model output that become a runnable arrow. codemode-executor.ts owns 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 answers 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. This package is where the two wordings meet. 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 holds.
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 answers 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, which is ASCII only |
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 a union. It takes 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 gate buys the dialect’s wording and a rejection that costs no worker.
Execution plan
Section titled “Execution plan”planExecution answers two things: 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 that way: async () => api['list-issues']({ repo: 'x' }) answers 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 and the generated proxy answers every property through its get trap. Its handler calls connector.binding.callTool(tool, args[0]). Connector calls take the first argument only. The dialect answers a connector’s control decisions with a marker object, not with 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 and reach the tool function spread, through parseDispatchArguments, under the dispatch grammar. Dispatch rides every request, including one that declares no providers at all. A call naming a provider no handler serves answers 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 arriving call answers 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 carrying their level structurally, 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
Recognition is ExecutionTimeoutError.matches, which tests the whole first sentence and 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.
One field is set on every native request the adapter issues, countHostWaits: true. By default the native clock stops while a tool call or a caller-served outbound request is in flight. But the dialect measures the wall 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 carries 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. But an executor here owns a pool of child processes, and a pool built per call has no warm worker to offer.
ExtendedExecuteOptions carries those three facts on the call instead. They are the same fields the native ExecuteRequest carries and they are forwarded unchanged, so one long-lived executor serves 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 stands. 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 the decision is made on the program’s statements. Each pair below is one of the module’s own answers, written as the JavaScript string literal it returns, 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.
Tool names and the collision text
Section titled “Tool names and the collision text”Tool names arrive free-form. MCP tools carry hyphens and dots. 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_$], answers _ 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, plus undefined, which generated source must not shadow. It matches the dialect’s own generator word for word, so the identifier the model was shown is the identifier a call reaches.
| 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 lands on one identifier:
Tool names "list-issues" and "list.issues" both sanitize to "list_issues" in provider "gh"
That string is contract, not a message somebody happens to read. Only the raw names know about the collision, because the wire carries the sanitized ones. Hosts on the dialect surface the envelope error to their model and to their own operators, and the differential suite diffs it character for character against the real executor. formatSanitizeCollision composes it 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 serves the native Executor contract across a capnweb WebSocket session to an executor server. IsolateExecutor serves 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 answers, 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. A caller whose handshake needs headers passes 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 reaches zero. Rotation is safe for that reason: a rotation triggered while another execution is still in flight leaves the retired session serving it, and disposal waits for the last settle.
Rotation’s counter lives on the executor. Every execute raises it in its finally, settled 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 landing 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 keeps serving a host’s own functions to a program running on another machine. A request carrying no dispatch sends 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.
The 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 holding 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 carry 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 the wire carried, because class identity does not survive an RPC boundary. But a failure carrying 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 ruling recorded in Compatibility is proven by a differential suite. The suite lives in apps/executor-testbed/src.
compatibility/cases.ts holds 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 carries 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 reaches for, 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 answers one execute per fetch through the real DynamicWorkerExecutor over a Worker Loader binding, started under wrangler dev by wrangler.ts and reached 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 reaches 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. The reason is written next to 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 reaches it, so the standalone path cannot start calling the reference unnoticed. Reasons standing 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 anything the reference wire cannot carry 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 hold on 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 that carries a provider’s own source. Each case that turns on one of those carries 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.