feat(pocket-pi): run unmodified pi-coding-agent + extensions on QuickJS (M5–M7) - #1
Merged
Merged
Conversation
Stand up and drive an AgentSession from the *unmodified* upstream
pi-coding-agent, running a real gpt-5.6 turn end-to-end on QuickJS with
no Node and no bun.
Path B esbuild-bundles the whole unmodified pi-coding-agent (every dep
real, only node:* external) into one ES module, sidestepping QuickJS's
multi-module linker crash on pi's ~500-module circular import graph. The
runtime supplies the Node/Web surface the bundle needs at runtime:
- Node builtins: http, https, net, tls, zlib, dns, querystring, assert,
timers, worker_threads, v8, vm, constants, async_hooks,
diagnostics_channel; module.createRequire now delegates to the CJS
require; legacy url.parse/format/resolve.
- cjs bootstrap: every builtin is captured into __builtinExports so
require(...) resolves the full set.
- Web/Node globals: Blob/File/FormData (the OpenAI transport references
FormData even for JSON requests), Intl stub, global alias, setImmediate.
- js/build-pi-full.mjs builds the full bundle (git-ignored, ~13 MB);
js/pi-full/{entry.mjs,driver.js} are the bundle entry + a session
driver; crates/pocket-pi/js/package.json backs pi's package.json read.
Tests: loads_bundled_pi_coding_agent confirms __piFullLoaded; the new
runs_bundled_pi_turn drives a real gpt-5.6 turn through Pocket Pi's
fetch + system proxy and asserts the streamed reply (both #[ignore],
network/bundle-gated). Full non-ignored suite stays green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…le tree The module system had grown ad-hoc while chasing Path B. The worst smell: builtins lived in TWO hand-synced places — the Rust `BUILTINS` array and a hand-written `__builtinExports` map inside a giant inline JS string — so a builtin registered in one but not the other threw "builtin not available" at runtime (hit repeatedly while wiring http/assert/etc.). Split the 675-line node.rs into a focused, documented tree with one job each: - node/builtins.rs — the SINGLE SOURCE OF TRUTH for `node:*` modules. The CJS `require` bridge's imports + `__builtinExports` map are now GENERATED from this one list (cjs_bootstrap_source), so adding a builtin is one line. A unit test (bootstrap_covers_every_builtin) enforces the invariant that used to fail silently at runtime. - node/resolve.rs — Node resolution. Replaced the dead-code exports-wildcard branch with a real single-`*` matcher (wildcard_capture) shared by exports and imports; condition resolution now handles fallback arrays + the browser condition. - node/transform.rs — every source rewrite (TS erasure, JSON, CJS→ESM, cycle-breaking re-export rewrite) behind one prepare_module_source entry. - node/ops.rs — the native __node/process surface. - node/mod.rs — thin loader + install_node orchestration. Moved the two large inline JS bootstraps out to real, lintable files (js/node/_bootstrap.js, js/node/_cjs-runtime.js); require() now also handles JSON modules. Adds 11 unit tests (registry invariant, resolver: split/wildcard/exports precedence/conditions/builtins, transforms: cjs-vs-esm/named-exports/reexport rewrite/dispatch). Full suite green (23 passed), clippy clean, bundle still loads, and the real gpt-5.6 turn still returns "pocket pi lives". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… jiti (M6) pi loads extensions with jiti (createJiti + jiti.import) — a Node-internals TS loader QuickJS can't run. But the only thing jiti provides is the extension's default-export factory, and Pocket Pi's own module loader already produces that: a dynamic import() of the .ts routes through NodeResolver/NodeLoader, which transpiles it natively with oxc. So we bypass jiti and use pi's unmodified `loadExtensionFromFactory` seam: import the extension through our loader to get the factory, then hand it straight to pi. entry.mjs now also exports createExtensionRuntime/createEventBus and reaches loadExtensionFromFactory by file path (it's in the bundle but not on the barrel; the path import keeps pi unmodified and esbuild dedupes it). Adds js/pi-full/example-extension.ts (a normal-shape extension registering an `echo` tool + an `agent_start` hook) and ext-probe.js. New test loads_pi_extension_via_our_loader (#[ignore], bundle-gated, offline) asserts the tool and hook register — proving extension compatibility, the other half of "run unmodified pi + extensions". Full suite green (23 passed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e (M6b, M7)
Finishes the roadmap: an unmodified pi extension is now bound into a *live*
AgentSession and its tool is callable in a real turn, and sessions persist to
disk and resume.
Extensions (M6b): DefaultResourceLoader has a first-class `extensionFactories`
option — inline factories loaded via pi's unmodified loadExtensionFromFactory,
never jiti. The session harness imports the extension .ts through OUR oxc loader
to get the factory and passes it there; the session's ExtensionRunner then picks
up the hook and tool (loadExtensionFactories runs regardless of noExtensions,
which only gates on-disk jiti paths). Proven two ways:
- binds_extension_into_session (offline): the session reports hasAgentStart +
the `echo` tool registered.
- runs_pi_turn_with_extension_tool (online): a real gpt-5.6 turn where the model
calls `echo`; the agent_start hook fires and the tool executes with
{text:"ping"}. Fixed the example tool to pi's real contract —
execute(toolCallId, input, …) returning { content: [{type:"text",text}] }.
Persistence (M7): pi's SessionManager writes/reads .jsonl through our fs builtin.
Implemented the last gap — openSync/readSync/closeSync (fd-backed slurp+window,
exactly the session-header peek pattern). persists_and_resumes_session (offline)
writes two messages, resumes in a fresh manager over the same dir, and confirms
the same session id + both messages round-trip.
driver.js is now a general session harness (prompt / extensionPath / tools /
sessionDir / resume). Full suite green (23 + 7 ignored), clippy clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… -D warnings Adds .github/workflows/ci.yml (push to main + PRs): clippy with -D warnings, build, and the unit/module-system test suite on stable. The Path B integration tests stay #[ignore] — they need the git-ignored ~13 MB esbuild bundle and (for the turn tests) an API key + proxy, so they run locally, not in CI. To let CI enforce -D warnings, fixed the three pre-existing clippy lints: div_ceil in base64_encode, and a repeated complex callback type extracted to a `type EventSink` alias. cargo clippy --workspace --all-targets -- -D warnings now exits clean; 23 unit tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
doodlewind
marked this pull request as ready for review
July 22, 2026 02:27
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.
What
Pocket Pi now stands up and drives an
AgentSessionfrom the unmodified upstreampi-coding-agent, running a real gpt-5.6 turn end-to-end on QuickJS — no Node, no bun. This is the milestone the whole runtime was building toward: a minimal QuickJS-based runtime that runs pi with zero source edits, synced from npm vianpm update.Two commits: the Path B feature, then a refactor that makes the module system solid.
Path B — run the unmodified full pi-coding-agent
QuickJS's module linker segfaults on pi's ~500-module circular import graph (
js_inner_module_linkingnull-deref). Path B esbuild-bundles the whole unmodifiedpi-coding-agent— every dependency real, onlynode:*external — into one ES module, so QuickJS evaluates it as a single unit. The runtime supplies the Node/Web surface the bundle needs at load and run time.http,https,net,tls,zlib,dns,querystring,assert,timers,worker_threads,v8,vm,constants,async_hooks,diagnostics_channel.module.createRequiredelegates to the synchronous CJSrequire; legacyurl.parse/format/resolve.Blob/File/FormData(the OpenAI transport referencesFormDataeven for JSON requests), anIntlstub, theglobalalias, andsetImmediate.js/build-pi-full.mjsbuilds the full bundle (git-ignored, ~13 MB — rebuild withnode js/build-pi-full.mjs).js/pi-full/entry.mjsis the bundle entry;js/pi-full/driver.jsstands up the session and streams one turn.crates/pocket-pi/js/package.jsonbacks pi's ownpackage.jsonread.Refactor — solidify the Node module system
The module system had grown ad-hoc; the worst smell was builtins living in two hand-synced places (a Rust array + a hand-written
__builtinExportsmap in an inline JS string), which threw "builtin not available" at runtime whenever they drifted. The 675-linenode.rsis now a focused tree:node/builtins.rs— the single source of truth fornode:*modules; therequirebridge's imports +__builtinExportsmap are generated from it, so adding a builtin is one line. A unit test enforces the invariant.node/resolve.rs— Node resolution; the dead-code exports-wildcard branch is replaced with a real single-*matcher shared byexports/imports.node/transform.rs— every source rewrite behind oneprepare_module_sourceentry.node/ops.rs— the native__node/processsurface.node/mod.rs— thin loader +install_node. Large inline JS bootstraps moved to lintable files (js/node/_bootstrap.js,_cjs-runtime.js).Tests
loads_bundled_pi_coding_agent— confirms the bundle evaluates (__piFullLoaded === true).runs_bundled_pi_turn— drives a real gpt-5.6 turn through Pocket Pi's fetch + system proxy and asserts the streamed reply (pocket→pocket pi→pocket pi lives, 352 in / 8 out tokens).Both integration tests are
#[ignore](network + bundle gated). Full non-ignored suite green (23 passed), clippy clean.Extensions (M6)
pi loads extensions with jiti (Node-internals TS loader QuickJS can't run). But the only thing jiti supplies is the extension's default-export factory — which our oxc loader already produces: a dynamic
import()of the.tsroutes throughNodeResolver/NodeLoaderand is transpiled natively. So Pocket Pi bypasses jiti and hands the factory to pi's unmodifiedloadExtensionFromFactory.loads_pi_extension_via_our_loader(offline, bundle-gated) loads a normal-shape extension (example-extension.ts, registering anechotool + anagent_starthook) and asserts both register — proving extension compatibility, the other half of "run unmodified pi + extensions".Live extension binding + persistence (M6b, M7)
DefaultResourceLoaderhas a first-classextensionFactoriesoption; the harness imports the extension.tsthrough our oxc loader to get the factory and passes it there (never jiti).binds_extension_into_session(offline) confirms the session's runner picked up the hook + tool;runs_pi_turn_with_extension_tool(online) runs a real gpt-5.6 turn where the model calls the extension'sechotool — the hook fires and the tool executes with{text:"ping"}.SessionManagerwrites/reads.jsonlthrough our fs builtin (implemented the last gap:openSync/readSync/closeSync).persists_and_resumes_session(offline) writes two messages, resumes them in a fresh manager over the same directory, and confirms the same session id + both messages round-trip.The goal is now fully met: unmodified pi-coding-agent + unmodified extensions run on QuickJS (no Node/bun), drive real gpt-5.6 turns with tools, and persist/resume. 7
#[ignore]integration tests (4 offline bundle-gated, 3 network-gated) + 23 unit tests, clippy clean.Writing a third-party extension
A Pocket Pi extension is the same file an unmodified pi extension is: a module whose default export is a factory
(pi) => void(or async). Pocket Pi loads it through its own module system — the.tsis transpiled natively with oxc (no jiti), and the file mayimportnode:builtins, relative modules, and npm packages resolved fromnode_modules.What an extension can call
Everything import-resolves (a bundled extension never crashes at load), but the surface splits into real vs. stub:
fs,fs/promisesopenSync/readSyncpath,buffer,events,util,stream,string_decoder,os,url,querystring,assert,timers,readline,module,processchild_processspawnSync/execSync/execFileSync(native subprocess)cryptorandomUUID(real host entropy),randomBytes/getRandomValues(Math.random-backed),createHash/createHmacuse FNV (fast, non-cryptographic — fine for cache keys/ids, not for security)http,https,net,tls,dns,zlib,vm,v8,worker_threads,async_hooks,perf_hooks,ttyFor networking, use the global
fetch()— it's real, streaming, and proxy-aware (routes through the Rust HTTP hub);http.request/net.Socketare intentionally stubbed toward it. Other Web globals present:Response,Headers,URL,URLSearchParams,ReadableStream,TextEncoder/TextDecoder,Blob/File/FormData,AbortController,structuredClone,Buffer,setTimeout/setImmediate.Bottom line: an extension that reads/writes files (
fs), shells out (child_process), or calls an HTTP API (fetch) runs unmodified. Socket servers, native compression, and worker threads do not (yet) — those builtins exist only so imports resolve. Adding a real one is one row innode/builtins.rs(the single source of truth) plus its shim.🤖 Generated with Claude Code