diff options
| author | Aman Kumar <145339995+amankumarsingh77@users.noreply.github.com> | 2026-06-21 00:46:44 +0530 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-06-21 00:46:44 +0530 |
| commit | da5d31af999b728c31ab0169373531cab4543f0c (patch) | |
| tree | 5d350ad1428ff332372a9c724ac46c964d8ca477 /src/daemon/bridge.ts | |
| parent | 8db684d43366d335760e106eae39a74376bd7640 (diff) | |
release: v0.6.0 — per-tab isolation, Unix socket daemon, on-demand init (#6)
* feat: Unix socket daemon with auto-reconnect for persistent Chrome CDP
Eliminates the 'Allow Remote Debugging' Chrome prompt that appeared on
every pi session by multiplexing pi instances through a single persistent
daemon process.
Key additions:
- Unix socket IPC (net.createServer + readline JSON framing)
- CdpBridge with ID multiplexer + event router
- DaemonTransport implementing CdpTransport interface
- Auto-spawn with ensureDaemon() (lazy, checks socket)
Fixes since initial implementation:
- Bridge auto-reconnects to Chrome with exponential backoff (1s-60s).
Previously the bridge tried once at startup and permanently failed
if Chrome wasn't ready — causing 'Chrome not connected' on every
browser tool call.
- Daemon spawn uses project-local tsx binary instead of global --import
flag, fixing silent spawn failure (ERR_MODULE_NOT_FOUND).
- Stale ctx crash: persistState in onOwnershipChange wrapped in
try/catch to prevent CDP event consumer crashes.
49 test assertions across 4 test files. Zero TypeScript errors.
* feat: on-demand browser initialization
Browser daemon no longer auto-starts on session_start. Instead:
- session_start creates the client lazily but does NOT connect or spawn
the daemon. Zero Chrome prompts for non-browser pi sessions.
- Browser tool wrapper checks for daemon socket before each call.
Socket missing → clear error: 'Run /browser-setup first'.
Socket exists → proceeds silently (daemon already running).
- /browser-setup is the explicit, one-time init point. It spawns
the daemon and connects to Chrome only when the user opts in.
- SKILL.md updated with Connection Setup instructions telling agents
to direct users to /browser-setup when socket is missing.
* fix: checkChromeRunning on macOS — match by substring not exact
ps -A -o comm= returns full executable paths on modern macOS
(e.g. /Applications/Google Chrome.app/Contents/MacOS/Google Chrome),
not the basename. The old exact-match check (browserProcesses.includes(l))
never matched, causing false 'No browser instance running' errors.
Changed to substring includes() with helper/renderer/crashpad filtering.
* feat: browser_setup as agent-callable tool
The agent can now self-recover from 'Browser harness not initialized'
errors by calling browser_setup directly — no user mediation needed.
- Extracted shared performSetup() from the slash command into setup.ts
- Created src/domains/setup.ts with ensureAlive:false to bypass the
socket guard (this tool IS the setup, can't require itself)
- Registered browser_setup in the tool registry
- Updated SKILL.md: agent calls browser_setup directly, not ask user
- Removed stale test/manual/spawn-test.ts
* release: v0.6.0 — per-tab isolation, Unix socket daemon, on-demand init
Diffstat (limited to 'src/daemon/bridge.ts')
| -rw-r--r-- | src/daemon/bridge.ts | 411 |
1 files changed, 411 insertions, 0 deletions
diff --git a/src/daemon/bridge.ts b/src/daemon/bridge.ts new file mode 100644 index 0000000..c2c1355 --- /dev/null +++ b/src/daemon/bridge.ts @@ -0,0 +1,411 @@ +/** + * CdpBridge — persistent WebSocket to Chrome, ID multiplexing, event routing. + * + * A single long-lived process connection to Chrome that proxies CDP traffic + * for multiple pi clients connected via the IpcServer. One Allow dialog ever. + * + * References: + * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals + * - CDP sessions + IDs: https://github.com/aslushnikov/getting-started-with-cdp#targets--sessions + * - Target domain: https://chromedevtools.github.io/devtools-protocol/tot/Target/ + */ + +import WebSocket from "ws"; +import { discoverWsUrl } from "../cdp/discovery"; +import type { CdpRawMessage } from "../cdp/types"; +import type { WireRequest, WireResponse, WireEvent } from "./protocol"; +import { CDP_CONNECT_TIMEOUT_MS, CDP_COMMAND_TIMEOUT_MS } from "./protocol"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type SendToClient = (clientId: string, msg: WireResponse | WireEvent) => void; + +export type EventHandler = (event: WireEvent, targetClientIds: string[]) => void; + +export type CloseHandler = () => void; + +export type CdpBridge = { + /** Start the bridge. Resolves when the daemon IPC + Chrome discovery loop + * are initialized (not when Chrome is connected — that may take retries). */ + start(): Promise<void>; + stop(): Promise<void>; + handleRequest(req: WireRequest, clientId: string, send: SendToClient): void; + /** True when the Chrome WebSocket is open and ready. */ + isAlive(): boolean; + recordSession(clientId: string, sessionId: string): void; + releaseSession(sessionId: string): void; + getSessionOwner(sessionId: string): string | undefined; + removeClient(clientId: string): void; + onEvent(handler: EventHandler): void; + onClose(handler: CloseHandler): void; +}; + +// ── ID Multiplexer ──────────────────────────────────────────────────────────── + +interface PendingEntry { + clientId: string; + localId: number; +} + +class IdMultiplexer { + private nextId = 1; + private localToRemote = new Map<string, Map<number, number>>(); + private pending = new Map<number, PendingEntry>(); + + allocate(clientId: string, localId: number): number { + const daemonId = this.nextId++; + let m = this.localToRemote.get(clientId); + if (!m) { m = new Map(); this.localToRemote.set(clientId, m); } + m.set(localId, daemonId); + this.pending.set(daemonId, { clientId, localId }); + return daemonId; + } + + resolve(daemonId: number): PendingEntry | null { + const e = this.pending.get(daemonId); + if (!e) return null; + this.pending.delete(daemonId); + const m = this.localToRemote.get(e.clientId); + if (m) m.delete(e.localId); + return e; + } + + clearClient(clientId: string): void { + const m = this.localToRemote.get(clientId); + if (m) { for (const [, did] of m) this.pending.delete(did); } + this.localToRemote.delete(clientId); + } +} + +// ── Event Router ─────────────────────────────────────────────────────────────── + +class EventRouter { + private owners = new Map<string, string>(); // sessionId → clientId + private clientSessions = new Map<string, Set<string>>(); // clientId → sessions + + record(clientId: string, sessionId: string): void { + const prev = this.owners.get(sessionId); + if (prev && prev !== clientId) { + this.clientSessions.get(prev)?.delete(sessionId); + } + this.owners.set(sessionId, clientId); + let s = this.clientSessions.get(clientId); + if (!s) { s = new Set(); this.clientSessions.set(clientId, s); } + s.add(sessionId); + } + + release(sessionId: string): void { + const owner = this.owners.get(sessionId); + if (owner) this.clientSessions.get(owner)?.delete(sessionId); + this.owners.delete(sessionId); + } + + getOwner(sessionId: string): string | undefined { + return this.owners.get(sessionId); + } + + removeClient(clientId: string): void { + const sessions = this.clientSessions.get(clientId); + if (sessions) { for (const sid of sessions) this.owners.delete(sid); } + this.clientSessions.delete(clientId); + } + + /** + * Route a CDP event to client(s). + * Session-scoped → only the owning client. + * Browser-level (no sessionId) → all connected clients. + */ + route(event: WireEvent): string[] { + if (event.sessionId) { + const owner = this.owners.get(event.sessionId); + return owner ? [owner] : []; + } + return [...this.clientSessions.keys()]; + } + + allClients(): string[] { + return [...this.clientSessions.keys()]; + } +} + +// ── Implementation ───────────────────────────────────────────────────────────── + +export const createCdpBridge = (): CdpBridge => { + let ws: WebSocket | null = null; + let wsUrl: string | null = null; + const mux = new IdMultiplexer(); + const router = new EventRouter(); + let eventHandler: EventHandler | null = null; + let closeHandler: CloseHandler | null = null; + + // daemonId → { send, timer, isAttach } + const callbacks = new Map<number, { + send: SendToClient; + clientId: string; + localId: number; + timer: ReturnType<typeof setTimeout>; + isAttach: boolean; + }>(); + + // ── Handle raw CDP messages from Chrome ───────────────────────────────── + + const onChromeMessage = (raw: string): void => { + let msg: CdpRawMessage; + try { msg = JSON.parse(raw); } catch { return; } + + // ---- Response (has id) ---- + if (msg.id !== undefined) { + const cb = callbacks.get(msg.id); + if (!cb) { + // Stale response — client already timed out or disconnected. + // Still clean up the multiplexer mapping. + mux.resolve(msg.id); + return; + } + clearTimeout(cb.timer); + callbacks.delete(msg.id); + + const localId = cb.localId; + + if (msg.error) { + cb.send(cb.clientId, { + type: "response", + id: localId, + error: { code: msg.error.code ?? -1, message: msg.error.message }, + }); + } else { + // Track session ownership for attach responses + if (cb.isAttach && msg.result) { + const result = msg.result as { sessionId?: string }; + if (result.sessionId) { + router.record(cb.clientId, result.sessionId); + } + } + cb.send(cb.clientId, { type: "response", id: localId, result: msg.result }); + } + + mux.resolve(msg.id); + return; + } + + // ---- Event (no id, has method) ---- + if (!msg.method) return; + + // Inspector.detached — release session ownership + if (msg.method === "Inspector.detached" && msg.sessionId) { + router.release(msg.sessionId); + } + + const wireEvent: WireEvent = { + type: "event", + method: msg.method, + ...(msg.params !== undefined ? { params: msg.params } : {}), + ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}), + }; + + // Route through the event handler for the IpcServer to broadcast + if (eventHandler) { + const targets = router.route(wireEvent); + if (targets.length > 0) { + eventHandler(wireEvent, targets); + } + } + }; + + // ── Connect to Chrome ─────────────────────────────────────────────────── + + // ── Auto-reconnecting Chrome connection ─────────────────────────────── + + let reconnectTimer: ReturnType<typeof setTimeout> | null = null; + let reconnectAttempt = 0; + let stopped = false; + + /** + * Try to discover Chrome and open a WebSocket. On failure, schedule + * an exponential backoff retry (capped at 60s). When Chrome finally + * becomes available, the bridge connects automatically — no manual + * daemon restart needed. + */ + const tryConnect = (): void => { + if (stopped) return; + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + + const attempt = async (): Promise<void> => { + // Already connected — nothing to do. + if (ws && ws.readyState === WebSocket.OPEN) return; + + // Resolve URL (discover if not cached, or clear cache on retry) + let url = wsUrl; + if (!url || reconnectAttempt > 0) { + const d = await discoverWsUrl(); + if (!d.success) { scheduleRetry(); return; } + url = d.data; + wsUrl = url; + } + + const settledPromise = new Promise<void>((settle) => { + let settled = false; + const settleOnce = (): void => { + if (settled) return; + settled = true; + settle(); + }; + + let sock: WebSocket; + try { + sock = new WebSocket(url!, { perMessageDeflate: false }); + } catch { + scheduleRetry(); + return; + } + + const timer = setTimeout(() => { + sock.close(); + settleOnce(); + }, CDP_CONNECT_TIMEOUT_MS); + + sock.on("open", () => { + clearTimeout(timer); + ws = sock; + reconnectAttempt = 0; + // Enable target discovery so we see page events + ws.send(JSON.stringify({ id: 0, method: "Target.setDiscoverTargets", params: { discover: true } })); + console.log("[pi-browser-daemon] Connected to Chrome ✓"); + settleOnce(); + }); + + sock.on("message", (data: WebSocket.Data) => { + onChromeMessage(typeof data === "string" ? data : data.toString()); + }); + + sock.on("error", () => { + clearTimeout(timer); + settleOnce(); + }); + + sock.on("close", () => { + clearTimeout(timer); + ws = null; + // Reject all pending callbacks + for (const [daemonId, cb] of callbacks) { + clearTimeout(cb.timer); + cb.send(cb.clientId, { + type: "response", + id: cb.localId, + error: { code: -32000, message: "Chrome disconnected" }, + }); + callbacks.delete(daemonId); + mux.resolve(daemonId); + } + closeHandler?.(); + // Auto-reconnect on unexpected close + scheduleRetry(); + }); + }); + + await settledPromise; + // If connection didn't succeed, schedule a retry + if (!ws || ws.readyState !== WebSocket.OPEN) { + scheduleRetry(); + } + }; + + attempt().catch(() => scheduleRetry()); + }; + + /** Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s (capped). */ + const scheduleRetry = (): void => { + if (stopped) return; + const delay = Math.min(1000 * Math.pow(2, Math.min(reconnectAttempt, 6)), 60_000); + reconnectAttempt++; + wsUrl = null; // force re-discovery on next attempt + reconnectTimer = setTimeout(tryConnect, delay); + }; + + const start = async (): Promise<void> => { + stopped = false; + // Don't await — the retry loop runs in the background. + // The daemon is "started" when the IPC server is listening, not when + // Chrome is connected. + tryConnect(); + }; + + const stop = async (): Promise<void> => { + stopped = true; + if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } + for (const [, cb] of callbacks) clearTimeout(cb.timer); + callbacks.clear(); + if (ws) { try { ws.close(1000, "Shutdown"); } catch {} ws = null; } + wsUrl = null; + }; + + const handleRequest = ( + req: WireRequest, + clientId: string, + send: SendToClient, + ): void => { + if (!ws || ws.readyState !== WebSocket.OPEN) { + send(clientId, { + type: "response", + id: req.id, + error: { code: -32000, message: "Chrome not connected" }, + }); + return; + } + + const daemonId = mux.allocate(clientId, req.id); + + // Store callback for the response + const timer = setTimeout(() => { + callbacks.delete(daemonId); + mux.resolve(daemonId); + send(clientId, { + type: "response", + id: req.id, + error: { code: -32000, message: `Timeout after ${CDP_COMMAND_TIMEOUT_MS}ms: ${req.method}` }, + }); + }, CDP_COMMAND_TIMEOUT_MS); + + const isAttach = req.method === "Target.attachToTarget"; + const isDetach = req.method === "Target.detachFromTarget"; + + if (isDetach && req.sessionId) { + router.release(req.sessionId); + } + + callbacks.set(daemonId, { send, clientId, localId: req.id, timer, isAttach }); + + const payload: Record<string, unknown> = { + id: daemonId, + method: req.method, + params: req.params ?? {}, + }; + if (req.sessionId) payload["sessionId"] = req.sessionId; + + try { + ws.send(JSON.stringify(payload)); + } catch (e) { + clearTimeout(timer); + callbacks.delete(daemonId); + mux.resolve(daemonId); + send(clientId, { + type: "response", + id: req.id, + error: { code: -32000, message: e instanceof Error ? e.message : String(e) }, + }); + } + }; + + return { + start, + stop, + handleRequest, + isAlive: () => ws !== null && ws.readyState === WebSocket.OPEN, + recordSession: (cid, sid) => router.record(cid, sid), + releaseSession: (sid) => router.release(sid), + getSessionOwner: (sid) => router.getOwner(sid), + removeClient: (cid) => { mux.clearClient(cid); router.removeClient(cid); }, + onEvent: (h) => { eventHandler = h; }, + onClose: (h) => { closeHandler = h; }, + }; +}; |
