# Process Sidecar Independent Bun process that owns all long-running work so the API server can restart without disrupting active sessions. ## Problem The API server (port 5000) previously owned all spawned processes: Pi agents, Claude Code sessions, the Anthropic proxy, and the job queue. Restarting the API server would: - Kill active Pi and Claude Code conversations mid-response - Regenerate the Anthropic proxy secret, breaking any Claude Code sessions using it - Lose in-flight job progress (queue engine ran in-process) ## Solution A separate Bun process ("process sidecar") on port 5100 that owns all spawned processes. The API server communicates with it over a single WebSocket connection. The sidecar is managed by pm2 and starts before the API server. ## Architecture ``` ┌─────────────────────────┐ ┌──────────────────────────────┐ │ API Server (:5000) │ WS │ Process Sidecar (:5100) │ │ │◄───────►│ │ │ - Browser WS clients │ │ - Anthropic Proxy (:5051) │ │ - REST API routes │ │ - Claude Code processes │ │ - Channel bots │ │ - Pi agent processes │ │ - sidecar-client.ts │ │ - Job queue engine │ │ (auto-reconnect) │ │ - State persistence │ └─────────────────────────┘ └──────────────────────────────┘ ``` ### What the sidecar owns | Concern | Previous location | Sidecar module | |---------|-------------------|----------------| | Anthropic proxy (port 5051) | `api/anthropic-proxy.ts` | `sidecar/proxy.ts` | | Claude Code spawn + session tracking | `channels/send-claude-code.ts` | `sidecar/claude-manager.ts` | | Pi agent spawn + RPC commands | `api/pi/pi-bridge.ts` | `sidecar/pi-manager.ts` | | Job queue engine + handlers | `queue/engine.ts` | `sidecar/queue-runner.ts` | ### What stays in the API server - Browser WebSocket connections (ephemeral by nature) - REST API routes (now thin proxies to sidecar) - Channel bots (Discord/Telegram/WhatsApp — already reconnect gracefully) - Browser relay (CDP state is ephemeral) - PTY sidecar (already its own process, unchanged) ## Communication Protocol Single WebSocket between API server and sidecar. JSON messages with `{ type, id?, ... }` envelopes. **Request/response**: Commands include an `id` field. The sidecar responds with a message carrying the same `id`. The client correlates responses via this ID with configurable timeouts. **Streaming events**: Pi and Claude Code output events are broadcast to all connected clients without a correlation ID. They carry a `sessionId` or `sessionKey` so the API server can route them to the correct browser WS. ### Command categories ``` ping / pong — health check state:sync — full state snapshot on connect proxy:secret — get persisted proxy secret claude:spawn / claude:result — blocking Claude Code exec claude:spawn-streaming / claude:event — streaming Claude Code exec claude:kill / claude:clear-session — session management pi:spawn / pi:prompt / pi:abort — Pi agent lifecycle pi:kill / pi:set-thinking — Pi session control queue:enqueue / queue:cancel — job management queue:list / queue:get — job queries ``` See `protocol.ts` for the full type definitions. ## State Persistence File: `data/sidecar/state.json` Written every 30 seconds (debounced) and on graceful shutdown (SIGTERM/SIGINT). Contains: - **proxySecret** — generated once on first boot, reused forever. This is the key fix: the Anthropic proxy secret no longer changes on restart. - **claudeSessions** — map of `sessionKey → Claude Code session_id` for `--resume` support across restarts. - **piSessions** — session metadata with PIDs for liveness checking on restart. ### Lockfile `data/sidecar/sidecar.lock` — contains the PID of the running sidecar. On startup, if the lock exists and the PID is alive, the sidecar exits. Stale locks (dead PID) are cleaned up automatically. ## File Structure ``` src/servers/sidecar/ index.ts — entry point: Bun.serve on :5100, WS dispatch, shutdown protocol.ts — shared message types (imported by both sides) proxy.ts — Anthropic proxy server (moved from anthropic-proxy.ts) claude-manager.ts — Claude Code blocking + streaming spawn, session map pi-manager.ts — Pi agent spawn, RPC (prompt/abort/thinking), event parsing queue-runner.ts — Job queue engine (lanes, retries, notifications) state.ts — File-backed state persistence + lockfile src/servers/sidecar-client.ts — API server's WebSocket client (singleton) ``` ## API Server Integration The API server connects to the sidecar on startup via `initSidecarClient()` in `server.tsx`. The client auto-reconnects with exponential backoff (200ms → 15s). On connect, it sends `state:sync` to get the current proxy secret and live session info. ### Modified API server files | File | Change | |------|--------| | `server.tsx` | `startAnthropicProxy()` → `initSidecarClient()` | | `bootstrap.ts` | Removed `initQueue()` (sidecar owns it) | | `channels/send-claude-code.ts` | 550 lines → 73 lines thin client | | `api/pi/websocket.ts` | Spawn/prompt/abort go through sidecar | | `api/pi/session-manager.ts` | Cleanup sidecar subscriptions on delete | | `api/queue/queue.ts` | Routes use sidecar client | | `channels/discord/handler.ts` | `enqueue` → `enqueueJob` via sidecar | | `channels/telegram/handler.ts` | Same | | `channels/whatsapp/handler.ts` | Same | ## PM2 Configuration ```js // ecosystem.config.cjs module.exports = { apps: [ { name: 'officer-sidecar', // starts first script: 'bun', args: 'run src/servers/sidecar/index.ts', watch: false, }, { name: 'officer', script: 'bun', args: 'start', watch: false, }, ], }; ``` The sidecar is listed first so pm2 starts it before the API server. The API server's sidecar client handles the case where the sidecar isn't ready yet (auto-reconnect with backoff). ## Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `SIDECAR_PORT` | `5100` | Sidecar HTTP/WS port | | `ANTHROPIC_PROXY_PORT` | `5051` | Anthropic proxy port (owned by sidecar) | | `DATA_PATH` | `./data` | Shared data directory | ## Manual Testing ### 1. Start the sidecar standalone ```bash # From monorepo root bun run src/servers/sidecar/index.ts ``` Expected output: ``` [sidecar:proxy] listening on 127.0.0.1:5051 [sidecar:queue] initialized [sidecar] listening on 127.0.0.1:5100 ``` ### 2. Health check ```bash # Basic liveness curl http://127.0.0.1:5100/ # → "process-sidecar" # Detailed health curl http://127.0.0.1:5100/health # → {"status":"ok","uptime":12345,"piSessions":0,"claudeSessions":0} ``` ### 3. Verify Anthropic proxy is running ```bash # Should reject without valid secret curl -s http://127.0.0.1:5051/v1/messages # → {"error":"Unauthorized"} # Check state file was created cat data/sidecar/state.json # → should show proxySecret, empty claudeSessions, empty piSessions ``` ### 4. Verify proxy secret persistence ```bash # Note the proxySecret from state.json cat data/sidecar/state.json | jq .proxySecret # Stop and restart the sidecar # Kill the sidecar (Ctrl+C or kill) bun run src/servers/sidecar/index.ts # Check the secret is the same cat data/sidecar/state.json | jq .proxySecret # → should be identical to before ``` ### 5. WebSocket communication test ```bash # In one terminal, start the sidecar bun run src/servers/sidecar/index.ts # In another terminal, connect with websocat (or wscat) # Install: cargo install websocat OR npm install -g wscat websocat ws://127.0.0.1:5100 # Send a ping {"type":"ping","id":"test1"} # → should receive: {"type":"pong","id":"test1"} # Send state sync {"type":"state:sync","id":"test2"} # → should receive: {"type":"state:sync","id":"test2","state":{...}} # Get proxy secret {"type":"proxy:secret","id":"test3"} # → should receive: {"type":"proxy:secret","id":"test3","secret":"sk-ant-api03-..."} ``` ### 6. Test lockfile protection ```bash # Start sidecar in one terminal bun run src/servers/sidecar/index.ts # Try starting another in a second terminal bun run src/servers/sidecar/index.ts # → should exit with: "[sidecar] another instance is already running" # Check lockfile cat data/sidecar/sidecar.lock # → PID of the running sidecar ``` ### 7. Full integration test (sidecar + API server) ```bash # Start sidecar first bun run src/servers/sidecar/index.ts & # Start API server bun start # → should see "[sidecar-client] connected" in logs # Test via the dashboard: # 1. Open a chat panel, send a message with claude-code model # → should see streaming response (routed through sidecar) # 2. Open a chat with a Pi model # → should see streaming response (routed through sidecar) # 3. Restart the API server (kill + bun start) # → active Claude Code processes should NOT die # → sidecar should show "client disconnected" then "client connected" # → proxy secret should remain the same ``` ### 8. Test API server restart resilience This is the key scenario that motivated the sidecar: ```bash # 1. Start sidecar + API server bun run src/servers/sidecar/index.ts & bun start & # 2. Start a Claude Code streaming session in the dashboard # 3. While Claude Code is running, kill the API server kill $(pgrep -f "bun start") # 4. Restart the API server bun start # 5. Check: # - The Claude Code process should still be running (check with ps) # - The proxy secret should be the same (check data/sidecar/state.json) # - The sidecar should show the reconnection in logs ``` ### 9. Test with pm2 ```bash pm2 start ecosystem.config.cjs # Check both are running pm2 status # → officer-sidecar: online # → officer: online # Restart API server only pm2 restart officer # Check sidecar is still running pm2 status curl http://127.0.0.1:5100/health # Stop everything pm2 stop all ``` ### 10. Queue test ```bash # With sidecar running, queue a job via the API curl -X POST http://localhost:5000/api/queue/jobs \ -H "Content-Type: application/json" \ -H "Authorization: Bearer " \ -d '{"lane":"test","type":"gmail-sync"}' # List jobs curl http://localhost:5000/api/queue/jobs \ -H "Authorization: Bearer " ``` ## Graceful Shutdown On SIGTERM or SIGINT, the sidecar: 1. Flushes pending state to `data/sidecar/state.json` 2. Releases the lockfile 3. Does **NOT** kill spawned processes — they are independent OS processes On restart, the sidecar reads the persisted state and checks which PIDs are still alive. ## Risks & Mitigations | Risk | Mitigation | |------|------------| | Sidecar dies with live sessions | pm2 auto-restart + state.json + processes are independent OS processes | | API↔sidecar WebSocket drops | Auto-reconnect with exponential backoff (200ms → 15s) | | Two sidecar instances running | Lockfile with PID liveness check on startup | | Sidecar needs DB access | Imports DB modules directly (same user, same filesystem) | | Queue handlers need server context | Handlers are self-contained modules imported by the sidecar | ## Future Possibilities The current architecture is one API server → one sidecar. But the protocol is sidecar-agnostic and could evolve in the opposite direction: one API server → many sidecars. ``` ┌── sidecar-A (machine 1, user X's files) API server ───┼── sidecar-B (machine 2, heavy compute) └── sidecar-C (localhost, default) ``` In this model the API server becomes the orchestrator — it decides which sidecar to dispatch to based on the user, the task type, or available capacity. A user's Claude Code session runs on the machine where their project files live. A long-running Pi agent gets dispatched to a box with more resources. Quick jobs stay local. What would need to change: - **Sidecar identity**: Each sidecar needs a name/id. The current hardcoded `ws://127.0.0.1:5100` becomes a registry of endpoints (config file or DB table). - **Connection pool**: `sidecar-client.ts` becomes a pool of connections rather than a singleton. - **Routing**: The API server needs a session→sidecar mapping so it knows where to forward browser messages. - **The protocol itself**: Doesn't change. A `pi:spawn` command works the same whether it goes to a local or remote sidecar.