Skip to content

feat(pocket-pi): run unmodified pi-coding-agent + extensions on QuickJS (M5–M7) - #1

Merged
doodlewind merged 5 commits into
mainfrom
feat/pocket-pi-run-unmodified-pi
Jul 22, 2026
Merged

feat(pocket-pi): run unmodified pi-coding-agent + extensions on QuickJS (M5–M7)#1
doodlewind merged 5 commits into
mainfrom
feat/pocket-pi-run-unmodified-pi

Conversation

@doodlewind

@doodlewind doodlewind commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

What

Pocket Pi now stands up and drives an AgentSession from the unmodified upstream pi-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 via npm 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_linking null-deref). Path B esbuild-bundles the whole unmodified pi-coding-agent — every dependency real, only node:* 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.

  • Node builtins: http, https, net, tls, zlib, dns, querystring, assert, timers, worker_threads, v8, vm, constants, async_hooks, diagnostics_channel. module.createRequire delegates to the synchronous CJS require; legacy url.parse/format/resolve.
  • Web/Node globals: Blob/File/FormData (the OpenAI transport references FormData even for JSON requests), an Intl stub, the global alias, and setImmediate.
  • Build + harness: js/build-pi-full.mjs builds the full bundle (git-ignored, ~13 MB — rebuild with node js/build-pi-full.mjs). js/pi-full/entry.mjs is the bundle entry; js/pi-full/driver.js stands up the session and streams one turn. crates/pocket-pi/js/package.json backs pi's own package.json read.

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 __builtinExports map in an inline JS string), which threw "builtin not available" at runtime whenever they drifted. The 675-line node.rs is now a focused tree:

  • node/builtins.rs — the single source of truth for node:* modules; the require bridge's imports + __builtinExports map 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 by exports/imports.
  • node/transform.rs — every source rewrite behind one prepare_module_source entry.
  • node/ops.rs — the native __node/process surface.
  • 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 (pocketpocket pipocket pi lives, 352 in / 8 out tokens).
  • 11 new module-system unit tests (registry invariant, resolver, transforms).

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 .ts routes through NodeResolver/NodeLoader and is transpiled natively. So Pocket Pi bypasses jiti and hands the factory to pi's unmodified loadExtensionFromFactory.

loads_pi_extension_via_our_loader (offline, bundle-gated) loads a normal-shape extension (example-extension.ts, registering an echo tool + an agent_start hook) and asserts both register — proving extension compatibility, the other half of "run unmodified pi + extensions".

Live extension binding + persistence (M6b, M7)

  • Extensions in a live sessionDefaultResourceLoader has a first-class extensionFactories option; the harness imports the extension .ts through 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's echo tool — the hook fires and the tool executes with {text:"ping"}.
  • Session persistence — pi's SessionManager writes/reads .jsonl through 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 .ts is transpiled natively with oxc (no jiti), and the file may import node: builtins, relative modules, and npm packages resolved from node_modules.

// my-extension.ts
export default (pi) => {
  pi.registerTool({
    name: "echo",
    description: "Echo text back.",
    parameters: { type: "object", properties: { text: { type: "string" } }, required: ["text"] },
    // pi calls execute(toolCallId, input, signal, onUpdate, ctx); return a content array.
    execute: async (_id, input) => ({ content: [{ type: "text", text: String(input.text) }] }),
  });
  pi.on("agent_start", () => { /* lifecycle hook */ });
  // also available: pi.registerCommand / registerFlag / registerShortcut /
  // registerMessageRenderer / sendMessage / exec / getActiveTools ...
};

What an extension can call

Everything import-resolves (a bundled extension never crashes at load), but the surface splits into real vs. stub:

Module Status
fs, fs/promises Real (native) — read/write/append/readdir/mkdir/stat/exists/realpath/unlink, sync + callback + promise, plus openSync/readSync
path, buffer, events, util, stream, string_decoder, os, url, querystring, assert, timers, readline, module, process Real JS implementations
child_process Real spawnSync / execSync / execFileSync (native subprocess)
crypto PartialrandomUUID (real host entropy), randomBytes/getRandomValues (Math.random-backed), createHash/createHmac use 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, tty Stub — imports resolve; classic socket/server/client calls throw or no-op

For networking, use the global fetch() — it's real, streaming, and proxy-aware (routes through the Rust HTTP hub); http.request/net.Socket are 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 in node/builtins.rs (the single source of truth) plus its shim.

🤖 Generated with Claude Code

doodlewind and others added 3 commits July 21, 2026 15:53
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>
@doodlewind doodlewind changed the title feat(pocket-pi): run unmodified pi-coding-agent on QuickJS (Path B, M5) feat(pocket-pi): run unmodified pi-coding-agent + extensions on QuickJS (M5–M6) Jul 21, 2026
…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>
@doodlewind doodlewind changed the title feat(pocket-pi): run unmodified pi-coding-agent + extensions on QuickJS (M5–M6) feat(pocket-pi): run unmodified pi-coding-agent + extensions on QuickJS (M5–M7) Jul 22, 2026
… -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
doodlewind marked this pull request as ready for review July 22, 2026 02:27
@doodlewind
doodlewind merged commit 0ab3070 into main Jul 22, 2026
1 check passed
@doodlewind
doodlewind deleted the feat/pocket-pi-run-unmodified-pi branch July 22, 2026 02:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant