Skip to content

feat(code-execution)!: code_execution runs JavaScript in the isolate, and the Python runner goes - #608

Merged
nissa-seru merged 42 commits into
anima-research:mainfrom
nissa-seru:imogen/code-execution-isolate
Oct 21, 2026
Merged

nissa-seru merged 42 commits into
anima-research:mainfrom
nissa-seru:imogen/code-execution-isolate

Conversation

@nissa-seru

@nissa-seru nissa-seru commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

tl;dr

What: this PR replaces the existing Python code_execution runner. With it, each agent script runs as JavaScript in QuickJS-ng compiled to WebAssembly, in a child process of its own, instead of as native Python.

Why: Having an efficient, agent-ergonomic runner for programmatic tool use / code mode is a prerequisite for Connectome to be sufficiently capable as a coding harness to enable it to be used to develop itself, without need for Claude Code or other provider-specific tooling that we cannot easily tailor to our needs.

Limitations of Python as a tool runner

  • Native Python has its user's reach: a script given no builtins and no imports still reached os.system on the host. In another run, a narrowed agent's script wrote inside a read-only mount and outside every mount, and ran a command. Coupling use of scripts to this level of permissiveness would preclude use cases for which Connectome may otherwise be attractive.

Alternatives considered

  • An operating-system sandbox around Python: the interpreter runs inside an operating-system sandbox on each platform: Landlock and seccomp, or bubblewrap, on Linux; Seatbelt through the deprecated sandbox-exec on macOS; and on Windows, which has neither, a mechanism of its own.
    • Why not: it needs a launcher and a whole policy for each platform, kept right as each platform changes, and the boundary holds only where the whole policy is in place. agent-framework would ship those policies to hosts we don't run.
  • CPython's WASI build: Python compiled to WebAssembly, which reaches only the imports we give it. It was weighed against feat(code-execution)!: code_execution runs JavaScript in the isolate, and the Python runner goes #608 on Oct 21, by runs.
    • Why not under Wasmtime: Wasmtime is a native runtime for each platform, which is what running QuickJS on the host's own engine removed.
    • Why not on the host's own Node or Bun, as feat(code-execution)!: code_execution runs JavaScript in the isolate, and the Python runner goes #608 runs QuickJS: in a prototype, it works there, concurrent tool calls included, through Python's own asyncio with a small event-loop adapter. But each script costs more. In matched runs, a script reached its first tool call four to six times later (292–361 ms against 51–78 ms, medians), and each child held two to four times the memory by then (157–192 MiB against 53–69 MiB). The runtime ships about thirty times the bytes (20.54 MB against 0.60 MB), and its glue must serve a read-only filesystem holding Python's standard library, where feat(code-execution)!: code_execution runs JavaScript in the isolate, and the Python runner goes #608's answers six kinds of system call.
    • What could still favour it: Python and its standard library, for the agents who write the scripts. How well models write these scripts in each language is unmeasured. Both languages cover the job: calling tools, running calls at once and shaping JSON. Empirically, Claudes (Opus 5.5) of the Rookery frequently use a JS-based runtime for programmatic tool calling (relevant as Anthropic's programmatic tool calling implementation uses Python.)
  • Pyodide: CPython compiled to WebAssembly, with a bridge to JavaScript, run inside Node.
    • Why not: as it ships, it isn't a boundary. With its default jsglobals, a Python script read the host's environment and files and ran a command through Node's process (our run, Pyodide 314.0.7 on Node 24). Making it a boundary would mean closing every route from Python to the host's JavaScript, and no one had shown an embedding that does.

Other improvements built into the #608 runner implementation

  • #608: calls every tool by its exact name.
    • main: turns each name into a Python identifier, and skips a name that collides, saying so only in the host's log.
  • #608: gives ALL_TOOLS with each tool's input schema.
    • main: gives the names, and each tool the same docstring with only its name in it.
  • #608: tells a refused call apart from a failure, so a script knows a refused call did nothing.
    • main: has no separate signal for a refused call.
  • #608: when a script ends, names each call whose answer it didn't see, with its arguments.
    • main: says nothing of which calls a stopped or crashed script made.
  • #608: a script can yield its output so far and keep running, and the agent can wait on it, stop it or list it.
    • main: a script holds the turn, or is detached as a background script.
  • #608: a script waiting on a promise that nothing can settle fails at once, as stalled.
    • main: such a script waits until it's stopped or its time runs out.
  • #608: bounds each script's memory with the WebAssembly memory's maximum.
    • main: sets no memory bound per script.
  • #608: a crash costs only its own script.
    • main: a foreground script's crash took the agent's interpreter and its globals with it.
  • #608: every result has the shape { content, structuredContent, isError }.
    • main: only a result carrying structured content has that shape, and any other arrives as a string carrying .is_error.
  • #608: once a script is stopped, nothing it does reaches the host, and one that keeps the stop from landing is killed after the stop's grace.
    • main: a script can catch the interrupt and go on, reaching whatever it can, until a kill 10 s later. On Windows no interrupt is sent.
  • #608: needs nothing on the host beyond its own Node, or Bun 1.4.0 or later.
    • main: needs Python 3.8 or later on the host.

Overview

code_execution scripts now run as JavaScript in QuickJS-ng compiled to WebAssembly, each in a process of its own, on the host's own runtime, Node's or Bun's. A script reaches only its agent's tools: inside it there is no network, file system, timer, package or environment. This replaces the Python runner, and one kind of script replaces foreground and background scripts: it runs in the agent's turn, yields with its output so far once it passes a bound, and keeps running until the agent waits on it, stops it or leaves it. The change is breaking for agents and the people who write their prompts (scripts are JavaScript), for operators (each Python setting is refused by name) and for library users (the runner's exports are gone). changelog.d/code-execution-isolate.breaking.md says what each needs to do.

The engine's commits come first, with quickjs-ng copied in, and a later commit takes the copy out: engine/build.sh fetches the pinned release, and CI rebuilds the module from it and compares the bytes, so the code to read is the glue around it, not quickjs-ng.

Problem

On 0.23.1, as on 0.23.0, code_execution runs scripts in a Python interpreter that the host keeps for each agent. The interpreter runs as the host's user, with an allowlisted environment and what codeExecution.env adds. A script's tool calls go through the agent's allowlist, but nothing stops the script from doing the same work without a tool: it can read and write the host's files by absolute path, .env included, open network connections, start processes and, on Linux, read the environment the host started with through /proc, provider keys included. So an agent with code_execution can do whatever the host's user can, whatever tools it was given. Every host that turns it on also needs Python 3.8 or later. And a script is either a foreground one, held to a deadline and killed there, or a background one, with a log file and a lifetime of its own, and the two follow different rules for output, endings and waking the agent.

What it promises

code_execution adds nothing to what an agent's tools allow, provided the host runtime's WebAssembly engine, Bun's or Node's, and our glue are right. It claims no containment beyond that. The script's process runs as the host's user, with an empty environment and no operating-system sandbox around it, so a defect in the engine or the glue that let a guest out of WebAssembly would reach whatever that user can. The glue is engine/interface.c, src/code-execution/engine.ts and the child's four imports in src/code-execution/isolate/child.ts.

What changes

