/** * pi-browser-harness — browser control extension for pi. * * Gives pi agents full control of a real Chrome browser via CDP. * Connects to the user's running Chrome (chrome://inspect/#remote-debugging), * registers browser_* tools, and injects browser control guidance into the * system prompt. * * Install: * pi install npm:pi-browser-harness * # or copy to .pi/extensions/pi-browser-harness/ * * Commands: * /browser-setup — guided setup wizard * /browser-profile — choose which browser profile the agent uses * /browser-status — show client status and current page * /browser-reload-daemon — restart the browser client * /deep-research — research a question on the web, write a cited report * * Flags: * --browser-namespace — override namespace (default: auto) * --browser-debug-clicks — enable debug click overlay */ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; import { type BrowserClient, createBrowserClient } from "./client"; import { getBrowserSystemPrompt } from "./prompt"; import { registerSetupCommand } from "./setup"; import { registerProfileCommand } from "./profile/command"; import { readPin } from "./profile/store"; import { registerDeepResearchCommand } from "./deep-research"; import { type BrowserState, defaultState, persistState, restoreState } from "./state"; import { registerAllTools } from "./registry"; import { cleanupTempDirs } from "./util/truncate"; import { createDaemonTransport } from "./cdp/daemon-transport"; import { asString } from "./util/guards"; export default function browserHarnessExtension(pi: ExtensionAPI): void { const flagNs = asString(pi.getFlag("browser-namespace")); const namespace = flagNs ?? `pi-${Math.random().toString(36).slice(2, 10)}`; let state: BrowserState = defaultState(namespace); let client: BrowserClient | null = null; let toolsRegistered = false; pi.registerFlag("browser-namespace", { description: "Browser daemon namespace. Default: auto-generated", type: "string", }); pi.registerFlag("browser-debug-clicks", { description: "Enable debug click overlay (saves annotated screenshots to /tmp)", type: "boolean", default: false, }); pi.registerCommand("browser-status", { description: "Show browser connection status and current page", handler: async (_args, ctx) => { if (!client) { ctx.ui.notify("Browser client not started. Run /browser-setup first.", "warning"); return; } const s = client.status(); const pin = client.profilePin(); const lines = [ `Browser: ${s.alive ? "🟢 Connected" : "🔴 Disconnected"}`, `Profile: ${pin ? pin.label : "not selected (uses whichever window is focused) — run /browser-profile"}`, `Session: ${s.sessionId ?? "none"}`, ]; if (s.remoteBrowserId) lines.push(`Browser ID: ${s.remoteBrowserId}`); if (s.alive) { const info = await client.pageInfo(); if (info.success) { if ("dialog" in info.data) { lines.push(`\n⚠️ Dialog open: ${info.data.dialog.type} — "${info.data.dialog.message}"`); } else { lines.push( `\nCurrent Page:`, ` URL: ${info.data.url}`, ` Title: ${info.data.title}`, ` Viewport: ${info.data.width}x${info.data.height}`, ); } } } ctx.ui.notify(lines.join("\n"), "info"); }, }); pi.registerCommand("browser-reload-daemon", { description: "Restart the browser client", handler: async (_args, ctx) => { if (!client) { ctx.ui.notify("Browser client not started.", "warning"); return; } ctx.ui.notify("Restarting browser client...", "info"); await client.stop(); const r = await client.start(); if (r.success) { ctx.ui.notify("Browser client restarted ✓", "info"); } else { ctx.ui.notify(`Restart failed: ${r.error.message}`, "error"); } }, }); pi.on("session_start", async (_event, ctx) => { state = restoreState(ctx, state.namespace); // The pinned profile lives on disk, not in the session, so it survives // session termination. Re-read it every session start: another pi session // may have changed it since this client was created. const profilePin = await readPin(); // 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; harnessWindowTargetId?: string; harnessWindowId?: number } = {}; if (state.ownedTargetIds !== undefined) initialOwnership.ownedTargetIds = state.ownedTargetIds; if (state.harnessWindowTargetId !== undefined) initialOwnership.harnessWindowTargetId = state.harnessWindowTargetId; if (state.harnessWindowId !== undefined) initialOwnership.harnessWindowId = state.harnessWindowId; client = createBrowserClient({ namespace: state.namespace, transport, profilePin, ...(Object.keys(initialOwnership).length > 0 ? { initialOwnership } : {}), onOwnershipChange: (snap) => { state = { ...state, ownedTargetIds: snap.ownedTargetIds, ...(snap.harnessWindowTargetId !== undefined ? { harnessWindowTargetId: snap.harnessWindowTargetId } : {}), ...(snap.harnessWindowId !== undefined ? { harnessWindowId: snap.harnessWindowId } : {}), }; // 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 */ } }, }); } else { // Client carried over from a previous session — adopt whatever pin is on // disk now, which another pi session may have changed. client.setProfilePin(profilePin); } // 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; } registerSetupCommand(pi, client); registerProfileCommand(pi, client); registerDeepResearchCommand(pi); }); pi.on("session_shutdown", async () => { if (client) { try { // Detach from the page target (removes the "Chrome is being controlled" // banner) but keep the transport alive for the next session. await client.detach(); // Close every tab this session opened so no stale harness tabs linger // in the user's browser between sessions. This also clears the persisted // ownership + window binding via onOwnershipChange, so the next session // starts with a fresh dedicated window rather than reattaching to leftovers. await client.closeOwnedTabs(); } catch (e) { console.warn("[pi-browser-harness] browser teardown failed during shutdown:", e); } // 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. } // Persist last — closeOwnedTabs mutates `state` through onOwnershipChange, // so this captures the cleared ownership set. persistState(pi, state); await cleanupTempDirs(); }); pi.on("session_tree", async (_event, ctx) => { state = restoreState(ctx, client?.namespace); persistState(pi, state); }); pi.on("before_agent_start", async (event) => { if (!client || !client.status().alive) { return { systemPrompt: event.systemPrompt + `\n\n## Browser Control\n\nBrowser tools (browser_*) are available but the browser is not connected. Run /browser-setup.`, }; } return { systemPrompt: event.systemPrompt + getBrowserSystemPrompt() }; }); }