aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/cdp/daemon-transport.ts277
-rw-r--r--src/client.ts18
-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
-rw-r--r--src/domains/navigate.ts20
-rw-r--r--src/domains/setup.ts41
-rw-r--r--src/index.ts71
-rw-r--r--src/registry.ts3
-rw-r--r--src/setup.ts84
-rw-r--r--src/util/tool.ts19
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(