Packages: Run Bun-compiled executables on Kandelo via spidermonkey-node (bun-run) - #1371
Open
brandonpayton wants to merge 44 commits into
Open
brandonpayton wants to merge 44 commits into
brandonpayton wants to merge 44 commits into
Conversation
Add the approved design for a Kandelo capability that runs Bun-compiled standalone executables (e.g. the Claude Code native binary) by extracting their embedded JS, caching it in a volatile tmpfs region, and executing it natively on spidermonkey-node. Ships as a userland launcher, factored so the detect/extract/cache/run handler can later be hoisted into the exec path (binfmt). Captures the enabling spidermonkey-node platform fixes (bare-specifier ESM resolution, import.meta population, the `using` build flag) and the cache-is-never-persisted invariant. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task-by-task plan implementing the approved bun-run design: bun-extract --prepare (hash+cache+remap), the bun-run.js bootstrap + Bun shim, the spidermonkey-node native-ESM fixes (bare-specifier resolution, import.meta population, the `using` build flag), and the end-to-end native run. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erflow, path traversal, OOM)
…ns + node_modules) The SpiderMonkey shell module loader resolves every ESM import specifier as a file path, so a bare specifier such as `import ... from "fs"` in a natively-loaded ES module became `//fs` and failed with "can't open //fs". That blocked real Node ESM apps (e.g. the bun-run app) from loading natively even though relative/absolute specifiers already worked. Route bare specifiers (not starting with "./", "../", "/", or "javascript:") through the existing node-compat resolver: - js/src/shell/ModuleLoader.cpp (new patch 0015): ModuleLoader::resolve calls globalThis.__kandeloResolveBare(specifier, referrerPath) for bare specifiers; when it returns a synthetic token path, fetchSource serves the generated source from globalThis.__kandeloBareSources. Unresolved bare specifiers fall through to the default file-path behavior so the failure stays visible (truthful failure). - node-compat/bootstrap.js: __kandeloResolveBare wraps a Node builtin (via _builtinModules, incl. `node:` prefix) or a node_modules package (resolved with _resolveFile, loaded with _makeRequire) as an ES module namespace — `default` plus own enumerable keys (valid identifiers, no reserved words) as named exports — reusing the object require() returns. All namespace generation stays in JS. Decision: a C shell patch was required because the native module loader is pure C++ with no JS-level resolve hook; the C seam is minimal and all resolution/namespace logic lives in bootstrap.js. Test: host/test/esm-probe-guest.test.ts gains a bare-builtin case (BARE true) and an assertion guarding the existing path-specifier case (ESMOK 43 hi!). Verified RED (BAREERR can't open //fs) before the fix and GREEN after rebuilding node.wasm. Browser-host parity validation: follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e native modules The SpiderMonkey shell's native ES-module metadata hook only set import.meta.url to the raw filesystem path and defined no dirname or require. Extracted Bun apps read import.meta.require and import.meta.dirname, so they broke on native modules without source rewriting. Add a C seam (patches/0016-kandelo-import-meta.patch) in ModuleLoader::populateImportMeta that calls globalThis.__kandeloModuleMeta with the resolved module path and copies url/dirname/require onto import.meta; falls back to the shell default (url = raw path) when the helper is absent so an unresolvable path stays a visible boundary. Implement __kandeloModuleMeta in node-compat/bootstrap.js reusing url.pathToFileURL, path.dirname, and _makeRequire. Same C-seam shape as the Task 3 bare-specifier patch (0015); needs no new includes. This is the native import() module system's import.meta, distinct from the _runEsmMain regex import.meta.url substitution, which is untouched. Browser-host parity validation: follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-node build ESR 140.11 ships the Explicit Resource Management implementation (`using` / `await using`) behind the compile-time macro ENABLE_EXPLICIT_RESOURCE_MANAGEMENT plus the runtime pref javascript.options.experimental.explicit_resource_management, which the StaticPrefList defaults to false on the ESR (non-Nightly) channel. Enable the macro via --enable-explicit-resource-management in the mozconfig and default the pref on in the Kandelo shell so node.wasm parses and runs `using` declarations without any source-lowering transform. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ermonkey-node Adds programs/bun-run.c, a thin exec-passthrough entrypoint that hands a Bun standalone executable to the bun-run.js bootstrap on spidermonkey-node (future binfmt call target). Rewrites host/test/claude-run-native-guest.test.ts into the milestone-1 end-to-end proof: stage the real ~207MB Claude Code Bun ELF, bun-extract, bun-run.js, and /bin/sh into a big-capacity rootfs image and drive 'node /usr/lib/kandelo/bun-run.js /usr/bin/claude --version' through the whole pipeline (bun-extract --prepare -> Bun shim -> native import() of the ~1819-module ESM graph). Confirms real output: 2.1.259 (Claude Code).
…oud patch apply, surfaced extract errors) ## Why The whole-branch review of the bun-run feature (running Bun standalone executables, including the real Claude Code CLI, on spidermonkey-node inside Kandelo) found one gap serious enough to undermine future confidence in the platform patches it depends on, plus a handful of smaller correctness and defense-in-depth issues: - `host/test/esm-probe-guest.test.ts` is the only regression test for three SpiderMonkey source patches (bare-specifier ESM resolution, `import.meta` population, and `using`/Explicit Resource Management support). Its fixtures lived only at `/tmp/cc-inspect/esm_probe`, which is not committed and does not exist on a fresh checkout. Every test case was gated on that directory existing, so on CI or a fresh clone all five cases silently skipped instead of running — the test file looked like a safety net but proved nothing. - `build-spidermonkey.sh` applied each of its SpiderMonkey source patches only when a dry-run succeeded, with no `else` branch. If the pinned Firefox ESR source ever drifted enough that a patch stopped applying cleanly, the build would silently skip that patch and finish green, quietly dropping a platform fix with no signal to whoever ran the build. - `bun-run.js` (the bootstrap that extracts and runs a Bun executable) built its own failure message from `bun-extract`'s captured stderr, but the spidermonkey-node child_process shim never actually captures child stderr into a JS string. A malformed or non-Bun input binary therefore surfaced only "exit 1" instead of `bun-extract`'s real diagnostic (e.g. "not a Bun standalone executable..."). ## What changed - `host/test/esm-probe-guest.test.ts`: inlined all 10 fixture file contents as string constants, written to a fresh `mkdtempSync`-created temp directory at test load time instead of reading from `/tmp/cc-inspect/esm_probe`. The `ready` gate no longer depends on that path existing, so all 5 cases now run (and pass) on a bare checkout. Removed the "Throwaway" framing from the file docstring since this is the durable regression guard for the 0015 (bare specifiers), 0016 (`import.meta`), and 0017 (`using`) patches. - `packages/registry/spidermonkey/build-spidermonkey.sh`: the patch loop now also checks a reverse dry-run to recognize an already-applied patch (prints a note and continues), and fails the build loudly, naming the offending patch file, when a patch applies neither forward nor in reverse — so source drift can no longer silently drop a platform fix. - `runtime/bun-run/bun-run.js`: passes `stdio: ["ignore", "pipe", "inherit"]` to the `bun-extract --prepare` spawn so `bun-extract`'s own stderr diagnostics reach the real process stderr instead of being redirected to `/dev/null`. The failure message no longer claims to include stderr text the shim never actually captures. - `host/test/claude-run-native-guest.test.ts`: the end-to-end assertion now also checks `result.exitCode === 0`, not just that stdout matches the version string. - `programs/bun-extract.c`: `utf16le_to_utf8` now rejects an input length that would overflow the `inlen * 3 + 4` capacity calculation before computing it, so a maliciously large UTF-16 module length in an untrusted Bun executable can't wrap `size_t` into a too-small heap allocation. - `docs/posix-status.md`: documented two node-compat platform boundaries surfaced by this work — `spawnSync`/`execSync` always shelling via `popen()` (requires `/bin/sh`, ignores `encoding`) and the milestone-2 interactive-runtime gaps (`setRawMode` no-op, unproven TLS keep-alive egress, non-CSPRNG `Math.random`-backed `crypto.randomBytes`) that a full interactive `claude` session will hit. ## Validation - `scripts/dev-shell.sh bash -c 'cd host && npx vitest run test/esm-probe-guest.test.ts'` — 5/5 cases run (not skipped) and pass: ESMOK, BARE true, META, `.mjs` main import, USING 1. - `scripts/dev-shell.sh bash -c 'cd host && npx vitest run test/bun-run-guest.test.ts'` — 1/1 pass (no regression from the stderr-routing change). - `scripts/dev-shell.sh scripts/build-programs.sh` rebuilt `bun-extract.wasm` with the overflow guard; `scripts/dev-shell.sh bash -c 'cd host && npx vitest run test/bun-extract-guest.test.ts'` — 2/2 pass. - `scripts/dev-shell.sh bash -c 'cd host && npx vitest run test/claude-run-native-guest.test.ts'` — 1/1 pass against the real Claude Code ELF present in this environment, including the new `exitCode === 0` assertion. - `build-spidermonkey.sh`'s new `else` branch was verified by code inspection (rebuilding SpiderMonkey end-to-end is a multi-minute build not needed to prove the shell logic is correct); a full rebuild was performed anyway as part of unblocking the above test runs and completed successfully with all patches applying cleanly. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Phase A) Approved design to make Claude Code's module graph instantiate on spidermonkey-node by providing the 40 builtin named-exports it statically imports (real where trivial, honest throwing stubs where hard, node-compat JS only). Prerequisite (module link-time) before the Phase B runtime gaps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ase A) Two-task plan (batched around one node.wasm rebuild) to add the 40 missing builtin named-exports Claude Code imports, plus a kept link-surface guard and a throwaway -p acceptance run that seeds Phase B. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…urface Claude Code's extracted ESM app graph named-imports 40 Node builtin exports that packages/registry/node-compat/bootstrap.js did not provide. A named import of a name a module doesn't export fails at ESM link time, before any code runs, so this graph could not instantiate on spidermonkey-node at all. Adds all 40: real implementations where the qjs:os/qjs:node native bindings make them trivial (os.availableParallelism, util.stripVTControlCharacters, crypto.timingSafeEqual, zlib.deflate/inflate, dns.promises.lookup, path/posix, events.setMaxListeners, child_process.ChildProcess, url.domainToASCII, util/types.isProxy, tls.rootCertificates/checkServerIdentity), honest throwing stubs everywhere else (fs.fsyncSync/ftruncateSync — no fsync/ftruncate primitive in the qjs:os native module; fs/promises.link/lutimes/opendir/statfs; crypto.randomFillSync/createCipheriv/createDecipheriv/createPrivateKey/createPublicKey/generateKeyPairSync/sign/verify; zlib.inflateRawSync/createZstdDecompress; tls.createSecureContext), and two constructable no-op/throw-on-construct class stubs (net.BlockList, crypto.X509Certificate) since npm/CLI code commonly instantiates these at module scope. host/test/node-compat-builtin-exports.test.ts stages a minimal ESM fixture that imports the exact 40-name surface and asserts it links on spidermonkey-node.wasm (LINKED40), with spot checks on a handful of the real implementations and on a stub actually throwing when called. Confirmed RED beforehand against the unmodified bootstrap.js (LINKERR can't open //dns/promises), then GREEN after rebuilding spidermonkey-node with these changes; the existing 0015/0016/0017 ESM regression guard (test/esm-probe-guest.test.ts) still passes 5/5. docs/posix-status.md records the throwing/class stubs as a tracked node-compat gap; Phase B graduates whichever the app actually calls at runtime to a real implementation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Phase B) Approved design: make spidermonkey-node load ESM targets as native modules through both the dynamic import()/bare-hook path (bootstrap.js) and a new synchronous require()-of-ESM capability (a C seam that compiles/links/ evaluates via the native loader+registry, throwing ERR_REQUIRE_ASYNC_MODULE on top-level await), deduping all routes through the native per-path registry. Unblocks headless `claude -p` past the dynamic-import SyntaxError and readies ESM npm-package require(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e B) Two tasks: (1) bare-hook ESM routing in bootstrap.js (fixes the -p dynamic import SyntaxError) + acceptance; (2) synchronous require()-of-ESM via new C seam patch 0018 + require() change, with dedup and ERR_REQUIRE_ASYNC_MODULE. One rebuild per task; kept guard extends esm-probe. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (dynamic import fix)
When a dynamic import() resolves a bare specifier (e.g. import("epkg")) to
a file whose nearest package.json says "type": "module" (or that ends in
.mjs), hand the real resolved path back to the native SpiderMonkey module
loader instead of routing it through node-compat require()'s CJS wrapper.
The CJS wrapper evaluates the file body as a plain function, so a top-level
import declaration inside it threw "import declarations may only appear at
top level of a module" -- this was the current claude -p blocker on
spidermonkey-node.
Returning an absolute path from __kandeloResolveBare is handled natively by
ModuleLoader.cpp (0015-kandelo-esm-bare-specifier.patch): it bypasses the
synthetic bare-token/namespace path entirely and is read + CompileModule'd
by the native per-path module registry, so dedup stays correct. The CJS
(non-ESM) bare path is unchanged.
Adds a regression case to esm-probe-guest.test.ts. The bare dynamic import
in the fixture happens from a genuine native ES module referrer
(dynhost.mjs), not directly from the CJS main: only a referrer compiled by
the native module loader carries the script-path private data
__kandeloResolveBare needs to walk node_modules from the right directory.
A classic/CJS script executed via the shell's evalScriptAsFunction helper
never registers that private data -- only the shell's own top-level RunFile
path does (js.cpp's RegisterScriptPathWithModuleLoader) -- so a bare
specifier dynamically imported directly from CJS always resolves with a
null referrer today. This mirrors the real Claude Code shape: a
lazily-loaded ESM chunk performing import() of a bare specifier from
within already-native ESM code.
…e load + dedup, ERR_REQUIRE_ASYNC_MODULE on TLA) Add a SpiderMonkey shell C seam, __kandeloRequireModule(path), that loads an ES module through the shell ModuleLoader's per-path registry, links and evaluates it, and returns its namespace -- so node-compat require() (and the Bun __breq / import.meta.require cross-chunk helpers) can load ESM chunks synchronously instead of CJS-wrapping them (which throws "import declarations may only appear at top level of a module"). require/import/import() of one resolved path share a single module instance and namespace. A required module with a pending top-level await throws ERR_REQUIRE_ASYNC_MODULE; a synchronous evaluation rejection is rethrown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nkey-node The whole-branch review of the require()-of-ESM work noted the dedup test only covered require-then-import; import-first-then-require is the dominant real ordering (import() is the common route, require(esm) rare) and its symmetry was assumed, not asserted. Add a reverse-order case: import() a type:module `.js`, then require() the same path, and assert one shared native-registry instance (shared counter + identity). Runs against the existing node.wasm -- no rebuild -- and passes (DEDUPREV 1 2 true), locking the symmetry. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…n on spidermonkey-node Headless `claude -p` loaded the whole module graph (Phase B) but then died at runtime with `Error: can't open //path/win32`: the app statically imports the Node builtin subpath `path/win32` (standard cross-platform code that imports both path variants and only calls the win32 one under `process.platform === 'win32'` guards, which are false on Kandelo -- platform is already `linux`). node-compat resolved `path/posix` but not `path/win32`, so the bare specifier fell through to file resolution and failed. Register `path/win32` -> path.win32 in `_builtinModules`, mirroring `path/posix`. (path.win32 is a POSIX approximation with `sep: '\\'`; real win32 semantics are tracked future work.) Also fold in the Phase B whole-branch-review canonicalization finding: require() of an ES module passed the realpath'd path to the native seam while import/ dynamic import() reach the shell ModuleLoader (lexical normalizePath, no symlink resolution) with the pre-realpath path, so the same specifier under a symlinked dir keyed the per-path registry twice and double-instantiated. Pass the pre-realpath `resolvedPath` to __kandeloRequireModule so require and import share one registry key (and one instance); the JS-side _moduleCache stays realpath-keyed. Record the complete inventory of node-compat's stubbed/approximated Node core surface in docs/posix-status.md as tracked future work, grouped by honesty class (fail-loud throwing stubs vs. silent approximations vs. approximate impls), including path/win32 and the CSPRNG/TLS-identity/BlockList silent stubs. Guard (host/test/esm-probe-guest.test.ts): path/win32 resolves via require and import (sep is backslash); require()+import() through a symlinked dir share one instance (SYM 1 2 true). All 12 esm-probe cases green after one node.wasm rebuild. Throwaway `claude -p` acceptance: the path/win32 error is gone; the app now reaches its next blocker, the external npm dep `ws` (Phase D seed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… socket deferred)
Headless `claude -p` loaded the whole graph (Phase C) but then died in main
init with `Error: can't open //ws`: the app imports the bare npm package `ws`
(WebSocket). Reconnaissance (Bun graph manifest + the npm package layout)
showed `ws` is neither bundled into the Bun standalone nor shipped in the
platform package -- Bun provides a native ws-compatible module at runtime, so
Bun-bundled apps mark `ws` external. node-compat is the equivalent runtime
layer on spidermonkey-node, so it provides `ws` here.
A scope probe (throwaway: a stubbed, instrumented ws staged via node_modules)
proved `claude -p` *imports* `ws` but never constructs or uses a WebSocket --
its ws usage is feature-gated. So a minimal, honest module unblocks `-p`
without implementing WebSocket I/O: `_builtinModules['ws']` is the `WebSocket`
class itself (matching real ws's `module.exports`), so `import WebSocket from
"ws"` / `require("ws")` resolve and the class, static ready-state constants,
`instanceof`, and subclassing all work at module scope; but constructing a
live `WebSocket`/`WebSocketServer` or calling `createWebSocketStream` throws a
clear "not implemented on spidermonkey-node" (fail-loud, never silently wrong).
Full real `ws` (a WebSocket client/server over the platform's TCP/TLS with
permessage-deflate; depends on the TLS keep-alive egress path + a CSPRNG for
Sec-WebSocket-Key) is recorded as tracked future work in docs/posix-status.md.
Guard (host/test/esm-probe-guest.test.ts): `ws` resolves via require and ESM
default+named import, default is the WebSocket class with OPEN===1, and
`new WebSocket()` throws (WS function 1 true true true). All 13 esm-probe
cases green after one node.wasm rebuild. Throwaway `claude -p` acceptance:
the `ws` error is gone; the next blocker is `zlib.constants.Z_SYNC_FLUSH`
(zlib constants + Brotli/gzip streaming for HTTP response decompression) --
the Phase E seed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…idermonkey-node
Headless `claude -p` loaded the whole graph (Phase D) but died in main init
with `TypeError: can't access property "Z_SYNC_FLUSH", (intermediate value).
constants is undefined`: node-compat's zlib module had real gzip/deflate/
inflate (native libz backend, patch 0012) but no `constants`, no `createUnzip`
(the Unzip class existed but was unexported), and no Brotli. The app reads
`zlib.constants.{Z_SYNC_FLUSH,BROTLI_OPERATION_FLUSH}` and uses `createUnzip`
and `createBrotliDecompress` for HTTP response decompression.
Add real `zlib.constants` (the standard Z_* and BROTLI_* numeric values Node
ships), expose `createUnzip` (backed by the native gunzip auto-detect), and
provide Brotli honestly: there is no Brotli codec in the native backend, so
`createBrotliCompress`/`createBrotliDecompress` construct (module-init that
builds a Brotli stream succeeds) but the stream errors on the first byte of
data, and `brotli*Sync` throw on call -- fail-loud, never silently wrong
bytes. gzip/deflate/inflate/createUnzip are real.
A scope probe (throwaway `claude -p`) proved the app reads zlib.constants and
constructs decompressors but never feeds the Brotli stream (no `Content-
Encoding: br` on the default Anthropic-API path), so the stub unblocks `-p`.
Full real Brotli (link libbrotli into the C build, or bundle a JS/wasm
decoder) is recorded as tracked future work in docs/posix-status.md.
Guard (host/test/esm-probe-guest.test.ts): zlib.constants Z_SYNC_FLUSH===2 and
BROTLI_OPERATION_FLUSH===1, createUnzip constructs, and createBrotliDecompress
errors on data (ZLIB 2 1 true true). All 14 esm-probe cases green after one
node.wasm rebuild. Throwaway `claude -p` acceptance: the zlib error is gone;
the next blocker is an ESM module-system re-entrancy error
("module record has unexpected status: Evaluating") -- the Phase F seed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e (M2 Phase F) The Milestone 2 Phase B require(esm) seam (patch 0018) links and evaluates its target unconditionally, so require() of a module that is mid-evaluation in a dependency cycle re-enters JS::ModuleEvaluate on an already-Evaluating module and the engine throws "module record has unexpected status: Evaluating" -- the blocker headless `claude -p` now hits in main init. The design adds a module-status branch to the seam so a cyclic require returns the module's partial namespace (Node's circular require(esm) semantics), an EvaluatingAsync module reached via a cycle throws ERR_REQUIRE_ASYNC_MODULE, and an errored already-Evaluated module rethrows its stored error -- with the confirmed cycle repro kept as a regression guard. ABI-neutral (shell C++ compiled into node.wasm). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ase F) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…on spidermonkey-node The require(esm) seam (patch 0018) linked and evaluated its target unconditionally, so require() (or import.meta.require / the Bun __breq helper) of a module mid-evaluation in a dependency cycle re-entered JS::ModuleEvaluate on an already-Evaluating module and the engine threw "module record has unexpected status: Evaluating". Branch on the module status (read from the internal js::ModuleObject::status(), since JS::GetModuleStatus is not public in ESR 140) after loadAndParse: an Evaluating module (cycle) returns its partial live namespace (Node circular require(esm) semantics) without re-evaluating; an Unlinked module links first; every other status evaluates as before (JS::ModuleEvaluate is idempotent for linked/evaluating-async/evaluated, so a pending top-level await still yields ERR_REQUIRE_ASYNC_MODULE and an errored Evaluated module still rethrows). Two shared helpers de-duplicate the namespace-return and the async-error synthesis. Requires builtin/ModuleObject.h (placed before builtin/TestingUtility.h to satisfy the include-order style check). ABI-neutral shell C++ compiled into node.wasm. Guard (host/test/esm-probe-guest.test.ts): a cyclic import.meta.require pair returns the partial namespace (CYC A B) instead of throwing; an uninitialized binding read mid-cycle is TDZ (TDZ EA TDZ:ReferenceError); a captured namespace sees a later-initialized export fill in (LIVE LATE). 17/17 esm-probe cases green. Scope note: this fixes the synchronous require() cycle (the Milestone 2 Phase F spec). The headless `claude -p` acceptance revealed the app's cyclic path also exercises the *dynamic* import() cycle and, deeper, a cyclic module resolved through the bare-specifier resolve hook during evaluation (engine HostResolveImportedModule), which are separate, deeper facets deferred to a follow-up phase -- so `-p` is not yet unblocked past this error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y-node (cyclic import)
After the sync require(esm) cycle fix, headless `claude -p` still died with
"module record has unexpected status: Evaluating". An instrumentation spike
pinned the exact site: SpiderMonkey's InnerModuleLinking (js/src/vm/Modules.cpp)
threw because its step-2 early-return set is {Linking, Linked, EvaluatingAsync,
Evaluated} -- it omits Evaluating. The ES spec omits it because it assumes the
whole module graph is linked before any evaluation begins, so a dependency can
never be mid-evaluation during linking. Kandelo's node-compat loads modules
incrementally (each require()/import() during evaluation links a fresh
sub-graph), so linking a newly loaded module legitimately recurses into an
already-evaluating module reached through a dependency cycle -- and the
assertion fired.
Add Evaluating to that early-return set (patch 0019). An evaluating module is
already fully linked (its environment and bindings were resolved before
evaluation started), so treat it like linked/evaluated and skip re-linking
instead of throwing. ABI-neutral shell/engine C++ compiled into node.wasm.
Guard (host/test/esm-probe-guest.test.ts): during alink's evaluation it
require()s blink, which statically imports alink; linking blink now recurses
into the still-evaluating alink and resolves (LINKCYC A A) instead of throwing.
18/18 esm-probe cases green.
Result: `claude -p` now gets past ALL module-loading/cycle errors and reaches
runtime -- the next blocker is a node-compat `vm` gap (`vm.runInContext` not a
function), a separate completeness item.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… Phase H) Headless `claude -p` reaches runtime and dies at `Vt.runInContext is not a function`. The app uses Node's vm as a real security sandbox (null-prototype contexts with codeGeneration disabled, ~32 runInContext sites); node-compat's vm shim does no isolation. This design commits to a HOLISTIC, faithful Node vm -- real bidirectional contextify via a sandbox-backed global (the mechanism Gecko uses for Window), genuine codeGeneration enforcement (eval/Function throw inside a strings:false context), and the full Script/createContext/isContext/ runInContext/runInNewContext/compileFunction surface with faithful cross-realm value and error marshalling -- rather than an eval-based shim that would silently break the sandbox. Any residual gap is a documented SpiderMonkey/wasm boundary, never a silent compromise. Plan step 1 is a focused spike confirming the sandbox-backed-global + codeGeneration mechanism before the rest is built. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…isolation (M2 Phase H) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…node
Implement Node's vm with real isolated contexts: a sandbox-backed global
(property ops delegate to the sandbox) gives faithful bidirectional contextify,
codeGeneration:{strings:false} disables eval/Function inside a context, and the
full createContext/isContext/runInContext/runInNewContext/runInThisContext/
Script/compileFunction surface marshals values and thrown errors across the
realm boundary. Backs the Claude Code sandbox (previously TypeError:
runInContext is not a function). C seam in the SpiderMonkey shell (patch 0020),
delegated through adapter.js, wrapped by node-compat's vm module. ABI-neutral.
Documented engine boundaries: reads are copy-on-first-access (ESR 140 JSClassOps
has no get/set interceptors, so an external sandbox mutation after first access
is not re-observed; write/delete/enumerate are fully live); options.timeout is
unsupported and throws rather than being silently ignored; createContext returns
a fresh isolated context per call (idempotency-by-sandbox not implemented).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hase I) Headless `claude -p` reaches app runtime and dies at "embedded text asset is missing or corrupt": the Claude app reads zstd-compressed embedded text assets via Bun.zstdDecompressSync/zstdDecompress, but node.wasm has no zstd decoder (native seam has zlib only; node-compat zlib.createZstdDecompress is a _notImpl stub). This design adds native zstd DECOMPRESSION the same way zlib is already linked: a kind=library `libzstd` dep (decompress-only, built from the in-tree zstd single-file decoder amalgamation, proven to compile for wasm32) linked into node.wasm, a self-contained __kandeloZstdDecompress C seam (ZSTD_getFrameContentSize + ZSTD_decompress, streaming fallback, fail-loud on error), delegated through adapter.js, graduating node-compat zlib's zstd stub and wiring Bun.zstd* to it. Compress is out of scope (honest not-impl). Spike confirmed the decode amalgamation compiles clean for wasm32 with the target toolchain. ABI-neutral. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A kind=library dep that builds a decompress-only libzstd.a from zstd 1.5.6's official single-file decoder amalgamation, for linking into node.wasm (native zstd decompression). Compress is intentionally absent. Mirrors the zlib library package. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why Headless `claude -p` aborted initialization with `Error: embedded text asset is missing or corrupt`. The Claude Code app stores some of its embedded text assets zstd-compressed and reads them at startup through `Bun.zstdDecompressSync` (and the Node 22+ `zlib.zstdDecompressSync` it maps to). spidermonkey-node's node.wasm had no zstd decoder, so every such read threw and the CLI could not start. Compression codecs are a platform capability, not something a consumer should have to polyfill, so this closes the gap in the runtime rather than working around it in the app. ## What changed Native zstd DECOMPRESSION, wired the same way patch 0012 links libz: - New decode-only library dependency `packages/registry/libzstd` (zstd 1.5.6, single-file decoder amalgamation → `libzstd.a` + `zstd.h`). Declared as a `spidermonkey` dependency and classified in `packages/sets/local-supported.toml`, so the build front-door resolves it and passes `WASM_POSIX_DEP_LIBZSTD_DIR`; the linker adds `libzstd.a` to LDFLAGS and the include dir to CFLAGS/CXXFLAGS. - SpiderMonkey shell patch 0021 adds `__kandeloZstdDecompress(bytes)`: explicit-size frames via `ZSTD_getFrameContentSize` + `ZSTD_decompress`, unknown-size frames via a streaming `ZSTD_decompressStream` loop. It fails loud — a truncated or corrupt frame throws `"zstd decompression failed: <ZSTD_getErrorName>"` rather than returning partial bytes. - adapter.js delegates the new native through the `_nodeNative` whitelist. bootstrap.js graduates `zlib.zstdDecompressSync` and `zlib.createZstdDecompress` to real implementations over the seam; bun-run.js exposes `Bun.zstdDecompressSync`/`zstdDecompress`. - COMPRESSION stays honest not-implemented: the decode-only amalgamation omits the compressor, so `zlib.zstdCompressSync` / `createZstdCompress` remain fail-loud stubs. Documented in `docs/posix-status.md`. This change is ABI-neutral: it is a SpiderMonkey shell C++ addition plus a statically linked library compiled into node.wasm, with no kernel, syscall, struct, or `ABI_VERSION` change. ## Validation - `./run.sh build spidermonkey-node` succeeds; `check_spidermonkey_style.py` passes (patch 0021's `<zstd.h>` include-order is correct). - `host/test/esm-probe-guest.test.ts` — the new zstd case decodes a known frame via both `zlib.zstdDecompressSync` and `Bun.zstdDecompressSync` to the exact text and confirms a corrupt frame fails loud (`ZSTD true hello zstd from kandelo THROW`). All Phase A–H ESM/vm/cyclic cases stay green. (One pre-existing in-kernel case per run hits its inline 90s timeout under concurrent host load; the failure rotates between different pre-existing tests across runs and is unrelated to this additive change.) - Throwaway `claude -p` acceptance (isolated HOME/CLAUDE_CONFIG_DIR, dummy API key, TCP egress): the 1819-module graph loads and init no longer hits `embedded text asset is missing or corrupt`; it now advances past asset load. Probe deleted after use. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tion ## Why Task-review of the native zstd decompression seam (patch 0021) found two defects against the fail-loud and bounded-allocation constraints. Because the seam decodes UNTRUSTED embedded bytes, both are real: 1. The streaming decode path (used by frames whose header carries no content size — including the app's own embedded assets) broke out of the decode loop when input was exhausted WITHOUT checking that the frame had actually completed, silently returning the partial bytes decoded so far. A truncated-tail frame must fail, not return garbage. 2. The explicit-content-size path called `out.resize(contentSize)` with a value taken directly from the frame header. A ~20-byte crafted frame can declare a multi-gigabyte size, so the allocation throws `bad_alloc` out of the JSNative (an abort/DoS) rather than a catchable JS error. ## What changed - Streaming path: break only when the decoder reports the frame complete (`ZSTD_decompressStream` returns 0). If it wants more input but the input is exhausted, throw `"zstd decompression failed: truncated frame"`. - Both paths: cap decoded size at a documented 512 MiB (`kMaxZstdDecodeBytes`) — far above any real embedded asset, safely below the wasm guest's address space — and fail loud when a frame's declared or accumulated size exceeds it, before any large allocation. - Empty input now decodes to empty output (matches Node/zlib-family behavior) instead of throwing "not a valid frame". - esm-probe zstd case now also asserts a truncated valid frame fails loud and empty input yields empty: `THROW THROW EMPTY`. Patch 0021 was regenerated as a real diff against the post-0020 source base (not a hand-edited hunk). node.wasm rebuilt; `check_spidermonkey_style.py` passes; esm-probe 25/25 green including the new assertions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why Headless `claude -p` on spidermonkey-node now boots + decompresses embedded zstd assets (Phase I) but aborts when the app require()s a Markdown embedded asset (loopAutonomousPreamble.md) — node-compat compiles it as an ES module (SyntaxError '#') instead of returning its contents as a string. Bun's standalone graph tags each module with a loader byte; our runtime drops it and dispatches by file extension, which cannot disambiguate the app's loaders (.js and .mjs each appear under multiple loaders). ## What changed Design doc for the faithful fix: carry Bun's per-file loader byte through bun-extract into a cache manifest and honor it in node-compat require() — text→string, file→on-disk path, json→JSON.parse, js→module, napi→honest throw. Closes the .md text-loader blocker now and the imminent .zst file-loader wave (read-by-path then Bun.zstdDecompressSync) in one coherent contract. Two tasks; ABI-neutral; scope boundary (static import-of-asset) documented. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why
Bun's standalone module graph tags each module with a loader byte
(text/file/json/js/napi). bun-extract dropped it, so the runtime had no
way to tell that a .md is a text asset (return its string) versus a
module to execute. This records that decision so require() can honor it.
## What changed
bun-extract classifies each module by its loader byte, gates the
/$bunfs/root/->cachedir remap on class==js (writing text/file/json/napi
verbatim, fixing a latent bug where a file-loader .js was remapped), and
emits "loaders":{"<relpath>":"text"|"file"|"json"|"napi"} into
cache/manifest.json (js omitted = default; unknown bytes omitted +
counted to stderr). Guest test asserts the manifest and verbatim writes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why Headless `claude -p` decompresses its embedded zstd assets (Phase I) then aborted: the app require()s a Markdown embedded asset (loopAutonomousPreamble.md) and node-compat compiled it as an ES module (SyntaxError '#') instead of returning its contents as a string. Bun's standalone graph records a loader byte per module; the runtime must honor it, because file extension cannot disambiguate (.js and .mjs each appear under multiple loaders). ## What changed bun-run reads the loader map bun-extract now emits and installs globalThis.__kandeloAssetLoaders (absolute cache path -> class). node-compat require() consults it before shebang-strip / .json-check / ESM dispatch: text -> raw string, file -> on-disk path, json -> JSON.parse, js -> module (unchanged), napi -> honest throw (native addons unsupported; real support is future work needing the in-Kandelo C/C++ toolchain). Backward compatible: no map -> require() unchanged. esm-probe covers all four dispatch paths; `claude -p` now advances past the .md text-asset load. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why Headless claude -p hangs on a subprocess deadlock: node-compat's child_process is synchronous popen-only (pid:0, no stdin, no streaming, no signals), so a spawned /bin/sh blocks on read(0)=EAGAIN with no way to deliver stdin EOF. The agent tool loop needs a real async subprocess. ## What changed Design doc for a real async child_process built on a general POSIX primitive surface (Approach 2): native bindings for posix_spawn (+file actions incl. chdir), pipe, async fd read/write/close, waitpid, kill, generalizing patch 0012's socket watch-list + job-queue loop to arbitrary fds and child reaping; plus wiring the dead real-delay timer drain into that loop. node-compat child_process rewritten in JS to a real streaming ChildProcess. ABI-neutral (existing syscalls). posix_spawn preserves observable semantics incl. cwd; detached/uid/gid documented as kernel boundaries. Two tasks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e K) ## Why node-compat had no real subprocess primitives — only synchronous popen. The Claude Code agent loop needs to spawn tools with streaming stdio, deliver stdin EOF, learn exit codes, and kill children. This adds the POSIX seam those require, and fixes real-delay timers (which were dead). ## What changed SpiderMonkey shell patch 0022 adds __kandeloSpawn (posix_spawn with dup2/close/open/chdir file-actions), __kandeloPipe, async __kandeloFdRead/Write/Close, __kandeloWaitPid (WNOHANG reap), and __kandeloKill, by generalizing patch 0012's socket watch-list + job-queue poll loop to arbitrary fds and child reaping. Wires the previously-dead __kandeloRunDueTimers into that loop so real-delay setTimeout fires. All natives delegated through adapter.js. ABI-neutral (existing syscalls). Guest seam test proves spawn+pipe+read+reap+timer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Why Headless claude -p deadlocked: node-compat's child_process ran every command through synchronous popen (pid:0, no stdin, no streaming), so a spawned shell blocked on read(0)=EAGAIN with no way to deliver stdin EOF. ## What changed Rewrote child_process spawn/exec/execFile on the Task-1 POSIX seam: a real streaming ChildProcess (async stdin/stdout/stderr, real pid, 'exit'(code,signal)/'close'/'error', kill(), full stdio matrix, cwd, shell). child.stdin.end() closes the write pipe -> sticky EOF, which dissolves the -p deadlock. spawnSync/execSync stay popen-based (follow-up). Guest tests cover streaming, the stdin-EOF regression, exit codes, kill, and cwd. Boundaries (detached/uid/gid) documented in posix-status.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…se K review) ## Why Task-2 review found two Important issues: (1) exec/execFile invoked their callback on 'exit', which races the async stdout/stderr drain and can deliver truncated output; a null exit code (signal-killed child) was also silently treated as success. (2) posix-status.md wrongly blamed the kernel for not implementing SETSID/SETPGROUP — the kernel process table fully implements both (and RESETIDS); the real gap is that node-compat's spawn() never wires options.detached/uid/gid into the posix_spawnattr it builds. ## What changed exec/execFile now share a _runWithCallback helper that waits for 'close' (after stdio has fully drained) and reports failure on `code !== 0 || signal !== null`, matching Node's own exec() error shape (.code/.signal/.killed). child.kill() now sets child.killed. Rewrote the posix-status.md boundary: SETSID/SETPGROUP/RESETIDS are kernel-implemented and unit-tested; options.detached is a small node-compat wiring gap, while options.uid/gid have no kernel-side posix_spawn mechanism yet at all (no SpawnAttrs field, no FileAction op) -- POSIX itself has no such spawn attribute either. Added two guest tests: exec() delivers non-truncated stdout for a 5000-byte command, and exec() of a non-zero-exit command yields an Error with the real .code instead of reporting success. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gression, spawn/exec fd leaks, SIGPIPE) — M2 Phase K
## Why
The async child_process phase's whole-phase review found four
fix-before-merge issues that reach the CLI's own hot paths.
The shared Writable change (adding `_maybeFinish`/`_writableState.ending`
`pending` so child stdin delivers EOF only after in-flight writes settle)
silently regressed every Duplex-derived stream — net.Socket, tls.TLSSocket,
Transform, PassThrough, zlib Gunzip — because the Duplex mixin copied only
`write`/`end`/`destroy` and a partial `_writableState`, so `.write()`/`.end()`
threw `TypeError: this._maybeFinish is not a function`. That breaks http/https
`socket.write` and `.pipe(createGunzip())`.
Two parent-side pipe fds leaked: a posix_spawn failure (e.g. ENOENT) returned
without closing the stdio pipe fds already created (up to 6 per failed spawn),
and exec/execFile never ended the child's stdin, leaking its write fd per call.
Under Claude Code's heavy ENOENT-retry shell-out loops both reach EMFILE.
Writing to a pipe/socket whose read end has closed raises SIGPIPE, whose
kernel default disposition is Terminate — with no `signal(SIGPIPE, SIG_IGN)`
anywhere in the guest, `child.stdin.write()` after a child exits could kill the
node process instead of surfacing EPIPE as a stream error.
## What changed
- Duplex now initializes the full `_writableState` shape
`{ended,ending,pending}` + `options.final`, and the mixin copies
`_maybeFinish`, so every Writable-shaped stream shares one coherent contract.
- `_spawn`'s posix_spawn-failure catch closes every created pipe fd (child and
parent ends) before returning the error-child.
- exec/execFile (`_runWithCallback`) end the child's stdin immediately, closing
the parent write fd and delivering stdin EOF (matching Node), close-once.
- SpiderMonkey shell patch 0022 adds `signal(SIGPIPE, SIG_IGN)` at node.wasm
startup, exactly as Node does. Authored via the real-diff method against the
reconstructed post-0021 js.cpp; applies clean; check_spidermonkey_style ok.
Guest tests add a Duplex/PassThrough write+end regression guard and a
SIGPIPE stdin-write-after-exit survival guard; child_process 9/9 and
esm-probe 26/26 green under the rebuilt node.wasm. ABI-neutral.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Modern JavaScript CLIs increasingly ship as Bun single-file
executables (
bun build --compile): a native launcher (Mach-O onmacOS, ELF on Linux, PE on Windows) with the application's JavaScript
embedded as a serialized "module graph." Claude Code is the motivating
example — since v2.1.113 its npm package ships only the Bun
executable; the last plain-Node
cli.jswas v2.1.112 (2026-04-16) andis now months stale. Codex and others are moving the same way.
Kandelo cannot run the native binary itself — it is compiled machine
code, and the app inside targets Bun's runtime. But Kandelo does have
a Node-compatible JavaScript runtime (spidermonkey-node). This PR turns
that into a real capability: run a Bun-compiled executable by
extracting its embedded JS, caching it, and executing it natively on
spidermonkey-node — no separate transpile/bundler step.
End-to-end proof: the real ~207 MB Claude Code Linux ELF prints
2.1.259 (Claude Code)on spidermonkey-node inside the kernel,natively.
What changed
The pipeline is
bun-run <binary>→ extract (cached) → run the app'sown ESM graph via native
import():programs/bun-extract.c—--preparemode. Container-agnosticextractor (anchors on the Bun trailer +
Offsetsstruct; one codepath for Mach-O/ELF/PE).
--prepare <binary> <cache-root>content-hashes the embedded module-graph (FNV-1a-64), extracts once into
<cache-root>/<hash>/, rewrites the app's baked-in/$bunfs/root/specifiers to the cache dir, writes
package.json {"type":"module"}and a
.mjsentry so the modules load as ESM, and printsCACHE=/ENTRY=. Reads are positioned (pread) so peak memory isone module, not the whole ~200 MB binary. The parser is hardened
against malformed/hostile input (entry-id range, content-length
integer overflow,
..path traversal, OOM, UTF-16 size overflow,JSON-unsafe module names).
runtime/bun-run/bun-run.js+programs/bun-run.c. The bootstrap(run on spidermonkey-node) invokes
bun-extract --prepare, installsa thin
Bunglobal shim (unimplemented members throw named errors —never silent), sets
process.argvto the app's view, andimport()s the cached entry in-process.bun-run.cis the thinentrypoint.
Three spidermonkey-node platform fixes (each a build-wired
SpiderMonkey patch that survives a
node.wasmrebuild — the enabling,reusable work):
0015— native ESM loader resolves bare specifiers (Nodebuiltins +
node_modules) via the existing node-compatrequireresolver, instead of treating them as file paths.
0016— populateimport.meta.{url,dirname,require}on native ESmodules.
0017— enable JavaScriptusing(Explicit Resource Management):the
--enable-explicit-resource-managementcompile flag anddefaulting the runtime pref on (honoring
--disable-...). ESR 140already contains the feature; this only turns it on.
docs/superpowers/{specs,plans}/2026-09-03-bun-run-kandelo*.md—the design and implementation plan.
How it was validated
host/test/in-kernel tests (real kernel viarunCentralizedProgram),all green on the tip:
esm-probe-guest(5 cases, self-contained inlined fixtures) — thedurable regression guard for patches 0015/0016/0017: dynamic-import
of a minified ESM graph, bare-specifier
import … from "fs",import.metapopulation,.mjsmain, and ausingdeclaration.bun-extract-guest(2) — extraction +--preparecache/remap.bun-run-guest(1) — the bootstrap runs a synthetic Bun app.bun-extract-real-guest(1) — extracts the real 207 MB ELF in-kernel(1819 modules).
claude-run-native-guest(1) — the end-to-end2.1.259 (Claude Code)run (gated onCLAUDE_BUN_ELF, skips in CI without thebinary).
The three runtime patches live in the guest
node.wasm+ sharednode-compat/bootstrap.js, so Node and browser hosts run the sameartifact; browser-host validation is a tracked follow-up. No
crates/or
abi/changes — the runtime and extractor are guest programs, so noABI_VERSIONbump.Not included / follow-ups
Scoped to getting a program to boot and run (
--version). A fullinteractive
claudesession (headless-pand beyond) needs thedeeper runtime gaps recorded in
docs/posix-status.md: asyncchild_process, a host-yielding TLS egress loop, a CSPRNG, andsetRawMode/PTY. Also deferred: binfmt exec-path integration (so abare
./claudeis transparently handled), Codex, and cache GC. Onecosmetic residual: with the stderr-surfacing
stdiooption,node-compat
spawnSyncreturnsstatus: 0, sobun-run.js'sr.statuscheck is redundant — failure is still caught deterministically(a failed extract never emits
ENTRY=) and exits non-zero.🤖 Generated with Claude Code
Scope update & merge plan
This branch is the accumulating line of work for running Claude Code on
Kandelo. Beyond the
bun-runcapability described above, it now alsoincludes Milestone 2 Phase A — node-compat builtin-export completeness
(
docs/superpowers/{specs,plans}/2026-09-03-node-compat-builtin-completeness*):the 40 Node builtin named-exports Claude Code statically imports, added to
node-compat so its 1819-module ESM graph instantiates on spidermonkey-node.
That change is a standalone node-compat improvement in its own right, but it
depends at runtime on the spidermonkey-node patches introduced in this PR
(notably
0015bare-specifier ESM resolution — Claude imports the builtinsas native-ESM bare specifiers), so it is stacked in this PR rather than
split into a separate PR against
main.Merge plan (not squash): when this is ready to land, we will
rebase-merge a curated set of commits — a clean, reviewable sequence
(extractor, bun-run bootstrap + entrypoint, each spidermonkey-node platform
patch, node-compat completeness) — so
mainreceives a tidy history ratherthan the exploratory and fix-wave commits accumulated during development.
Further milestones (dynamic-import interop, then the runtime gaps behind a
full headless
claude -p) will continue on this branch and be curated thesame way.