5.8 KiB
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.
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.
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-Policywith a per-request nonce (nounsafe-inline)X-Frame-Options: DENYX-Content-Type-Options: nosniffReferrer-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
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
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()"]