# Architecture ## Overview `pi-remote-control` is a pi extension that exposes a running pi session over HTTP/WebSocket. The machine running pi is the **host**; any browser that connects is a **guest**. The server supports two transport modes. In **Surge Ponte** mode it binds to `127.0.0.1` and is reached through a localhost proxy. In **Tailscale** mode it binds to `0.0.0.0` and is reachable via the Tailscale tailnet IP, making it accessible from any device on your tailnet including Android and iOS. The guest interacts through a self-contained single-page app served inline — no CDN, no external assets. ```mermaid graph LR subgraph Host["Host machine"] PI["pi process"] EXT["remote-control extension"] SRV["HTTP + WS server"] PI -->|events| EXT EXT -->|broadcast| SRV SRV -->|sendUserMessage / abort| PI end TUNNEL["Surge Ponte or Tailscale"] SRV <-->|localhost or tailnet| TUNNEL subgraph Guest["Guest browser"] UI["Single-page app"] end TUNNEL <-->|HTTPS / WSS| UI ``` ### Transport modes | Mode | Bind address | Reachable via | Notes | |------|-------------|---------------|-------| | Surge Ponte | `127.0.0.1` | Surge Ponte hostname | macOS only | | Tailscale | `0.0.0.0` | Tailscale IP (100.x.y.z) | Any OS, works on Android/iOS | In Tailscale mode the server binds to `0.0.0.0` so the Tailscale virtual interface can reach the port; access is still protected by the one-time token and session cookie auth flow. ## Files | File | Purpose | |------|---------| | `extensions/pi-remote-control/index.ts` | Extension entry point — registers flag, command, and event bridge | | `extensions/pi-remote-control/server.ts` | HTTP + WebSocket server: `startServer` (Surge), `startServerTailscale` (Tailscale), auth enforcement, client management | | `extensions/pi-remote-control/messages.ts` | Wire protocol: serialize session entries → `RenderMsg`, build `sync` payloads | | `extensions/pi-remote-control/html.ts` | Inline single-page web UI (self-contained, no external deps) | | `extensions/pi-remote-control/auth.ts` | One-time token generation/validation, session cookie helpers | | `extensions/pi-remote-control/config.ts` | Read/write `remote-control.json`, transport mode config, Tailscale IP detection (`detectTailscaleIp`), URL normalization | ## Authentication Flow First-visit authentication exchanges the one-time token for a 24-hour session cookie, keeping the token out of browser history and subsequent requests. Note that the initial `GET /?token=TOKEN` may still be recorded by any proxy or tunnel sitting in front of the server before the 302 redirect issues the cookie. ```mermaid sequenceDiagram actor Guest participant Server Note over Guest: Has URL with ?token=TOKEN Guest->>Server: GET /?token=TOKEN Server->>Server: validateToken() - timing-safe compare Server-->>Guest: 302 to / + Set-Cookie pi_rc_session=ID HttpOnly Guest->>Server: GET / (cookie only) Server-->>Guest: 200 HTML with CSP nonce Guest->>Server: GET /ws (cookie in header) Server->>Server: isAuthenticated() - checks session map Server-->>Guest: 101 Switching Protocols Note over Server,Guest: WebSocket open, full sync sent immediately ``` **Security headers on every HTML response:** - `Content-Security-Policy` with a per-request nonce (no `unsafe-inline`) - `X-Frame-Options: DENY` - `X-Content-Type-Options: nosniff` - `Referrer-Policy: no-referrer` ## Wire Protocol (WebSocket) ### Host → Guest | Message type | Payload fields | When sent | |---|---|---| | `sync` | `messages[]`, `state` (`isStreaming`, `model`, `cwd`, `sessionName`) | On connect, session switch, model change | | `message_update` | `message: RenderMsg` | Streaming assistant turn (partial) | | `message_end` | `message: RenderMsg` | Assistant turn finalized | | `tool_start` | `toolCallId`, `toolName`, `args` | Tool execution begins | | `tool_end` | `toolCallId`, `result`, `isError` | Tool execution completes | | `agent_start` | — | Agent turn starts | | `agent_end` | — | Agent turn ends | | `status` | `clientCount` | Client disconnects | ### Guest → Host | Message type | Payload | Effect | |---|---|---| | `prompt` | `text: string` | `pi.sendUserMessage(text)` — as normal message when idle, as follow-up when busy | | `stop` | — | `ctx.abort()` — cancels the current agent turn | **Per-connection server-side rate limit:** max 30 prompt messages per 60-second sliding window; messages over 64 KB are silently dropped. The limit applies per WebSocket connection — opening multiple connections multiplies it. ### RenderMsg shape ```typescript interface RenderMsg { id: string; // SessionEntry id, or "pending" while streaming role: "user" | "assistant" | "tool_result"; text: string; toolCalls?: Array<{ id: string; name: string; args: string }>; toolName?: string; // tool_result only toolCallId?: string; // tool_result only isError?: boolean; // tool_result only model?: string; // assistant only } ``` ## Session Lifecycle Events ```mermaid flowchart TD SS["session_start"] -->|--remote-control flag| MODE{"Transport?"} MODE -->|Surge Ponte| START["startServer()"] MODE -->|Tailscale| START_TS["startServerTailscale()"] START --> SYNC0["sync all clients"] SW["session_switch"] --> SYNC1["scheduleSync()"] MS["model_select"] -->|when idle| SYNC1 AGs["agent_start"] --> B1["broadcast agent_start"] AGe["agent_end"] --> B2["broadcast agent_end"] MU["message_update"] --> B3["broadcast message_update"] ME["message_end"] --> B4["broadcast message_end"] TS["tool_execution_start"] --> B5["broadcast tool_start"] TE["tool_execution_end"] --> B6["broadcast tool_end"] SD["session_shutdown"] --> STOP["server.stop()"] ```