RPC Mode & pi-server
Beyond the terminal: JSONL RPC & a supervisor daemon
“One pi --mode rpc child per instance; length-delimited IPC over a Unix socket”
Deep Dive
One
pi --mode rpcsubprocess; length-prefixed IPC over Unix socket
Learning Objectives
After this chapter you will be able to:
- Explain how RPC mode lets non-Node programs (Java, Python, browsers) drive pi
- Describe the JSONL request-response + event-stream dual protocol design
- Explain how the supervisor guards multiple RPC subprocesses and recovers after restart
- Understand why Unix socket length-prefixed frames are binary-safe without escaping
Key Source Files
| File | Lines | Responsibility |
|---|---|---|
packages/coding-agent/src/modes/rpc/rpc-mode.ts | 801 | RPC mode: stdin/stdout JSONL protocol, method routing |
packages/coding-agent/src/modes/rpc/rpc-client.ts | 600 | Client SDK for IDE extensions: reconnect, buffering |
packages/server/src/supervisor.ts | 354 | Subprocess lifecycle management, crash recovery |
packages/server/src/rpc-process.ts | 201 | RpcProcessInstance: spawn + multiplexing |
packages/server/src/ipc/protocol.ts | 142 | Length-prefixed frame encode/decode |
Design Motivation: Why RPC Mode?
Problem: pi is a Node.js/Bun program. But its users live in:
- JetBrains plugins (Java/Kotlin — cannot call Node directly)
- Web frontends (browser — cannot spawn processes)
- Automation scripts (Python/Go — want to drive pi programmatically)
If only interactive mode existed, non-Node environments could not use pi. If only print mode existed, there would be no long-lived connection or event subscription.
Analogy: Like Language Server Protocol (LSP) for editors — the editor need not understand the compiler internals; it just speaks JSON-RPC. pi's RPC mode is an "Agent Server Protocol."
Solution: stdin/stdout JSONL protocol. Any language that can spawn a process and read/write pipes can drive pi.
Protocol: JSONL Request-Response + Event Stream
IDE/script ──stdin──→ pi --mode rpc ──stdout──→ IDE/script
Request (one JSON per line, has id):
{"id": 1, "method": "prompt", "params": {"text": "fix bug", "session": "abc"}}
{"id": 2, "method": "new_session", "params": {"cwd": "/repo"}}
{"id": 3, "method": "event_subscribe", "params": {"session": "abc"}}
Response (matched by id):
{"id": 1, "result": {"stopReason": "end_turn"}}
{"id": 2, "result": {"sessionId": "abc"}}
Event stream (pushed continuously after subscribe, no id):
{"event": "text_delta", "session": "abc", "data": {"text": "Looking at"}}
{"event": "tool_use", "session": "abc", "data": {"name": "edit", "args": {...}}}
{"event": "agent_end", "session": "abc", "data": {}}
Key design decisions:
- Request-response is synchronous (id-matched) — caller blocks until result
- Event stream is asynchronous (subscribe once, receive continuously) — no polling
- One connection can subscribe to multiple sessions simultaneously
Supervisor: Subprocess Guardianship
class Supervisor {
private instances = new Map<string, RpcProcessInstance>()
async spawn(opts: SpawnOptions): Promise<RpcProcessInstance> {
const proc = new RpcProcessInstance(opts)
await proc.start() // spawn: node rpc-entry or bun pi --mode rpc
this.instances.set(proc.id, proc)
this.persistState() // write instances.json for crash recovery
return proc
}
// Recover after daemon restart: reattach surviving child processes
async recoverAfterRestart() {
const saved = JSON.parse(readFileSync("instances.json", "utf-8"))
for (const entry of saved) {
if (isProcessAlive(entry.pid)) {
this.instances.set(entry.id, RpcProcessInstance.reattach(entry))
}
}
}
}
Why a supervisor? Multiple IDE windows may connect simultaneously — each session gets its own subprocess. Crashed children need cleanup. After daemon upgrade, existing sessions must not be lost.
IPC: Unix Socket Length-Prefixed Frames
// protocol.ts — frame format
// ┌──────────┬─────────────────┐
// │ 4 bytes │ N bytes │
// │ length N │ JSON payload │
// └──────────┴─────────────────┘
function encodeFrame(msg: object): Buffer {
const json = Buffer.from(JSON.stringify(msg))
const header = Buffer.alloc(4)
header.writeUInt32BE(json.length)
return Buffer.concat([header, json])
}
function decodeFrames(stream: Readable): AsyncGenerator<object> {
// read 4-byte length → read N-byte JSON → yield parsed object
}
Why length-prefixed instead of newline-delimited?
- JSON content may contain newlines (multi-line code snippets)
- Length prefix is binary-safe — no escaping required
- Parsing is trivial: read 4 bytes → read N bytes → done
Engineering Insights
-
rpc-mode.ts at 801 lines: Mostly method routing (prompt/fork/list_models/export…) and error handling. The protocol itself is simple — complexity lies in mapping AgentSession's rich capabilities onto flat RPC methods.
-
rpc-client.ts at 600 lines: A client SDK for IDE extensions. Handles reconnection, event buffering, and type safety. The VS Code extension consumes this package directly.
-
instances.json crash-safety: Supervisor state lives not only in memory — persisting to file enables recovery after daemon restart. This echoes the append-only crash-safety philosophy from Chapter 4's JSONL design.
Cross-Chapter Links
| Direction | Chapter | Relationship |
|---|---|---|
| Prerequisite | Ch10 CLI Modes | --mode rpc dispatched by main.ts |
| Prerequisite | Ch11 AgentSession | RPC methods ultimately call AgentSession |
| Prerequisite | Ch08 Auth & OAuth | Remote sessions require credential forwarding |
| Follow-up | Ch20 SQLite Backend | Multi-process session sharing needs SQLite |
| Related | Ch04 Session Tree | fork/branch exposed through RPC methods |
Check Your Understanding
- If RPC used newline-delimited framing instead of length-prefixed, what would happen when a code snippet contains embedded newlines?
- How does
recoverAfterRestartdetermine a child process is "still alive"? What if the PID has been reused by another process? - Why is the event stream "subscribe then push" rather than polling? What are the latency and resource implications?
Takeaways
- RPC = stdin/stdout JSONL — any language can drive pi
- Dual protocol: request-response (synchronous) + event stream (async subscribe)
- Supervisor guards multiple subprocesses + instances.json enables restart recovery
- Unix socket length-prefixed frames — binary-safe, no escaping needed
This chapter is based on the main-branch source of earendil-works/pi.