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 | |
| 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')
| -rw-r--r-- | src/cdp/daemon-transport.ts | 277 | ||||
| -rw-r--r-- | src/client.ts | 18 | ||||
| -rw-r--r-- | src/daemon/bridge.ts | 411 | ||||
| -rw-r--r-- | src/daemon/index.ts | 114 | ||||
| -rw-r--r-- | src/daemon/protocol.ts | 162 | ||||
| -rw-r--r-- | src/daemon/server.ts | 245 | ||||
| -rw-r--r-- | src/daemon/spawn.ts | 85 | ||||
| -rw-r--r-- | src/domains/navigate.ts | 20 | ||||
| -rw-r--r-- | src/domains/setup.ts | 41 | ||||
| -rw-r--r-- | src/index.ts | 71 | ||||
| -rw-r--r-- | src/registry.ts | 3 | ||||
| -rw-r--r-- | src/setup.ts | 84 | ||||
| -rw-r--r-- | src/util/tool.ts | 19 |
13 files changed, 1481 insertions, 69 deletions
diff --git a/src/cdp/daemon-transport.ts b/src/cdp/daemon-transport.ts new file mode 100644 index 0000000..9d4202c --- /dev/null +++ b/src/cdp/daemon-transport.ts @@ -0,0 +1,277 @@ +/** + * DaemonTransport — CdpTransport backed by a Unix socket to the browser daemon. + * + * Satisfies the exact CdpTransport interface so BrowserClient, CdpSession, and + * all domain tools work without changes. Internally uses newline-delimited JSON + * over a Unix domain socket instead of a WebSocket to Chrome. + * + * References: + * - CdpTransport interface: src/cdp/transport.ts + * - net.createConnection IPC: https://nodejs.org/docs/latest-v22.x/api/net.html#netcreateconnection + * - readline.createInterface: https://nodejs.org/docs/latest-v22.x/api/readline.html#readlinecreateinterfaceoptions + */ + +import { createConnection, type Socket } from "node:net"; +import { createInterface } from "node:readline"; +import { type Result, err, ok } from "../util/result"; +import { type CdpError, cdpError } from "../cdp/errors"; +import type { CdpTransport } from "../cdp/transport"; +import type { CdpEvent } from "../cdp/types"; +import { + DAEMON_SOCKET_PATH, + type WireRequest, + type WireControl, + deserialize, + serialize, +} from "../daemon/protocol"; + +// ── Types ────────────────────────────────────────────────────────────────────── + +type EventQueue = { + readonly push: (e: CdpEvent) => void; + readonly end: () => void; + readonly iter: AsyncIterable<CdpEvent>; +}; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const makeEventQueue = (): EventQueue => { + const buf: CdpEvent[] = []; + const waiters: Array<(v: IteratorResult<CdpEvent>) => void> = []; + let ended = false; + return { + push(e) { + if (ended) return; + const w = waiters.shift(); + if (w) w({ value: e, done: false }); + else buf.push(e); + }, + end() { + ended = true; + for (const w of waiters.splice(0)) w({ value: undefined as unknown as CdpEvent, done: true }); + }, + iter: { + [Symbol.asyncIterator]() { + return { + next: (): Promise<IteratorResult<CdpEvent>> => + new Promise((resolve) => { + const next = buf.shift(); + if (next) resolve({ value: next, done: false }); + else if (ended) resolve({ value: undefined as unknown as CdpEvent, done: true }); + else waiters.push(resolve); + }), + }; + }, + }, + }; +}; + +// ── Implementation ───────────────────────────────────────────────────────────── + +export const createDaemonTransport = (clientId: string): CdpTransport => { + let socket: Socket | null = null; + let rl: ReturnType<typeof createInterface> | null = null; + let queue = makeEventQueue(); + const closeListeners = new Set<() => void>(); + const pending = new Map<number, { + resolve: (v: Result<unknown, CdpError>) => void; + timer: ReturnType<typeof setTimeout>; + method: string; + }>(); + let registered = false; + let nextRequestId = 1; + + const cleanup = (reason: string): void => { + for (const [, p] of pending) { + clearTimeout(p.timer); + p.resolve(err(cdpError("transport_closed", reason, p.method))); + } + pending.clear(); + queue.end(); + queue = makeEventQueue(); + registered = false; + + if (rl) { rl.close(); rl = null; } + if (socket) { try { socket.destroy(); } catch {} socket = null; } + + for (const cb of closeListeners) cb(); + }; + + // ── Connect ──────────────────────────────────────────────────────────── + + const connect = (_url: string, opts?: { timeoutMs?: number }): Promise<Result<void, CdpError>> => { + // url is ignored — we always connect to the daemon socket + const timeoutMs = opts?.timeoutMs ?? 10_000; + + if (socket && !socket.destroyed && registered) { + return Promise.resolve(ok(undefined)); + } + + return new Promise((resolve) => { + let settled = false; + const settle = (r: Result<void, CdpError>): void => { + if (settled) return; + settled = true; + resolve(r); + }; + + try { + socket = createConnection(DAEMON_SOCKET_PATH); + } catch (e) { + settle(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e)))); + return; + } + + const connectTimer = setTimeout(() => { + cleanup("Connection timeout"); + settle(err(cdpError("timeout", `Daemon connection timed out after ${timeoutMs}ms`))); + }, timeoutMs); + + socket.on("connect", () => { + clearTimeout(connectTimer); + rl = createInterface({ input: socket!, crlfDelay: Infinity }); + + rl.on("line", (line: string) => { + const msg = deserialize(line); + if (!msg) return; + + if (msg.type === "control" && msg.action === "registered" && msg.clientId === clientId) { + registered = true; + settle(ok(undefined)); + return; + } + + if (msg.type === "control" && msg.action === "shutdown") { + cleanup(`Daemon shutting down: ${msg.reason ?? "unknown"}`); + return; + } + + if (!registered) return; + + if (msg.type === "response") { + const p = pending.get(msg.id); + if (!p) return; + pending.delete(msg.id); + clearTimeout(p.timer); + + if (msg.error) { + p.resolve(err(cdpError( + msg.error.message.includes("Session with given id not found") + ? "session_not_found" + : "remote_error", + msg.error.message, + p.method, + ))); + } else { + p.resolve(ok(msg.result)); + } + return; + } + + if (msg.type === "event") { + queue.push({ + method: msg.method, + params: msg.params, + ...(msg.sessionId !== undefined ? { sessionId: msg.sessionId } : {}), + }); + return; + } + }); + + socket!.on("error", () => { + // Errors are surfaced via 'close' + }); + + socket!.on("close", () => { + clearTimeout(connectTimer); + cleanup("Daemon socket closed"); + // If connect hasn't settled yet, settle with error + settle(err(cdpError("transport_closed", "Daemon socket closed before registration"))); + }); + + // Send registration + const regMsg: WireControl = { type: "control", action: "register", clientId }; + socket!.write(serialize(regMsg) + "\n"); + }); + + socket.on("error", (e: NodeJS.ErrnoException) => { + clearTimeout(connectTimer); + const msg = e.code === "ENOENT" + ? "Daemon not running — socket not found" + : e.message; + settle(err(cdpError("transport_closed", msg))); + }); + }); + }; + + // ── Close ────────────────────────────────────────────────────────────── + + const close = async (): Promise<void> => { + if (registered && socket && !socket.destroyed) { + const dereg: WireControl = { type: "control", action: "deregister", clientId }; + try { socket.write(serialize(dereg) + "\n"); } catch {} + } + cleanup("close() called"); + }; + + // ── Request ──────────────────────────────────────────────────────────── + + const request = ( + method: string, + params: Record<string, unknown>, + opts?: { sessionId?: string | null; timeoutMs?: number }, + ): Promise<Result<unknown, CdpError>> => { + const timeoutMs = opts?.timeoutMs ?? 15_000; + const sessionId = opts?.sessionId ?? null; + + if (!socket || socket.destroyed || !registered) { + return Promise.resolve(err(cdpError("transport_closed", "Daemon not connected", method))); + } + + const id = nextRequestId++; + const req: WireRequest = { + type: "request", + id, + method, + params, + ...(sessionId !== null && sessionId !== undefined ? { sessionId } : {}), + }; + + return new Promise((resolve) => { + const timer = setTimeout(() => { + pending.delete(id); + resolve(err(cdpError("timeout", `Daemon timeout after ${timeoutMs}ms: ${method}`, method))); + }, timeoutMs); + + pending.set(id, { resolve, timer, method }); + + try { + socket!.write(serialize(req) + "\n"); + } catch (e) { + clearTimeout(timer); + pending.delete(id); + resolve(err(cdpError("transport_closed", e instanceof Error ? e.message : String(e), method))); + } + }); + }; + + // ── Events ───────────────────────────────────────────────────────────── + + const events = (): AsyncIterable<CdpEvent> => queue.iter; + + // ── State ────────────────────────────────────────────────────────────── + + const state = (): "open" | "closed" | "connecting" => { + if (!socket) return "closed"; + if (!registered) return "connecting"; + if (socket.destroyed) return "closed"; + return "open"; + }; + + const onClose = (cb: () => void): (() => void) => { + closeListeners.add(cb); + return () => closeListeners.delete(cb); + }; + + return { connect, close, request, events, state, onClose }; +}; diff --git a/src/client.ts b/src/client.ts index f0eb016..3c79f1a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -11,6 +11,7 @@ import type { DaemonStatus, DialogInfo, PageInfo, TabInfo } from "./cdp/types"; export type BrowserClientOptions = { readonly namespace: string; + readonly transport?: CdpTransport; readonly remote?: { readonly cdpUrl: string; readonly browserId: string }; readonly initialOwnership?: { readonly ownedTargetIds?: ReadonlyArray<string>; @@ -28,6 +29,9 @@ export type BrowserClient = { status(): DaemonStatus; start(): Promise<Result<void, CdpError>>; stop(): Promise<void>; + /** Detach from the current page target (removes the "Chrome is being controlled" banner) + * while keeping the transport connection alive. Call on session shutdown. */ + detach(): Promise<void>; evaluateJs(expression: string, sessionId?: string): Promise<Result<unknown, CdpError>>; pageInfo(): Promise<Result<PageInfo | { readonly dialog: DialogInfo }, CdpError>>; takeDialog(): DialogInfo | null; @@ -77,7 +81,7 @@ const parsePageInfoPayload = (v: unknown): Result<PageInfo, CdpError> => { }; export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient => { - const transport = createCdpTransport(); + const transport = opts.transport ?? createCdpTransport(); const ownershipInit: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {}; if (opts.initialOwnership?.ownedTargetIds !== undefined) { ownershipInit.ownedTargetIds = opts.initialOwnership.ownedTargetIds; @@ -298,9 +302,19 @@ export const createBrowserClient = (opts: BrowserClientOptions): BrowserClient = ...(remote?.browserId !== undefined ? { remoteBrowserId: remote.browserId } : {}), }); + const detach = async (): Promise<void> => { + const cur = session.current(); + if (!cur) return; + // Target.detachFromTarget removes the "Chrome is being controlled" banner + // and releases the page session. The transport (WebSocket/Unix socket) + // stays alive for reuse by the next session. + // Reference: https://chromedevtools.github.io/devtools-protocol/tot/Target/#method-detachFromTarget + await transport.request("Target.detachFromTarget", { sessionId: cur.sessionId }, { sessionId: null }); + }; + return { namespace: opts.namespace, - ensureAlive, status, start, stop, + ensureAlive, status, start, stop, detach, evaluateJs, pageInfo, takeDialog: () => session.takeDialog(), listTabs, switchTab, newTab, closeTab, 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; +}; diff --git a/src/domains/navigate.ts b/src/domains/navigate.ts index 7012d15..4477485 100644 --- a/src/domains/navigate.ts +++ b/src/domains/navigate.ts @@ -3,6 +3,7 @@ import { Text } from "@mariozechner/pi-tui"; import { type Result, err, ok } from "../util/result"; import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; import { applyTruncation } from "../util/truncate"; +import { safeJs } from "../util/js-template"; const NavigateArgs = Type.Object({ url: Type.String({ description: "Full URL to navigate to (e.g. https://github.com)" }), @@ -82,11 +83,18 @@ export const openUrlsTool = defineBrowserTool({ renderCall: (a) => new Text(`🌐 Opening ${a.urls.length} URL${a.urls.length !== 1 ? "s" : ""}…`, 0, 0), async handler(args, { client, onUpdate }): Promise<Result<ToolOk, ToolErr>> { const total = args.urls.length; - // Phase 1: create tabs in parallel. + // Phase 1: create tabs in parallel. Own them so they appear in + // browser_list_tabs scope:'owned' and group in the harness window. + const hw = client.ownership().harnessWindow(); const created = await Promise.all(args.urls.map(async (url): Promise<TabResult> => { - const r = await client.session().callBrowser("Target.createTarget", { url: "about:blank" }); + const params: Record<string, unknown> = { url: "about:blank" }; + if (hw) params["openerId"] = hw; + else params["newWindow"] = true; + const r = await client.session().callBrowser("Target.createTarget", params); if (!r.success) return { url, targetId: "", ok: false, error: r.error.message }; - const c = r.data as { targetId: string }; // CDP boundary cast: Target.createTarget returns { targetId: string } + const c = r.data as { targetId: string }; + if (params["newWindow"]) client.ownership().setHarnessWindow(c.targetId); + client.ownership().add(c.targetId); return { url, targetId: c.targetId, ok: true }; })); // Phase 2: attach + navigate each created tab in parallel. @@ -101,6 +109,12 @@ export const openUrlsTool = defineBrowserTool({ if (!enabled.success) return { ...tab, ok: false, error: enabled.error.message }; const nav = await client.session().callOnTarget("Page.navigate", { url: tab.url }, sid); if (!nav.success) return { ...tab, ok: false, error: nav.error.message }; + // Mark the tab with a green circle so the user can see which + // tabs the agent opened. Best-effort; don't fail on CSP blocks. + client.evaluateJs( + safeJs`if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`, + sid + ).catch(() => {}); return tab; } finally { completed++; diff --git a/src/domains/setup.ts b/src/domains/setup.ts new file mode 100644 index 0000000..bbd767f --- /dev/null +++ b/src/domains/setup.ts @@ -0,0 +1,41 @@ +/** + * browser_setup tool — agent-callable browser initialization. + * + * The agent can call this directly when browser tools fail with + * "not initialized" instead of asking the user. Idempotent: safe + * to call even when already connected. + */ + +import { Type } from "typebox"; +import { Text } from "@mariozechner/pi-tui"; +import type { Result } from "../util/result"; +import { err, ok } from "../util/result"; +import { defineBrowserTool, type ToolErr, type ToolOk } from "../util/tool"; +import { performSetup } from "../setup"; + +const SetupArgs = Type.Object({}); + +export const setupTool = defineBrowserTool({ + name: "browser_setup", + label: "Browser Setup", + description: + "Initialize browser control. Spawns the daemon, connects to Chrome, opens a test tab. " + + "Call when browser tools fail with 'Browser harness not initialized'. Idempotent.", + promptSnippet: "Initialize browser connection", + promptGuidelines: [ + "Call browser_setup when you get a 'Browser harness not initialized' error from any browser tool.", + "This tool is idempotent — calling it when already connected is harmless.", + "After browser_setup succeeds, retry the browser tool that failed.", + ], + parameters: SetupArgs, + // ensureAlive:false — this tool IS the setup; it must bypass the socket guard + ensureAlive: false, + renderCall: () => new Text("🔧 Initializing browser...", 0, 0), + async handler(_args, { client }): Promise<Result<ToolOk, ToolErr>> { + const result = await performSetup(client); + if (result.success) { + return ok({ text: result.data }); + } + return err({ kind: "internal", message: result.error }); + }, +}); diff --git a/src/index.ts b/src/index.ts index c2aa2d0..7265263 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ import { registerSetupCommand } from "./setup"; import { type BrowserState, defaultState, persistState, restoreState } from "./state"; import { registerAllTools } from "./registry"; import { cleanupTempDirs } from "./util/truncate"; +import { createDaemonTransport } from "./cdp/daemon-transport"; export default function browserHarnessExtension(pi: ExtensionAPI): void { const flagNs = pi.getFlag("browser-namespace") as string | undefined; @@ -98,25 +99,43 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { pi.on("session_start", async (_event, ctx) => { state = restoreState(ctx, state.namespace); - const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {}; - if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds; - if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId; - client = createBrowserClient({ - namespace: state.namespace, - ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}), - onOwnershipChange: (snap) => { - state = { - ...state, - ownedTargetIds: snap.ownedTargetIds, - ...(snap.harnessWindowTargetId !== undefined - ? { harnessWindowTargetId: snap.harnessWindowTargetId } - : {}), - }; - persistState(pi, state); - }, - }); - // failure is fine — surfaced via tool errors when the agent first tries to use the browser - await client.start(); + + // Create the client lazily on first session. The daemon transport + // is created but NOT connected — that only happens when the user + // runs /browser-setup or a browser tool call finds an existing socket. + // This means zero Chrome prompts for non-browser pi sessions. + if (!client) { + const transport = createDaemonTransport(state.namespace); + + const initialOwnership: { ownedTargetIds?: ReadonlyArray<string>; harnessWindowTargetId?: string } = {}; + if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds; + if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId; + + client = createBrowserClient({ + namespace: state.namespace, + transport, + ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}), + onOwnershipChange: (snap) => { + state = { + ...state, + ownedTargetIds: snap.ownedTargetIds, + ...(snap.harnessWindowTargetId !== undefined + ? { harnessWindowTargetId: snap.harnessWindowTargetId } + : {}), + }; + // Ownership changes can fire from CDP events at any time, + // including after session replacement when pi's ctx is stale. + // Persistence is best-effort — failure must not crash the event consumer. + // ponytail: try/catch is the simplest guard against stale-ctx. + try { persistState(pi, state); } catch { /* stale ctx — safe to ignore */ } + }, + }); + } + + // Do NOT call client.start() here — browser control is on-demand. + // The first browser tool call or /browser-setup triggers the actual + // daemon socket connection and Chrome prompt. + if (!toolsRegistered) { registerAllTools(pi, client); toolsRegistered = true; @@ -132,15 +151,17 @@ export default function browserHarnessExtension(pi: ExtensionAPI): void { persistState(pi, state); if (client) { try { - await client.stop(); + // Detach from the page target (removes the "Chrome is being controlled" + // banner) but keep the transport alive for the next session. + await client.detach(); } catch (e) { - // Shutdown is best-effort, but a stuck stop() points at a transport - // bug worth surfacing for debugging. - console.warn("[pi-browser-harness] client.stop() failed during shutdown:", e); + console.warn("[pi-browser-harness] client.detach() failed during shutdown:", e); } - client = null; + // ponytail: keep the transport alive across sessions. + // Do NOT call client.stop() or null out client — the daemon connection + // persists and eliminates the per-session "Allow Remote Debugging" prompt. + // Only Chrome restart or daemon death triggers a new prompt. } - toolsRegistered = false; await cleanupTempDirs(); }); diff --git a/src/registry.ts b/src/registry.ts index 8f625ae..fcdb66a 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -17,11 +17,14 @@ import { httpGetTool, networkRequestsTool } from "./domains/network"; import { consoleTool } from "./domains/console"; import { snapshotTool } from "./domains/snapshot"; import { executeJsTool, runScriptTool } from "./domains/js"; +import { setupTool } from "./domains/setup"; // Mutation tools that modify page / browser state are marked `serialized: true` // so they run through a shared async mutex. Observation / read-only tools are // left unmarked and can execute in parallel. const TOOLS: ReadonlyArray<AnyBrowserToolDefinition> = [ + // setup comes first — the agent calls this to self-recover from not-initialized + setupTool, { ...clickTool, serialized: true }, { ...typeTool, serialized: true }, { ...pressKeyTool, serialized: true }, diff --git a/src/setup.ts b/src/setup.ts index 3909603..561a343 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,37 +1,50 @@ /** - * Setup command — verifies Chrome is running with remote debugging - * and connects the pi agent to it. + * Setup — verifies Chrome is running with remote debugging and connects + * the pi agent to it. Exposed as both a slash command (/browser-setup) for + * users and a tool (browser_setup) for the agent to self-recover. */ -import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; +import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { execSync } from "node:child_process"; import type { BrowserClient } from "./client"; +import { ensureDaemon } from "./daemon/spawn"; + +// ── Public: register the /browser-setup slash command ────────────────────── export function registerSetupCommand(pi: ExtensionAPI, client: BrowserClient): void { pi.registerCommand("browser-setup", { description: "Connect pi to your Chrome browser", handler: async (_args, ctx) => { - await runSetup(ctx, client); + const result = await performSetup(client); + if (result.success) { + ctx.ui.notify(result.data, "info"); + } else { + ctx.ui.notify(result.error, "error"); + } }, }); } -async function runSetup(ctx: ExtensionContext, client: BrowserClient): Promise<void> { - ctx.ui.notify("browser setup: checking Chrome...", "info"); +// ── Shared setup result ──────────────────────────────────────────────────── + +export type SetupResult = { success: true; data: string } | { success: false; error: string }; + +// ── Shared setup logic (used by both the command and the tool) ───────────── +export async function performSetup(client: BrowserClient): Promise<SetupResult> { // Step 1: Check Chrome is running const chromeRunning = checkChromeRunning(); if (!chromeRunning) { - ctx.ui.notify( - "No browser instance running. Please open your browser and then run /browser-setup.", - "error", - ); - return; + return { success: false, error: "No browser instance running. Please open your browser and then run /browser-setup." }; } - ctx.ui.notify("Chrome is running ✓", "info"); - // Step 2: Try to connect to Chrome DevTools - ctx.ui.notify("Connecting to Chrome DevTools...", "info"); + // Step 2: Start the browser daemon (spawns if not running, silently reuses if alive) + const daemonReady = await ensureDaemon(); + if (!daemonReady) { + return { success: false, error: "Could not start the browser daemon. Check /tmp/pi-browser-daemon.sock." }; + } + + // Step 3: Connect to Chrome DevTools const startResult = await client.start(); if (!startResult.success) { const msg = startResult.error.message; @@ -43,30 +56,23 @@ async function runSetup(ctx: ExtensionContext, client: BrowserClient): Promise<v lower.includes("econnrefused") || lower.includes("cannot reach chrome devtools") ) { - ctx.ui.notify( - "Chrome remote debugging needs to be enabled.\n\n" + - "Open chrome://inspect/#remote-debugging in your browser, tick the\n" + - "\"Discover network targets\" / Allow checkbox, then run /browser-setup again.\n\n" + + return { + success: false, + error: + "Chrome remote debugging needs to be enabled.\n\n" + + 'Open chrome://inspect/#remote-debugging in your browser, tick the\n' + + '"Discover network targets" / Allow checkbox, then retry.\n\n' + "Or set BU_CDP_WS to a remote browser WebSocket URL.", - "warning", - ); - return; + }; } - ctx.ui.notify(`Connection failed: ${msg}`, "error"); - return; + return { success: false, error: `Connection failed: ${msg}` }; } - ctx.ui.notify("Connected to Chrome ✓", "info"); - // Step 3: Verify with test navigation - ctx.ui.notify("Testing browser control...", "info"); + // Step 4: Verify with test navigation const tabResult = await client.newTab("https://github.com"); if (!tabResult.success) { - ctx.ui.notify( - `Browser connected but test navigation failed: ${tabResult.error.message}`, - "warning", - ); - return; + return { success: false, error: `Browser connected but test navigation failed: ${tabResult.error.message}` }; } const info = await client.pageInfo(); @@ -75,24 +81,24 @@ async function runSetup(ctx: ExtensionContext, client: BrowserClient): Promise<v } const pageUrl = info.success && !("dialog" in info.data) ? info.data.url : "github.com"; - ctx.ui.notify(`Browser control verified ✓\nNavigated to: ${pageUrl}`, "info"); + return { success: true, data: `Browser connected ✓\nNavigated to: ${pageUrl}` }; } +// ── Chrome process detection ─────────────────────────────────────────────── + function checkChromeRunning(): boolean { try { if (process.platform === "darwin") { - // Exact line matching — excludes "Google Chrome Helper" etc. - // that linger after the user quits the browser. + // ps -o comm= returns the full executable path on modern macOS. + // We check for "Google Chrome" anywhere in the path (case-insensitive), + // excluding helper/renderer subprocesses. const out = execSync("ps -A -o comm=", { timeout: 5000 }).toString().toLowerCase(); const lines = out.split("\n").map((l) => l.trim()).filter(Boolean); - const browserProcesses = ["google chrome", "chromium", "microsoft edge"]; return lines.some((l) => { - if (l.includes("helper") || l.includes("renderer")) return false; - return browserProcesses.includes(l); + if (l.includes("helper") || l.includes("renderer") || l.includes("crashpad")) return false; + return l.includes("google chrome") || l.includes("chromium") || l.includes("microsoft edge"); }); } else if (process.platform === "linux") { - // Use args to distinguish the main browser process from sub-processes - // (GPU, renderer, utility, etc.) which carry --type= flags. const out = execSync("ps -A -o comm=,args=", { timeout: 5000 }).toString().toLowerCase(); const lines = out.split("\n").map((l) => l.trim()).filter(Boolean); const browserComms = ["chrome", "chromium", "chromium-browser", "msedge", "microsoft-edge", "google-chrome"]; diff --git a/src/util/tool.ts b/src/util/tool.ts index c2bb3f8..1e33c43 100644 --- a/src/util/tool.ts +++ b/src/util/tool.ts @@ -9,6 +9,8 @@ import type { ToolRenderResultOptions, } from "@mariozechner/pi-coding-agent"; import type { Component } from "@mariozechner/pi-tui"; +import { access } from "node:fs/promises"; +import { DAEMON_SOCKET_PATH } from "../daemon/protocol"; import type { BrowserClient } from "../client"; import type { Result } from "./result"; @@ -142,6 +144,23 @@ export const registerBrowserTool = ( : {}), async execute(_toolCallId, args, signal, onUpdate, extensionCtx) { if (def.ensureAlive !== false) { + // Check daemon socket exists before attempting browser control. + // If missing, the user hasn't run /browser-setup yet. Return a clear + // error so the agent knows to direct the user to initialize. + try { + await access(DAEMON_SOCKET_PATH); + } catch { + return toToolResult( + { + success: false, + error: { + kind: "not_connected", + message: "Browser harness not initialized. Run /browser-setup first", + }, + }, + def.name, + ); + } const alive = await client.ensureAlive(); if (!alive.success) { return toToolResult( |
