ππ Agent
Chapter 19 / 21

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
No simulation scenario available for this chapter

Deep Dive

~7 min read·6619 chars

One pi --mode rpc subprocess; 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

FileLinesResponsibility
packages/coding-agent/src/modes/rpc/rpc-mode.ts801RPC mode: stdin/stdout JSONL protocol, method routing
packages/coding-agent/src/modes/rpc/rpc-client.ts600Client SDK for IDE extensions: reconnect, buffering
packages/server/src/supervisor.ts354Subprocess lifecycle management, crash recovery
packages/server/src/rpc-process.ts201RpcProcessInstance: spawn + multiplexing
packages/server/src/ipc/protocol.ts142Length-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

  1. 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.

  2. 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.

  3. 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

DirectionChapterRelationship
PrerequisiteCh10 CLI Modes--mode rpc dispatched by main.ts
PrerequisiteCh11 AgentSessionRPC methods ultimately call AgentSession
PrerequisiteCh08 Auth & OAuthRemote sessions require credential forwarding
Follow-upCh20 SQLite BackendMulti-process session sharing needs SQLite
RelatedCh04 Session Treefork/branch exposed through RPC methods

Check Your Understanding

  1. If RPC used newline-delimited framing instead of length-prefixed, what would happen when a code snippet contains embedded newlines?
  2. How does recoverAfterRestart determine a child process is "still alive"? What if the PID has been reused by another process?
  3. 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.