aboutsummaryrefslogtreecommitdiff
path: root/src/daemon
diff options
context:
space:
mode:
authorAman Kumar <145339995+amankumarsingh77@users.noreply.github.com>2026-06-21 00:46:44 +0530
committerGitHub <noreply@github.com>2026-06-21 00:46:44 +0530
commitda5d31af999b728c31ab0169373531cab4543f0c (patch)
tree5d350ad1428ff332372a9c724ac46c964d8ca477 /src/daemon
parent8db684d43366d335760e106eae39a74376bd7640 (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')
-rw-r--r--src/daemon/bridge.ts411
-rw-r--r--src/daemon/index.ts114
-rw-r--r--src/daemon/protocol.ts162
-rw-r--r--src/daemon/server.ts245
-rw-r--r--src/daemon/spawn.ts85
5 files changed, 1017 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; },
+ };
+};
diff --git a/src/daemon/index.ts b/src/daemon/index.ts
new file mode 100644
index 0000000..d2c43a8
--- /dev/null
+++ b/src/daemon/index.ts
@@ -0,0 +1,114 @@
+/**
+ * Browser daemon entry point — standalone long-lived process.
+ *
+ * Owns a single persistent WebSocket to Chrome. Exposes a Unix domain socket
+ * for pi instances to connect through. One Allow dialog ever (per Chrome
+ * lifetime), zero repeated prompts.
+ *
+ * Run: npx tsx src/daemon/index.ts
+ */
+
+import { createIpcServer } from "./server";
+import { createCdpBridge, type SendToClient } from "./bridge";
+import { DAEMON_IDLE_TIMEOUT_MS } from "./protocol";
+
+async function main() {
+ console.log("[pi-browser-daemon] Starting...");
+
+ const ipcServer = createIpcServer();
+ const cdpBridge = createCdpBridge();
+
+ // ── Wire IpcServer → CdpBridge ─────────────────────────────────────────
+
+ // When a pi client sends a CDP request, proxy it through the bridge to Chrome.
+ ipcServer.onMessage((msg, client) => {
+ if (msg.type !== "request") return;
+
+ const send: SendToClient = (cid, resp) => {
+ ipcServer.send(cid, resp);
+ };
+
+ cdpBridge.handleRequest(msg, client.id, send);
+ });
+
+ // When Chrome sends an event, route it to the correct pi client(s).
+ cdpBridge.onEvent((event, targetClientIds) => {
+ for (const cid of targetClientIds) {
+ ipcServer.send(cid, event);
+ }
+ });
+
+ // When a client disconnects, clean up its CDP state.
+ ipcServer.onDisconnect((client) => {
+ cdpBridge.removeClient(client.id);
+ });
+
+ // When Chrome disconnects, notify all clients.
+ cdpBridge.onClose(() => {
+ console.log("[pi-browser-daemon] Chrome disconnected");
+ ipcServer.broadcast({
+ type: "control",
+ action: "shutdown",
+ reason: "chrome_disconnected",
+ });
+ });
+
+ // ── Idle timeout ───────────────────────────────────────────────────────
+
+ // The daemon exits after DAEMON_IDLE_TIMEOUT_MS with zero connected clients.
+ // This prevents zombie processes when pi sessions end and never reconnect.
+ // The timeout resets whenever a client connects.
+ let idleTimer: ReturnType<typeof setTimeout> | null = null;
+
+ const resetIdleTimer = (): void => {
+ if (idleTimer) clearTimeout(idleTimer);
+ if (ipcServer.clientCount() === 0) {
+ idleTimer = setTimeout(() => {
+ console.log("[pi-browser-daemon] Idle timeout — no clients for " +
+ `${DAEMON_IDLE_TIMEOUT_MS / 60000} minutes. Shutting down.`);
+ shutdown();
+ }, DAEMON_IDLE_TIMEOUT_MS);
+ }
+ };
+
+ const cancelIdleTimer = (): void => {
+ if (idleTimer) { clearTimeout(idleTimer); idleTimer = null; }
+ };
+
+ ipcServer.onConnect(() => { cancelIdleTimer(); });
+ ipcServer.onDisconnect(() => { resetIdleTimer(); });
+
+ // ── Start ──────────────────────────────────────────────────────────────
+
+ try {
+ await ipcServer.start();
+ console.log("[pi-browser-daemon] IPC server listening");
+ } catch (e) {
+ console.error("[pi-browser-daemon] Failed to start IPC server:", e);
+ process.exit(1);
+ }
+
+ // Connect to Chrome (auto-retries with backoff in the background).
+ // The daemon is ready immediately — Chrome may connect later.
+ await cdpBridge.start();
+
+ // Start the idle timer (no clients yet at startup).
+ resetIdleTimer();
+
+ // ── Graceful shutdown ──────────────────────────────────────────────────
+
+ const shutdown = async () => {
+ console.log("[pi-browser-daemon] Shutting down...");
+ await cdpBridge.stop();
+ await ipcServer.stop();
+ process.exit(0);
+ };
+
+ process.on("SIGINT", shutdown);
+ process.on("SIGTERM", shutdown);
+}
+
+main().catch((e) => {
+ console.error("[pi-browser-daemon] Fatal:", e);
+ process.exit(1);
+});
diff --git a/src/daemon/protocol.ts b/src/daemon/protocol.ts
new file mode 100644
index 0000000..9d777bc
--- /dev/null
+++ b/src/daemon/protocol.ts
@@ -0,0 +1,162 @@
+/**
+ * Unix socket daemon protocol — wire message types, type guards, and constants.
+ *
+ * The daemon speaks JSON-line protocol over a Unix domain socket.
+ * One JSON object per newline. Four message types: request, response, event, control.
+ *
+ * References:
+ * - CDP message format: https://github.com/aslushnikov/getting-started-with-cdp#protocol-fundamentals
+ * - net.createServer IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback
+ * - readline line-delimited: https://nodejs.org/docs/latest-v22.x/api/readline.html#event-line
+ */
+
+// ── Message types ──────────────────────────────────────────────────────────────
+
+/** A CDP command sent from a pi client to the daemon. */
+export type WireRequest = {
+ readonly type: "request";
+ /** Client-local CDP id. The daemon remaps this to a globally-unique id for Chrome. */
+ readonly id: number;
+ readonly method: string;
+ readonly params?: Record<string, unknown>;
+ /** Optional CDP session id. Omitted for browser-level commands. */
+ readonly sessionId?: string;
+};
+
+/** A CDP response (result or error) sent from the daemon back to the pi client. */
+export type WireResponse = {
+ readonly type: "response";
+ /** Matches the id from the corresponding WireRequest. */
+ readonly id: number;
+ readonly result?: unknown;
+ readonly error?: {
+ readonly code: number;
+ readonly message: string;
+ readonly data?: string;
+ };
+};
+
+/** A CDP event forwarded from Chrome through the daemon to pi client(s). */
+export type WireEvent = {
+ readonly type: "event";
+ readonly method: string;
+ readonly params?: Record<string, unknown>;
+ /** Session-scoped events carry this; browser-level events omit it. */
+ readonly sessionId?: string;
+};
+
+/** Daemon lifecycle control messages. Bidirectional. */
+export type WireControl = {
+ readonly type: "control";
+ readonly action: "register" | "registered" | "deregister" | "shutdown";
+ /** pi client namespace (required for register/registered/deregister). */
+ readonly clientId?: string;
+ /** Human-readable reason for shutdown. */
+ readonly reason?: string;
+};
+
+/** All messages that may appear on the Unix socket. */
+export type WireMessage = WireRequest | WireResponse | WireEvent | WireControl;
+
+// ── Type guard ─────────────────────────────────────────────────────────────────
+
+const isObject = (v: unknown): v is Readonly<Record<string, unknown>> =>
+ typeof v === "object" && v !== null && !Array.isArray(v);
+
+/**
+ * Validates that an unknown parsed-JSON value is a well-formed WireMessage.
+ * Does NOT validate the full CDP semantics — only structural shape.
+ */
+export const isWireMessage = (v: unknown): v is WireMessage => {
+ if (!isObject(v)) return false;
+ const t = v["type"];
+ if (typeof t !== "string") return false;
+
+ switch (t) {
+ case "request": {
+ if (typeof v["id"] !== "number") return false;
+ if (typeof v["method"] !== "string") return false;
+ const sid = v["sessionId"];
+ if (sid !== undefined && typeof sid !== "string") return false;
+ return true;
+ }
+ case "response": {
+ if (typeof v["id"] !== "number") return false;
+ const errVal = v["error"];
+ if (errVal !== undefined) {
+ if (!isObject(errVal)) return false;
+ if (typeof errVal["message"] !== "string") return false;
+ if (typeof errVal["code"] !== "number") return false;
+ }
+ return true;
+ }
+ case "event": {
+ if (typeof v["method"] !== "string") return false;
+ const sid = v["sessionId"];
+ if (sid !== undefined && typeof sid !== "string") return false;
+ return true;
+ }
+ case "control": {
+ const action = v["action"];
+ if (typeof action !== "string") return false;
+ if (!["register", "registered", "deregister", "shutdown"].includes(action)) return false;
+ const cid = v["clientId"];
+ if (
+ (action === "register" || action === "registered" || action === "deregister") &&
+ typeof cid !== "string"
+ ) {
+ return false;
+ }
+ return true;
+ }
+ default:
+ return false;
+ }
+};
+
+// ── Wire message constructors ──────────────────────────────────────────────────
+
+/** Serialize a WireMessage to a JSON line (no trailing newline — caller adds \\n). */
+export const serialize = (msg: WireMessage): string => JSON.stringify(msg);
+
+/** Deserialize a single JSON-line string to a WireMessage. Returns null on parse failure. */
+export const deserialize = (line: string): WireMessage | null => {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(line);
+ } catch {
+ return null;
+ }
+ if (!isWireMessage(parsed)) return null;
+ return parsed;
+};
+
+// ── Constants ──────────────────────────────────────────────────────────────────
+
+/** Filesystem path for the daemon's Unix domain socket. */
+export const DAEMON_SOCKET_PATH = "/tmp/pi-browser-daemon.sock";
+
+/**
+ * How long the daemon stays alive with zero connected clients before exiting
+ * (milliseconds). After this period, the daemon cleans up and shuts down.
+ */
+export const DAEMON_IDLE_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
+
+/**
+ * Hard cap on concurrent pi client connections. Extra connections are rejected
+ * immediately to prevent resource exhaustion.
+ */
+export const DAEMON_MAX_CLIENTS = 16;
+
+/**
+ * If true, the daemon unlinks (deletes) any stale socket file from a previous
+ * daemon crash before calling server.listen(). Without this, a crashed daemon
+ * leaves a socket inode that blocks the next listen() call with EADDRINUSE.
+ */
+export const DAEMON_STALE_SOCKET_CLEANUP = true;
+
+/** CDP WebSocket connection timeout (ms). */
+export const CDP_CONNECT_TIMEOUT_MS = 10_000;
+
+/** CDP command timeout when proxying through daemon (ms). */
+export const CDP_COMMAND_TIMEOUT_MS = 10_000;
diff --git a/src/daemon/server.ts b/src/daemon/server.ts
new file mode 100644
index 0000000..fea9bb7
--- /dev/null
+++ b/src/daemon/server.ts
@@ -0,0 +1,245 @@
+/**
+ * IpcServer — Unix socket server for daemon ↔ pi client communication.
+ *
+ * Accepts multiple concurrent pi client connections over a Unix domain
+ * stream socket. Uses newline-delimited JSON framing (one WireMessage per
+ * line). Clients are anonymous until they send a control.register message.
+ *
+ * References:
+ * - net.createServer: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateserveroptions-connectionlistener
+ * - server.listen(path): https://nodejs.org/docs/latest-v22.x/api/net.html#serverlistenpath-backlog-callback
+ * - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions
+ */
+
+import { createServer, type Server, type Socket } from "node:net";
+import { createInterface, type Interface } from "node:readline";
+import { unlinkSync } from "node:fs";
+import {
+ type WireMessage,
+ type WireControl,
+ isWireMessage,
+ DAEMON_SOCKET_PATH,
+ DAEMON_MAX_CLIENTS,
+ DAEMON_STALE_SOCKET_CLEANUP,
+} from "./protocol";
+
+// ── Types ──────────────────────────────────────────────────────────────────────
+
+/** A connected pi client with its socket and readline interface. */
+export type ClientSocket = {
+ /** Client namespace (set after registration). */
+ id: string;
+ readonly socket: Socket;
+ readonly rl: Interface;
+ /** True once a valid control.register message has been received. */
+ registered: boolean;
+};
+
+/** Handler called for every valid WireMessage from any client. */
+export type MessageHandler = (msg: WireMessage, client: ClientSocket) => void;
+
+/** Handler called when a client connects or disconnects. */
+export type ConnectionHandler = (client: ClientSocket) => void;
+
+export type IpcServer = {
+ /** Start listening on the Unix socket. Resolves when the server is ready. */
+ start(): Promise<void>;
+ /** Close the server and all client connections. Resolves when cleaned up. */
+ stop(): Promise<void>;
+ /** Register a handler for incoming WireMessages from any client. */
+ onMessage(handler: MessageHandler): void;
+ /** Register a handler for client connect events. */
+ onConnect(handler: ConnectionHandler): void;
+ /** Register a handler for client disconnect events. */
+ onDisconnect(handler: ConnectionHandler): void;
+ /** Send a WireMessage to a specific client by clientId. No-op if client not found. */
+ send(clientId: string, msg: WireMessage): void;
+ /** Send a WireMessage to all connected (registered) clients. */
+ broadcast(msg: WireMessage): void;
+ /** Force-disconnect a client by clientId. */
+ disconnectClient(clientId: string): void;
+ /** All currently connected clients (by clientId). */
+ clients(): ReadonlyMap<string, ClientSocket>;
+ /** Number of connected clients. */
+ clientCount(): number;
+};
+
+// ── Implementation ─────────────────────────────────────────────────────────────
+
+export const createIpcServer = (): IpcServer => {
+ let server: Server | null = null;
+ // Anonymous temp-id → ClientSocket. Temp ids are assigned before registration.
+ const sockets = new Map<string, ClientSocket>();
+ // clientId → ClientSocket (only registered clients).
+ const clients = new Map<string, ClientSocket>();
+ let messageHandler: MessageHandler | null = null;
+ let connectHandler: ConnectionHandler | null = null;
+ let disconnectHandler: ConnectionHandler | null = null;
+ let nextTempId = 1;
+
+ const start = (): Promise<void> =>
+ new Promise((resolve, reject) => {
+ // Clean up stale socket file from a previous crashed daemon.
+ if (DAEMON_STALE_SOCKET_CLEANUP) {
+ try {
+ unlinkSync(DAEMON_SOCKET_PATH);
+ } catch {
+ // ENOENT is expected — no stale socket to clean.
+ }
+ }
+
+ server = createServer({ pauseOnConnect: false }, (socket: Socket) => {
+ // Reject if at capacity (check registered clients only).
+ if (clients.size >= DAEMON_MAX_CLIENTS) {
+ socket.destroy();
+ return;
+ }
+
+ const tempId = `anon-${nextTempId++}`;
+ const rl = createInterface({ input: socket, crlfDelay: Infinity });
+ let currentClient: ClientSocket = { id: tempId, socket, rl, registered: false };
+
+ sockets.set(tempId, currentClient);
+
+ // Buffer for partial lines. readline handles line splitting, but
+ // we store a reference in case we need it for diagnostics.
+ let lineCount = 0;
+
+ rl.on("line", (line: string) => {
+ lineCount++;
+ let msg: WireMessage | null;
+ try {
+ const parsed: unknown = JSON.parse(line);
+ if (!isWireMessage(parsed)) return;
+ msg = parsed;
+ } catch {
+ // Malformed JSON — ignore silently, don't crash the daemon.
+ return;
+ }
+
+ // Handle registration before forwarding to the general handler.
+ if (msg.type === "control" && msg.action === "register" && !currentClient.registered) {
+ const clientId = msg.clientId;
+ if (!clientId) return;
+
+ // If a client with this id is already connected, reject.
+ if (clients.has(clientId)) {
+ const reply: WireControl = {
+ type: "control",
+ action: "shutdown",
+ reason: `clientId ${clientId} is already connected`,
+ };
+ socket.write(JSON.stringify(reply) + "\n");
+ socket.destroy();
+ return;
+ }
+
+ // Promote from anonymous to registered — mutate in place so the
+ // closure captures the updated client reference.
+ sockets.delete(tempId);
+ currentClient.id = clientId;
+ currentClient.registered = true;
+ clients.set(clientId, currentClient);
+
+ // Acknowledge registration.
+ const ack: WireControl = { type: "control", action: "registered", clientId };
+ socket.write(JSON.stringify(ack) + "\n");
+
+ connectHandler?.(currentClient);
+ return;
+ }
+
+ // Require registration for non-control messages.
+ if (!currentClient.registered) return;
+
+ // Forward to the general handler.
+ messageHandler?.(msg, currentClient);
+ });
+
+ socket.on("error", () => {
+ // Socket errors (ECONNRESET, etc.) are handled by the 'close' event.
+ });
+
+ socket.on("close", () => {
+ // Clean up whichever map the client is in.
+ const found = clients.get(currentClient.id) ?? sockets.get(tempId);
+ if (found) {
+ clients.delete(found.id);
+ sockets.delete(tempId);
+ // Notify if this was a registered client.
+ if (found.registered) {
+ disconnectHandler?.(found);
+ }
+ }
+ // Clean up readline to prevent memory leaks.
+ rl.close();
+ });
+ });
+
+ server.on("error", (err: NodeJS.ErrnoException) => {
+ reject(err);
+ });
+
+ server.listen(DAEMON_SOCKET_PATH, () => {
+ resolve();
+ });
+ });
+
+ const stop = (): Promise<void> =>
+ new Promise((resolve) => {
+ // Close all client sockets.
+ for (const [, client] of clients) {
+ try { client.socket.destroy(); } catch { /* best-effort */ }
+ }
+ for (const [, client] of sockets) {
+ try { client.socket.destroy(); } catch { /* best-effort */ }
+ }
+ clients.clear();
+ sockets.clear();
+
+ if (server) {
+ server.close(() => {
+ // Clean up the socket file.
+ try { unlinkSync(DAEMON_SOCKET_PATH); } catch { /* ENOENT ok */ }
+ resolve();
+ });
+ } else {
+ resolve();
+ }
+ });
+
+ return {
+ start,
+ stop,
+ onMessage(handler) {
+ messageHandler = handler;
+ },
+ onConnect(handler) {
+ connectHandler = handler;
+ },
+ onDisconnect(handler) {
+ disconnectHandler = handler;
+ },
+ send(clientId, msg) {
+ const client = clients.get(clientId);
+ if (!client) return;
+ try {
+ client.socket.write(JSON.stringify(msg) + "\n");
+ } catch {
+ // Socket write error — the close handler will clean up.
+ }
+ },
+ broadcast(msg) {
+ for (const [clientId] of clients) {
+ this.send(clientId, msg);
+ }
+ },
+ disconnectClient(clientId) {
+ const client = clients.get(clientId);
+ if (!client) return;
+ client.socket.destroy();
+ },
+ clients: (): ReadonlyMap<string, ClientSocket> => clients,
+ clientCount: (): number => clients.size,
+ };
+};
diff --git a/src/daemon/spawn.ts b/src/daemon/spawn.ts
new file mode 100644
index 0000000..e9f4d51
--- /dev/null
+++ b/src/daemon/spawn.ts
@@ -0,0 +1,85 @@
+/**
+ * Daemon lifecycle helpers — spawn detection and lazy launch.
+ *
+ * The daemon is a standalone long-lived process that owns the single WebSocket
+ * to Chrome. pi instances connect to it via a Unix domain socket. This module
+ * provides the plumbing to detect a running daemon and spawn one if needed.
+ *
+ * Reference:
+ * - child_process.spawn detached: https://nodejs.org/docs/latest-v22.x/api/child_process.html#optionsdetached
+ */
+
+import { spawn, type ChildProcess } from "node:child_process";
+import { access } from "node:fs/promises";
+import { existsSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import { DAEMON_SOCKET_PATH } from "./protocol";
+
+const moduleDir = dirname(fileURLToPath(import.meta.url));
+
+/**
+ * Check whether the daemon is running by testing if its Unix socket exists.
+ * The socket file is created by the daemon on startup and removed on shutdown.
+ */
+export const isDaemonRunning = async (): Promise<boolean> => {
+ try {
+ await access(DAEMON_SOCKET_PATH);
+ return true;
+ } catch {
+ return false;
+ }
+};
+
+/**
+ * Spawn the daemon as a detached child process. The daemon will continue
+ * running after the parent pi process exits.
+ *
+ * The daemon entry point is resolved relative to this module. When running
+ * from source (tsx), the entry is src/daemon/index.ts. When running compiled,
+ * it's dist/daemon/index.js.
+ *
+ * Returns the child process handle (caller should call child.unref()).
+ */
+export const spawnDaemon = (): ChildProcess | null => {
+ // Resolve the daemon entry point and tsx binary.
+ const daemonScript = join(moduleDir, "index.ts");
+ const tsxBin = join(moduleDir, "..", "..", "node_modules", ".bin", "tsx");
+
+ // Use tsx to run the daemon TypeScript source directly.
+ // The spawed process inherits the parent's env so module resolution works.
+ // Fall back to `npx tsx` if the local binary isn't available.
+ const cmd = existsSync(tsxBin) ? tsxBin : "npx";
+ const args = existsSync(tsxBin) ? [daemonScript] : ["tsx", daemonScript];
+
+ const child = spawn(cmd, args, {
+ detached: true,
+ stdio: "ignore",
+ env: { ...process.env },
+ });
+
+ child.unref();
+ return child;
+};
+
+/**
+ * Ensure the daemon is running. If not, spawn it and wait (up to timeoutMs)
+ * for the socket file to appear.
+ *
+ * Returns true if the daemon is running (or was successfully spawned).
+ */
+export const ensureDaemon = async (timeoutMs = 10_000): Promise<boolean> => {
+ if (await isDaemonRunning()) return true;
+
+ const child = spawnDaemon();
+ if (!child) return false;
+
+ // Poll for the socket file to appear
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ if (await isDaemonRunning()) return true;
+ await new Promise((r) => setTimeout(r, 200));
+ }
+
+ return false;
+};