core-agent-ide/docs/js_repl.md
Curtis 'Fjord' Hawthorne 657841e7f5
Persist initialized js_repl bindings after failed cells (#13482)
## Summary

- Change `js_repl` failed-cell persistence so later cells keep prior
bindings plus only the current-cell bindings whose initialization
definitely completed before the throw.
- Preserve initialized lexical bindings across failed cells via
module-namespace readability, including top-level destructuring that
partially succeeds before a later throw.
- Preserve hoisted `var` and `function` bindings only when execution
clearly reached their declaration site, and preserve direct top-level
pre-declaration `var` writes and updates through explicit write-site
markers.
- Preserve top-level `for...in` / `for...of` `var` bindings when the
loop body executes at least once, using a first-iteration guard to avoid
per-iteration bookkeeping overhead.
- Keep prior module state intact across link-time failures and
evaluation failures before the prelude runs, while still allowing failed
cells that already recreated prior bindings to persist updates to those
existing bindings.
- Hide internal commit hooks from user `js_repl` code after the prelude
aliases them, so snippets cannot spoof committed bindings by calling the
raw `import.meta` hooks directly.
- Add focused regression coverage for the supported failed-cell
behaviors and the intentionally unsupported boundaries.
- Update `js_repl` docs and generated instructions to describe the new,
narrower failed-cell persistence model.

## Motivation

We saw `js_repl` drop bindings that had already been initialized
successfully when a later statement in the same cell threw, for example:

    const { context: liveContext, session } =
      await initializeGoogleSheetsLiveForTab(tab);
    // later statement throws

That was surprising in practice because successful earlier work
disappeared from the next cell.

This change makes failed-cell persistence more useful without trying to
model every possible partially executed JavaScript edge case. The
resulting behavior is narrower and easier to reason about:

- prior bindings are always preserved
- lexical bindings persist when their initialization completed before
the throw
- hoisted `var` / `function` bindings persist only when execution
clearly reached their declaration or a supported top-level `var` write
site
- failed cells that already recreated prior bindings can persist writes
to those existing bindings even if they introduce no new bindings

The detailed edge-case matrix stays in `docs/js_repl.md`. The
model-facing `project_doc` guidance is intentionally shorter and focused
on generation-relevant behavior.

## Supported Failed-Cell Behavior

- Prior bindings remain available after a failed cell.
- Initialized lexical bindings remain available after a failed cell.
- Top-level destructuring like `const { a, b } = ...` preserves names
whose initialization completed before a later throw.
- Hoisted `function` bindings persist when execution reached the
declaration statement before the throw.
- Direct top-level pre-declaration `var` writes and updates persist, for
example:
  - `x = 1`
  - `x += 1`
  - `x++`
- short-circuiting logical assignments only persist when the write
branch actually runs
- Non-empty top-level `for...in` / `for...of` `var` loops persist their
loop bindings.
- Failed cells can persist updates to existing carried bindings after
the prelude has run, even when the cell commits no new bindings.
- Link failures and eval failures before the prelude do not poison
`@prev`.

## Intentionally Unsupported Failed-Cell Cases

- Hoisted function reads before the declaration, such as `foo(); ...;
function foo() {}`
- Aliasing or inference-based recovery from reads before declaration
- Nested writes inside already-instrumented assignment RHS expressions
- Destructuring-assignment recovery for hoisted `var`
- Partial `var` destructuring recovery
- Pre-declaration `undefined` reads for hoisted `var`
- Empty top-level `for...in` / `for...of` loop vars
- Nested or scope-sensitive pre-declaration `var` writes outside direct
top-level expression statements
2026-03-05 11:01:46 -08:00

7 KiB

JavaScript REPL (js_repl)

js_repl runs JavaScript in a persistent Node-backed kernel with top-level await.

Feature gate

js_repl is disabled by default and only appears when:

[features]
js_repl = true

js_repl_tools_only can be enabled to force direct model tool calls through js_repl:

[features]
js_repl = true
js_repl_tools_only = true

When enabled, direct model tool calls are restricted to js_repl and js_repl_reset; other tools remain available via await codex.tool(...) inside js_repl.

Node runtime

js_repl requires a Node version that meets or exceeds codex-rs/node-version.txt.

Runtime resolution order:

  1. CODEX_JS_REPL_NODE_PATH environment variable
  2. js_repl_node_path in config/profile
  3. node discovered on PATH

You can configure an explicit runtime path:

js_repl_node_path = "/absolute/path/to/node"

Module resolution

js_repl resolves bare specifiers (for example await import("pkg")) using an ordered search path. Local file imports are also supported for relative paths, absolute paths, and file:// URLs that point to ESM .js / .mjs files.

Module resolution proceeds in the following order:

  1. CODEX_JS_REPL_NODE_MODULE_DIRS (PATH-delimited list)
  2. js_repl_node_module_dirs in config/profile (array of absolute paths)
  3. Thread working directory (cwd, always included as the last fallback)

For CODEX_JS_REPL_NODE_MODULE_DIRS and js_repl_node_module_dirs, module resolution is attempted in the order provided with earlier entries taking precedence.

Bare package imports always use this REPL-wide search path, even when they originate from an imported local file. They are not resolved relative to the imported file's location.

Usage

  • js_repl is a freeform tool: send raw JavaScript source text.
  • Optional first-line pragma:
    • // codex-js-repl: timeout_ms=15000
  • Top-level bindings persist across calls.
  • If a cell throws, prior bindings remain available, lexical bindings whose initialization completed before the throw stay available in later calls, and hoisted var / function bindings persist only when execution clearly reached their declaration or a supported write site.
  • Supported hoisted-var failed-cell cases are direct top-level identifier writes and updates before the declaration (for example x = 1, x += 1, x++, x &&= 1) and non-empty top-level for...in / for...of loops.
  • Intentionally unsupported failed-cell cases include hoisted function reads before the declaration, aliasing or direct-IIFE-based inference, writes in nested blocks or other nested statement structure, nested writes inside already-instrumented assignment RHS expressions, destructuring-assignment recovery for hoisted var, partial var destructuring recovery, pre-declaration undefined reads, and empty top-level for...in / for...of loop vars.
  • Top-level static import declarations (for example import x from "pkg") are currently unsupported; use dynamic imports with await import("pkg").
  • Imported local files must be ESM .js / .mjs files and run in the same REPL VM context as the calling cell.
  • Static imports inside imported local files may only target other local .js / .mjs files via relative paths, absolute paths, or file:// URLs. Bare package and builtin imports from local files must stay dynamic via await import(...).
  • import.meta.resolve() returns importable strings such as file://..., bare package names, and node:fs; the returned value can be passed back to await import(...).
  • Local file modules reload between execs, so a later await import("./file.js") picks up edits and fixed failures. Top-level bindings you already created still persist until js_repl_reset.
  • Use js_repl_reset to clear the kernel state.

Helper APIs inside the kernel

js_repl exposes these globals:

  • codex.tmpDir: per-session scratch directory path.
  • codex.tool(name, args?): executes a normal Codex tool call from inside js_repl (including shell tools like shell / shell_command when available).
  • codex.emitImage(imageLike): explicitly adds exactly one image to the outer js_repl function output.
  • Imported local files run in the same VM context, so they can also access codex.*, the captured console, and Node-like import.meta helpers.
  • Each codex.tool(...) call emits a bounded summary at info level from the codex_core::tools::js_repl logger. At trace level, the same path also logs the exact raw response object or error string seen by JavaScript.
  • Nested codex.tool(...) outputs stay inside JavaScript unless you emit them explicitly.
  • codex.emitImage(...) accepts a direct image URL, a single input_image item, an object like { bytes, mimeType }, or a raw tool response object that contains exactly one image and no text.
  • codex.emitImage(...) rejects mixed text-and-image content.
  • Example of sharing an in-memory Playwright screenshot: await codex.emitImage({ bytes: await page.screenshot({ type: "jpeg", quality: 85 }), mimeType: "image/jpeg" }).
  • Example of sharing a local image tool result: await codex.emitImage(codex.tool("view_image", { path: "/absolute/path" })).

Avoid writing directly to process.stdout / process.stderr / process.stdin; the kernel uses a JSON-line transport over stdio.

Debug logging

Nested codex.tool(...) diagnostics are emitted through normal tracing output instead of rollout history.

  • info level logs a bounded summary.
  • trace level also logs the exact serialized response object or error string seen by JavaScript.

For codex app-server, these logs are written to the server process stderr.

Examples:

RUST_LOG=codex_core::tools::js_repl=info \
LOG_FORMAT=json \
codex app-server \
2> /tmp/codex-app-server.log
RUST_LOG=codex_core::tools::js_repl=trace \
LOG_FORMAT=json \
codex app-server \
2> /tmp/codex-app-server.log

In both cases, inspect /tmp/codex-app-server.log or whatever sink captures the process stderr.

Vendored parser asset (meriyah.umd.min.js)

The kernel embeds a vendored Meriyah bundle at:

  • codex-rs/core/src/tools/js_repl/meriyah.umd.min.js

Current source is meriyah@7.0.0 from npm (dist/meriyah.umd.min.js). Licensing is tracked in:

  • third_party/meriyah/LICENSE
  • NOTICE

How this file was sourced

From a clean temp directory:

tmp="$(mktemp -d)"
cd "$tmp"
npm pack meriyah@7.0.0
tar -xzf meriyah-7.0.0.tgz
cp package/dist/meriyah.umd.min.js /path/to/repo/codex-rs/core/src/tools/js_repl/meriyah.umd.min.js
cp package/LICENSE.md /path/to/repo/third_party/meriyah/LICENSE

How to update to a newer version

  1. Replace 7.0.0 in the commands above with the target version.
  2. Copy the new dist/meriyah.umd.min.js into codex-rs/core/src/tools/js_repl/meriyah.umd.min.js.
  3. Copy the package license into third_party/meriyah/LICENSE.
  4. Update the version string in the header comment at the top of meriyah.umd.min.js.
  5. Update NOTICE if the upstream copyright notice changed.
  6. Run the relevant js_repl tests.