Skip to content

pocket-stack/pocket-pi

Repository files navigation

Pocket Pi

Run the unmodified pi coding-agent — and its extensions — inside QuickJS. No Node, no bun.

Pocket Pi is a small Rust runtime that embeds QuickJS and gives JavaScript exactly enough of a Node/Web platform — a module system, the node: builtins, fetch, and a native TypeScript loader — that the whole, unmodified pi-coding-agent runs on it, drives real LLM turns with tools, loads real extensions, and persists sessions. It is the substrate the cat desktop-assistant harness runs on, and the sibling of PocketJS: where PocketJS proved a UI runtime can live outside the browser under a tiny budget, Pocket Pi does the same for an agent runtime.

┌────────────────────────── PiRuntime (one QuickJS realm, one thread) ──────────────────────────┐
│  prelude.js        timers · AbortController · fetch/Response/ReadableStream · Blob/FormData     │
│  node: builtins    fs · path · child_process · process · buffer · events · stream · … (JS)      │
│  pi-full.bundle    the UNMODIFIED pi-coding-agent, esbuild-bundled to one ES module             │
│  your extension    a .ts factory, transpiled by oxc at load — NOT jiti                          │
│        │  __node (resolve/fs/spawn)   host.http   host.transpile   host.tool   host.emit        │
│  ──────┼─────────────────────────────────────────────────────────────────────────────────────  │
│ native │  NodeResolver/NodeLoader  ·  oxc TS→JS  ·  fs & subprocess ops  ·  HTTP hub (TLS+SSE)  │
│ pump() │  timers → deliver fetch/LLM chunks → drain job queue → flush events → your callback    │
└────────┴───────────────────────────────────────────────────────────────────────────────────────┘

What works today

  • Unmodified pi-coding-agent runs end-to-end on QuickJS — a real gpt-5.6 turn streams tokens and completes, tools and all. No source edits to pi; it's a real npm dependency synced with npm update.
  • Extensions load through Pocket Pi's own loader (oxc TypeScript transpile, no jiti). A normal pi extension registers tools and lifecycle hooks, and the agent can call those tools in a live turn.
  • Sessions persist and resume — pi's SessionManager reads/writes .jsonl through the runtime's fs builtin.
  • A near-complete Node/Web platform: a Node resolver/loader (relative, node_modules, exports/imports, .ts on the fly), CommonJS interop, ~30 node: builtins, and WHATWG fetch/Response/ReadableStream/Headers/URL backed by a native, proxy-aware HTTP hub.
  • A coalesced frame scheduler — an agent spends almost all its time waiting on the model, so the host drives work in pump() frames and can run as slow as 2 Hz while a turn streams, at near-zero idle CPU. See ARCHITECTURE.md.
  • CI: clippy (-D warnings) + build + test on every push and PR.

Writing an extension

An extension for Pocket Pi 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, so the file is written in TypeScript and may import node: builtins, relative modules, and npm packages.

// my-extension.ts
export default (pi) => {
  pi.registerTool({
    name: "echo",
    description: "Echo the given text back.",
    parameters: {
      type: "object",
      properties: { text: { type: "string" } },
      required: ["text"],
    },
    // pi calls execute(toolCallId, input, signal, onUpdate, ctx).
    // Return a result whose `content` is an array of content blocks.
    execute: async (_id, input) => ({
      content: [{ type: "text", text: String(input.text) }],
    }),
  });

  pi.on("agent_start", () => {
    /* lifecycle hook — fires when a turn begins */
  });

  // also available: pi.registerCommand / registerFlag / registerShortcut /
  // registerMessageRenderer / sendMessage / exec / getActiveTools / …
};

Pocket Pi loads it through pi's own extensionFactories seam — no jiti, no Node. The host imports the file (which routes through the oxc loader) and hands the factory to createAgentSession; the session's extension runner then exposes the tool and hook. The runnable reference is js/src/pi-full/driver.ts:

// the essence — see driver.js for the full session setup
const factory = (await import("/abs/path/my-extension.ts")).default; // ← oxc transpiles the .ts
const resourceLoader = new DefaultResourceLoader({
  cwd, agentDir, settingsManager,
  noExtensions: true,           // skip on-disk (jiti) discovery
  extensionFactories: [factory] // inject ours — loaded via loadExtensionFromFactory, no jiti
});
await resourceLoader.reload();
const { session } = await createAgentSession({ model, resourceLoader, tools: ["echo"], /* … */ });

What an extension can call

Everything import-resolves (a bundled extension never crashes at load), but the surface splits into real, partial, and 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), 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() — real, streaming, and proxy-aware (routes through the native HTTP hub); http.request/net.Socket are stubbed toward it. Also available as globals: Response, Headers, URL, URLSearchParams, ReadableStream, TextEncoder/TextDecoder, Blob/File/ FormData, AbortController, structuredClone, Buffer, setTimeout/setImmediate.