The engine

  • quickjs-ng v0.17.0, fetched: engine/build.sh clones the release at its tag, refuses it unless it's the commit pinned, and applies any patch in engine/patches/, where there are none yet. None of its C is in the repository or the npm package.
  • engine/build.sh: wasi-sdk 32, checked by sha256, and binaryen 130.0.0, with one wasm-opt pass. On Linux x86_64, a rebuild gave the committed module byte for byte: src/code-execution/quickjs.wasm, 600,387 bytes, sha256 4d0fb1f3….
  • .github/workflows/engine.yml: when engine/** or the module changes, it rebuilds the module and fails if it differs.
  • engine/interface.c (780 lines with comments): the module's interface, held to what the host calls. It has init, start (a prelude, then the script, with top-level await), settle a call by id, and dispose, over four imports: a tool call, the interrupt, output, and the host's supervision at every check, a stop's included. Its allocator, adapted from quickjs-wasi under its MIT notice, holds QuickJS's allocations and the host's buffers to the memory limit, so a script that reaches the limit with the allocator's headroom closed almost always catches a real out-of-memory error rather than null. What runs once the headroom is open can spend it, and an error after that can be null. A result the guest can't take, for want of memory, for nesting deeper than MAX_RESULT_DEPTH (2,000) or for failing to parse, takes the error the host gives it, and settle says it didn't fit. Once the script's ending is fixed, the script's own code that runs as it's read, a toJSON or a getter, can't reach the host, and nor can the jobs it queues.
  • src/code-execution/engine.ts (595 lines with comments): the binding, with loadEngine(limits, slots), start, settle and dispose. A run that fails once its script has run (failed, an exception that escaped the script's promises) stays failed through a later stop, and a settle whose result the call's promise never took answers dropped, never with unfit. The host makes the memory, so one module serves every budget, and a budget past 2 GiB is refused. Its WASI imports give the clocks and four random bytes at load, keep what the engine writes to stdout or stderr for a crash's message, and refuse the rest.
  • src/code-execution/quickjs.wasm.LICENSES: the notices for quickjs-ng, quickjs-wasi's allocator, wasi-libc, musl, cloudlibc and dlmalloc.

A script's process

  • One child per script (isolate/child-main.ts, isolate/child.ts), spawned with the parent's own process.execPath and an empty environment, in a directory of its own, from an entry that exists: child-entry with the runner's own extension, .ts where a Bun host loads the source. The parent's frames go in on the child's stdin, and the child's come back on fd 3, written with writeSync, since the child's imports run while the engine is mid-call. Those are the channels that behave the same under Node 20, Node 24 and Bun 1.4: a socket on another fd reads nothing under Bun, and a file stream on one leaves Node's exit waiting on a blocked read. The child ends its process when it sees its parent gone, which it looks for at the engine's interrupt checks, a stop's included, at most once a stop check. Its start names the parent's pid, so a child whose parent died before it read its start ends too. By our reading, Windows keeps an orphan's parent pid, so there an orphan isn't seen. The script's own ending is the first it reaches, kept through a later stop, but an exception that escapes the script's promises once it runs (a queueMicrotask or FinalizationRegistry callback's throw, or one of QuickJS's own jobs failing) ends the run as failed at once, past an ending kept. A result the engine never delivered to the script isn't reported as seen, so the record names it as unread.
  • The prelude (isolate/prelude.ts) is adapted from @earendil-works/pi-codemode under its MIT notice. It gives tools["name"](args) by exact name, with a close-match error for a name that isn't a tool, and nothing is sent. ALL_TOOLS lists each tool with its inputSchema, parsed from JSON. It also has text(), console.log and the other output helpers, error formatting for what it logs, which keeps only the script's own stack frames, wake_agent(), and the stall check, which fails a script at once when it waits on a promise nothing can settle. The script runs as an async function body, so return gives its value, which the prelude turns into JSON once. A value JSON can't hold comes back as a string that says so, with the value's string form if it has one. A body that returns while its calls are still running waits for them, and for the calls its pending work goes on to make, so tools.a().then(tools.b) does what it says: the script is still running until they're done, and a stop or its lifetime ends it.
  • A stop: the parent creates a stop file in the child's private directory and sends a stop frame. A computing guest's interrupt looks for the file at most every 3 ms, and an idle child reads the frame. A guest stuck inside one native call is ended after a grace, and its ending says its output so far may be short. A script that had already returned or thrown when the stop came ends as it did: the stop ends only the wait for its calls still in flight.
  • Wakes: the child refuses a payload past its cap, then a wake past the script's cap, so a wake refused for its size uses none of the script's. A wake takes no place among the calls in flight, so it goes out at once, even while a result settles. Other calls the guest makes while a result settles are held until its seen, at most 16 of them: a guest that makes more has the rest sent at once, which the parent crashes it for, and is stopped.
  • Output: the child flushes it in bounded batches, and before each call, so what a script wrote before a call reaches the host first. The parent keeps each return's first and last 8,000 characters, with a count of what was dropped between, both cut alike past the agent's inline cap where the rest of the return leaves them room.

The parent

  • isolate/frames.ts: length-prefixed frames, JSON for messages and binary for a result, whose bytes go to the engine with no second parse. The decoder copies each frame's body into one buffer of the length its header names, keeping none of the chunks it came in, so however the bytes arrive it holds one frame. The parent takes frames from the child of at most 25 MiB, the longest a call with 4 MiB of arguments needs.
  • isolate/protocol.ts: a check of every frame from the child, which is untrusted, and the caps on a call's arguments and a wake's payload, which the child checks before it sends. A frame from the child holds at most 32 values, counted before it's parsed, since its arguments, output and values travel as strings.
  • isolate/runner.ts: one run per script, with its calls, their timeouts, the stop and its grace, and the ending.
    • A child that breaks the protocol ends its script as crashed, and the ending says how: a frame that doesn't decode or holds more than 32 values, a call to a tool the script wasn't given, a call's id used again before the script saw its answer, a seen for an answer not yet written, more than 16 calls out at once, more wakes than the cap, arguments or a wake payload past their caps, output past the child's batch, an exit before saying how the script ended, or a stop no one asked for. A child that has said how its script ended but doesn't exit is killed after the stop's grace.
    • Results: a result past a third of the script's memory isn't sent, and the script's ResultTooLarge says the call took effect. A result the guest can't take as it's parsed, too large for its memory or nested more than 2,000 deep, gets the same error from the child.
    • What the host holds for a script is bounded. At most 16 of its tool calls are out at once, each with at most 4 MiB of JSON arguments holding at most 100,000 values. A call past the 16 waits in the script, in its own memory, and is never refused. The host also holds at most 16 of the script's calls at once, timed-out ones whose tools still run included, counted by call. A call past those waits in the runner, as its text, and one whose timeout passes there gets a TimeoutError saying it was never sent. When no result comes for a call, the bridge answers for it after toolCallTimeoutMs and 30 s more, but the host can't tell a tool still running from a result that was lost, so the call keeps its place until the run ends. Once a script's process has exited, nothing it asks goes out: a call waiting for room, a call the host reads after the exit, or a wake. Results wait as encoded frames and go to the child one at a time, each after its stdin has drained. A wake waits here for its spacing, counted on a monotonic clock from when the wake before it was delivered. One still waiting when its script ends, once a stop is asked, or once its process has exited is never sent, and the run's end lets it go.
    • Once a stop is asked, the script's further calls and wakes are refused, with nothing sent, and so is a call that was waiting for room in the host when its turn comes.
  • isolate/calls.ts: the call record, kept in the parent so that losing the child loses none of it. Answered calls are counted. A call whose answer the script didn't see is named with its arguments: one still running at the end, one that timed out, one too large, or one answered just as the script was stopped or crashed. At most ten are named, and the rest are counted by tool. A call that never went out, because it timed out waiting for room in the host or the run ended first, is counted apart, as never sent.
  • isolate/render.ts: what the model reads for each return. First comes the ending, or that the script is still running, then its calls, its value, and the output since the last return.
  • isolate/scripts.ts: an agent's scripts, with run, wait, stop and list.
    • Limits: at most 3 running per agent and 16 children per host.
    • Past a turn: the primary agent's scripts outlive a turn, or any agent's when none is named.
    • Wakes: a left script wakes its agent when it fails, crashes or is stopped at its limit, after any wake it sent that is still being delivered, unless a wait or stop has meanwhile returned the ending. wake_agent() keeps its spacing and its cap, and its payload is at most 64 KiB of JSON. A cap of 0 turns wakes off, and the refusal says so.
    • Glimpses: a listing, and a left script's failure notice, show a failure's message as its first 300 characters and an ellipsis, since the message may already be cut, and a second cut's count would misstate it. A wait gives the message as a return shows it.
    • Kept: the 8 whose endings came last, in the order they came, and none of a gone agent's.
    • One watcher at a time: a wait or stop on a script another call is watching is refused, and nothing changes. A stop watches with the call that asked it, so a turn-ending result that comes during the stop ends that call's turn.
    • Ids carry a tag of the host process's, since a resident outlives a restart of its host.

The framework

  • code_execution (framework.ts, tool-definition.ts) takes run, wait, stop and list. Its result is a text block, and isError is true for the endings that wake a left script (failed, crashed, stopped at its limit) and for a refusal of its input or of capacity. The Python runner's inputs, background, time_limit_ms and cancel, are refused by name, each saying what replaced it.
  • A script's call goes through the ordinary dispatcher as its agent's, with the agent's allowlist and every gate. It carries the RFC-007 origin of the code_execution call that ran the script, and, when it reaches the host while a call watches the script, that call's turn. A call's endTurn goes to the call watching when its result arrives.
  • Every result reaches a script in one shape, { content, structuredContent, isError }. content is what the model would read: a structured result's blocks as its tool sent them, so images survive, and otherwise one text block.
  • ToolResult.refused (changelog.d/tool-result-refused.added.md) is true exactly when the dispatcher refused the call before it opened, as RFC-007's lifecycle refuses one, so nothing ran and the call certainly had no effect.
    • It's set at the five places the dispatcher refuses: the agent's allowedTools, a refused skip_reply wake, an MCPL server that's absent or still connecting, the host's policy for that server, and a fork reaching outside its channel. Each sets the field and RFC-007's mark from one place, so they can't disagree.
    • A check made after the call opened, such as the module registry's, is a failure whose text says nothing was done, and so is every check of a programmatic call (executeToolCall, ModuleContext.callTool), which opens no RFC-007 call. A module's own result can't claim refused on any route: a claim is taken off a copy of the result, and a result that can't be read is a failure saying so.
    • A script's refused call throws ToolRefused, and any other failure is a result with isError. A refused call is safe to retry, and a failed one may have done something.
  • Turn ends:
    • A stream's settle ends its own turn, as abandoned when it ended exhausted, whether or not it still owns the agent. Turns can overlap, a silent one beside an open one, so each script goes by the turn its run call was made in.
    • A non-primary agent's script lives while the turn that started it is live: the agent's open logical turn, or the turn of the start holding the agent's turn, from its dequeue until that turn settles, though its teardown holds the turn on. A turn's end, or a dropped start's, stops, quietly, its scripts whose turn isn't live, and those started in no turn. A run made in a turn that has ended is refused.
    • An abandoned stream stops a script its own calls watch that hadn't made its first return, whatever the agent, and whether or not its turn goes on, since no call is left to receive that return. It lets go of one that had. That's each stop path that used to abort the Python runner's script, abandoning the turn of the stream it stopped; a quiesce abandons its own stream's turn, and the host stopping every turn.
    • An ephemeral agent's end stops its scripts, a conversation fork that's disposed lets its scripts go, and stop() stops every script and waits for their processes to exit.
  • A script's calls count toward its round's routing, as fix(streaming): the outgoing stream carries only what delivery publishes, and a script's calls count as its round's #360 made them on main for the Python runner, and this carries that over.
    • Which calls: a script's calls that reach the host while a run or wait call of the round watches it, whether or not the round waits on that script, as the same call made directly counts; one the bridge refuses before dispatch, as for arguments that aren't an object, counts as one the allowedTools refuses. An inner skip_reply keeps the round private, an inner silencing send, a drafts resend among them, holds it and the turn's later prose as drafts, and an inner think under the private same-round policy holds it back. What counts is the host's receipt of a call, not the script's making it: a call that reaches the host while no call of the round watches the script isn't that round's, as one made once its run has yielded, one made while watched that waited, with 16 of the script's calls already out, until the watching call had returned, and one still on its way when the call yielded. Such a call's effect can come after the round's prose, and it counts toward the round of a later run or wait that watches the script when it arrives, if any.
    • Which rounds wait: a round waits for a run or wait call, until it returns at the script's end or at its yield, only when the code of the script it watches names a tool whose call changes the round's routing in the turn's mode: in locus and hybrid mode skip_reply, a silencing send, drafts, say or whisper; in explicit mode skip_reply; and in either think under the private policy. Each counts as a whole JavaScript identifier anywhere in the code, so tools["chat--say"] names say and say_hello doesn't. A wait reads the code of the script it names, which the script keeps (AgentScripts.codeOf). A stop or a list holds nothing, and neither does a code_execution call the agent's allowedTools refuses, which on main held its round though no script ran, so the round was withheld when its stream ended.
    • What it costs: a comment or a string that names one of those tools holds its round as a call would, for as long as the yield its call asks for: 30 s by default, and up to maxYieldMs, 600 s at the defaults. A tool the code doesn't spell out, by a name it builds at runtime or writes with escapes, or one it picks by enumerating ALL_TOOLS, goes unseen, and its round is routed as before scripts counted, as on main.
    • What carries over: fix(streaming): the outgoing stream carries only what delivery publishes, and a script's calls count as its round's #360's round machinery, re-keyed from a script's return to a run or wait call's return: ScriptRound and its settle by the result its own dispatch made, the stream teardown's withhold with its two identity checks, a newer round displacing an older one, and roundCallOf. fix(streaming): the outgoing stream carries only what delivery publishes, and a script's calls count as its round's #360's Python reader (script-tool-names.ts) and its per-interpreter marks go, since a script runs alone, and nothing one defines reaches another.
  • A script's call carries resultMaxChars (feat(workspace): port hands-mcp's edit planner, and add edit_many, several edits stored all or none #463's field) as the longest result that reaches the script whole whatever its characters: (maxResultBytes(memoryMaxBytes) − 80) / 6, 7,456,527 characters at the default memory, since a character of a result takes at most six bytes in the answer's JSON and a text answer's envelope is 80, and no more than the host's longest string allows, which binds on Node past about 1.5 GiB. It doesn't count a result's structured value, which the answer carries beside the content, nor what that result's blocks carry past their text, such as each further block's keys or an image's data, and a result nested past 2,000 levels, or whose parse needs more of the script's memory than is free, doesn't reach the script whatever its length. On main it was the Python runner's cut, 5,000,000.
  • The Python runner is removed: py-runner.ts, runtime-py.ts, their exports and their tests.

Configuration

  • Refused by name: create() refuses each setting the Python runner took, naming what to do. pythonPath, idleReclaimMs, env and inheritEnv are gone, and scriptTimeoutMs, maxScriptTimeoutMs, backgroundMaxLifetimeMs and maxBackgroundScripts become yieldMs, maxYieldMs, scriptLifetimeMs and maxRunningScripts.
  • New: maxScriptChildren (default 16) and memoryMaxBytes (default 128 MiB, from 4 MiB to 2 GiB in 64 KiB pages).
  • Bun 1.4.0 or later: on an older Bun, create() refuses codeExecution, naming the version and why. Bun 1.3.14 loses a child's fd 3 at some starts, which crashes the script.
  • Sizing: the memory setting's documentation gives both figures. A script's process stays within about its runtime's base plus 2.7 times memoryMaxBytes, plus what its calls take as they go out, up to about 200 MiB under Node and 410 MiB under Bun. The host holds for a script at most about 6 times memoryMaxBytes, plus 653 MiB, plus 128 KiB for each wake maxWakesPerScript allows, about 1.4 GiB at the defaults: its 16 calls out at once with their arguments, up to 16 more waiting for those as their text, up to 16 answers until the child takes them, the one being encoded also as its text, the frame from the child being read, and its waiting wakes. A tool's own result, and the one serialization the bridge makes of it to check its size, are outside it.
  • buildCodeExecutionToolDefinition() takes the codeExecution settings that create() takes, each left out at its default.

CI

  • A job of its own runs code_execution's files from source under Bun 1.4.0, the oldest release create() admits, and under Bun 1.4.3, on Linux and macOS, one bun test process per file: the isolate's, the engine's and the framework's code-execution.test.ts.
  • The pack job, after its check that loads the packed package under Bun 1.3.14, runs the settings file under that Bun, so a run checks that create() reads the Bun it runs under and refuses one too old.

Pending changelog entries

0.23.0 published the Python runner's last entries, and 0.23.1 changed nothing of code execution, so the breaking fragment says what changes from 0.23.1, as from 0.23.0. #495's, #596's and #599's unreleased fragments, which described the Python runner, go. Three more from main described it, and now describe what ships: #360's hold-round-speech.fixed.md, for a script's calls counted by the call watching it and the gate on the code; #463's tool-call-result-max-chars.added.md, for a script call's resultMaxChars; and #536's mount-include.added.md, whose list of the framework's own files, like the workspace's notes beside it, names no background scripts' folder.

Why each part is here

The engine

The engine's side came to about 550 lines of C and 360 of TypeScript (549 and 363), not counting comments or blank lines, with about 140 lines of build and CI (105 and 38).

  • quickjs-ng, pinned and built here: build.sh fetches the pinned release and checks its commit, and a defect is fixed by bumping the pin or adding a patch in engine/patches, which it applies, without waiting on someone else's release. A copy of the C in the repository bought a check that it was that release, which the fetch's check of the commit gives, and a rebuild offline once the toolchain is cached, which the fetch gives up; a rebuild comes only with an engine change. The packages weighed against it were quickjs-emscripten and quickjs-wasi. quickjs-emscripten has had no release since February 16, and its quickjs-ng variant carries v0.12.1, inside the stated affected range of six of quickjs-ng's ten advisories this year, the critical one among them. quickjs-wasi made three breaking major versions in six months, and carries snapshot and extension machinery we'd never call.
  • engine/build.sh: the pinned toolchain, the flags and one wasm-opt pass. Two x86_64 Linux machines built the same bytes for the first module. Its refusals over engine/patches, an entry that isn't a regular .patch file or a link in its place, keep anything there from being passed over without a word. It runs git with none of the machine's configuration, no GIT_ variable but the network's, and templates from an empty directory: without that, a global eol attribute, a filter, a hook or a GIT_COMMON_DIR rewrote the checkout while HEAD still named the pin. So a proxy or a CA reaches the clone from the environment alone. Its patch loop runs under bash 3.2. LC_ALL=C holds the patches to the byte order of their names, which a locale's collation could change, and it compiles from its own scratch tree with relative paths, so no machine's paths reach the module, which CI's byte compare relies on.
  • engine/interface.c, held to the calls the host makes:
    • its error path turns a rejection into a name, a message and a stack in C. We weighed having the prelude wrap each completion so that C only stringifies. That would tie the engine to the prelude's convention, and a failure before the wrapper runs would still need a path in C.
    • the limiting allocator: with QuickJS's own limit, a script at the limit caught a bare null, since QuickJS's out-of-memory error needs an allocation the limit refuses.
    • the host's buffers inside the limit: with them outside it, a script that filled memory while a result's copy was still held ran out of linear memory before the limit refused anything, and caught a bare null.
    • supervision at every interrupt check: the child's watch over its parent is called before each stop decision and after one, a failed slot's included, and looks at most once a stop check, so a child whose script keeps a stop from landing still ends once its parent goes. Without it, one whose stop came before its parent went ran on.
    • a result the guest can't take gets the child's ResultTooLarge, and settle says so. The call took effect, so a SyntaxError or RangeError at its await would read as a failed call.
    • the depth limit: a result nested 8,000 deep crashed the engine under Node 24, and 5,000 under V8's optimizing tier alone. QuickJS's stack check measures the module's own stack, which the C recursion of JSON's parse barely uses, so the host's stack overflowed first.
    • the reading guard: reading a script's value or error runs its own code, a toJSON or a getter, after its ending is fixed. Without the guard, that code could still call a tool or write output.
    • dispose: JS_FreeRuntime's leak assertion catches a bookkeeping slip in C.
  • src/code-execution/engine.ts: the host makes the memory, so one module serves every budget. The 2 GiB cap keeps every address non-negative as a signed 32-bit number, with no conversions. The module is read from beside the binding, in src/ under Bun and dist/src/ under Node.
  • quickjs.wasm ships in both src/ and dist/src/, about 1.2 MB of the package.
  • A failure stays the run's answer: an exception that escapes the script's promises once it runs, such as a queueMicrotask callback's throw, ends the run as failed, ahead of a later stop and in the place of an ending already read. Without it, a run that failed after its ending, or while a stop landed, was reported as finished with its value, or as stopped.
  • engine_state, read after an ending: the jobs that reading the ending queued can end the run. Without it, a run with no call left to settle finished as if nothing had gone wrong.
  • dropped on a settle: the parent's record says which results the script was handed. Without it, a result settled after a stop, or one whose hand-over was stopped on its way in, was recorded as seen.
  • A stop stays a stop: once the host says stop, the engine doesn't ask again. QuickJS runs the script's own code after a stop, as it builds an error's backtrace (a valueOf on Error.stackTraceLimit, and Error.prepareStackTrace), and a stop that lands in that code is lost when QuickJS puts back the error it was building. Without the latch, a host whose stop slot said stop once would see the script run on from there.
  • __host and __output refuse once the script is stopped: without it, a call or output from the code QuickJS runs after a stop reached the host.
  • A throw at the start comes after a stop: it's the script's own ending, so a stop during its read is a stop, as for a rejection. Without it, a later call found the throw again and read it again, running its getters a second time.
  • A stop ends a failure's read: what was read before it stays, and nothing more is read, the message first; a part whose getter or toString was running when the stop came, and kept it from landing, is left out. Without it, the script's getters and toString ran after the stop, and what they gave went into the failure as its parts.
  • Each part of a failure made a string as it's read (error_part): the name and the message are each read and made a string, and the stack read, as a string or not at all, before the next is read, in one function that holds the read's checks. Without it, a message that isn't a string, read before a name getter that the stop cut off, was dropped, since the string forms were made only after every read; now it keeps its string form, a number's or an object's. A part that's there with no string form, such as a Symbol message, is left out with no stand-in, which a missing message would get.
  • No stand-in for a missing message after a stop: a failure with no message is told by its JSON or string form, but not once a stop has come, nor when the stop came while its toJSON or toString ran and QuickJS dropped it. Without it, a stop that cut the read gave a message made from the object's JSON or its toString, script code run after the stop, which isn't the message.
  • The engine test's interrupt calibration: the test for a resolve call stopped on its way in finds, by halving, the k at which QuickJS's check falls on that call. Without it, a fixed k would stop reaching the branch, silently, when the prelude or QuickJS changes its count of checks.

The host's side

The host's side is about 2,150 lines of code in src/code-execution/isolate/, not counting comments or blank lines, the runner's 624 and an agent's scripts' 337 the largest. In the files it changes it adds about 550 lines and takes out about 660, most of them in framework.ts (338 and 490) and the tool's definition (149 and 134), and it removes the Python runner's, about 1,745, F55's 65 and #360's Python reader's 95. Most of it is the parent's side: the call record and the text an agent reads, the frame codec with binary result frames, an agent's scripts with their limits, wakes and turns, and the runner's checks of an untrusted child. The tests add about 6,690 lines, the engine's 940 among them, and take out about 5,990, most of them the Python runner's.

  • A child per script: nothing a script held stays after it, and there's no instance lifetime or idle reclaim to manage. Its cost is a start per script. A script that returns at once ended a median 109 ms after its spawn under Node 24.22.0, and 64 ms under Bun 1.4.3, in 30 runs each on Linux x86_64 with the load average near 11 on 12 CPUs.

  • The child on the parent's runtime: pinning Bun would make Bun a dependency of every Node host that turns code_execution on, for no gain in what it promises.

  • Binary result frames: a result in transit takes two to five times its size in the child, so it goes to the engine as the bytes it is, with no second parse or string copy.

  • The output bound in the parent: every return gives its own first and last 8,000 characters, since a long stretch's start is often where its trouble began, such as the first error before a cascade. Past the agent's inline cap they're cut alike, where the framework's cut would take the tail first. A puppeted call's pair is stored under the cap its call carried; a model's, under the cap at the store, as every tool's is in main, so, by our reading, a cap lowered between its return and its store takes the stored copy's tail. The child can't know when the parent will return, so the bound can't sit there. The cost is that a printing loop's whole output crosses the pipe, in bounded batches.

  • The stop file: it reaches a guest that computes without its event loop. A pipe the interrupt could poll would need bun:ffi, libc and a different flag on each platform in every child. Only the file's existence counts, and the parent says why it asked.

  • The orphan's exit (ChildIo.orphaned): a child that sees its parent gone ends its process rather than stop its script, since a script that keeps QuickJS from taking a stop has only the parent's kill to end it, which an orphan has lost. At a check, it's looked for before waiting output is sent, which has no reader then. Without it, the engine test's orphan, whose script drops every stop, spins on (DROPS_EVERY_STOP).

  • The call record: the agent can tell which effects may have taken place, and from which calls.

  • 16 calls in flight, and backpressure: without them, results answered while a guest computes wait in the host with nothing to bound them. In a heap run before the bound, a parent under Node held about twice the calls in flight times the result size: 1,008 MiB at 64 calls of 8 MiB.

  • A call past the 16 waits in the script: waiting in the parent, calls kept their arguments in the host, with nothing to bound how many: 112 waited in one reader's run. In the guest they cost the script that made them, inside its own memory.

  • The arguments' caps, 4 MiB of JSON and 100,000 values: in a reader's run, parsing a call's arguments in the parent took 288 MiB for 20 MiB of nested arrays, and a probe of the costliest shapes found about 92 bytes a value. With the caps, the host's figure per script can be stated, and 4 MiB still holds a generated file of a few MB sent as one string.

  • At most 16 calls out in the host, timed-out ones included: a call that times out in the script frees its place there, while its tool may still run. In a reader's run, with a 300 ms timeout and a bridge that held each call 3 s, 160 calls were held at once. A call that waits past its timeout is told it was never sent, rather than refused, since the same call may go through a moment later.

  • A wake's payload, at most 64 KiB: without it, a script's 100 wakes, waiting for their spacing, could each hold a frame's worth in the host.

  • The runner's checks on the child: each stops a child from making the host do what no script could: free a place it hasn't earned with a seen for an answer never sent, confuse two calls by naming an id again before its seen, call a tool its script wasn't given, or keep more than 16 calls or more wakes than the cap.

  • A wake still waiting when its script ends is dropped: the ending already reaches the agent, and a wake sent after it would arrive out of order, as if the script were still running.

  • A child that has ended its script but doesn't exit is killed: otherwise it would hold its place for good and outlive dispose().

  • One call watching a script at a time: two calls waiting on one script would each take part of its output, and only one its ending.

  • Ids tagged with the host process: a resident outlives a restart of its host, so without the tag a new script could answer to an old one's id.

  • The unseen state: an answer sent as the script was stopped took effect, and the script never read it, so the record names that call.

  • The child's own directory, and an entry that exists: under Bun, a child started in the host's directory loads the host's .env, and one given the .js path Bun maps to the .ts beside it runs with npm_* variables, NODE and a PATH in its environment. The child's environment is part of what the promise says a script doesn't get.

  • Bun 1.4.0 or later: under Bun 1.3.14, 30 of 200 children started one at a time lost fd 3 before their first write, and none of 200 did under 1.4.0, 1.4.1, 1.4.2 or 1.4.3. Refusing it at create() tells an operator at start. A respawn of a child that lost fd 3 would be code kept as long as one old runtime's defect.

  • The child decides whether a wake goes out: it checks the payload's size and then the script's cap, in one place, so a wake refused for its size costs none of the script's wakes. The prelude's own count is gone.

  • A frame from the child holds at most 32 values, counted before it's parsed: the byte limit alone let one frame of empty objects become an object graph far past the host's figure. Its arguments, output and values travel as strings, so a legitimate frame holds eight at most.

  • An answer can be seen once its frame is written: otherwise a child could free its places for answers still queued in the host, and grow that queue without bound.

  • Output flushed before a call: what a script wrote before a call then reaches the host before the call does, so a return reads in the order the script ran.

  • A script's own ending kept through a stop: a script that had returned or thrown when a stop came gives its value or its error, and the record names its calls still in flight, rather than a bare stop.

  • The run's end lets go every wake and call waiting: a wake waiting behind another's delivery, or on its spacing, and a call waiting for room in the host. Nothing of an ended run stays suspended, holding its text.

  • Ending order by sequence, not the clock: two scripts ending in one millisecond, or a clock stepped back, would otherwise drop the wrong one.

  • A left script's ending after its last wake's delivery: the ending is the script's last word, and a wake still being written to the workspace would otherwise land after it.

  • Room for one marker when the parent bounds what the child cut: the parent bounds a child's ending again, since the child is untrusted, and without the room it replaced an honest count of what was dropped with its own count of the marker.

  • The host's places counted by call: a set of ids let a child name a timed-out call's id again, once its script had seen the TimeoutError, and hold any number of tools behind one place.

  • A call the bridge answered for keeps its place: without it, a tool still running past the bridge's own timer freed its place, and a script could keep the host running any number of such tools (Ada-1017's decision).

  • One buffer per frame being read: the decoder kept every chunk until a frame was whole, so a child that sent one in small pieces held a buffer and its overhead for each, and a large frame peaked at twice its bytes when they were joined.

  • The child's frames at most 25 MiB: the largest an honest child sends is a call frame with 4 MiB of arguments, so the host's figure can count the frame being read, which a 64 MiB frame and its parse would have passed.

  • Wakes counted before a stop's refusal: otherwise a child that ignored a stop could send wakes without a bound, each a record line and an answer.

  • Nothing goes out once the process has exited: a call or a wake the host reads after the exit, or a call waiting for room then, would run a tool for a script that's gone.

  • A long refusal stays a refusal: one past the script's result bound went out as ResultTooLarge, saying the call took effect, though nothing was done.

  • A notice not sent twice: a wait or stop that has returned a left script's ending keeps its notice from repeating it.

  • Each script's own turn, and the turns live: a script records the turn its run call was made in, and a turn's end stops a non-primary agent's scripts whose turn isn't live. Without it, a silent turn that settled beside an open one stopped the open turn's scripts, and a stale settle could stop a newer turn's; with ids compared instead of liveness, a turn superseded before it settled left its scripts running for good.

  • Three wake counts: the cap counts every wake the child sends, refused ones included, so a child that ignores a stop stays bounded; the record counts those handed to the host; and a listing shows those the host delivered, answered and not as an error, so an agent reading it isn't told a refused or failed wake reached it.

  • The child holds at most 16 calls while a result settles: a guest held to the prelude never has more out, and one whose promises were replaced would otherwise have its calls held without bound in the child's memory. The rest go out at once, and the parent crashes the run.

  • One rule for a failure's frames, in two places: the child puts every failure's stack in the agent's form (scriptFrames), and the prelude puts a logged Error's the same way inside the guest, where the script reads what it logs. The guest gets the rule's two patterns as they are, the child reads its lines in place by the same rule, and tests hold the two together. Without it, a failure showed QuickJS's raw frames, the prelude's among them, its lines numbered from the wrapper. A frame is a line shaped as QuickJS writes one of the script's, at … ending in the script's place, its line at most ten digits: without the shape, a line of a stack the script set that names the script's source would be read as a frame, and without the limit, the walk never ends on a line of 400 digits, which parse to Infinity (J25308).

  • A failure's frames sent apart from its name and message: the child puts the script's frames in the agent's form and cuts them on their own as it reads them, in two walks of the stack that make no string for a line the cut doesn't keep, so beside a stack the script set the child holds only the cut, however its lines run, under Bun as under Node (scriptFrames, cutOf). That holds for a thrown error as for one that escaped the script's promises, and a return prints them as they are. Without it, a cut message or name broke the match by which a return found where the frames began, so a message line that looked like a frame was printed as one.

  • An unfit answer says what the engine could have refused it for: too large, too deep or not JSON, leaving out what the child had already ruled out, so the script isn't told an answer the child parsed as JSON might not have been JSON.

  • A script that ends as it starts is trimmed with the rest: it ends before it's in the map, so the trim is taken again once it is. Without it, such a script stayed past the agent's kept endings.

  • An unfit answer keeps its name: a refusal the guest couldn't take reaches it as a refusal, with its message's start, so whether the call ran isn't lost.

  • Turns live by the agent's turn token, until they settle: a start counts as in progress while it holds the agent's turn, and its turn stops being live at its settle. The agent object's status read a start as idle through its hooks, compile and restarts, and without the settle's mark, a run made during the teardown started a script for a turn that had ended.

  • A quiesce abandons its own stream's turn: the scripts of an open logical turn or a silent turn beside it that its calls don't watch are left to whether their turn is live; every turn only when the quiesce names none, and at the host's stop. It once abandoned every turn of the agent, stopping the scripts of turns it didn't end.

  • A dropped start ends its turn for its scripts: a start dropped before its stream sent anything tells the agent's scripts its turn ended. Without it, a non-primary agent's script of that turn ran on past it.

  • A cancel ends its own stream's turn: a stream that no longer owns its agent, cancelled, abandons and settles its own turn. It once abandoned every turn of the agent, stopping the scripts of the turn that had taken the agent.

  • A start's turn is live again: a start that runs on a routing an earlier settle marked settled, as a resume can when it takes a silent start's routing, takes the mark off. Without it, a non-primary agent's run made in the resumed turn was refused as one from a turn that had ended.

  • A gone agent's scripts are its own: a later agent of the same name doesn't list, wait on, stop or count them, though the host's count holds their processes until they exit.

  • Each field of a claiming result read once, on the way to a failure too: a getter that threw isn't asked again, so it can't answer differently the second time.

  • Waiting calls in the record: a call waiting for room in the host is among those the script made, so a yield's count is what the script made.

  • The child encodes against the parent's cap: one constant, so the two limits can't drift apart.

  • The gate on a script's code, a regular expression: a JavaScript tokenizer would spare the few rounds whose comments or strings name a routing tool a bounded wait, as a module every later corvid maintains. With no marks, since each script runs alone, a false hold costs one round's narration, for no longer than its call's yield.

  • Tool names escaped in the gate's pattern: each routing tool's name is escaped before the names are joined into the gate's pattern, as protocol-family.ts escapes its names. No tool's name needs it today. Without it, a later routing tool whose name holds a pattern character, such as . or $, would make the gate hold for other names or for none.

  • A script keeps its code: a wait's round reads the code of the script it would watch, and only the script has it.

  • A round's calls found by the watch: a call counts toward the round of the run or wait call watching when it reaches the host, and the bridge knows that call only by the watch it's handed, so the round's list is kept by watch, as fix(streaming): the outgoing stream carries only what delivery publishes, and a script's calls count as its round's #360 keeps a round by its result.

  • A waiting round counts every one of its scripts' calls: a round that waits for one script also counts the calls of the others its code_execution calls watch. Without it, a skip_reply by a name built at runtime, made while the round waited on another script, was lost, and the round's prose went out.

  • Each kind of call kept once: a round reads its calls as a set, so the bridge keeps one of each name, cause and refusal. Without it, a script that looped on a fast tool for its whole yield grew the host's list by a call each time.

  • A call the bridge refuses still counts: one whose arguments aren't an object, or that comes as the host stops, counts as a call the allowedTools refuses, as a direct one would. Without it, a think or a skip_reply given no object left its round's prose public.

  • resultMaxChars from the script's memory: a text result that fits it reaches the script whole whatever its characters. A larger figure would be false for text that needs escapes, and none would leave a module at its own bound, the workspace's 24,000. What it can't count, the field's documentation says.

  • An unconvertible result's failure cut to fit: its reason and the tool's own error are cut to 8,000 characters each, so the failure reaches the script whatever its memory.

  • A child that breaks the protocol, one mode per way (test/helpers/bad-child-entry.ts): each check the runner makes of an untrusted child needs a child that commits that breach. Without it, the parent's reading of garbage on fd 3, a result frame from a child, an ending after the exit and the rest would have no test.

  • The round tests' module and helpers (RoundToolModule, scriptRound, forgeBeside): a round test needs a turn of rounds through the real framework, a registry stub, and tools that hold until a test lets them go, so the order of a round's prose and its scripts' calls can be seen; a forged call goes through the real bridge with a running script's context, as a child that ignores its tools could send it.

  • ProviderErrorStream (test/code-execution.test.ts): a stand-in stream whose first event is a provider's error, as membrane reports one. A thrown error reaches the site where a stream throws instead, so without it the abandon a provider's error makes, and the turn it names, would have no test.

  • ToolResult.refused: it tells a script, and any caller, "nothing happened" apart from "the tool failed". It's set only where RFC-007's lifecycle refuses, before the call opens, so that it agrees with what observers see, and a check made once the call has opened is a failure whose text says nothing was done.

Cut while building: an explicit bounds check on the binding's pointers, whose typed arrays check them; a deadline in the child, since the parent's lifetime timer asks for the stop; a head and tail in the child, since each return has its own bound in the parent; the workspace spill of a large structured result, which ResultTooLarge replaces; the background log file; an estimate of a call frame's size, since the frame decides; and a queue in the parent for calls past a script's 16, which wait in the script.

Left out:

  • from pi-codemode, the freezing of built-ins, store and load, image() and its worker placement. The calls and the ending are recorded outside the guest, so a script that patches a built-in can only break itself.
  • a cap on a script's total output, since each return's bound holds the memory.

Known limits, each of which ends only the script's own run, as a crash:

  • a script's own data nested 4,000 deep or more: under Node, its JSON.stringify of it crashes its run from about 5,000 deep, or 4,000 under V8's optimizing tier alone, and its JSON.parse from about 8,000, or 5,000, and both passed at 3,000, since QuickJS's stack check barely sees that recursion in C. The prelude's stringify of a call's arguments or a return value nested that deep is such a case. Under Bun, QuickJS's catchable RangeError comes first, from about 15,000, and the run goes on, though a parse nested 22,000 deep still crashed it. A result nested past 2,000 is held back as ResultTooLarge before it's parsed.
  • a script whose text nests deeply: function expressions 300 deep, or brackets 700 deep, crash its run under Node, since QuickJS's parser recurses in C.
  • a long chain of iterator helpers: 3,000 crash the run under Node, as recursion in C the stack check barely sees; 1,000 pass, and under Bun 10,000 get a catchable RangeError. Under Bun, 30,000 trap the module, since each helper's next calls the one before directly, past QuickJS's checks, and runs the module's own 1 MiB stack out. The stack sits first in memory, so the overflow wraps below address 0 and traps rather than writing over data.

A known limit of a failure's text: QuickJS names every source it compiles <input>, so a frame in code a script compiles at run time reads as the script's own: in code it evals, at the eval'd string's line less two, and in a Function constructor's body, at the body's own line plus one for each line break in its parameters.

A stop a script keeps from landing: a script can keep QuickJS from taking a stop, since QuickJS drops a stop that lands inside a stack hook (Error.prepareStackTrace, or a valueOf on Error.stackTraceLimit) or an iterator's return, and puts back the error it was building. Nothing such a script does after the stop reaches the host. The parent's kill after the stop's grace ends its run, and an orphan ends its process once it sees its parent gone, which the engine's supervision lets it look for at every interrupt check, a stop's included.

Tests

  • New:
    • test/code-execution-engine.test.ts (71 tests): each seam the child relies on, and the engine's incidents: a null out-of-memory error in two shapes, a result's copy held outside the limit, the unfit mapping, both ends of the documented memory range, a result nested past the host's stack, recursion at the stack limit eight ways, and a slot's failure seen through the interrupt.
    • test/code-execution-isolate-*.test.ts: the call record, the child in process, the runner with real children, an agent's scripts, the output bound, the protocol, the text the model reads, the settings, and end to end on the engine.
    • Most of these run real child processes on a stand-in engine (test/helpers/fake-engine.ts, on node:vm), and those on the engine run the real module. test/helpers/bad-child-entry.ts is a child that breaks the protocol, one mode per way.
  • Rewritten for the new surface: test/code-execution.test.ts: the tool offered only when it's on, scripts calling module tools as their agent's, endTurn at the code_execution result, yields and waits, every input refused by name or shape, list, stop and the caps, a left script's wake, an ephemeral agent's and the host's ends, the bridge's refusals, ALL_TOOLS, the settings, the turn-settle hook, and create()'s refusals.
  • Ported from Python scripts to JavaScript ones on the engine, each still checking what it checked:
    • agent-allowed-tools: a script can't name a tool outside its ALL_TOOLS or reach the host call the prelude closes over, a call a child forges is refused by the dispatch, and a refused code_execution starts no script.
    • mcpl-tool-lifecycle-script: RFC-007 reports each inner call under its own tool, a dispatch that throws after doing work, and a call still running when its script is stopped.
    • turn-id: a script's calls carry the turn of the call watching it, and a left script's carry none.
    • code-execution-structured-results: the whole shape check, through a real MCPL connection, structured views that are false, 0 or null, and a result past the Python runner's 5,000,000-character cut reaching the script whole.
    • refusals-are-error-results, unconvertible-tool-result, tool-result-reference-uri, script-structured-references and mcp-modern-framework: what a script receives through the bridge.
    • stop-step-throws, stop-with-host-operations and host-quiesce: the stop and abandon steps, through the scripts' dispose() and turn ends.
    • route-failure-targeting: a script's wake for an agent or fork that's gone is dropped, logged once.
    • code-execution, fix(streaming): the outgoing stream carries only what delivery publishes, and a script's calls count as its round's #360's round tests from main, and new ones: an inner skip_reply, send, drafts resend and private think in each mode, a refused one, and one a child sends past the script's tools; a round going out once its script returns; the gate on a script's code, its edges included; a round routed at its run's yield, and a call the host receives only after it, counted toward the later wait's round; two scripts in one round; a waiting round counting a script it doesn't wait on, and a left script's calls; calls the bridge refuses; a stop that holds nothing; the locus and >> target taken when the round ended; and the withholds, an ephemeral disposed, a stream stopped or displaced, and a round its stream no longer owns. fix(streaming): the outgoing stream carries only what delivery publishes, and a script's calls count as its round's #360's mark and interpreter tests, and script-tool-names.test.ts, go with the Python reader.
    • skip-reply-wake-xml-mode: a skip_reply refused inside a script keeps its round private in XML tool mode.
    • tool-call-result-max-chars: a script's call carries the figure from its memory, a result of exactly that many NUL characters reaches a script with 8 MiB whole, one more character reaches it as ResultTooLarge with the call taken effect, and at 2 GiB the host's longest string bounds the figure.
    • unconvertible-tool-result: a failure for a result the script can't receive cuts the tool's own error to fit.
  • For ToolResult.refused: mcpl-tool-resolution tells a refusal from a failure on the model's, executeToolCall's and a module's path, and module-tool-args, agent-allowed-tools, utils-surface, discord-tool-errors and refusals-are-error-results check that a check made once the call has opened answers with a failure.
  • Removed: test/code-execution-env.test.ts, which tested the Python interpreter's environment.
  • Removals, the host's side: each of its 262 pieces was taken out on its own and the tests aimed at it run. 216 ran at the tree before the last fixes (J24548–J24551), and the pieces those fixes added, with every piece whose code they changed, ran after them (J24657, J24798, J24848, J25126, J25127, J25528, J25529, J25637, J25638, J25719, J26022, J26193, J26486, J26543). All but three failed a test: the child's ending once its parent has gone leaves its tests spinning past the pass's 900 s bound, and two survived. A non-owning settle's stream argument stays with no test: at that settle the stream owns its agent, since the loop's head drops a non-owning stream's events and nothing awaits between, and it keeps the one rule that every settle names its own turn. So does the walk's making no string for a frame's place the cut doesn't keep, which saves memory, not output: a probe measures it (J25087).
  • Removals, the engine: each piece of the engine and its binding was taken out or inverted on its own, with the C pieces rebuilt into the module, on Linux x86_64 under Bun 1.4.3, in Gladys-2592's runs at the round that last changed it; her commits' messages give each round's. Each fails a test, among them the latch, __host and __output refusing once the script is stopped, and each part of a failure made a string as it's read. This round's, at its head, fail one too (J26022, J26193, J26486). Kept with no test that reaches it:
    • what the engine writes to stdio, kept for a crash's message, up to 4,096 characters: only an assertion reaches it, and a module built with one that fires showed its text in the EngineError under Node 24 and Bun 1.4.3;
    • random_get filling its buffer: libc takes four bytes once, as the module loads, and Math.random is seeded from the clock;
    • the child's error copied in before the result: it matters only for a result whose copy takes the memory to within the error's length of the limit;
    • drop_late clearing a late part's exception: nothing reads a pending exception after a stop, though dispose's leak check holds its freeing;
    • js_host's cleanup when a call's entry can't be made, and the binding's answers when the module can't allocate a result's text or its own state: each needs an allocation to fail at one exact point.
  • engine/build.sh's fetch, on the VPS: from the pinned release, it built the head's committed module byte for byte (J26539), and 1ed704c7's, as the copy had (J26482, J25843). An entry in engine/patches that isn't a .patch file, a link in its place, a patch that doesn't apply and a pin the tag doesn't match are each refused, and a patch that applies is built in (J25902, and Esther-2584's runs of its patch steps). Under ten builder configurations, GIT_COMMON_DIR and GIT_INDEX_FILE among them, the head's build.sh checked out the pinned files exactly, where plain git changed the checkout in seven, overwrote a file outside it in one and stopped in one (Esther-2584's runs, git 2.55.0).
  • engine/build.sh with relative paths, from the root and from src/, built the committed bytes, and npm run predev copied them into dist.
  • The whole 2 GiB, once, outside the suite, since it takes 10 to 19 s. Under Node 24.22.0, Node 20.20.2 and Bun 1.4.3, the guest held 1,981 strings of 1 MiB, then caught an out-of-memory error. After it released some, a 32 MiB result arrived intact, and a later call settled.

Checks

On Linux x86_64:

  • At the head, a0fe88ae (J26538):
    • Node 24.22.0: tsc --noEmit is clean, and npm test ran 5,788 tests: 5,746 passed, 42 were skipped and none failed.
    • Node 20.20.2: the 14 test files that exercise code_execution pass, one process per file.
    • Bun 1.4.3: each of code_execution's eleven files passes from source, one bun test --timeout 60000 process per file. The settings file passes under Bun 1.3.14 too.
  • CI's Bun job as it is written (J26539): with no build, each of code_execution's eleven files passes from source under Bun 1.4.0 and under Bun 1.4.3.
  • At the other commits (J24684, J25265, J25341, J25527, J25718, J26482, J26540–J26542):
    • at d4adf51c, the engine's tip, tsc --noEmit is clean and the engine's 65 tests pass;
    • at d8e8971d, it's clean and the seven files that check ToolResult.refused pass, 191 tests;
    • at 987f63b4, it's clean, and the isolate's eight files and the engine's, 294 tests, pass under Node 24.22.0 and under Bun 1.4.3;
    • at 1bf16d83 and 115c0316, the same files pass, and at 115c0316 the code-execution and tool-call-result-max-chars files too, under Node;
    • at 1ed704c7, 294e26d0, 64a5f60f and 53e5000f, it's clean, and the engine's file, the isolate's engine, child, runner and scripts files and code-execution pass under Node 24.22.0 and Bun 1.4.3, each commit's module built from its own interface.c.
  • The child's memory, with the head's child code (J25087, J25190, J25222, J25230, J25236, J25255, J25305): a long output, return value, result and failure's stack kept the child within its runtime's base plus 2.7 times memoryMaxBytes, at 128 MiB and 1 GiB, under Node 24.22.0 and Bun 1.4.3. Sixteen calls of 4 MiB, in a burst or held while a result settled, added at most 201 MiB to its peak under Node and 405 under Bun, at 32 MiB to 1 GiB; with 64, fanned out or one after another, it stayed within base plus 2.7 times the budget plus those.

CI runs the suite with Node 20 and 24 on Ubuntu and macOS, and code_execution's files from source under Bun 1.4.0 and 1.4.3 on Ubuntu and macOS.

Not verified

  • Windows: nothing ran on it, and the engine's rebuild check runs only on Linux.
  • Bun 1.4.1 and 1.4.2: only the fd 3 probe ran on them, 200 children each. The isolate and engine files ran under 1.4.0 and 1.4.3.
  • The stack limit on arm64: it was measured on Linux x86_64 only. CI's macOS arm64 jobs pass its tests, and V8's frames on arm64 may differ.
  • build.sh's fallback to shasum: it hasn't run on macOS.
  • Models on this text: a live check ran it on Claude Sonnet 4.6 and Opus 4.6, but no fixture reached the inline cap's clause or a timeout.
  • connectome-host against this branch.

Other pull requests


  • Changelog fragments added: changelog.d/code-execution-isolate.breaking.md and changelog.d/tool-result-refused.added.md.

Written by Imogen-2490 (Claude Opus 5.5), the host's side, and Gladys-2592 (Claude Opus 5.5), the engine, contributing through nissa-seru, to a design by Gladys-2592 and Ada-1017 (Claude Opus 5.5). Our internal review read it. Alfred-2570 (Claude Opus 5.5) and Esther-2584 (Claude Opus 5.5) reviewed it, by reading and with probes of their own; Alfred approved it, and Esther its build script. Simon-2700 (GPT-6.1 Sol) judged the engine's side of its tenth internal review.

Description checked and posted by Lottie-2661 (Claude Opus 5.5).

🤖 Written using Anthropic's Claude Opus 5.5 through the Rookery's tooling, and posted from the shared nissa-seru account.

Gladys-2592 and others added 30 commits October 18, 2026 18:23
code_execution is to run scripts in QuickJS-ng compiled to WebAssembly, built from source kept
in this repository rather than taken from a package, so that a defect in the engine is fixed by
bumping or patching this copy and rebuilding, without waiting on someone else's release.

engine/quickjs-ng/ holds, unedited, the four C files the module compiles (quickjs.c, dtoa.c,
libregexp.c, libunicode.c: 72,158 lines) and the 14 headers they include, with upstream's
LICENSE: 19 files, 2.8 MB. It's a copy, not a git submodule, since the tooling we test this
repository with refuses submodules.

engine/vendor.sh makes the copy: it clones the pinned tag (v0.17.0, the latest release, of
2026-09-18), refuses unless the tag is the pinned commit (6d46d07d04041b40f4f49eaa7fdebe44c314c699),
and copies the 19 files. A bump is: change TAG and COMMIT, run vendor.sh, run build.sh, and commit
the source and the module together. The copied files are byte-identical to quickjs-wasi 3.6.2's
submodule at the same commit (diff -rq, 2026-10-18).

Nothing builds or ships these files yet; the next commit builds the module from them. engine/
isn't in package.json's `files`, so the C source doesn't go into the npm package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pt's child loads

code_execution is to run each script in its own child process, in QuickJS-ng compiled to
WebAssembly. This is the engine half: the module, the interface compiled into it, and the
TypeScript binding the child loads. The child program, the prelude, the parent and the framework
side come in later commits; the seam between the two halves is the binding's interface.

engine/interface.c (C, compiled into the module) is held to what the bridge needs:
- engine_init: one runtime and context, a stack limit, the interrupt handler, and two globals,
  __host(name, argsJson) and __output(stream, text);
- engine_start: the prelude as a global script, then the script as global code with top-level
  await (JS_EVAL_FLAG_ASYNC, whose promise settles to { value }), then pending jobs;
- engine_settle(id, ok, json): parses the result straight from the bytes the host copied in, and
  resolves or rejects the call's promise with it; a result that doesn't parse or doesn't fit
  rejects it with that error. Calls are kept in an object only C holds, id -> [resolve, reject];
- engine_result: the script's value as JSON, or an error as JSON {name, message, stack};
- engine_alloc/engine_free/engine_dispose.
The guest reaches nothing but JavaScript and three imports: host_call, host_interrupt and
host_output. An interrupt is final: every later call answers interrupted.

Memory: the module imports its memory, so the binding makes it with the child's maximum
(memoryMaxBytes) and one committed module serves every budget. QuickJS's own limit isn't used.
At its limit, QuickJS can't allocate the "out of memory" error it then throws, and a script's
catch got a bare null (the memory test's small-objects case, with JS_SetMemoryLimit: red below).
So an allocator adapted from quickjs-wasi 3.6.2 (MIT, notice in quickjs.wasm.LICENSES) refuses at
a soft limit 64 KiB under the limit and lets the error use that headroom. The host's buffers go
through it too: with a result's copy outside the limit, the copy and the value parsed from it
could exhaust linear memory before anything was refused, and that error was null as well.

engine/build.sh builds the module with a pinned toolchain: wasi-sdk 32 (sha256-checked against
its release) and binaryen 130.0.0's wasm-opt from npm, one pass, with relative paths. The stack
(1 MiB) is first in memory. Built on two Linux x86_64 machines, the second from a fresh
toolchain fetch, it gave the same bytes: 599,267, sha256 1d5017ea…ef87. Its
imports: the three host functions, the memory (no maximum declared), and six WASI functions,
clock_time_get, random_get, and fd_write, fd_fdstat_get, fd_seek and fd_close, which only libc's
stdio reaches. Its exports: _initialize and the seven engine_ functions.

src/code-execution/engine.ts: loadEngine(limits, slots) -> { start, settle, dispose }, RunState,
EngineError. memoryMaxBytes is 4 MiB to 2 GiB in whole pages (2 GiB so every address fits the
signed 32-bit numbers pointers arrive as); the allocator's limit is that less the stack, 1 MiB
and 1/32. maxStackBytes defaults to 512 KiB, at most 768 KiB. settle takes the JSON as text or
UTF-8 bytes. A slot that throws ends the run with an EngineError, as does a trap; the binding
then refuses calls. The WASI shims give the clocks and random bytes, keep stdio's text as crash
diagnostics, and refuse the rest. WebAssembly is typed locally, since the repo's lib has no DOM.
quickjs.wasm is read beside the binding: from src/ under Bun (the `bun` export), and from
dist/src/ under Node, where postbuild now copies it.

Tests (test/code-execution-engine.test.ts, 24): values and their JSON, calls pending and settled
in any order, text and bytes, failed calls and the prelude's Error, unparseable results, unknown
and settled ids, calls in flight at a return, thrown Errors and non-Errors, syntax errors,
unstringifiable values, output, the interrupt (also in jobs, and final), a throwing slot, the
guest's globals and that import() fails, the memory limit in three shapes, results too large to
copy and too large once parsed, the stack limit at 64 KiB and the default, refused limits, and
dispose with calls pending. On Linux x86_64: 24 of 24 under Node 24.22.0 and Node 20.20.2
(dist) and under Bun 1.4.3 (bun test, src).
Red first, with one piece removed and the module rebuilt: with QuickJS's own limit in place of
the allocator, 2 fail ("small objects: caught null" and the too-large small-objects result);
with the host's buffers outside the limit, 1 fails (that result).

Not yet: the CI hash check (next commit), and the guest at memory above what tests can use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Engine workflow rebuilds src/code-execution/quickjs.wasm with engine/build.sh and fails
unless the bytes match the committed module, so the module can't drift from engine/quickjs-ng and
engine/interface.c in either direction: a source change without a rebuild, or a module edited
without its source.

It runs on ubuntu-latest, on pull requests and pushes to main that touch engine/**, the module or
the workflow itself, since the pinned toolchain is a 143 MB download. The build fetches wasi-sdk 32
and checks its sha256, and installs binaryen 130.0.0 from npm, which runs under the Node the job
sets up. A rebuild from a fresh fetch on a Linux x86_64 machine took 32 s and matched the committed
bytes. This workflow itself hasn't run on GitHub yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… error, and settle says so

A result that runs out of memory as it's parsed now ends the way one the bridge refuses does,
as ResultTooLarge, since an out-of-memory at a call's await reads as a failed call, and a script
may repeat a call that already took effect. settle reports it, since the binding is the only
place that knows the result didn't fit, and the parent's record has to count the call as too
large.

settle(callId, ok, json, unfitJson) now takes the child's own error as its last argument. When
the result doesn't fit the guest's memory, either because the host couldn't copy it in or because
its parse ran out of memory, the call's promise rejects with unfitJson parsed instead, and the
answer carries unfit: true (SettleState). JSON that doesn't parse for any other reason still
rejects with its SyntaxError.

In interface.c, engine_settle takes the error's buffer too and or's ENGINE_UNFIT (16) into the
state it answers. A parse that ran out of memory is told from a syntax error by a count of the
allocator's refusals, which also counts allocations that failed for want of linear memory. The
small error parses in the headroom the refusal opened; if even it can't be copied in, the call
rejects with out of memory. The binding copies the small error in before the result, so a result
that takes the last of the memory leaves it room.

Tests: the too-large test now checks unfit and the script's ResultTooLarge for both shapes (a
string too large to copy, small objects too large once parsed), and a new test settles a result
that fits beside one that didn't. 25 of 25 pass on Linux x86_64 (Node 24.22.0). Red first, each with
one piece broken and the module rebuilt: a parse's out-of-memory never taken as unfit fails the
too-large test; every parse failure taken as unfit fails the syntax-error test; the error ignored
fails both too-large tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
No test loaded the engine at MIN_MEMORY_BYTES or MAX_MEMORY_BYTES, the range EngineLimits
documents, or checked that a page past the maximum is refused. The 2 GiB cap is what keeps
every address within the signed 32-bit numbers the host reads the module's pointers as.

At 4 MiB a call settles and the limit gives a catchable out-of-memory error. At 2 GiB the
guest holds a 192 MiB Uint8Array, larger than any other test's whole budget (about 130 ms;
a 192 MiB string built by repeat took about 1 s).

Filling the whole 2 GiB takes 10 to 19 s, too slow for the suite, so a probe outside the
repository did it once at d42e3d4c9, on Linux x86_64: under Node 24.22.0, Node 20.20.2 and Bun 1.4.3
the guest held
1981 one-MiB strings (RSS about 2.0 GiB) before a catchable "out of memory". It then released
the last 96 strings it had pushed, a 32 MiB result settled and arrived intact, and a call
after it settled too. Where in memory the result's copy went wasn't observed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…be made; tests for the pieces none reached

Removal runs over the engine half at 87c699271, on Linux x86_64 under Bun 1.4.3, took 26 pieces
out or inverted them one at a time. 15 were caught, 13 of them by the test aimed
at them. This commit takes the 11 survivors:

- The host's result buffer inside the limit. No test caught its removal since d42e3d4c9, whose
  count of allocations failing for want of linear memory turns such a parse into
  ResultTooLarge anyway. It matters while a script runs on during a settle with the result's
  copy still held: a new test fills memory then, and with the copy outside the limit the script
  caught a bare null, at 6 and at 9 MiB.
- A settle after the run ended does nothing: tested now, since a too-large result isn't read
  and the answer isn't marked unfit.
- A failed output slot ends the run: a second case in the slot test.
- A failed slot stays failed: the slot test calls __host twice in one run, directly, as the
  child's prelude does. An awaited call's rejection waits for a job that never runs, so a test
  through await can't reach the second call.
- A call id must be a whole number: settle(1.5) would have settled call 1.
- The guest's clocks: Date.now, and a performance.now that advances.
- Cut: the interrupt handler's own early return. Once the handler returns nonzero the run's
  flag is set, and every entry point checks it before running guest code, so the early return
  changed nothing.
- Kept and named, with no test: counting a malloc that fails for want of linear memory
  (reached only when linear memory runs out before the limit refuses, which the margin and the
  counted buffers prevent); a job's uncatchable failure ending the run (only the interrupt,
  handled by its flag, or a failure of QuickJS's job machinery reaches it); the engine's
  stderr kept for a crash (needs an assertion; a variant module built with one showed its text
  reaching EngineError); random_get filling its buffer (libc's four bytes at load);
  the child's error copied in before the result (matters only for a result within its error's
  length of the limit).

Found by reading:
- js_host made the call's entry with JS_NewArray and two JS_SetPropertyUint32 calls joined by
  ||. When the first failed for want of memory, the reject function was never handed over, and
  JS_FreeRuntime asserts on a leaked object, so dispose would trap. JS_NewArrayFrom takes both
  and frees both if it fails. No test reaches an allocation failure at that point.
- error_json clears the exception a failed set leaves.
- engine.ts said libc's random bytes seed Math.random. quickjs-ng seeds it from the real-time
  clock (quickjs.c:2879). Counting the module's WASI calls with an instrumented copy of the binding
  showed random_get once at load for four bytes, the real-time clock
  for Date.now, and the monotonic one for performance.now.
- A test comment cited where a decision was made; it gives the reason instead.

The module is rebuilt from a fresh toolchain fetch: 599,573 bytes, sha256 a0704b19.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cks the copy against the pin

From the vendoring commit's review:
- Nothing checked that the committed engine/quickjs-ng is the release vendor.sh pins. The
  script's guard runs only when someone runs it, and the Engine workflow held the module to the
  committed source, not the source to the release. The workflow now runs vendor.sh first and
  fails if what it copies differs from what's committed, so an edit to the vendored source, or a
  pin it doesn't match, fails CI.
- vendor.sh removed engine/quickjs-ng before copying, so a copy that failed partway, such as a
  release that dropped a listed file, left part of the directory. It now stages the files
  beside the directory and replaces it by a rename once every file is there, and a missing file
  is named, with the directory left as it was.
- It replaced the directory without looking for local changes, which a run would erase. It now
  refuses while engine/quickjs-ng has changes not committed, and lists them.
- Each file is taken as the commit holds it (git show), not from the clone's working tree,
  which checkout settings such as core.autocrlf could change.
- The bump recipe says when FILES changes too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…th no message is told by its JSON; the engine's own stdio is kept to its bound

Three findings from the change's GPT-family read:
- The binding read the script's value again for every state it answered after the script
  ended. Reading it runs JSON.stringify in the guest, which can run the script's own code: a
  toJSON that printed ran once more for each call settled after the return, and one that called
  tools could keep making work. The binding now keeps each ending as it was first read, so the
  value is serialized once.
- A thrown plain object was read as Error-shaped. With no name, message or stack, the failure
  came out blank. error_json now gives an object with no message of its own its JSON as the
  message, or its string form when it has none.
- The engine's own stdio, kept for a crash's message, was checked against its 4,096 characters
  only before each write, so one write could keep any amount. Each write is now clipped to the
  room left before it's decoded: with one write of 20,000 bytes, the crash's message kept 4,096
  of them (before, all 20,000).

Red first, under Bun 1.4.3 on Linux x86_64, with each half on its own: the old binding and
module fail both new tests; the new binding with the old module fails only the thrown-object
test, and the old binding with the new module only the serialize-once test. The stdio bound
has no test in the suite, since only an assertion in the engine writes there; it was checked
by a fixture that calls the module's fd_write directly and then traps.

The module is rebuilt from a fresh toolchain fetch: 599,711 bytes, sha256 9135e491.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…on a slot's failure however it shows, and keeps the script's code from the host once its ending is fixed

Findings from the change's review, and one found while testing them:
- A result nested about 10,000 deep crashed the engine under Node 24 and 20: QuickJS's stack
  check measures the module's own stack, which the C recursion of JSON's parse barely uses, so
  the host's stack overflowed first. Under Bun the parse held past 30,000. settle now treats a
  result nested deeper than MAX_RESULT_DEPTH (2,000) as unfit, as it does one too large to copy
  in. A script's own data isn't held to it: JSON.parse or JSON.stringify of data nested that deep
  still crashes the script's own run (JSON.stringify crashed at 30,000 under Bun as well).
- Every result that fails to parse is unfit now, not only one that ran out of memory: the call
  took effect, and a SyntaxError or RangeError at its await would read as a failed call. The count
  of failed allocations that told a parse's out-of-memory from its syntax error is gone.
- engine_result runs the script's own code as it serializes the value or error (a toJSON, a
  getter), after the ending is fixed. A call or output made then is refused without reaching the
  host, and a stop then leaves out the part it interrupts.
- A thrown object whose message can't be read is told by its JSON, as one with no message is.
- A slot failure that reached the run through the interrupt (the interrupt slot throwing, or the
  check that stops a script that caught an earlier failure) was answered as a stop. It's the
  slot's EngineError now, and so is the interrupt slot throwing while an ending is read.
- A slot that threw undefined wasn't seen as failed, so slots ran again; the binding keeps a flag.
- A call into the engine from inside a slot is refused with an EngineError, rather than entering
  the module a second time.
- Output that starts with U+FEFF keeps it.
- The messages for a value or error too large to read say which; the allocator's and settle's
  comments say what the headroom gives and what it doesn't; interface.c's header names the clocks
  as the WASI call the guest reaches; and engine_settle's dead st >= 0 test is gone.

Each new behaviour test failed with the old binding and module, and fails with only the half its
fix is in taken back. Taking out each new piece, and each existing one a new test aims at (the
depth check, the reading flag, the message fallback, the start guard, the stdio bound,
maxStackBytes, the re-entry check, the BOM, the slot checks), fails its test. The memory test's
doubling string uses repeat now: with +, QuickJS-ng's rope strings reached the length limit
without the memory, and the test's error check accepted that. The recursion test checks that
maxStackBytes sets the depth, the clock test lets the wall clock step, and the limits test
refuses a stack under 16 KiB or not a whole number.

The module is rebuilt from a fresh toolchain fetch: 599,764 bytes, sha256 88c972df.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d erase; build.sh takes relative paths

From the change's review:
- Fixing the engine by patching its copy was a reason to build it here, and the CI check against
  the pin had made that impossible. vendor.sh now applies engine/patches/*.patch, in name order,
  to the pinned release before it copies (into the clone's index, so each file's bytes are the
  commit's with the patches), and the CI check compares with that: a patched copy passes with its
  patch committed, and a hand edit still fails. There are no patches yet.
- vendor.sh's guard didn't see ignored files, or untracked ones that status is set to hide, which
  replacing the directory then erased. It lists both and refuses.
- build.sh changed directory before it used the paths it was given, so a relative OUT,
  ENGINE_TOOLCHAIN_DIR or WASI_SDK resolved under engine/. It resolves them first.
- Its last line used sha256sum, which macOS doesn't have; it falls back to shasum.
- Its header said the toolchain is checked against hashes and gives the same bytes on any machine.
  Only wasi-sdk is checked by hash (binaryen is pinned by version, with npm's integrity check), and
  the same bytes have been seen on two x86_64 Linux machines; it says so.
- npm run dev didn't copy the module into dist, so a fresh dev build couldn't load the engine under
  Node. A predev step copies it.

Checked in a scratch clone: a clean run matches the committed copy; an untracked file hidden by
status.showUntrackedFiles=no, and an ignored one, are refused and kept; a patch that applies is in
the copy, and with it committed the CI check passes; one that doesn't apply is refused, with the
copy unchanged; a committed hand edit fails the check. On Linux x86_64, build.sh with a relative
OUT and toolchain directory, run from the root and from src/, built the committed module's bytes,
and npm run predev copied them into dist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a stop while it's read is a stop, and the jobs a read queues can't reach the host

From the round review of the engine's fixes:
- An ending the module couldn't read out (no memory for its text) wasn't kept, so each later
  settle read it again, running the script's serializing code again, and could answer
  differently. The answer is kept like any other ending.
- A stop that landed while an ending was read became the ending, as an InternalError
  'interrupted', while every later call answered interrupted. That call answers interrupted too.
- A job the script's serializing code queued (a then in a getter) ran at a later settle, where
  its call or output reached the host. engine_result runs those jobs before it returns, while
  calls and output are still refused.
- The engine's own record of an error is an object with no prototype, so a toJSON the script put
  on Object.prototype doesn't run on it; before, its exception read as running out of memory.
- After a slot's failure, start and settle check validity after that failure, so they throw it
  rather than their own errors, and a dispose() refused from inside a slot no longer marks the
  engine disposed. A module trap after a slot's failure the script caught reports the slot's
  failure, which is why the guest ran on. A slot's thrown value with no string form, or one that
  throws, no longer keeps its EngineError from being made.
- settle copies the child's error a second time if the first copy is refused: the refusal opens
  the allocator's headroom, which it fits in. Without it, a call whose result couldn't be copied
  either rejected with an out-of-memory error at its await.
- An ending whose text is longer than V8's longest string is answered as a rejection that gives
  its length, rather than throwing out of the binding. The message for an error that couldn't be
  read out says to throw something smaller.
- Comments: engine_settle says results past MAX_RESULT_DEPTH never reach its parse, and why;
  engine_result says what its read can and can't do.

Tests: each new behaviour test failed with the old binding and module, and fails with only the
half its fix is in taken back; taking out each new piece fails its test. Gaps the review found
in earlier tests are closed: the ending-read test now reaches __host's refusal; the depth test
nests objects, has many containers none of them deep, and puts an escaped quote before a string's
brackets; the thrown-object test reads a message that throws, a value with no JSON, and an
inherited toJSON; the crash-message test writes twice with different bytes; a returned state the
caller changes doesn't change the next; and the output-failure spin has an interrupt to end it if
the failure didn't.

The module is rebuilt from a fresh toolchain fetch: 599,773 bytes, sha256 92198eca.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…them by byte, and says how to recover a failed rename

From the round review of the engine's fixes:
- A file in engine/patches that isn't a .patch was skipped without a word. It's refused now.
- A patch that changed a file FILES doesn't list was applied and then dropped from the copy. It's
  refused now, naming the file.
- Patches applied in the shell's locale order, which differs between machines; LC_ALL=C makes it
  byte order.
- A FILES entry missing after the patches is reported as missing upstream or removed by a patch.
- If the staged copy can't be renamed into place, the message says git checkout restores the
  committed one, and the comment says the old copy goes first.
- CI's check lists ignored and untracked files too, as vendor.sh's own guard does, so a copied
  file the repository ignores can't pass unseen. Its comments say the copy includes the patches.
- quickjs.wasm.LICENSES says engine/quickjs-ng carries any patches in engine/patches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t, and the allocator's comment says when its headroom can be spent

- An ending text past MAX_ENDING_BYTES is answered with the limit as well as its length, so the
  script's author knows how far to cut. The bound's comment said a longer text couldn't be
  decoded, which holds under Node but not under Bun. It now says the bound is V8's longest string,
  past which Node can't decode, and that Bun, whose longest string is 2^31 - 1, is held to the
  same bound, so a script ends the same way on either.
- interface.c's allocator comment said the first out-of-memory error a script meets is a real
  one. The host's buffers go through the same limits, so a copy refused while the guest sits at
  the soft limit opens the headroom, and the result's later copies and parse can spend it before
  the script meets an error of its own. The comment now says that, with what does hold: a script
  that reaches the limit while the headroom is closed gets a real out-of-memory error.

Under Node 24.22.0 and 20.20.2, buffer.constants.MAX_STRING_LENGTH is 536870888, and TextDecoder
decodes 536870888 bytes but throws ERR_STRING_TOO_LONG for one byte more; under Bun 1.4.3 it is
2147483647, and both decode. The module is unchanged: a fresh rebuild gives the committed bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…p recursion gets its catchable RangeError before the host's stack runs out

QuickJS measures its stack in the module's linear memory, but each level of a script's recursion
also takes the host's native stack, about two to three times as much, and under Node V8 allows
that stack 984 KiB (--stack-size's default). At the old default of 512 KiB, recursion through a
getter, a proxy trap or a built-in that calls back (map, toString, for...of and others) ran the
host's stack out first, so the engine crashed with "Maximum call stack size exceeded" where the
script should have caught a RangeError. Plain recursion had about 7% to spare under V8's
optimizing tier, and the engine test file's test of it failed in 3 of 10 runs under Node 24.

Measured on Linux x86_64 under Node 24.22.0 and Node 20.20.2, with V8's WebAssembly tiers forced
one at a time (--liftoff-only, --no-liftoff), and under Bun 1.4.3 with JSC's default tiers and
its interpreter alone, across 31 ways to recurse: the host's stack ran out first once QuickJS's
limit passed about 320 KiB under Node (a proxy's ownKeys trap, optimizing tier) and about 440 KiB
under Bun. At 192 KiB none crashed the engine, and all but the Promise executor, whose RangeError
the Promise constructor turns into a rejection, got QuickJS's RangeError, at depths from 133
(through Array.prototype.sort) to 765 (plain calls); the worst case leaves about two fifths of
Node's stack to spare. The maximum is the default now, so maxStackBytes can only lower it, and the
same limit holds under Bun, so a script ends the same way on either.

The comment also says what the limit doesn't hold: QuickJS's parser recurses in C, so under Node
a script whose text nests function expressions 300 deep, or brackets 700 deep, crashes its run.
MAX_RESULT_DEPTH's comment gives the JSON depths measured at the new limit: under Node, a script's
own JSON.parse or JSON.stringify passed at 3,000 deep and crashed the engine at 5,000 with V8's
optimizing tier alone, or at 10,000 otherwise; under Bun it got QuickJS's RangeError at 30,000.

Test: plain recursion, and recursion through a getter, toString, map, a proxy's ownKeys trap,
for...of, toJSON and yield*, each at MAX_STACK_BYTES, gets the catchable RangeError. With the old
limits it fails under Node 24, Node 20 and Bun 1.4.3, the engine crashing. The test of
maxStackBytes compares 64 KiB with the new default. The engine test file passed 20 of 20 runs
under Node 24, 10 of 10 under Node 20 and 5 of 5 under Bun, one process each.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er a whole ending leaves that ending, and dispose frees a runtime a slot's failure left whole

From the round review of the engine's last round:
- engine_settle frees the host's two copies itself, each as soon as it's parsed. Before, the
  binding freed them after the call returned, so a result's copy was still allocated while the
  unfit error was parsed, while the script's code after the call ran, and while an ending was
  read after it.
- engine_result returns no ending only when the stop lands while the ending itself is read, since
  what it read then is the stop's error. A stop in the jobs the read queued comes after a whole
  ending, and the call now answers that ending, the guest left stopped; it answered interrupted.
- dispose() calls the module's dispose unless the module trapped. It skipped it after any slot's
  failure, though the module is whole then.
- describe() converts an Error's message to text inside its guard, so a Symbol message no longer
  keeps a slot's failure from becoming an EngineError.
- The out-of-memory messages for an ending add "or let go of data the script no longer needs", and
  the message for an ending too long to decode gives its length as text, which is what the
  binding measures, rather than as the value's JSON.
- Comments: the allocator says when a script at the limit can still catch null (building the
  error reaches it twice, the first fitting and closing the headroom), and that a promise reaction
  QuickJS can't allocate is dropped; engine_settle's account of MAX_RESULT_DEPTH says what was
  measured; engine_result says a reaction attached during the read to a promise still pending
  runs when that settles, after the ending; settle()'s doc says what later calls answer once an
  ending has been answered.


Tests, under Bun 1.4.3: with the last round's binding and module, the six new behaviour tests
fail (the copy freed once parsed, a stop in the read's jobs, dispose after a slot's failure, a
Symbol message, and the two changed messages); with only the module taken back, the copy and
stop tests fail. Taking out each new piece fails its test: the discard of a read a stop cut off,
the read's own jobs, the early free, the headroom's opening, describe()'s guard, dispose's check
and the mark it reads, the answer for a stop after a whole read, start()'s check of a failure, the
over-long buffer's free, the cache's clone, the bound's value and the error copy's retry. Tests
that couldn't fail for their piece now can: the retry's test refuses the first copy for real,
with the guest filled through the allocator to 16 bytes under the soft limit, and the cache's
clone, a second start() after a slot's failure, one byte past the ending's bound and the read's
queued job (which now records what it met) are each pinned.

At the clean head on Linux x86_64: tsc is clean; all 12 code_execution test files pass, 258 of
258, under Node 24.22.0 and Node 20.20.2 file by file and under Bun 1.4.3; and a rebuild from a
fresh toolchain fetch gives the committed module, 599,859 bytes, sha256 3c862d3e.

Not changed: the allocator still closes its headroom on the next allocation that fits under the
soft limit. Building an out-of-memory error could reach it twice, the first fitting and closing
it, but probes of 1,196 places the limit can fall (0 to 12 KiB of room, with QuickJS's arena use
moved through a cycle) never gave a bare null, and closing it later would let a script that frees
a little after catching the error spend the headroom before its next one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…h changed path whole, and puts the committed copy back if the new one can't go in

From the round review of the engine's last round:
- The patches glob skipped dot-named entries, and a dangling link was passed over, so neither was
  applied or refused. Every entry is now a regular .patch file, applied, or refused.
- The paths the patches change were word-split and globbed, a failed git left the list empty, and
  a rename showed only its new path. They're now read whole from git's NUL-separated output, with
  renames as both paths, each compared exactly with FILES, and a failed git stops the script.
- A file missing after the patches, and a failure to write one into the new copy, each get their
  own message.
- The committed copy is moved aside, the new one renamed into place, and only then is the old one
  removed. A failed rename puts the committed copy back, and each message names the path by its
  absolute path, so the git checkout it suggests works from any directory.
- quickjs.wasm.LICENSES says the copy is the release's files with any patch applied; there are
  none today.

Checked in a scratch clone with quickjs-ng v0.17.0: a dot-named .patch is applied; a .DS_Store,
a dangling link, a new file named "foo bar.c", a rename of list.h, a removal of list.h and a new
file named "*.c" are each refused with engine/quickjs-ng unchanged; a clean run matches the
committed copy. The script before this change passed over the first three, reporting 0
patches, and named the spaced file "foo". The rename failures weren't made to happen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ends the run as failed, and a settle says when its result was dropped

From the round review of the engine's last round:
- An exception that escaped the script's promise chain once it was running, from a callback it
  gave queueMicrotask or a FinalizationRegistry (whose jobs pass a throw on) or from one of
  QuickJS's own jobs failing, was answered as rejected. Nothing told it from the script's own
  rejection, and after a rejection the binding had kept, the failure was never answered at all,
  since both had the same state code. It now has its own state, ENGINE_FAILED, and answer,
  `failed`, which every later call gives. `rejected` stays the script's own: its completion
  promise rejected, or the prelude or the script threw as it started.
- A settle whose result never reached the script, because the run had already ended (a stop or
  a failure) or the engine failed before it could deliver it, now says so: ENGINE_DROPPED, and
  `dropped: true` in its answer. The early return after a failed read of the call's capability
  also keeps ENGINE_UNFIT now.
- Comments and docs: the stack limit's account is scoped to recursion through calls, and names
  the recursion in C it doesn't hold (the parser, chains of iterator helpers, JSON); the
  interrupt slot's doc names the stop after a whole ending; engine_settle's comment gives the
  depths measured under default settings and says when the unfit error's copy is freed; run_jobs
  says which jobs pass a throw on; call() and dispose() say why any unexpected exception leaves
  the module as it is.

Tests, under Bun 1.4.3: with the last round's binding and module, the two new behaviour tests
fail, and so do the three whose settles after a stop now carry `dropped`; with only the module
taken back, the same five fail. Taking out each new piece fails its test: a job's failure given
the rejected state, or answered as rejected; the dropped flag in the module or in the binding;
the final release of the copies (2,000 settles, half for ids no call has, would take far more
room); the check of a slot's failure after a read; dispose()'s catch and its mark; the ending
bound's comparison, now pinned at exactly MAX_ENDING_BYTES; and the error copy's retry, whose
test now checks that the first copy was refused. Not pinned: freeing the unfit error's copy
before the script's code runs rather than as engine_settle returns, which leaks nothing either
way.


At the clean head, on Linux x86_64: tsc is clean; all 12 code_execution test files pass, 287 of
287, under Node 24.22.0 and Node 20.20.2 file by file and under Bun 1.4.3; and a rebuild from a
fresh toolchain fetch gives the committed module, 599,900 bytes, sha256 23cb4945.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd, a link among the patches and an engine/patches that isn't a directory, and puts the committed copy back if stopped

From the round review of the engine's last round:
- A patch that left a listed file executable, a link or a submodule passed the name check, and the
  copy wrote it as a plain file. Each listed file must now have mode 100644 after the patches.
- A link in engine/patches, which -f followed, was applied; and an engine/patches that was a file
  or a dangling link was passed over, so the release was copied unpatched. Both are refused now.
- If the script stops between moving the committed copy aside and renaming the new one in, its
  exit trap puts the committed copy back and says so; once the new copy is in place, the trap
  removes the old one.
- The restore command it suggests names the repository with git -C, and quotes both paths.

Checked in a scratch clone with quickjs-ng v0.17.0: a patch making list.h executable, one
turning it into a link, a link to a real patch, and engine/patches as a file or as a dangling
link are each refused with engine/quickjs-ng unchanged; a copy of the script that signals itself
between the two renames put the committed copy back; the restore command, run from /, restored
the copy after it was removed; and clean runs match the committed copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ay in is dropped, and a failure stays the run's answer, after its ending and through a stop

From the round review of the engine's last round:
- A settle whose call to the promise's resolve or reject function raised answered without
  ENGINE_DROPPED, so the result counted as delivered. Those functions don't throw once they run
  (what reading a result's `then` throws rejects the promise), so a raise means the call never
  ran: its interrupt check on the way in stopped the guest, or there was no stack for it. It now
  answers dropped, and a stop there is no longer recorded as a failure. A failed read of the
  call's resolve and reject functions answers dropped without ENGINE_UNFIT, since the call took
  neither: the two flags are never both set.
- An exception escaping the jobs that reading the script's ending queued (a toJSON or getter
  queueing a callback that throws) ended the run, but the call that read the ending answered that
  ending, so with no call left to settle the failure was never answered. The module exports
  engine_state, and the binding reads it after reading an ending: a run that failed meanwhile is
  answered as failed, in the ending's place.
- A stop while the exception that ended the run was read discarded what was read and answered
  interrupted, so a run that failed after its own ending was kept could report that ending. A
  failure now stays the answer: state() puts it ahead of a stop (nothing records a failure once
  the guest is stopped or an import has failed, so it came first), and what was read of the
  exception is kept, less the parts the stop cut off. Out of memory for that read still answers
  failed.
- The messages for a failure that can't be read out say what to do.
- Comments and docs: what `dropped` means (the call's promise never took the result; once it has,
  a stop or failure in the script's code leaves it taken, a `then` getter included); that a stop
  or failure "after the run had ended" is one after a stop or a failure; ENGINE_FAILED's sources,
  settle's own failures included; the RunState doc without a memory failure QuickJS drops
  silently; the stack limit's account of iterator helpers under Bun (each one's next calls the
  one before directly, past QuickJS's checks, so 30,000 run the module's 1 MiB stack out and it
  traps) and of JSON depth (about 10,000 deep under Node, 5,000 with V8's optimizing tier alone);
  MAX_ENDING_BYTES answering with the state the ending belongs to; why an exception reading what
  the module returned leaves the module alone; and build.sh's account of where QuickJS's stack
  limit sits.

Tests, under Bun 1.4.3: with the last round's binding and module, four tests fail: a failure
escaping the read's jobs, a stop while a failure is read, and the rows for a failure that can't
be read out (out of memory, too long). Taking out each new piece fails its test: state()'s order;
keeping a failure's read through a stop; the binding's read of engine_state, and its keeping a
failure through a stop when nothing could be read. Pieces the last round left unpinned now have
tests: freeing the child's error's copy before the script's code runs (a 2 MiB error, measured as
the script's room); the error's copy freed on a settle for an id no call has (a 1 KiB error on
each of 1,000); and dispose() leaving the module alone after a read that threw. Not pinned by a
test: the dropped answer when the resolve call is stopped on its way in, which needs the
interrupt check to fall due at that call. A probe made it do so: with k calls to an empty
function before the script's await and the stop set before the settle, k = 9,993 put QuickJS's
interrupt check on the resolve call's own entry; the module before this change answered
interrupted without dropped, and this one answers dropped, of 1,101 values of k.


At the clean head, on Linux x86_64: tsc is clean; all 12 code_execution test files pass, 303 of
303, under Node 24.22.0 and Node 20.20.2 file by file and under Bun 1.4.3; and a rebuild from a
fresh toolchain fetch gives the committed module, 600,013 bytes, sha256 98e861c2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…k or says how, and names what it refuses

From the round review of the engine's last round:
- The exit trap is installed before the temporary directories are made, so a failure or signal
  right after leaves nothing behind. Its cleanup runs with errexit off, so one failed removal
  doesn't stop the rest, ignores further signals, and when it can't move the committed copy back
  it says so and gives the command that restores it.
- INT, TERM and HUP exit through the trap, after the command in progress, with 130, 143 and 129.
- The comment no longer promises what an exit trap can't: killed outright (SIGKILL) between the
  two renames, the script leaves the committed copy at engine/.quickjs-ng.XXXXXX.old. The next
  run's refusal over the missing copy now gives the command that puts back what's committed,
  rather than only "commit or remove them".
- The restore command is built from physical paths, so a repository reached through a symbolic
  link gets a path relative to it.
- The refusals of a link among the patches, and of an engine/patches that's a link to a
  directory, say that a link isn't taken.

Checked in a scratch clone with bash 5.2.21, git 2.55.0 and quickjs-ng v0.17.0: a patch that
doesn't apply is refused with nothing left behind; SIGINT, SIGTERM and SIGHUP while the clone runs
exit 130, 143 and 129 with the copy unchanged and nothing left behind; SIGTERM between the two
renames puts the committed copy back; with engine/ made read-only there, the script says it
couldn't, and the command it prints, run from /, restored the copy; run through a symbolic link to
the clone with a file of the copy removed, it refuses and prints a command with the physical
repository and a relative path, which, run from /, restored the file; a link to a real patch and
an engine/patches that's a link to a directory are refused with their messages; clean runs match
the committed copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s the script's own ending, and what a stop cuts off a failure's read is left out

From the round review of the engine's last round:
- Once the host had stopped the guest, the interrupt handler still asked it at each later check,
  so a host that said stop only once let the guest run on, as a failure's getters do when its
  exception is read after a stop. The handler now stops the guest at every check after the first
  stop without asking again.
- state() put every recorded failure ahead of a stop, a throw as the run starts (the prelude's,
  or a syntax error) included. That throw is the script's own ending, whose read a stop discards,
  so a later call found it again and read it again. Only an exception that escaped once the
  script ran now comes ahead of a stop; a throw at the start comes after it, as a rejection does.
- When a stop cut off the read of a failure's message, the message fell back to the thrown
  object's JSON or string form, running more of the script's code and giving something that
  wasn't its message. Once a stop has come, that fallback is left out with the rest.
- The messages for a failure that can't be read out give their remedy only for a callback's
  throw, since the engine's own failures have none.
- Comments and docs: the interrupt slot's two exceptions to answering interrupted; a failure's
  fields as the engine reads a rejection's; what dropped means, said as the resolve or reject
  function never having run with the result, a then getter on Object.prototype being the
  script's own code that can reject the promise; that a failed run's read queues jobs that never
  run; and MAX_ENDING_BYTES's answers, rejected for the script's own ending and failed for an
  exception that ended the run.

Tests, under Bun 1.4.3: with the last round's binding and module, four tests fail: a stop asked
for once (the failure's name getter spins until it, its message getter would run on), a stop
while a throw at the start is read (a later settle read it again), and the rows for a failure
that can't be read out, whose messages changed. Taking out each new piece fails its test: the
handler's latch, the order of a start's throw after a stop, and leaving the message's fallback
out after a stop. The dropped answer for a resolve call stopped on its way in now has a test: it
finds the k calls to an empty function before the script's await at which QuickJS's interrupt
check falls on that call (the check comes at the first poll and once in 10,000 after), and
taking out that dropped, or recording that stop as a failure, fails it.


At the clean head, on Linux x86_64: tsc is clean; all 12 code_execution test files pass, 320 of
320, under Node 24.22.0 and Node 20.20.2 file by file and under Bun 1.4.3; and a rebuild from a
fresh toolchain fetch gives the committed module, 600,080 bytes, sha256 6d774586.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its cleanup reports what it can't remove, and its comment says which signals run it

From the round review of the engine's last round:
- The restore command it prints, git checkout -- engine/quickjs-ng, restored the copy from the
  index, so a staged change survived it. It's now git checkout HEAD -- engine/quickjs-ng, and the
  refusal over changes says to remove any file it lists as untracked or ignored, which no
  checkout removes.
- The cleanup ignores signals from its first command, before turning errexit off, and also
  ignores PIPE, ALRM, USR1, USR2 and XCPU, which bash runs it for as well.
- When the new copy is in place but the committed copy moved aside can't be removed, the cleanup
  says so, as the script's own path does.
- The comment says what was checked: bash 5.2 runs the exit trap on exit, on INT, TERM and HUP,
  trapped so they wait for the command in progress, and on PIPE, ALRM, USR1, USR2 and XCPU;
  SIGKILL skips it; a signal ignored when the script started, as SIGINT is for a background job
  of a non-interactive shell, stays ignored, and the script carries on.

Checked in a scratch clone with bash 5.2.21, git 2.55.0 and quickjs-ng v0.17.0: a staged edit to
list.h and an untracked file are refused, and the printed command, run from /, restored list.h,
index and file, leaving only the untracked file; SIGTERM after the new copy is in place, with the
committed copy moved aside made undeletable, exits 143 and says it couldn't be removed; SIGUSR1,
SIGALRM and SIGPIPE while the clone runs leave the copy unchanged and nothing behind; SIGTERM
between the two renames puts the committed copy back; a clean run matches the committed copy. A
script with only an EXIT trap ran it on USR1, USR2, ALRM, PIPE, XCPU, HUP and TERM; SIGQUIT didn't
end it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gets no stand-in for one, its string form included

From the round review of the engine's last round:
- After a stop, a failure with no message of its own was still given its string form when the
  stop landed inside its JSON (a toJSON that spins): the guard covered only the JSON. Both
  stand-ins are now left out once a stop has come, since each runs more of the script's code and
  neither is the message. The parts themselves are still read after a stop, and code that spins
  there is ended at the next check.
- The last round's message claimed a test failed without the JSON's guard; none did, since that
  test's object had an enumerable getter its JSON reached. A test now pins each guard: a
  non-enumerable name getter ended by the stop (the JSON would be {"code":5}), and a toJSON ended
  by it (the toString would give "made up").
- Comments and docs: the interrupt handler's latch says a check ends the script's code at the
  next check, QuickJS checking at its first poll and once in 10,000 after, rather than at once;
  engine_result says the read's jobs run until the run fails or is stopped; the interrupt slot's
  doc says a throw at the start is answered rejected, which a stop while it's read turns into
  interrupted; a failed state's doc says no stand-in is made after a stop.
- The test for a resolve call stopped on its way in checks one call fewer only when there is
  one.

Tests, under Bun 1.4.3: with the last round's binding and module, the new test fails; taking out
either guard fails it, and taking out the interrupt handler's latch fails the test of a stop asked
for once.


At the clean head, on Linux x86_64: tsc is clean; all 12 code_execution test files pass, 379 of
379, under Node 24.22.0 and Node 20.20.2 file by file and under Bun 1.4.3; and a rebuild from a
fresh toolchain fetch gives the committed module, 600,099 bytes, sha256 1091cfd0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it refuses over leftovers of a run killed outright, and its cleanup holds against a second signal

From the round review of the engine's last round:
- git checkout HEAD -- engine/quickjs-ng restored what the commit holds but kept a file staged
  as added, or a rename's new name, so following the advice left the refusal in place. The
  restore is git checkout --no-overlay HEAD, which drops them (git 2.22 and later). The refusal
  over changes says to run it and then the script again, which lists what's left that git
  doesn't track or ignores, rather than listing, before the restore, files it may bring back.
- A run killed outright leaves its stage, or the committed copy moved aside, as a hidden
  .quickjs-ng.* directory in engine/, which no refusal named. The script now refuses while one is
  there, naming them.
- Each signal trap ignores the signals that end a shell before it exits, as the cleanup's first
  line does, so a second signal can't cut the cleanup short; XFSZ joins them.
- When the committed copy moved aside couldn't be removed, the notice was given twice, by the
  script and again by its cleanup; now once.
- The comment names the bash version its signal claims were checked on, and that bash ignores
  QUIT.

Checked in a scratch clone with bash 5.2.21, git 2.55.0 and quickjs-ng v0.17.0: a staged
addition, a staged rename, and a file deleted from the index but kept on disk (listed as D and
??) are each refused, and the printed command, run from /, put the copy back as committed, the
last on running the script again; leftover .quickjs-ng.* directories are refused by name; a
removal of the old copy that fails gives one notice; SIGINT and SIGTERM while the clone runs exit
130 and 143 with nothing left behind; SIGTERM between the two renames puts the committed copy
back; a clean run matches the committed copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…it is read

From the round review of the engine's last round:
- After a stop, a failure's read still read its other parts and made a non-string name or
  message into a string, which can run the script's getters and toString after the stop, and a
  stand-in made before the stop was kept beside a part the stop cut. One rule now: a stop ends
  the read. What was read before it stays, and nothing more is read, neither a part, a part's
  string form, nor a stand-in for a missing message. The message is read first, as the part most
  worth keeping, so a spinning name or stack doesn't take it.
- With that, nothing the engine does runs the script's code after a stop, so the interrupt
  handler's latch is never reached today. It stays, so that a stop holds if a later change runs
  code after one, and its comment says so; no test can show it.
- Comments and docs: a stack that isn't a string is left out; a thrown Symbol leaves the message
  empty, since it has no string form here; start and settle run jobs until none is left, the
  guest is interrupted, or the run fails.
- The test for a resolve call stopped on its way in moves the check by one when the search finds
  k = 0, and fails if it still does, rather than skipping its second half.

Tests, under Bun 1.4.3: with the last round's binding and module, the new test fails: a one-shot
stop in a spinning message getter leaves a name getter that would give "made up" unread, and a
stop in a spinning stack getter leaves a name object's toString unmade. Taking out each guard
fails a test: before the name's read, before a part's string form, and on each stand-in; reading
the name before the message fails two. Taking out the latch fails none.


At the clean head, on Linux x86_64: tsc is clean; all 12 code_execution test files pass, 387 of
387, under Node 24.22.0 and Node 20.20.2 file by file and under Bun 1.4.3; and a rebuild from a
fresh toolchain fetch gives the committed module, 600,155 bytes, sha256 244cd3c0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nd to restore before removing them, and each notice about the committed copy is given once

From the round review of the engine's last round:
- The refusal over .quickjs-ng.* entries called every one the leftover of a run killed outright
  and said the copy was unchanged, though one can be a run going now, and after a kill the copy
  can be missing or already replaced. It now says the entries are what a run makes while it
  replaces the copy, one that didn't finish or one running now, and, if none is running, to put
  back what's committed and then remove them.
- When neither rename could go through, the script gave its notice and the cleanup tried the move
  again and gave its own. The script now clears its record of the moved-aside copy before giving
  a notice, on that path and the failed removal's, so the notice comes once even if a signal
  lands just after.
- The comment says what bash 5.2 was checked to do: run the exit trap on PIPE, ALRM, USR1, USR2,
  XCPU, XFSZ and VTALRM, not on PROF or PWR, and ignore QUIT; that the signals ignored are those in
  ignore_signals, which gains VTALRM; and what SIGKILL leaves.

Checked in a scratch clone with bash 5.2.21, git 2.55.0 and quickjs-ng v0.17.0: leftover entries
are refused with the new message; with engine/ made read-only before the second rename, neither
rename goes through and one notice is given, and the printed command, run from /, restored the
copy; a removal of the old copy that fails gives one notice; SIGVTALRM while the clone runs leaves
the copy unchanged and nothing behind; a clean run matches the committed copy. A script with only
an EXIT trap ran it on SIGXFSZ and SIGVTALRM, and not on SIGPROF or SIGPWR.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t reach the host, and each part of a failure is a string before the next is read

From the round review of the engine's last round:
- QuickJS runs the script's own code after a stop: building an error's backtrace converts
  Error.stackTraceLimit, which can call a valueOf, and calls Error.prepareStackTrace, for the
  error a stop throws as for any other. On the last round's module, a call from each of those,
  and output from the second, reached the host after a stop. And a stop that lands in such code
  is lost when QuickJS puts back the error it was building, so the script goes on from there.
  __host and __output now refuse once the script is stopped, as they do while its ending is
  read, so nothing such code does reaches the host.
- The interrupt handler's latch is what ends that code at its next check, without asking the
  host again. The last round's message and the comment it wrote said nothing reached the latch
  and no test could show it; both were wrong, and the comment now says what reaches it.
- A failure's parts: each is read and made a string before the next is read, in one function,
  error_part, which holds the one guard that ends the read at a stop. A message that isn't a
  string, read before a name getter the stop cut off, was dropped when the string forms were made
  after all the reads; now it keeps its string form, a number's or an object's. A part that's
  there with no string form, such as a Symbol message, is still left out with no stand-in for it.
- The last round's test of a part's string form after a stop changes with that: its name is now
  an object whose toString spins until the stop, with a stack getter after it that isn't run.

Tests, under Bun 1.4.3: with the last round's binding and module, four tests fail. A call from a
stackTraceLimit valueOf and from a prepareStackTrace, and the latter's output, reach the host
after a stop; a stop that lands in a prepareStackTrace doesn't hold, and the script's call after
it goes out; a number and an object read as a failure's message before the stop are left out;
and in the changed test, the stack is read before the stop cuts off the name's string form.
Taking out each piece fails a test: the latch (once QuickJS has lost the stop, the script runs
on and the slot is asked again), each refusal, the read's guard, making each part a string at
once, and leaving a part with no string form without a stand-in. The guard on a stack read after
a stop, which the last round had, now has a test of its own.

At the clean head, on Linux x86_64: tsc is clean; all 12 code_execution test files pass, 397 of
397, under Node 24.22.0 and Node 20.20.2 file by file; under Bun 1.4.3 the engine's file passes
65 of 65, and the 12 files 396 of 397 with bun test's default limit of 5 s a test. The one left
is a routing test in test/code-execution.test.ts whose run yields at its own bound of 5 s, so it
outlasts that limit, with the last round's module in place too; CI runs each file with
--timeout 60000, and the test's yield is 3 s since. A rebuild from a fresh toolchain fetch gives
the committed module, 600,208 bytes, sha256 2e89e400.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…fore it opened

`refused: true` means the dispatcher refused the call before it opened, as
RFC-007's lifecycle refuses one, so nothing ran and the call certainly had
no effect. It is set at the five places the dispatcher refuses:
- the agent's allowedTools;
- a refused skip_reply wake;
- an MCPL server that isn't there or is still connecting;
- the host's MCPL tool policy for that server;
- a fork reaching outside its channel.
Each sets the field and RFC-007's mark from one helper, refusedResult(),
so the two can't disagree.

A check made once the call has opened is a failure whose text says nothing
was done, not a refusal, since observers have seen the call open: the
module registry's malformed name, module not found and schema check,
`utils run`'s, each module's answer for a tool it doesn't have, and an MCPL
request that couldn't be written. So is a check off the dispatcher, where
nothing opens the call: executeMcplToolCall's, the allowedTools check of a
programmatic call (executeToolCall, ModuleContext.callTool), which traces
its failure, and that check when `utils run` makes it. The texts that
didn't say nothing was done, or that the call didn't run, now do. A
module's own result can't claim `refused` on any route: the registry drops
the field from the copy it stores, and an in-process caller gets the
module's own result unless it claims one. Then it gets a copy less the
claim: every field ToolResult declares, read by name, so a result class's
getters count, and every other own enumerable field, string or symbol, as
a spread takes them. A result that can't be read, its claim included, is
a failure saying so, as on the stored route, keeping the turn's end and
the module's error when those could be read, each field read once, so a
getter that threw isn't asked again: the call completed.

Tests: mcpl-tool-resolution tells a refusal from a failure on the model's,
executeToolCall's and a module's path, and module-tool-args,
agent-allowed-tools, utils-surface and history-module check that the checks
made after the call opened answer with failures, with no `refused`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ne and the parent's side of it

`src/code-execution/isolate/` runs one script in a child process of its
own, on the engine, and keeps the parent's side of it. Nothing in the
framework uses it yet.

- The child (child-main.ts, child.ts, child-entry.ts) is spawned on the
  parent's own runtime with an empty environment, in a directory of its
  own, and from an entry that exists (CHILD_ENTRY), so that Bun loads no
  .env into it and runs the file as it is. The parent's frames come in on
  stdin, and the child's go out on fd 3 with writeSync. It fills the
  engine's three imports: a tool call goes to the parent, the interrupt
  looks for the stop file at most every 3 ms, and output is flushed in
  bounded batches and before each call. It refuses a wake's payload past
  its cap, then a wake past the script's cap, and sends a wake at once,
  even while a result settles. Other calls made while a result settles
  wait for its seen, at most 16 of them: a guest that makes more has the
  rest sent at once, which the parent crashes it for. An answer in a
  result's place that the guest can't take keeps its name and its
  message's start. A script that had returned or thrown when a stop came
  ends as it did, but an exception that escapes the script's promises once
  it runs (the engine's `failed`) ends the run as failed at once, past an
  ending kept. Every failed or rejected ending's stack frames go apart
  from its name and message, the script's own, numbered as written
  (scriptFrames): a failure's stack holds frames only, QuickJS's or one
  the script set, and the lines kept are those shaped as QuickJS writes
  the script's frames. A result the engine never delivered (`dropped`)
  gets no seen: the script never had it, and what script code QuickJS
  still runs as a stop lands can't reach the parent, since the engine
  refuses its calls once stopped; the run's end names it as unread. The
  child ends its process once its parent, whose pid its start names, has
  gone, rather than stop its script, since a script can keep QuickJS from
  taking a stop (one QuickJS takes inside a stack hook or an iterator's
  return is dropped) and only the parent kills a child that runs on, and
  encodes its frames against the parent's cap, which protocol.ts holds for
  both.
- The prelude (prelude.ts), adapted from @earendil-works/pi-codemode under
  its MIT notice: tools by exact name with a close-match error, ALL_TOOLS
  with the input schemas parsed from JSON, the output helpers, error
  formatting for what the script logs, which keeps only its own frames by
  the child's rule and patterns, wake_agent(), and the stall check. The
  script runs as an async function body, and its value goes out as JSON
  text, serialized once; a value JSON can't hold comes back saying so, and
  a throw that isn't an Error fails the script with the thrown value's
  JSON or string form as its message. At most 16 of its calls are out at
  once, and one past them waits in the script.
- frames.ts and protocol.ts: length-prefixed frames, binary for a result,
  a strict check of every frame from the child, which is untrusted, with
  at most 32 values counted before it's parsed, and the caps on a call's
  arguments (4 MiB of JSON holding 100,000 values) and a wake's payload
  (64 KiB), which the child checks before it sends. A result frame is
  written into one buffer, and the decoder copies each frame's body into
  one buffer of the length its header names, keeping no chunk. A failed
  ending's error carries the script's stack frames apart from its name and
  message.
- runner.ts: one run per script, with its calls and their timeouts,
  results held as frames and written one at a time after the child's stdin
  drains, a result too large for the script refused before it's sent, the
  stop and its grace, and an ending decided once the process has exited
  and fd 3 and stderr have ended. A child that breaks the protocol crashes
  its run, saying how: a call to a tool it wasn't given, more than 16 out
  at once, an id used again before its answer was seen, a seen for an
  answer not yet written, more wakes than the cap, arguments or a wake
  past their caps, output past the child's batch, a frame cut off (unless
  a stop's kill cut it), a frame longer than a call frame needs, or one
  holding more values than the protocol's (a byte-order mark is no JSON).
  The host holds at most 16 of a script's calls, timed-out ones included,
  counted by call rather than by the id the child names; one past them
  waits as its text, and one whose timeout passes there is told it was
  never sent. A call the bridge answered for, with no result from the host
  (`held`), keeps its place until the run ends. Once a stop is asked,
  calls and wakes are refused, and wakes still count against the cap,
  though a listing counts only the wakes the host delivered. Once the
  process has exited, nothing goes out, and a call read then counts as
  never sent. A refusal too long for the script's memory reaches it as a
  refusal, its start and a note. A wake waits for its spacing on a
  monotonic clock, from the last delivery's end, and one still waiting
  when its script ends, once a stop is asked or once its process has
  exited is never sent, and the record counts it so; the end lets every
  waiting wake and call go. A child that has ended its script but doesn't
  exit is killed after the grace, and an ended run holds no child. A
  process that never started counts as exited, and a stop asked once the
  process has exited isn't its ending's cause.
- calls.ts and render.ts: the call record, which names each call whose
  answer the script didn't see (still running, timed out, too large, or
  answered as the script was stopped), counts a refusal as one though the
  script couldn't receive it, counts a call never sent apart, one still
  waiting for room at the end included, and counts the calls waiting for
  room, and the wakes waiting for their turn, among those made; and the
  text the model reads for each return, its durations said exactly, and a
  failure's name and message given once, its frames after them as they
  came.
- output.ts: the child's bounded batches, and each return's first and last
  8,000 characters in the parent.
- scripts.ts: an agent's scripts, with run, wait, stop and list, one call
  watching a script at a time, the limits per agent and per host, turn
  ends by each script's own turn, the one its run call was made in (a
  non-primary agent's script lives while that turn is live, and an
  abandoned turn stops, for any agent, a script its calls watch that never
  returned), wakes, ids tagged with the host process, and the ended
  scripts kept: the 8 whose endings came last, by sequence, and none of a
  gone agent's, whose scripts a later agent of its name doesn't see. A
  tool list that throws refuses a run with nothing taken, a script that
  ends as it starts is trimmed with the rest, a left script's ending
  follows any wake of its still being delivered unless a wait has returned
  it meanwhile, and a stop watches with the call that asked it.

Tests: the isolate's own, with real child processes on a stand-in engine
(test/helpers/fake-engine.ts, on node:vm) and on the real one, a child
that breaks the protocol in each of those ways
(test/helpers/bad-child-entry.ts), and the protocol and the rendering on
their own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… and the Python runner goes

code_execution now runs each script in the isolate: JavaScript in
QuickJS-ng compiled to WebAssembly, in a process of its own, on the host's
own runtime. A script reaches only its agent's tools. One kind of script
replaces foreground and background ones: it runs in the agent's turn,
yields with its output so far past yield_time_ms (default 30 s), and keeps
running until the agent waits on it, stops it or leaves it.

- code_execution takes a run ({code, yield_time_ms?}), wait, stop and
  list. Its result is a text block, and isError is true for the endings
  that wake a left script (failed, crashed, stopped at its limit) and for
  refusals of its input or of capacity. background, time_limit_ms and the
  cancel action are refused by name, each saying what replaced it. Once
  the host has begun to stop, a run starts no script.
- A script's call goes through dispatchScriptToolCall as its agent's, with
  the agent's allowlist and every gate. It carries the RFC-007 origin of
  the code_execution call that ran the script, and, when it reaches the
  host while a call watches the script, that call's turn. A call that ends
  the turn ends it at the code_execution result of the call watching when
  its result arrives, a stop's included. When no result comes for a call,
  the bridge answers for it after toolCallTimeoutMs and 30 s more, as
  before, at most the longest a timer can wait, and marks the answer so
  the script's runner keeps the call's place in the host. A script's call
  carries resultMaxChars as the longest result that reaches the script
  whole whatever its characters, from its memory: (maxResultBytes - 80) /
  6, 7,456,527 at the default, and no more than the host's longest string
  allows. It can't count what the stored measure leaves out (a structured
  result's value, its blocks past their text and an image's data), deep
  nesting, or a parse past the memory left, which its documentation says.
  main gave it the Python runner's cut, 5,000,000.
- A script's calls count toward its round's routing, as main's anima-research#360 made
  them for the Python runner: those that reach the host while a run or
  wait call of the round watches it, whether or not the round waits on
  that script, and a call that arrives while none of the round's calls
  watches it counts toward the round of a later run or wait watching then;
  one the bridge refuses counts as one the allowedTools refuses, and each
  kind is kept once. A round waits for such a call, until it returns at
  the script's end or its yield, only when the watched script's code names
  a tool whose call changes the round's routing in the turn's mode, as a
  whole JavaScript identifier anywhere in the code (routingToolsNamed); a
  wait reads the code of the script it names (AgentScripts.codeOf). A
  comment or a string naming one holds its round too, for no longer than
  the call's yield, and a tool the code doesn't spell out goes unseen.
  anima-research#360's round machinery is re-keyed to the run or wait call's return: the
  calls are kept by the watch that saw them (scriptRoundCalls). A stop, a
  list and a code_execution call the allowedTools refuses hold nothing; on
  main the last held its round though no script ran, and the round was
  withheld.
- Every result reaches a script as { content, structuredContent, isError
  }. content is what the model would read: a structured result's blocks as
  its tool sent them, an MCPL peer's references as their stubs (fetched
  for a structured failure too), and otherwise one text block. A refused
  call (ToolResult.refused) throws ToolRefused in the script.
- Turn ends: a stream's settle ends its own turn for the agent's scripts
  (AgentScripts.turnEnded), whether or not the stream still owns the
  agent, naming the turns still live: the open logical turn, and the turn
  of the start holding the agent's turn (activeTurnTokens) until that turn
  settles (settledTurns), and a start that runs on a turn a settle had
  ended, as a resume can, takes that mark off. A non-primary agent's
  scripts whose turn isn't live stop, and, when the turn ended abnormally,
  a script its calls watch that hadn't made its first return stops first,
  whatever its turn. Each stop path that aborted the Python runner's
  script abandons its own stream's turn the same way, a quiesce its
  stream's, and the host stopping every turn. A non-primary agent's run
  made in a turn that has ended is refused, and a conversation fork
  disposed lets its scripts go. An ephemeral agent's end stops its
  scripts, and stop() stops every script and waits for their processes.
- A left script's wake enters its agent's context with metadata.source
  code-execution-script and its scriptId, and asks for a turn. A host that
  has begun to stop wakes no one, whether it began before the wake or
  while the wake's text was being written to the workspace.
- Configuration: create() refuses each key the Python runner took, naming
  what to do, and refuses codeExecution on a Bun older than 1.4.0, a
  prerelease of 1.4.0 included, which loses a child's fd 3 at some starts.
  maxScriptChildren and memoryMaxBytes are new, and memoryMaxBytes'
  documentation gives both the child's figure and the host's, the host's
  with a term for each wake maxWakesPerScript allows and the frame from
  the child being read, its text and its parse taken at two bytes a
  character. buildCodeExecutionToolDefinition() takes the same settings,
  and says a duration that isn't a whole second exactly. The tool's text
  says a script whose body returns waits for the calls its pending work
  goes on to make too.
- CI: a job of its own runs code_execution's files from source under Bun
  1.4.0, the oldest release create() admits, and under Bun 1.4.3, on Linux
  and macOS, one process per file: the isolate's, the engine's and
  code-execution.test.ts. The pack job runs the settings file under Bun
  1.3.14, which create() refuses.
- Removed: py-runner.ts, runtime-py.ts, their exports,
  test/code-execution-env.test.ts, test/helpers/pytc-stop-rows.ts, anima-research#495's
  tests of the runner's output, F55's result-text.ts (anima-research#599), the Python
  runner's ending as the model read it, with its test, and anima-research#360's Python
  reader, script-tool-names.ts, with its test. ToolResult.structured's doc
  keeps F55's wording but names no code_execution field: the isolate's
  result carries none.
- Changelog: 0.23.0 published the Python runner's last entries, and 0.23.1
  changed nothing of code execution, so code-execution-isolate.breaking.md
  says what changes from 0.23.1, as from 0.23.0. main's unreleased
  hold-round-speech.fixed.md (anima-research#360), tool-call-result-max-chars.added.md
  (anima-research#463) and mount-include.added.md (anima-research#536) now describe what ships, as do
  the workspace's notes on where the framework keeps its own files, which
  name no background-scripts folder. anima-research#495's two fragments, anima-research#596's and
  F55's two (anima-research#599) go: no release carried them, and the release with this
  has no Python runner for them to describe.

Tests: code-execution.test.ts is rewritten for the new surface, and each
framework test that drove Python scripts drives JavaScript ones on the
engine, still checking what it checked. anima-research#360's round tests are ported to
JavaScript scripts, a call sent past a script's tools through the bridge
in place of Python's forged one, with new ones for a round's yield, two
scripts in a round, a left script's calls, a wait's round, a wait on a
script that names no such tool, a stop, and a code_execution call the
allowedTools refuses; its mark and interpreter tests go with the reader. A
start that resumes a turn its settle ended is checked to run with that
turn live again. tool-call-result-max-chars runs a real script at 8 MiB,
which takes a result of exactly its figure in NUL characters whole, and
skip-reply-wake-xml-mode's script is JavaScript. The resident wake test
gives each wake's turn its own response, since the stand-in membrane hands
a stream every response queued when it opens, and checks each wake's own
inference request by text only that wake has. mcp-modern-framework's test
of a request that couldn't be written matches main's words for it ("The
request wasn't sent").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 4/5 Tier: apex

[High impact] The change reaches every framework user who enables code_execution and changes how their scripts run.

Fix the lost output tail before merging; the duplicate stop notice is a smaller follow-up.

Findings

  1. P1 Script output tails disappear ▶
  2. P2 Stop repeats failure notices ▶
Fix with agent prompt
### Issue 1
src/framework.ts:19528-19537
`renderReturn()` no longer fits the whole return to the agent's inline cap. An 8,000-character value plus the retained output exceeds the default 24,000-character cap once headings are added. The ordinary result path then keeps only the start, dropping the output's tail.

Without a writable workspace, those final lines are lost. `observe()` has already consumed the output, so another wait cannot recover them. Pass the effective cap into the renderer and preserve each section's head and tail within that budget.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

### Issue 2
src/code-execution/isolate/scripts.ts:266-268
`stop()` yields before marking an already-ended script's ending as returned. If a failure notice is waiting behind an earlier wake, a stop queued as that wake finishes can let the notice run before `observe()` sets `endingReturned`. The agent then gets an extra failure wake alongside the same failure from its stop call.

`wait()` already avoids this by marking the ending before yielding. Do the same for an already-ended stop and extend the delivery-order test to cover it.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

Replaces the Python runner with JavaScript scripts in separate QuickJS WebAssembly processes.

  • Each code_execution script runs in its own JavaScript process and can be managed as it runs.
  • Tool results say when the dispatcher stopped a call before it started.
  • The QuickJS WebAssembly engine is built from pinned source and checked in CI.

Focused checks reproduced both findings. The full test suite was not run.

Acknowledged by nissa-seru: Python APIs and settings, persistent interpreter state, journals and whole-output retention are intentionally removed. The coarse routing gate, deep native-recursion crashes, runtime-compiled stack labels, rare bare-null memory errors, and stop/orphan gaps are stated limits rather than findings here.

Reviews (1) · Last reviewed commit: "feat(code-execution)!: code_execution ru..." · Reviewed by Greptile

Comment thread src/framework.ts Outdated
Comment on lines +19528 to +19537
const text = renderReturn(r, { boundMs: ce.scripts.bound(yieldMs), lifetimeMs: ce.settings.scriptLifetimeMs });
// A script's call whose result ends the turn ends it at this result, the
// one watching the script (scriptToolCall).
const endTurn = watch.endTurn ? { endTurn: true } : {};
// An error exactly for the endings that wake a left script: it failed,
// crashed or was stopped at its limit.
// As a text block, which the model reads as the text itself.
return r.ending !== null && endingWakes(r.ending)
? { success: false, error: text, isError: true, ...endTurn }
: { success: true, data: [{ type: 'text', text }], isError: false, ...endTurn };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Script output tails disappear

renderReturn() no longer fits the whole return to the agent's inline cap. An 8,000-character value plus the retained output exceeds the default 24,000-character cap once headings are added. The ordinary result path then keeps only the start, dropping the output's tail.

Without a writable workspace, those final lines are lost. observe() has already consumed the output, so another wait cannot recover them. Pass the effective cap into the renderer and preserve each section's head and tail within that budget.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/framework.ts
Line: 19528-19537

Comment:
**Script output tails disappear**

`renderReturn()` no longer fits the whole return to the agent's inline cap. An 8,000-character value plus the retained output exceeds the default 24,000-character cap once headings are added. The ordinary result path then keeps only the start, dropping the output's tail.

Without a writable workspace, those final lines are lost. `observe()` has already consumed the output, so another wait cannot recover them. Pass the effective cap into the renderer and preserve each section's head and tail within that budget.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 115c0316. The framework now passes the cap the result is held to, an in-process caller's own or the agent's, into renderReturn. The output gets the room the rest of the return leaves, its start and its end cut alike with an exact count of what was dropped. When the rest leaves the output no room, the return is cut as any long tool result is.

— Imogen-2490

Comment on lines +266 to +268
script.watch = watch;
await script.run.stop('agent');
return this.observe(script, 0, watch);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Stop repeats failure notices

stop() yields before marking an already-ended script's ending as returned. If a failure notice is waiting behind an earlier wake, a stop queued as that wake finishes can let the notice run before observe() sets endingReturned. The agent then gets an extra failure wake alongside the same failure from its stop call.

wait() already avoids this by marking the ending before yielding. Do the same for an already-ended stop and extend the delivery-order test to cover it.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/code-execution/isolate/scripts.ts
Line: 266-268

Comment:
**Stop repeats failure notices**

`stop()` yields before marking an already-ended script's ending as returned. If a failure notice is waiting behind an earlier wake, a stop queued as that wake finishes can let the notice run before `observe()` sets `endingReturned`. The agent then gets an extra failure wake alongside the same failure from its stop call.

`wait()` already avoids this by marking the ending before yielding. Do the same for an already-ended stop and extend the delivery-order test to cover it.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in ebdbc5c3. stop() now marks an ended run's ending as returned before it yields, as observe() marks a wait's, and a delivery-order test covers that order.

— Imogen-2490

Imogen-2490 and others added 3 commits October 19, 2026 22:01
…onment

On macOS, CI's Node jobs found the child's environment holding
__CF_USER_TEXT_ENCODING, though the runner starts the child with an empty
one: by our reading, the system sets it in a Node process itself. The test
now checks that the child gets nothing of the host's environment or of the
host's .env file, and allows that one name on macOS. Under Bun on macOS,
and on Linux, the child's environment was empty, as the test asked before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t's inline cap

A return could pass its agent's inline cap for tool results: a finished
script with a long value and a long output came to about 24,150
characters, and a failed one with a long message, frames and output to
about 32,400, against the default cap of 24,000. Past the cap the
framework's cut keeps a result's start, and the output is the return's
last section, so without a writable workspace its tail was lost, though
the tool text promises its first and last 8,000 characters.

The framework now passes the cap the code_execution result is held to,
an in-process caller's own or its agent's, to renderReturn, and the
output, which reaches it as its head, its tail and the count dropped
between them, gets the room the rest of the return leaves: its start and
its end are cut alike, a stretch with nothing dropped yet cut as one, and
the count takes in what they gave up. The ending's line, the frames, the
calls' record and the value stay as they are. A cap that leaves the
output no room for that count leaves it the count alone, and the
framework cuts the return there as it cuts any long tool result. The tool
text and the changelog say so, and the changelog now names a refused call
among the error results too.

Found by Greptile's review of agent-framework anima-research#608.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e it yields

stop() awaited the run's stop before observe() marked the ending as
returned. So for a left script that had already ended, a notice of its
ending waiting behind an earlier wake's delivery could be sent in that
await, and the agent got the notice beside the same ending from its stop.
stop() now marks an ended run's ending before it yields, as observe()
marks a wait's, and a delivery-order test covers it.

Found by Greptile's review of agent-framework anima-research#608.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Imogen-2490 and others added 8 commits October 20, 2026 03:32
…ase, fetched, not a copy

engine/build.sh now clones quickjs-ng at the pinned tag into its own
scratch directory, checks the clone's commit against the pin, applies
the patches in engine/patches in the byte order of their names, and
compiles from there with the same relative paths as before. So the 19
files of quickjs-ng's source that engine/quickjs-ng held, 82,874 lines,
leave the repository, and engine/vendor.sh with them.

Its git runs with none of the machine's configuration: no global or
system config or attributes file, no hooks, templates from an empty
directory, and no GIT_ variable from the environment but the network's
(GIT_SSL_*, GIT_HTTP_*, GIT_PROXY_*). Any of the rest could rewrite the
checkout or a patch, or write elsewhere, while HEAD still names the pin.
So a proxy or a CA reaches the clone from the environment alone, and a
mirror a builder's git configuration names isn't taken.

Nothing that uses the package needs the C, since it runs
src/code-execution/quickjs.wasm, which is committed. What the copy
bought was CI's check that it was the pinned release with the patches,
which a build that checks the commit it fetches makes unneeded, and a
rebuild offline once the toolchain was cached, which the fetch gives up:
a rebuild comes only with an engine change. The engine workflow's
rebuild and compare stays, and builds the committed module's bytes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… still ends

The engine asked the child nothing more once it had a stop, and the child
looked for its parent only when asked whether to stop. So a child whose
script kept QuickJS from taking a stop, as a stack hook or an iterator's
return can, ran on once its parent went, with nothing left to end it.

The module gets a fourth import, host_supervise, which the interrupt
handler calls at every check, before it asks host_interrupt and after a
stop. The binding calls the child's supervise() whatever has happened, a
failed slot included, while a stopped guest is still asked nothing more
and a failed slot is still not called again. The child's look for its
parent moves from interrupt() to supervise(), which looks at most once a
stop check, so an orphan ends its process at the first check that looks
once its parent has gone, whether its stop came before its parent went
or not, on Linux and macOS, where an orphan's parent pid changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s left out

Reading the parts of what ended a run, error_part checked for a stop only
before each read, and error_json before the JSON or string form it makes
in place of a missing message. QuickJS drops a stop that lands in a stack
hook or an iterator's return, so a getter, a toJSON or a toString the
read runs could go on past the stop and give its text afterwards, which
then reached the ending. Each now checks again after the code returns,
and what's given or thrown after a stop is dropped, with its value or
its exception.

Tests hold each, and that a stack that isn't a string is left out with
its string form never made, beside a string stack that's kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…as ended

__host and __output refuse a call or output once a stop has come, or
while the script's ending is read, and said "the script has ended, so its
calls no longer reach the host". The script's own code can read that
while it still runs, in a getter the ending's read runs or after a stop
it kept from landing, so each now says only "the script's calls no longer
reach the host", or its output, and a test holds both texts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d data ends its run

The docs said a script's own JSON nested about 10,000 deep (5,000 under
V8's optimizing tier alone) crashed its run under Node. Measured under
Node 24.22, the guest's JSON.stringify ends the run from about 5,000 deep
(4,000 with the optimizing tier alone), a call's arguments included, and
its JSON.parse from about 8,000 (5,000). Under Bun 1.4.3, QuickJS's
catchable RangeError comes first from about 15,000, though a parse nested
22,000 deep still runs Bun's stack out. MAX_RESULT_DEPTH's 2,000 keeps its
margin in every tier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… are cut alike

The changelog said each return's output keeps its first and last 8,000
characters cut alike to fit the agent's inline cap. They are, where the
rest of the return leaves them room; where it doesn't, the return is cut
as any long tool result is, as the tool's own text says. The changelog
now says so too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s first call in either order

The lifecycle test of a call still running when its script is stopped
asserted that the inner call starts before the run call completes. The
run call completes when its script yields, at 500 ms, which a child slow
to start beat on a loaded machine, failing the test with the code right.
It now takes those two in either order, and still holds the first event
to the run call's start and the last to the inner call's completion.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its call carried

puppetToolCall gives its call the agent's inline cap as resultMaxChars,
then stored the pair under the cap read again after the call. A
code_execution return fits its output to the cap its call carries, so a
puppeted script that lowered its agent's cap during the call rendered to
the old cap and was cut to the new one from its start, losing its
output's tail. The pair is now stored under the cap its call carried,
the agent's at the call, so what it renders and what it stores both use
that cap, and a test puppets such a script and finds its return stored
whole.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nissa-seru

Copy link
Copy Markdown
Contributor Author

This push takes quickjs-ng's source out of the repository. engine/build.sh now clones the pinned tag, refuses it unless it's the pinned commit, applies engine/patches/ in name order and builds, with none of the machine's git configuration and no GIT_ variable but the network's, so a proxy or a CA reaches the clone from the environment alone. CI rebuilds the module and fails unless it matches the committed one byte for byte. The copy, 82,874 lines, bought a check that it was that release, which the fetch's own check now gives, and an offline rebuild once the toolchain was cached, which we give up, since a rebuild comes only with an engine change. The push also makes three engine fixes. An orphaned child now ends even when its script kept a stop from landing. A failure's parts, or a stand-in for its message, that script code gave after a stop are left out. The refusal texts no longer say the script has ended. The docs now give the measured depths at which a script's own nested data ends its run on each runtime. Besides those, the puppet stores a call's result under the cap its call carried, so a puppeted script that lowers its agent's cap keeps its output's tail.

— Imogen-2490

@anima-research anima-research deleted a comment from github-actions Bot Oct 21, 2026
@nissa-seru
nissa-seru merged commit 97ca7eb into anima-research:main Oct 21, 2026
11 checks passed
nissa-seru pushed a commit to nissa-seru/agent-framework that referenced this pull request Oct 21, 2026
…ove)

main moved by 14 pull requests since this branch's last merge of it
(ea4297a, in 180b703). git stops at four places in src/framework.ts,
each resolved by keeping both sides:

- After EphemeralRun, anima-research#608 adds scriptResultBrief and this branch adds
  the re-initialization alerts' builders and ManagerReinitFailure. Both
  are kept, the builders first.
- Where a channel message or a push is written into a conversation
  fork, anima-research#252 stamps the stored copy's digest (withStoredDigest) and this
  branch writes through writeToFork, which defers the write while a move
  holds the store. writeToFork now gets the stamped metadata, so a write
  it defers lands with the digest of the blocks it stores.
- readdDrained: anima-research#616 passes a failed reply queued for a target that's no
  longer registered to the primary, and this branch adds the traceAs
  option. Both are kept: the trace covers what the drain stored, the
  passed replies included.

main's other hunks within 40 lines of this branch's were read in the
merged file: anima-research#616's rework of stop()'s comment and its pass for
postponed releases, beside this branch's status lines for writes kept
queued, and anima-research#622's withoutHostKeys, beside writeToFork's call.

anima-research#616 changes what stop() does to a fork a quiesced host disposed: the
pass for its postponed release now drains what was queued for it, so a
failed reply goes to the primary, deferred again, and the rest is
dropped, logged, at the stop() rather than at the restarted host's
resume. At this merge the test "a fork disposed on a quiesced host: a
stop before the resume leaves its held writes queued, ..." fails for
that (0 entries queued, not 3), and the disposal alert's sentence and
the description's Not covered point still say what main did before
anima-research#616. The commit after this one adapts them.

Checked at this merge's tree before committing: tsc and the build pass;
npm test 6,010 pass, 1 fail (that test), 44 skipped of 6,055; the three
files CI runs alone 130 of 130.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant