@thezzisu/droidnode
v0.150.1
Published
Run Factory's droid CLI on plain Node.js. Sidesteps the Bun 1.3.x standalone-init NULL-allocator race that crashes `droid --resume`, and Bun's long-session memory leaks.
Downloads
1,144
Maintainers
Readme
@thezzisu/droidnode
A Node.js-side launcher for Factory's droid CLI. It extracts the JS bundle from the official droid binary and runs it under plain Node — not Bun — to sidestep two long-standing Bun 1.3.x issues that hurt droid users on long sessions:
NULL-allocator race in standalone init.
droid --resume <id>of a large mission session crashes withSegmentation fault at address 0x0— Bun's standalone fork/fanout pressure leavesbun.default_allocatorwith a NULL vtable, and the firsthash.update(<long string>)call segfaults insideAllocator.rawAlloc. Backtrace decoded from bun.report:Allocator.zig:129 mem.Allocator.rawAlloc ← NULL vtable.alloc array_list.zig:57 AlignedManaged.initCapacity unicode.zig:315 toUTF8AllocWithType encoding.zig:483 bun.js.webcore.encoding.constructFromU16 encoding.zig:63 Bun__encoding__constructFromUTF16 JSBuffer.cpp:551 WebCore::constructFromEncoding JSHash.cpp:182 Bun::jsHashProtoFuncUpdate ← hash.update(<string>) <JS> droid session-resume / digest pipelineRelated upstream reports: oven-sh/bun#25798, oven-sh/bun#14254, anthropics/claude-code#17546. The race only fires inside Bun's standalone-executable init under spawn pressure; running the same JS bundle through a fresh Bun (or Node) process never enters that path.
Bun memory growth on long-lived sessions. Mission-mode droid sessions running for hours accumulate RSS in Bun beyond what the bundle's logical heap explains. Node + V8 doesn't exhibit the same drift.
droid is © Factory AI. This repository ships zero proprietary code — no JS bundle, no native binaries, no assets. We depend on the official
droidnpm package as the sole source of truth and apply a small set of Node-compat patches at extraction time. All trademarks and copyrights belong to their respective owners.
Install
npm install -g @thezzisu/droidnodeOr run ad-hoc (downloads droid + @factory/cli- + koffi the first time):
npx -y @thezzisu/droidnode --resume <session-id>Both forms transparently install the droid npm package as a dependency, so a separate npm install -g droid is not required.
Use
droidnode --version # tracks the underlying droid version
droidnode --help # full droid help (passthrough)
droidnode --resume <id> # the case this exists for
droidnode --fork <id> # forks
droidnode exec "do thing" # non-interactiveEvery droid flag/subcommand is passed through verbatim.
Introspection
droidnode --print-paths # JSON: resolved droid binary, Node, shim dir, cache dir
droidnode --reextract # wipe the cache for the current droid binary and re-extractEnvironment
| Variable | Purpose |
|---|---|
| DROID_BIN | Override droid binary location |
| DROIDNODE_VERBOSE | Print extraction progress on first run |
| XDG_CACHE_HOME | Cache base (defaults to ~/.cache) |
Cache lives at $XDG_CACHE_HOME/droidnode/<binary-fingerprint>/. The key includes the droid binary's size + head/tail hash + the shim directory path, so multiple droid versions and multiple droidnode installs coexist without collisions.
How it works
- Resolve
droidviarequire('droid/platform.js').getBinaryPathWithInfo()— the same logic Factory's own shim uses, picking@factory/cli-<platform>{,-baseline}based on the host CPU's AVX2 support. - Locate the
.bunELF section by scanning for the/$bunfs/root/droid\0// @bun\nopener. Files inside are packed sequentially as<path>\0<content>; we walk the list by looking for\0/$bunfs/root/boundaries. - Extract every non-
droidentry to<cache>/embedded/<basename>and chmod +x the natives (rg,librust_pty-*.so,agent-browser, install shell scripts). - Apply the following patches to the JS bundle and write it as
<cache>/droid.node.mjs:- Truncate at the last
//# debugId=<hex>\nline. Bun appends raw Zstd sourcemap blobs past it that fail Node's JS parser on the embedded NULL bytes. - Replace every
/$bunfs/root/literal with the absolute path to<cache>/embedded/. import.meta.require→globalThis.__bunRequire(installed bybun-shim.mjs; Node has noimport.meta.require)."bun:ffi"→ absolute path tosrc/shims/bun-ffi.mjs(koffi-backed dlopen for the PTY .so)."bun:jsc"→ absolute path tosrc/shims/bun-jsc.mjs(emptyheapStatsstub; droid only calls it inside try/catch).from"ws"→ absolute path to thewspackage's entry (so the bundle'simport X from "ws"resolves outside anynode_modulestree).this.server=Bun.serve(→this.server=await Bun.serve(×2. Our Bun.serve polyfill is async; Bun's original is sync.
- Truncate at the last
- Spawn
node --import bun-shim.mjs --enable-source-maps <cache>/droid.node.mjs <argv>. The shim preload:- Overrides
process.execPath/argv0/argv[0]so droid's self-spawn (subagent fanout, restart-after-update) re-enters us viabasename(execPath).includes("droid"). - Installs
globalThis.Bunwith subset ofspawn/spawnSync/file/which/gc/connect/serve/fileURLToPath/version. - Implements
Bun.serveovernode:http+ws(the only thingdroid daemonneeds that Bun has and Node doesn't). - Routes
import.meta.requirethroughcreateRequire(import.meta.url), interceptingnode-fetchandabort-controllerto the Node 18+ / 15+ globals so neither package needs to be installed. - Aliases
Buffer.SlowBuffer = Buffer(Node 22+ removedSlowBuffer; the bundledbuffer-equal-constant-timestill references it).
- Overrides
Subsequent invocations hit the cache and skip extraction.
Cross-version compatibility
The patch set is stable across the droid versions we've tested (0.135.1 through 0.140.0 — every published version we could install at the time). The CI smoke job runs extraction + --version on every push; the auto-track workflow re-runs it daily against the latest droid release before bumping our pin.
Despite the patches being stable, the dependencies.droid range is pinned to ^<latest-tested> so a stale install never silently runs against an untested droid major. The bot opens a new tag/release whenever upstream droid moves.
Automation
.github/workflows/ci.yml— runsscripts/smoke.js(extract +--version) on Node 24 and 26, on every push and PR..github/workflows/auto-track.yml— runs daily; ifnpm view droid versionexceeds ourpackage.jsonversion, runs the smoke against the new droid, bumps bothversionanddependencies.droid, commits tomain, tagsv<version>, and creates a GitHub release..github/workflows/publish.yml— triggered onv*.*.*tag push (or manual dispatch). Publishes to npm via Trusted Publisher (OIDC) — no long-livedNPM_TOKEN, no static credentials. The workflow exchanges GitHub Actions' built-in OIDC token for an npm publish token at request time, and emits a provenance attestation so users can verify the published tarball was built from this exact commit.
One-time Trusted Publisher setup
- Sign in to npmjs.com as the publisher account (must have publish rights on
@thezzisu). - Go to Settings → Trusted Publishers, click Add Trusted Publisher.
- Fill in:
- Publisher:
GitHub Actions - Organization or username:
thezzisu - Repository:
droidnode - Workflow filename:
publish.yml - Environment: leave blank (or pin to
releaseif you create that environment for required reviewers)
- Publisher:
- Save. From the next tag push, the publish workflow authenticates via OIDC — no secrets in the repo, no token rotation, and the published version page on npmjs.com shows a "Built and signed on GitHub Actions" badge linking back to the workflow run.
The very first publish of a new package name can be done either by manually dispatching publish.yml once the trusted publisher is configured (npm now supports OIDC-based namespace claims), or by a single manual npm publish from a maintainer's machine to register the name, after which OIDC takes over.
Platform support
Verified: Linux x64 (the configuration that hits the bug hardest, especially under WSL2). The extractor and shims are platform-agnostic; darwin-arm64 and darwin-x64 are wired in package.json — community testing welcome.
Windows is not supported in this release. droid's standalone uses a different process model on win32 and we haven't reproduced or fixed the same crash there.
Limitations
- Subagent fanout in long missions hasn't been stress-tested end-to-end. Session restore + UI/Plan reload + cwd restoration are confirmed working.
droid update(the in-place updater) tries to overwrite its own binary. Through this wrapper it would target the resolved@factory/cli-*/bin/droidinside ournode_modules; for npm-managed installsnpm update -g @thezzisu/droidnodeis the supported upgrade path.keytar-backed credential storage uses droid's own Linux fallback when keytar can't load (the bundle wraps it in try/catch). The wrapper does not provide it separately.
License
Wrapper code (bin/, lib/, src/, scripts/, this README) is MIT — see LICENSE.
The droid CLI, its JS bundle, the @factory/cli-* binaries, and all related Factory AI intellectual property are not covered by this license and remain subject to Factory's own terms. This project does not redistribute any of it.
Acknowledgements
- Factory AI — the actual CLI we're patching around.
- The oven-sh/bun team — fixing this upstream eventually will retire this shim.