Bottom line: an extension that reads/writes files, shells out, or calls an HTTP API 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 crates/pocket-pi/src/node/builtins.rs (the single source of truth for builtins) plus its JS shim.


Embedding Pocket Pi (Rust API)

If you're building a host on top of Pocket Pi — like cat — this is your surface. PiRuntime owns one QuickJS realm and is driven from a single thread; the host owns the pump() cadence.

use pocket_pi::{PiRuntime, ToolResult};

let mut rt = PiRuntime::new()?;

// Native tools are Rust closures the agent calls by name.
rt.register_tool("current_time", |_args| ToolResult::text(now_unix().to_string()));
rt.on_event(|ev| println!("{}: {}", ev.kind, ev.raw));   // start / text / tool_* / end

rt.boot(r#"{"model":"gpt-5.6","apiKey":"…","systemPrompt":"Be terse."}"#)?;
rt.prompt("What time is it?")?;

// Pump at whatever cadence suits the host — 2 Hz is plenty while streaming.
while !rt.is_idle() {
    rt.pump()?;
    std::thread::sleep(std::time::Duration::from_millis(500));
}

PiRuntime::new() embeds and loads the whole, unmodified pi-coding-agent — sessions, extensions, pi's own tool suite — plus a host harness. boot stands up a pi session from a config (provider/model/apiKey/systemPrompt/tools); each config tool bridges to the native Rust closure you registered with register_tool (that's how cat's look_at_screen screenshot tool reaches the agent); prompt runs a turn and events stream to on_event. There is one path — no trimmed variant, no feature flag.

Other PiRuntime methods: run_module, eval_script, get_global_json,abort, is_idle.


How pi runs unmodified

QuickJS's module linker null-derefs (js_inner_module_linking, quickjs.c:30806) when you import createAgentSession and pull pi-coding-agent's ~500-module circular graph — an engine-level bug on graphs that large. The fix is to hand QuickJS one module instead of five hundred: esbuild bundles the unmodified pi source into a single ES module (only node:* left external), and Pocket Pi's Node/Web layer satisfies it at runtime. Bundling isn't forking — every dependency is the real, unmodified upstream package. (The one substitution is undici, pi's HTTP-proxy transport, aliased to a stub — Pocket Pi proxies in the native hub, so undici is never used; this keeps its whole web-fetch stack out of the bundle.)

cd js && npm install
node build.mjs   # → pi-full.bundle.js.gz (~1.8 MB, committed + embedded) + runtime glue

The .gz is committed and include_bytes!'d into the crate, so PiRuntime carries the whole pi with no external files — and cargo build (including from cat's vendored submodule) needs only Rust, no Node.

Staying in sync with upstream pi (@earendil-works/pi-coding-agent) is npm update + node js/build.mjs + commit — no patches to carry.


Footprint

Everything ships as one self-contained binary carrying the whole unmodified pi (the ~9 MB bundle, gzip-embedded to ~1.8 MB) — nothing to npm install at the destination:

Shipping pi as… Size
Pocket Pi — one binary, full unmodified pi embedded ~8.9 MB
bun build --compile (providers external; embeds JavaScriptCore) ~61 MB
node runtime + node_modules (pi-agent-core + pi-ai + deps) ~114 MB + ~131 MB

The base is dominated by oxc (~1.6 MB — the TypeScript transpiler for extensions), TLS rustls+ring (~0.6 MB), regex (~0.5 MB), and QuickJS (~0.5 MB).


Build & test

# Acceptance: a self-contained ~8.9 MB binary carrying the whole pi says hello.
OPENAI_API_KEY=…  cargo run --release --example hello    # → "Hello! Nice to meet you."

# The full pi bundle is committed + embedded, so the whole suite runs with only
# Rust — no Node, no build step. This includes the offline pi tests: it loads,
# an extension binds to a session, sessions persist to disk.
cargo test
cargo clippy --workspace --all-targets -- -D warnings

# Rebuild the guest layer only after editing js/src/**:
cd js && npm install && node js/build.mjs

# Real-turn tests need an API key + (here) a proxy; they skip without one:
OPENAI_API_KEY=… OPENAI_MODEL=gpt-5.6 https_proxy=http://127.0.0.1:7897 \
  cargo test -p pocket-pi live_openai_turn -- --nocapture         # boot/prompt → real turn
OPENAI_API_KEY=… https_proxy=http://127.0.0.1:7897 \
  cargo test -p pocket-pi runs_pi_turn_with_extension_tool -- --ignored --nocapture

MIT.

About

Run the pi coding-agent core inside QuickJS — no Node, no bun — on a PocketJS-style coalesced frame scheduler. The substrate for the cat desktop assistant.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages