System Documentation

Rig Architecture Logbook

Complete mechanical reference of the Rig dispatch console — covering the React SPA, Fastify proxy, pi subprocess bridge, every component, every endpoint, and every data flow.

Binary Target pi --mode rpc (Node.js CLI, Stdio IPC)
Proxy Engine Fastify 5 + @fastify/websocket (:3100)
Presentation Shell React 19 + Vite SPA (:5173)
Session Storage ~/.pi/agent/sessions/ (JSONL)
01. System Topology

Three Hard Boundaries

Rig runs across three decoupled layers: a static React SPA (the presentation shell), a Node.js Fastify server (the state-holding proxy), and pi child processes (the agent execution engine). The server never imports pi as a library — it discovers it via which pi and spawns it with spawn() from node:child_process. This means Rig is structurally immune to internal changes within the agent, so long as the RPC protocol contract is upheld.

graph LR
    subgraph Frontend [" React 19 SPA (:5173) "]
        APP["App.tsx — Router + State"]
        BOARD["Board.tsx — Session List"]
        LOG["SessionLog.tsx — Work Log"]
        HOOK["useSessionBridge — WS Hook"]
    end

    subgraph Server [" Fastify Proxy (:3100) "]
        ROUTES["routes.ts — REST + WS"]
        BRIDGE["pi-bridge.ts — Process Manager"]
        STORE["session-store.ts — JSONL Reader"]
        FTRACK["file-tracker.ts — Edit Tracker"]
    end

    subgraph PiLayer [" Pi Subprocess "]
        PI["pi --mode rpc"]
        SESS[("~/.pi/agent/sessions/")]
        CONF[("settings.json")]
    end

    APP -->|"REST /api/*"| ROUTES
    HOOK <-->|"WebSocket /api/ws/:id"| ROUTES
    ROUTES -->|"spawn + stdin"| BRIDGE
    BRIDGE -->|"spawn()"| PI
    PI -->|"stdout JSONL"| BRIDGE
    STORE -->|"readFile"| SESS
    ROUTES -->|"readPiSettings()"| CONF
    PI -->|"write"| SESS
                    

File Structure

Project Layout
rig/ ├── frontend/src/ │ ├── components/ # 11 React components │ │ ├── Board.tsx, SessionLog.tsx, EmptyDetail.tsx │ │ ├── NewDispatch.tsx, ModelPicker.tsx, FolderPicker.tsx │ │ ├── ToolCallLine.tsx, FilesPanel.tsx, StatusDot.tsx │ │ ├── ProjectBadge.tsx, ExtensionRequest.tsx │ ├── hooks/ # useSessionBridge.ts (live WS hook) │ ├── lib/ # api.ts (server client), utils.ts │ ├── types/ # TypeScript type definitions │ ├── App.tsx # Root — global state + layout │ └── index.css # Tailwind 4 theme tokens └── server/src/ ├── index.ts # Fastify entry, CORS, static serving ├── routes.ts # REST + WebSocket route handlers ├── pi-bridge.ts # Spawn + manage pi child processes ├── pi-config.ts # Read pi's settings.json (models, defaults) ├── config.ts # Rig config (project registry, port) ├── session-store.ts # Parse JSONL session files for listing └── file-tracker.ts # Track files from tool_execution_start events
02. Frontend Architecture

The Operations Ledger

The React SPA operates as a dispatch console, not a chatbot. Sessions are work orders. Tool calls are first-class log entries — not hidden behind collapsibles. The Board shows all sessions across all projects in one flat list. Active sessions pulse. Completed sessions are calm.

Stack: React 19 + TypeScript + Vite + Tailwind CSS 4. Fonts: Bricolage Grotesque (UI/headings via font-ui), IBM Plex Mono (code/labels via font-mono). Icons: lucide-react. Color: warm charcoal + amber accent. Dark mode is primary.

Core Views

App.tsx Root

Global state anchor. Fetches sessions, projects, and models on mount (polling every 10s). Manages session selection, dispatch flow, and the useSessionBridge hook for live sessions. Desktop: master-detail layout. Mobile: push-navigation.

Board.tsx Session List

Flat unified list of all sessions across all projects, newest first. Active sessions cluster at top. Search bar filters by project name or prompt text. Each row shows project badge, task summary, status dot, and relative timestamp.

SessionLog.tsx Work Log

The core rendering engine. Maps JSONL events into a sequential timeline of directives (user prompts), agent prose (markdown), and tool call log lines. Supports streaming with a live cursor. Includes model selector, thinking level toggle, stop/resume controls, and message input.

EmptyDetail.tsx Empty State

Rich empty state shown when no session is selected. Displays a system status readiness panel (project count, model count), schematic SVG, and a CTA to dispatch new work.

Dispatch & Configuration

NewDispatch.tsx Dispatch Modal

Bottom sheet (mobile) / modal (desktop). Project selector, message input (auto-focus), model selector. Dispatches via POST /api/dispatch. Creates a placeholder session immediately — Pi doesn't flush the session file until the first assistant message.

ModelPicker.tsx Model Selector

Popover menu for model selection. Searchable list of enabled models (shortcuts). "Show all" expands to the full registry grouped by provider, fetched via GET /api/models/all. Two-line layout: display name + model ID.

FolderPicker.tsx Directory Browser

Server-side filesystem browser for selecting project directories. Uses GET /api/browse to traverse the host filesystem. Supports navigation up to parent directories. Hidden dotfiles are filtered.

Log Elements & UI Primitives

ToolCallLine.tsx Log Entry

Compact colored log line per tool call. Color-coded by operation: read (blue), edit (amber), write (green), bash (violet). Shows timestamp, tool name, and file path or command. The design hook — tool calls ARE the work.

FilesPanel.tsx Side Panel

Collapsible panel showing files touched in the current session. Desktop: side panel. Sorted by most recently touched. Extracted from tool_execution_start events.

StatusDot.tsx Indicator

Running: amber pulse animation. Done: static green. Error: red. Used in Board session rows and session detail header.

ProjectBadge.tsx Badge

Deterministic color derived from project path hash — each project always gets the same color. Users recognize projects by color before reading the name.

ExtensionRequest.tsx Modal

Modal overlay for interactive Pi extension requests: confirm, select, input, and editor dialogs. Responses are sent back via extension_ui_response WebSocket messages.

Hooks & Library

useSessionBridge.ts Hook

The critical live session hook. Manages WebSocket connection to /api/ws/:bridgeId. Processes RPC events (message_start, message_update, tool_execution_start/end, extension_ui_request) into log entries. Handles event replay from the server's buffer on connect. Tracks thinking level and touched files.

api.ts Client

Server API client. Typed fetch wrappers for all REST endpoints. Time formatting (timeAgo), model display name shortening, WebSocket URL construction. Uses relative URLs for Vite dev proxy compatibility.

03. Server Architecture

The Fastify Proxy

An intermediary Node.js server holding live session state between the browser and the Pi agent process. Handles raw terminal IO proxying, session file reading, config management, and file tracking.

Entry & Configuration

index.ts Entry Point

Fastify server startup. Registers CORS, WebSocket, and static file plugins. Serves built frontend from frontend/dist/ with SPA fallback. Graceful shutdown: calls killAll() to terminate all Pi processes on SIGINT/SIGTERM.

config.ts Rig Config

Manages ~/.pi/agent/rig.json — the project registry and server port (default 3100). CRUD operations for projects. Read/write.

pi-config.ts Pi Settings

Reads Pi's settings.json — enabled models, default provider/model, thinking level. Read-only. Also exports getSessionsDir() for session file discovery.

Core Services

pi-bridge.ts Process Manager

Spawns pi --mode rpc child processes via spawn() from node:child_process. Manages a pendingRequests Map for request-response correlation over stdio. A readline interface on stdout parses JSON lines — responses match pending request IDs, everything else emits as events. Handles process lifecycle: SIGTERM on stop, SIGKILL after 2000ms fallback.

routes.ts REST + WS

All HTTP routes and WebSocket handler. The registerSession() function wires bridge events to WS clients with an eventBuffer for the dispatch-to-WS race condition. Holds an activeSessions Map binding bridge IDs to live { bridge, fileTracker, wsClients, eventBuffer } objects.

session-store.ts JSONL Reader

Reads Pi's JSONL session files to build the Board listing. Parses each file fully to extract: session header, first user message, message count, last model, thinking level, and modified time. Supports per-project listing via Pi's encoded-cwd directory structure.

file-tracker.ts Edit Tracker

Passively intercepts tool_execution_start events to track files touched by read, edit, and write tools. Maintains a per-session Map of file paths to actions and timestamps. bash commands are intentionally excluded.

04. API Reference

Complete Endpoint Map

All REST and WebSocket endpoints exposed by the Fastify server on port 3100.

Method Path Description
GET /api/health Health check — returns { status, timestamp }
GET /api/sessions List all sessions (optional ?cwd= filter). Includes isActive and bridgeId for live sessions.
GET /api/sessions/:id/entries Read raw JSONL entries for a session. Requires ?path= query param.
GET /api/models Enabled models + default from Pi's settings.json.
GET /api/models/all Full model registry from Pi's runtime. Queries an active bridge or spawns a temporary Pi process. Cached after first fetch.
GET /api/settings Raw Pi settings (pass-through of settings.json).
GET /api/projects Registered projects (merged with auto-discovered projects from session history).
POST /api/projects Register a project. Body: { path, name }.
DEL /api/projects Remove a project. Body: { path }.
GET /api/browse Browse server filesystem directories. Optional ?path= (defaults to $HOME). Returns subdirectories (hidden files excluded).
POST /api/dispatch Spawn a new Pi session. Body: { cwd, message, provider?, model? }. Returns { bridgeId, sessionId, sessionFile }.
POST /api/resume Resume an existing session. Body: { sessionFile, cwd }. Returns { bridgeId }. Returns alreadyActive: true if session is already running.
POST /api/stop Kill an active session. Body: { bridgeId }.
GET /api/active List all active Pi bridges with their state (cwd, alive, WS client count, tracked files).
WS /api/ws/:bridgeId Live session WebSocket. Server sends: state, files, event (buffered replay + live), exit. Client sends: command, extension_ui_response.
05. Execution Flow

The Dispatch Lifecycle

Rig uses a split sequence to start an agent. POST /api/dispatch spawns the Pi process, queries its state, and sends the initial prompt — all before returning the HTTP response. Meanwhile, Pi begins emitting events immediately after spawn.

The frontend needs hundreds of milliseconds to process the HTTP response, swap views, and open a WebSocket. To prevent losing Pi's initial output, the server caches all events in an eventBuffer array until the first WebSocket client connects, then flushes the buffer in order.

sequenceDiagram
    autonumber
    participant Client as React SPA
    participant Server as Fastify API
    participant Bridge as pi-bridge.ts
    participant Pi as pi --mode rpc

    Client->>Server: POST /api/dispatch
    Server->>Bridge: spawnPi(cwd, model)
    Bridge->>Pi: spawn("pi", ["--mode", "rpc"])

    Note right of Pi: Emits immediately
    Pi-->>Bridge: stdout JSONL events
    Bridge-->>Server: emit("event", data)

    rect rgba(195, 90, 57, 0.08)
    Note over Server: No WS clients
    Server->>Server: eventBuffer.push(data)
    end

    Server->>Bridge: sendCommand(get_state)
    Bridge->>Pi: stdin: get_state req
    Pi-->>Bridge: stdout: response
    Bridge-->>Server: resolve(state)

    Server->>Bridge: sendCommand(prompt, message)
    Bridge->>Pi: stdin: prompt req
    Pi-->>Bridge: stdout: more events
    Bridge-->>Server: emit("event", data)
    Server->>Server: eventBuffer.push(data)

    Server-->>Client: 200 OK bridgeId

    Note over Client,Server: WebSocket handshake
    Client->>Server: WS connect /api/ws/:bridgeId
    Server-->>Client: flush eventBuffer[]
    Server->>Server: wsClients.add(socket)

    Pi-->>Bridge: stdout: live events
    Bridge-->>Server: emit("event", data)
    Server-->>Client: WS: live event stream
                    
routes.ts — Event buffer in registerSession()
bridge.events.on("event", (event: any) => { fileTracker.processEvent(event); if (wsClients.size === 0) { eventBuffer.push(event); } else { const json = JSON.stringify({ type: "event", event }); for (const ws of wsClients) { if (ws.readyState === 1) ws.send(json); } } });
06. Stream Engineering

Synchronous RPC over Streams

Standard Unix streams don't inherently map responses to requests. When Fastify queries Pi for something like get_available_models, it writes raw JSON to an asynchronous pipe (stdin). There's no built-in way to correlate the response.

pi-bridge.ts constructs a synchronous request-response layer over the async streams. Every outbound command gets tagged with a unique ID (req_1, req_2, ...). An active JavaScript Promise resolve() closure is stored in a pendingRequests Map. A readline loop on stdout checks every incoming JSON line — if it has a matching data.id, the corresponding Promise is resolved. Everything else emits as an event to the WebSocket layer.

flowchart TD
    subgraph BridgeOut ["Outbound (stdin)"]
        A["sendCommand(bridge, cmd)"] --> B["Tag with id: req_N"]
        B --> C[("pendingRequests Map")]
        B --> D["stdin.write(JSON + newline)"]
    end

    subgraph PiProc ["Pi Process"]
        D --> E(["Agent event loop"])
        E --> F["Generate output"]
        F --> G["stdout line"]
    end

    subgraph Parser ["Inbound Parser (readline on stdout)"]
        G --> H{"Valid JSON?"}
        H -->|No| SKIP["Discard silently"]
        H -->|Yes| I{"type === response AND id in Map?"}
        I -->|Yes| J["resolve(data) — Promise completes"]
        I -->|No| K["events.emit — forward to WS"]
    end
                    

The File Tracker Side-Channel

Alongside the Promise-based RPC, file-tracker.ts passively intercepts all events flowing through the bridge. It never sends anything back — it's a read-only observer. When it sees a tool_execution_start event for read, edit, or write tools, it extracts the file path from the args and adds it to its tracking Map. The frontend similarly tracks files in useSessionBridge via the same event type — both server and client maintain parallel file lists.

pi-bridge.ts — The readline parser core
rl.on("line", (line) => { try { const data = JSON.parse(line); // Response to a pending request? if (data.type === "response" && data.id && bridge.pendingRequests.has(data.id)) { const pending = bridge.pendingRequests.get(data.id)!; bridge.pendingRequests.delete(data.id); clearTimeout(pending.timer); pending.resolve(data); return; } // Otherwise it's an event — emit to subscribers bridge.events.emit("event", data); } catch { // Non-JSON line, ignore } });