diff --git a/extension/src/worker.ts b/extension/src/worker.ts index e048da1..e06c979 100644 --- a/extension/src/worker.ts +++ b/extension/src/worker.ts @@ -29,7 +29,8 @@ declare const chrome: any; // ── Bridge link constants ── const RECONNECT_MIN_MS = 1_000; -const RECONNECT_MAX_MS = 15_000; +// Cap retries low so a live worker re-links to a fresh `connect` bridge quickly. +const RECONNECT_MAX_MS = 5_000; /** * Secondary wake: if the worker is ever killed while the bridge is down, this * alarm revives it to retry connecting. The PRIMARY keepalive is the bridge's @@ -442,21 +443,26 @@ async function reResolveRef(drv: TabDriver, ref: string): Promise { type BrowserAction = { type: string; [k: string]: any }; type BrowserResult = Record; -async function executeAction(drv: TabDriver, action: BrowserAction): Promise { +async function executeAction(action: BrowserAction): Promise { + // Navigation goes through chrome.tabs (no debugger attach needed), so it + // works even when the active tab is a chrome:// / new-tab page that CDP can't + // attach to. We attach only afterwards, once it's a real page, for the snapshot. + if (action.type === "navigate") { + let tabId: number; + try { + tabId = await navigateAgentTab(action.url); + } catch (err) { + throw new Error(`navigate failed: ${err instanceof Error ? err.message : String(err)}`); + } + await new Promise((r) => setTimeout(r, 500)); // settle (≈ networkidle) + const drv = await ensureAttached(tabId); + const info = await drv.info(); + const snapshot = await takeSnapshot(drv, { interactiveOnly: true }); + return { type: "done", ...info, message: `Navigated to ${action.url}`, snapshot }; + } + + const drv = await ensureAttached(requireAgentTab()); switch (action.type) { - case "navigate": { - const loaded = drv.once("Page.loadEventFired", 30_000); - try { - await drv.send("Page.navigate", { url: action.url }); - } catch (err) { - throw new Error(`navigate failed: ${err instanceof Error ? err.message : String(err)}`); - } - await loaded; - await new Promise((r) => setTimeout(r, 500)); // settle (≈ networkidle) - const info = await drv.info(); - const snapshot = await takeSnapshot(drv, { interactiveOnly: true }); - return { type: "done", ...info, message: `Navigated to ${action.url}`, snapshot }; - } case "click": { const urlBefore = (await drv.info()).url; let nodeId = resolveRef(drv, action.ref); @@ -631,28 +637,88 @@ async function navigateHistory(drv: TabDriver, delta: number): Promise { let driver: TabDriver | null = null; -async function resolveTargetTabId(): Promise { - let tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); - let tab = tabs[0]; - if (!tab) { - tabs = await chrome.tabs.query({ active: true, currentWindow: true }); - tab = tabs[0]; - } - if (!tab) { - tabs = await chrome.tabs.query({ active: true }); - tab = tabs[0]; +/** + * The agent drives its OWN tab — created on the first navigate — rather than + * hijacking whatever the user is looking at. Cookies/logins are profile-wide, + * so this tab is fully signed in, and the user can keep using their other tabs + * (and switch away) without disrupting the agent. Reused across actions until + * it's closed; a closed tab is reopened on the next navigate. + */ +let agentTabId: number | null = null; + +async function tabExists(id: number): Promise { + try { + await chrome.tabs.get(id); + return true; + } catch { + return false; } - if (!tab || typeof tab.id !== "number") throw new Error("No active tab to control"); - const url: string = tab.url ?? ""; - if (/^(chrome|edge|brave|devtools|chrome-extension|about):/i.test(url) || url.startsWith("https://chromewebstore.google.com")) { - throw new Error(`Can't control this page (${url || "internal page"}). Switch to a normal website tab and try again.`); +} + +/** Navigate the agent's tab to a URL, creating the tab if it doesn't exist yet. */ +async function navigateAgentTab(url: string): Promise { + const current = agentTabId; + if (current != null && (await tabExists(current))) { + const complete = waitForTabComplete(current, 30_000); + await chrome.tabs.update(current, { url, active: true }); + await complete; + return current; } - return tab.id; + const tab = await chrome.tabs.create({ url, active: true }); + if (typeof tab?.id !== "number") throw new Error("could not open a new tab"); + const newId: number = tab.id; + agentTabId = newId; + if (tab.status !== "complete") await waitForTabComplete(newId, 30_000); + return newId; +} + +function requireAgentTab(): number { + if (agentTabId == null) throw new Error("No page open yet — navigate to a URL first."); + return agentTabId; +} + +/** chrome.debugger can't attach to chrome://, the Web Store, etc. */ +function isAttachable(url: string): boolean { + if (/^(chrome|edge|brave|devtools|chrome-extension|about|view-source):/i.test(url)) return false; + if (url.startsWith("https://chromewebstore.google.com")) return false; + if (url.startsWith("https://chrome.google.com/webstore")) return false; + return true; } -/** Ensure we're attached to the current active tab; (re)attach + enable domains. */ -async function ensureAttached(): Promise { - const tabId = await resolveTargetTabId(); +/** Resolve once a tab finishes loading (chrome.tabs status), or after `timeout` ms. */ +function waitForTabComplete(tabId: number, timeout: number): Promise { + return new Promise((resolve) => { + let done = false; + const fin = () => { + if (done) return; + done = true; + try { + chrome.tabs.onUpdated.removeListener(listener); + } catch { + /* ignore */ + } + resolve(); + }; + const listener = (id: number, info: any) => { + if (id === tabId && info?.status === "complete") fin(); + }; + chrome.tabs.onUpdated.addListener(listener); + setTimeout(fin, timeout); + }); +} + +/** Ensure we're attached to the given tab; (re)attach + enable CDP domains. */ +async function ensureAttached(tabId: number): Promise { + let url = ""; + try { + const t = await chrome.tabs.get(tabId); + url = t?.url ?? ""; + } catch { + /* ignore */ + } + if (!isAttachable(url)) { + throw new Error(`Can't control this page (${url || "internal page"}). Open a normal website tab and try again.`); + } if (driver && driver.tabId === tabId) return driver; if (driver) { try { @@ -688,6 +754,12 @@ chrome.debugger.onDetach.addListener((source: any) => { if (driver && source?.tabId === driver.tabId) driver = null; }); +// If the agent's tab is closed, forget it so the next navigate opens a fresh one. +chrome.tabs.onRemoved.addListener((tabId: number) => { + if (tabId === agentTabId) agentTabId = null; + if (driver && driver.tabId === tabId) driver = null; +}); + // ── Bridge connection ── let socket: any = null; @@ -704,8 +776,7 @@ function sendToBridge(msg: Record): void { async function handleCommand(id: string, action: BrowserAction): Promise { try { - const drv = await ensureAttached(); - const result = await executeAction(drv, action); + const result = await executeAction(action); sendToBridge({ type: "response", id, result }); } catch (err) { sendToBridge({ type: "response", id, error: err instanceof Error ? err.message : String(err) }); diff --git a/zero/src/cli/commands/browser.ts b/zero/src/cli/commands/browser.ts index cb9da1d..965b20d 100644 --- a/zero/src/cli/commands/browser.ts +++ b/zero/src/cli/commands/browser.ts @@ -14,7 +14,7 @@ import { browser } from "../../sdk/browser.ts"; import * as fs from "node:fs/promises"; import { hasFlag, getOption, printJson } from "../format.ts"; -import { companionConnect } from "./companion.ts"; +import { companionConnect, companionSetup } from "./companion.ts"; const HELP = `zero browser - drive the per-project browser session @@ -28,7 +28,8 @@ Usage: zero browser snapshot [--mode interactive|full] [--selector ] zero browser extract [--max ] zero browser status - zero browser connect [--cdp ] [--chromium] (run on YOUR laptop) + zero browser setup (run on YOUR laptop — one-time: add the Chrome extension) + zero browser connect (run on YOUR laptop — drive the agent with your Chrome) Context-efficient tip: prefer \`snapshot\` (text a11y tree) or \`extract\` (query-driven paragraphs) over \`screenshot\` / full DOM dumps. @@ -39,8 +40,11 @@ export async function browserCommand(args: string[]): Promise { const [action, ...rest] = args; if (!action || action === "--help" || action === "-h") { process.stdout.write(HELP); return 0; } - // `connect` runs the LOCAL companion (laptop side) rather than issuing a - // remote browser action. It never touches the SDK call path. + // `setup` and `connect` run the LOCAL companion (laptop side) rather than + // issuing a remote browser action. They never touch the SDK call path. + if (action === "setup") { + return companionSetup(rest); + } if (action === "connect") { return companionConnect(rest); } diff --git a/zero/src/cli/commands/companion.ts b/zero/src/cli/commands/companion.ts index a9f8fc2..8c52889 100644 --- a/zero/src/cli/commands/companion.ts +++ b/zero/src/cli/commands/companion.ts @@ -1,34 +1,60 @@ -import { hasFlag } from "../format.ts"; import { loadConfig } from "../../sdk/config.ts"; import { CompanionRunner } from "../../companion/runner.ts"; +import { BridgeEngine } from "../../companion/bridge-engine.ts"; -const HELP = `zero browser connect - let the agent use YOUR Chrome (with your logins) +const SETUP_HELP = `zero browser setup - one-time setup so the agent can use your Chrome + +Usage: + zero browser setup + +Adds the "Zero Companion" extension to your Google Chrome. Run this once: it +opens chrome://extensions and the extension folder for you — turn on Developer +mode and drag the folder in. Once it's added it stays added. + +After setup, run \`zero browser connect\` to start a session. +`; + +const CONNECT_HELP = `zero browser connect - let the agent use YOUR Chrome (with your logins) Usage: zero browser connect zero companion (alias for "zero browser connect") -Installs the Zero Companion extension into your Google Chrome and lets the agent -drive your active tab for the bound project — your real session, your logins, no -separate browser. You can keep browsing while it works. Press Ctrl-C to stop and -hand control back to the agent's own browser. +Lets the agent drive a tab in your own Google Chrome for the bound project — +your real session, your logins, no separate browser. The agent works in its own +tab, so you can keep browsing. While it's driving you'll see "Zero Companion +started debugging this browser" — that's expected. Press Ctrl-C to stop. -The first time (or after you fully quit Chrome), Chrome reopens once with the -helper loaded — your tabs are restored. Chrome shows "Zero Companion started -debugging this browser" while the agent is driving; that's expected. +Run \`zero browser setup\` once first. Requires \`zero login\`. +`; -Requires \`zero login\` first. +function isHelp(args: string[]): boolean { + return args.includes("--help") || args.includes("-h"); +} -Options: - --no-launch Don't reopen Chrome automatically. Load the extension yourself - via chrome://extensions (Developer mode → Load unpacked), then - this just waits for it to connect. -`; +/** `zero browser setup` — one-time install of the companion extension. */ +export async function companionSetup(args: string[]): Promise { + if (isHelp(args)) { + process.stdout.write(SETUP_HELP); + return 0; + } + const write = (line: string) => process.stdout.write(`${line}\n`); + const engine = new BridgeEngine({ onWarn: write, onStatus: write }); + try { + await engine.setup(); + return 0; + } catch (err) { + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); + return 1; + } finally { + await engine.stop(); + } +} -/** Run the companion runner until interrupted. Shared by `browser connect` and `companion`. */ +/** `zero browser connect` — link the agent to your already-installed extension. */ export async function companionConnect(args: string[]): Promise { - if (hasFlag(args, "--help") || hasFlag(args, "-h")) { - process.stdout.write(HELP); + if (isHelp(args)) { + process.stdout.write(CONNECT_HELP); return 0; } const cfg = loadConfig(); @@ -37,11 +63,8 @@ export async function companionConnect(args: string[]): Promise { return 1; } - const noLaunch = hasFlag(args, "--no-launch"); - const write = (line: string) => process.stdout.write(`${line}\n`); const runner = new CompanionRunner({ - noLaunch, onWarn: write, onStatus: write, // Displaced by another computer on this account: the runner has already @@ -63,7 +86,7 @@ export async function companionConnect(args: string[]): Promise { try { await runner.start(); } catch (err) { - process.stderr.write(`companion failed: ${err instanceof Error ? err.message : String(err)}\n`); + process.stderr.write(`${err instanceof Error ? err.message : String(err)}\n`); return 1; } diff --git a/zero/src/cli/index.ts b/zero/src/cli/index.ts index b621a10..3649934 100644 --- a/zero/src/cli/index.ts +++ b/zero/src/cli/index.ts @@ -40,7 +40,7 @@ Groups: image generate tasks add (with --schedule, --event, or --script), ls, update, rm creds ls, get, set, rm - browser open, snapshot, click, fill, screenshot, evaluate, wait, extract, status, connect + browser open, snapshot, click, fill, screenshot, evaluate, wait, extract, status, setup, connect companion run the local companion (drive the agent's browser with your Chrome) apps create, delete, list llm generate diff --git a/zero/src/companion/bridge-engine.ts b/zero/src/companion/bridge-engine.ts index 950a33e..648bcf6 100644 --- a/zero/src/companion/bridge-engine.ts +++ b/zero/src/companion/bridge-engine.ts @@ -19,30 +19,33 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { randomBytes } from "node:crypto"; +import { spawn } from "node:child_process"; import { WebSocketServer, WebSocket } from "ws"; import { zeroHomeDir } from "../sdk/config.ts"; import type { BrowserAction, BrowserResult } from "../sdk/browser-protocol.ts"; import { EXTENSION_ASSETS } from "./extension-assets.ts"; -import { ensureChromeWithExtension } from "./chrome-launch.ts"; export interface EngineOptions { /** Sink for non-fatal warnings. */ onWarn?: (line: string) => void; /** Sink for human-readable status lines (shared with the runner). */ onStatus?: (line: string) => void; - /** - * Skip the one-time Chrome relaunch and just wait for the extension to - * connect (for users who loaded the extension themselves). Default false. - */ - noLaunch?: boolean; } /** How long a single forwarded action may take before we give up (matches the server's 90s). */ const COMMAND_TIMEOUT_MS = 90_000; -/** Grace period to see if the extension is already live before relaunching Chrome. */ +/** Grace period to see if the extension is already installed and live. */ const INITIAL_WAIT_MS = 2_500; -/** How long to wait for the extension to connect after relaunching Chrome. */ -const LAUNCH_WAIT_MS = 30_000; +/** + * `connect` wait for the extension's worker to (re)connect. Sized to exceed the + * extension's 30s service-worker wake alarm so a dormant-but-installed worker + * is reliably picked up. + */ +const CONNECT_WAIT_MS = 35_000; +/** How long `setup` keeps waiting for first install before giving up. */ +const INSTALL_WAIT_MS = 10 * 60_000; +/** Re-print a gentle nudge on this cadence while waiting for first install. */ +const NUDGE_MS = 45_000; /** * Ping the extension on this cadence. Must stay under Chrome's 30s MV3 * service-worker idle kill: an incoming WS message resets that timer @@ -76,40 +79,99 @@ export class BridgeEngine { this.opts.onStatus?.(line); } - async start(): Promise { + private async prepare(): Promise { this.materializeExtension(); await this.listen(); this.writeBridgeConfig(); + } + + /** + * `zero browser connect` — link to an extension that's already installed. + * Does NOT walk through install; if nothing connects, points at `setup`. + */ + async start(): Promise { + await this.prepare(); + this.status("linking to your browser…"); + if (await this.waitForExtension(CONNECT_WAIT_MS)) return; + throw new Error( + "Couldn't reach the Zero Companion extension.\n" + + "Run `zero browser setup` once to add it to Chrome, make sure Chrome is open, then try again.", + ); + } - // The extension may already be live from a prior connect — give it a beat. + /** + * `zero browser setup` — one-time install of the extension into Chrome. + * Chrome 137+ disabled --load-extension, so the only no-store path is + * Load-unpacked: open the page, reveal the folder to drag in, and block until + * the extension connects (confirming it works). Once loaded it persists. + */ + async setup(): Promise { + await this.prepare(); if (await this.waitForExtension(INITIAL_WAIT_MS)) { + this.status("✓ Zero Companion is already set up — run `zero browser connect` to start."); + return; + } + this.printInstallGuide(); + this.openExtensionsPage(); + this.revealExtensionFolder(); + if (await this.waitForInstall()) { + this.status(""); + this.status("✓ Zero Companion is set up. Run `zero browser connect` to start."); return; } + throw new Error( + "Timed out waiting for the Zero Companion extension. Once you've added it in " + + `chrome://extensions (Developer mode → Load unpacked → ${this.extDir}), run \`zero browser setup\` again.`, + ); + } - if (this.opts.noLaunch) { - this.status("waiting for the Zero Companion extension to connect…"); - if (await this.waitForExtension(LAUNCH_WAIT_MS)) return; - throw new Error( - "The Zero Companion extension never connected. Load it in chrome://extensions " + - `(Developer mode → Load unpacked → ${this.extDir}) and re-run \`zero browser connect\`.`, - ); + /** Print the one-time, copy-pasteable setup steps. */ + private printInstallGuide(): void { + const mac = process.platform === "darwin"; + const lines = [ + "", + "──────────────────────────────────────────────────────────────", + " One-time setup — add the Zero Companion extension to Chrome", + "──────────────────────────────────────────────────────────────", + " 1. Chrome should have opened chrome://extensions.", + " (If not, open that page yourself.)", + " 2. Turn ON \"Developer mode\" — the toggle in the top-right.", + mac + ? " 3. Drag the \"extension\" folder from the Finder window that" + : " 3. Click \"Load unpacked\" and choose this folder:", + mac ? " just opened onto the chrome://extensions page." : ` ${this.extDir}`, + mac ? " (or click \"Load unpacked\" and choose the folder below)." : "", + mac ? ` Folder: ${this.extDir}` : "", + "──────────────────────────────────────────────────────────────", + " Waiting for the extension to connect… (leave this running)", + "", + ].filter((l) => l !== ""); + for (const l of lines) this.status(l); + } + + private openExtensionsPage(): void { + // Best-effort; the printed steps cover it if the OS blocks the deep link. + if (process.platform === "darwin") { + this.spawnDetached("open", ["-a", "Google Chrome", "chrome://extensions/"]); + } else if (process.platform === "win32") { + this.spawnDetached("cmd", ["/c", "start", "chrome", "chrome://extensions/"]); + } else { + this.spawnDetached("google-chrome", ["chrome://extensions/"]); } + } + + private revealExtensionFolder(): void { + if (process.platform === "darwin") this.spawnDetached("open", ["-R", this.extDir]); + } - this.status("opening Chrome with the Zero Companion helper (your tabs will be restored)…"); - this.status("Chrome will show \"Zero Companion started debugging this browser\" — that's expected."); + private spawnDetached(cmd: string, args: string[]): void { try { - await ensureChromeWithExtension(this.extDir, (l) => this.status(l)); - } catch (err) { - throw new Error( - `Couldn't open Chrome with the helper extension: ${err instanceof Error ? err.message : String(err)}\n` + - `You can load it manually in chrome://extensions (Developer mode → Load unpacked → ${this.extDir}).`, - ); + const child = spawn(cmd, args, { detached: true, stdio: "ignore" }); + child.on("error", () => {}); + child.unref(); + } catch { + /* best-effort */ } - if (await this.waitForExtension(LAUNCH_WAIT_MS)) return; - throw new Error( - "Chrome opened but the Zero Companion extension didn't connect in time.\n" + - `Open chrome://extensions and confirm "Zero Companion" is enabled (or Load unpacked → ${this.extDir}), then re-run \`zero browser connect\`.`, - ); } async stop(): Promise { @@ -303,6 +365,23 @@ export class BridgeEngine { }); } + /** + * Block until the extension connects (first-time install), nudging every so + * often so the wait doesn't look frozen. Resolves true on connect, false on + * the overall INSTALL_WAIT_MS timeout. + */ + private async waitForInstall(): Promise { + const deadline = Date.now() + INSTALL_WAIT_MS; + while (Date.now() < deadline) { + const slice = Math.min(NUDGE_MS, deadline - Date.now()); + if (await this.waitForExtension(slice)) return true; + if (Date.now() < deadline) { + this.status("… still waiting — finish the steps above in chrome://extensions"); + } + } + return this.isAlive(); + } + private materializeExtension(): void { mkdirSync(this.extDir, { recursive: true }); for (const [name, content] of Object.entries(EXTENSION_ASSETS)) { diff --git a/zero/src/companion/chrome-launch.ts b/zero/src/companion/chrome-launch.ts deleted file mode 100644 index 92c5e56..0000000 --- a/zero/src/companion/chrome-launch.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * One-time Chrome relaunch that side-loads the Zero Companion extension. - * - * `--load-extension` only takes effect on a cold Chrome start, so if Chrome is - * already running we quit it gracefully first (Chrome saves the session) and - * relaunch with `--restore-last-session` so the user's tabs/logins come back. - * The extension then connects to the localhost bridge on its own. - * - * macOS is the primary, fully-supported path; Windows/Linux are best-effort - * with a clear manual fallback (Load unpacked in chrome://extensions). - */ -import { spawn } from "node:child_process"; -import { existsSync } from "node:fs"; -import { join } from "node:path"; - -type Logger = (line: string) => void; - -function run(cmd: string, args: string[]): Promise<{ code: number; out: string }> { - return new Promise((resolve) => { - let out = ""; - const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "ignore"] }); - child.stdout?.on("data", (d) => (out += d.toString())); - child.on("error", () => resolve({ code: -1, out })); - child.on("exit", (code) => resolve({ code: code ?? -1, out })); - }); -} - -function delay(ms: number): Promise { - return new Promise((r) => setTimeout(r, ms)); -} - -async function isChromeRunningMac(): Promise { - const { code } = await run("pgrep", ["-x", "Google Chrome"]); - return code === 0; -} - -async function ensureChromeWithExtensionMac(extDir: string, log: Logger): Promise { - if (await isChromeRunningMac()) { - log("quitting Chrome so it can reopen with the helper (your session will be restored)…"); - await run("osascript", ["-e", 'tell application "Google Chrome" to quit']); - // Wait for the process to actually exit before relaunching, else `open` - // just focuses the live instance and drops our --args. - for (let i = 0; i < 30; i++) { - if (!(await isChromeRunningMac())) break; - await delay(400); - } - if (await isChromeRunningMac()) { - throw new Error("Chrome is still running — quit it (Cmd-Q) and re-run `zero browser connect`."); - } - } - const args = ["-a", "Google Chrome", "--args", `--load-extension=${extDir}`, "--restore-last-session"]; - const { code } = await run("open", args); - if (code !== 0) throw new Error("could not launch Google Chrome via `open`"); -} - -function findChromeWindows(): string | null { - const candidates = [ - join(process.env["PROGRAMFILES"] ?? "C:/Program Files", "Google/Chrome/Application/chrome.exe"), - join(process.env["PROGRAMFILES(X86)"] ?? "C:/Program Files (x86)", "Google/Chrome/Application/chrome.exe"), - join(process.env["LOCALAPPDATA"] ?? "", "Google/Chrome/Application/chrome.exe"), - ]; - return candidates.find((p) => p && existsSync(p)) ?? null; -} - -async function ensureChromeWithExtensionWindows(extDir: string, log: Logger): Promise { - const chrome = findChromeWindows(); - if (!chrome) throw new Error("could not find chrome.exe"); - log("closing Chrome so it can reopen with the helper…"); - await run("taskkill", ["/IM", "chrome.exe", "/F"]); - await delay(1500); - const child = spawn(chrome, [`--load-extension=${extDir}`, "--restore-last-session"], { - detached: true, - stdio: "ignore", - }); - child.unref(); -} - -async function ensureChromeWithExtensionLinux(extDir: string, log: Logger): Promise { - // Best-effort: assume `google-chrome` is on PATH (the common case). - const bin = "google-chrome"; - log("closing Chrome so it can reopen with the helper…"); - await run("pkill", ["-x", "chrome"]); - await run("pkill", ["-x", "google-chrome"]); - await delay(1500); - const child = spawn(bin, [`--load-extension=${extDir}`, "--restore-last-session"], { - detached: true, - stdio: "ignore", - }); - child.unref(); -} - -/** - * Relaunch the user's Chrome with the companion extension side-loaded. Quits a - * running Chrome first (session is restored). Throws if Chrome can't be driven, - * with the manual Load-unpacked path as the fallback (surfaced by the caller). - */ -export async function ensureChromeWithExtension(extDir: string, log: Logger): Promise { - switch (process.platform) { - case "darwin": - return ensureChromeWithExtensionMac(extDir, log); - case "win32": - return ensureChromeWithExtensionWindows(extDir, log); - default: - return ensureChromeWithExtensionLinux(extDir, log); - } -} diff --git a/zero/src/companion/extension-assets.ts b/zero/src/companion/extension-assets.ts index bc4e197..aa3a452 100644 --- a/zero/src/companion/extension-assets.ts +++ b/zero/src/companion/extension-assets.ts @@ -2,5 +2,5 @@ // Run `cd extension && bun build.ts` to regenerate. export const EXTENSION_ASSETS: Record = { "manifest.json": "{\n \"manifest_version\": 3,\n \"name\": \"Zero Companion\",\n \"version\": \"1.0.0\",\n \"description\": \"Lets the Zero agent drive a tab in your real Chrome — your logins, your session, no separate browser.\",\n \"permissions\": [\"debugger\", \"tabs\", \"scripting\", \"activeTab\", \"alarms\"],\n \"host_permissions\": [\"\"],\n \"background\": {\n \"service_worker\": \"worker.js\"\n }\n}\n", - "worker.js": "(() => {\n // src/worker.ts\n var RECONNECT_MIN_MS = 1000;\n var RECONNECT_MAX_MS = 15000;\n var KEEPALIVE_ALARM = \"zero-keepalive\";\n var KEEPALIVE_PERIOD_MIN = 0.5;\n var MAX_SNAPSHOT_LINES = 150;\n var INCREMENTAL_THRESHOLD = 0.5;\n async function readBridgeConfig() {\n try {\n const res = await fetch(chrome.runtime.getURL(\"bridge.json\"), { cache: \"no-store\" });\n if (!res.ok)\n return null;\n const cfg = await res.json();\n if (typeof cfg?.port === \"number\" && typeof cfg?.secret === \"string\")\n return cfg;\n return null;\n } catch {\n return null;\n }\n }\n\n class TabDriver {\n tabId;\n refMap = new Map;\n snapshotCache = {};\n consoleLogs = [];\n waiters = new Map;\n constructor(tabId) {\n this.tabId = tabId;\n }\n send(method, params) {\n return chrome.debugger.sendCommand({ tabId: this.tabId }, method, params ?? {});\n }\n onEvent(method, params) {\n if (method === \"Runtime.consoleAPICalled\") {\n try {\n const level = params?.type ?? \"log\";\n const args = (params?.args ?? []).map((a) => {\n if (a == null)\n return String(a);\n if (\"value\" in a)\n return typeof a.value === \"string\" ? a.value : JSON.stringify(a.value);\n return a.description ?? a.unserializableValue ?? \"\";\n }).join(\" \");\n this.consoleLogs.push(`[${level}] ${args}`);\n } catch {}\n return;\n }\n const list = this.waiters.get(method);\n if (list) {\n this.waiters.delete(method);\n for (const fn of list)\n fn();\n }\n }\n once(method, timeout) {\n return new Promise((resolve) => {\n const list = this.waiters.get(method) ?? [];\n let done = false;\n const fire = () => {\n if (done)\n return;\n done = true;\n resolve();\n };\n list.push(fire);\n this.waiters.set(method, list);\n setTimeout(fire, timeout);\n });\n }\n async info() {\n try {\n const tab = await chrome.tabs.get(this.tabId);\n return { url: tab?.url ?? \"\", title: tab?.title ?? \"\" };\n } catch {\n return { url: \"\", title: \"\" };\n }\n }\n }\n function resolveRef(drv, ref) {\n const entry = drv.refMap.get(ref);\n if (!entry) {\n throw new Error(`Element ref [${ref}] not found. Take a snapshot first to get current refs.`);\n }\n return entry.backendNodeId;\n }\n async function resolveNode(drv, backendNodeId) {\n const { object } = await drv.send(\"DOM.resolveNode\", { backendNodeId });\n if (!object?.objectId) {\n throw new Error(\"Could not resolve node — it may have been removed from the DOM. Take a new snapshot.\");\n }\n return object.objectId;\n }\n async function getNodeCenter(drv, backendNodeId) {\n const objectId = await resolveNode(drv, backendNodeId);\n const result = await drv.send(\"Runtime.callFunctionOn\", {\n objectId,\n functionDeclaration: `function() {\n const r = this.getBoundingClientRect();\n return JSON.stringify({ x: r.x + r.width / 2, y: r.y + r.height / 2, width: r.width, height: r.height });\n }`,\n returnByValue: true\n });\n await drv.send(\"Runtime.releaseObject\", { objectId }).catch(() => {});\n return JSON.parse(result.result.value);\n }\n async function clickNode(drv, backendNodeId) {\n const objectId = await resolveNode(drv, backendNodeId);\n await drv.send(\"Runtime.callFunctionOn\", {\n objectId,\n functionDeclaration: `function() { this.scrollIntoViewIfNeeded(); }`\n }).catch(() => {});\n await drv.send(\"Runtime.releaseObject\", { objectId }).catch(() => {});\n const pos = await getNodeCenter(drv, backendNodeId);\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mouseMoved\", x: pos.x, y: pos.y });\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mousePressed\", x: pos.x, y: pos.y, button: \"left\", clickCount: 1 });\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mouseReleased\", x: pos.x, y: pos.y, button: \"left\", clickCount: 1 });\n }\n async function hoverNode(drv, backendNodeId) {\n const pos = await getNodeCenter(drv, backendNodeId);\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mouseMoved\", x: pos.x, y: pos.y });\n }\n async function focusAndType(drv, backendNodeId, text) {\n try {\n await drv.send(\"DOM.focus\", { backendNodeId });\n } catch {\n const pos = await getNodeCenter(drv, backendNodeId);\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mouseMoved\", x: pos.x, y: pos.y });\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mousePressed\", x: pos.x, y: pos.y, button: \"left\", clickCount: 1 });\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mouseReleased\", x: pos.x, y: pos.y, button: \"left\", clickCount: 1 });\n }\n const objectId = await resolveNode(drv, backendNodeId);\n await drv.send(\"Runtime.callFunctionOn\", {\n objectId,\n functionDeclaration: `function() {\n if ('value' in this) { this.value = ''; this.dispatchEvent(new Event('input', { bubbles: true })); }\n else if (this.isContentEditable) { this.textContent = ''; this.dispatchEvent(new Event('input', { bubbles: true })); }\n }`\n });\n await drv.send(\"Runtime.releaseObject\", { objectId }).catch(() => {});\n await drv.send(\"Input.insertText\", { text });\n }\n function stripRefs(line) {\n return line.replace(/ \\[ref=e\\d+\\]/g, \"\");\n }\n async function buildA11ySnapshot(drv, options) {\n const refMap = new Map;\n let refCounter = 0;\n const relaxed = options?.relaxed ?? false;\n const interactiveOnly = options?.interactiveOnly ?? false;\n let rootBackendNodeId;\n if (options?.selector) {\n try {\n const doc = await drv.send(\"DOM.getDocument\", { depth: 0 });\n const { nodeId } = await drv.send(\"DOM.querySelector\", {\n nodeId: doc.root.nodeId,\n selector: options.selector\n });\n if (nodeId) {\n const { node } = await drv.send(\"DOM.describeNode\", { nodeId });\n rootBackendNodeId = node.backendNodeId;\n }\n } catch {}\n }\n const ax = await drv.send(\"Accessibility.getFullAXTree\", { depth: 50 });\n const nodes = ax.nodes;\n const nodeMap = new Map;\n const children = new Map;\n for (const node of nodes) {\n nodeMap.set(node.nodeId, node);\n if (node.parentId) {\n const kids = children.get(node.parentId) ?? [];\n kids.push(node.nodeId);\n children.set(node.parentId, kids);\n }\n }\n let scopeNodeId;\n if (rootBackendNodeId) {\n for (const node of nodes) {\n if (node.backendDOMNodeId === rootBackendNodeId) {\n scopeNodeId = node.nodeId;\n break;\n }\n }\n }\n const skipRoles = new Set([\n \"none\",\n \"InlineTextBox\",\n \"LineBreak\",\n \"StaticText\",\n \"RootWebArea\",\n \"ignored\",\n ...relaxed ? [] : [\"generic\"]\n ]);\n const interactiveRoles = new Set([\n \"button\",\n \"link\",\n \"textbox\",\n \"checkbox\",\n \"radio\",\n \"combobox\",\n \"menuitem\",\n \"tab\",\n \"switch\",\n \"slider\",\n \"searchbox\",\n \"spinbutton\",\n \"option\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"treeitem\"\n ]);\n const lines = [];\n const lineLimit = interactiveOnly ? Infinity : MAX_SNAPSHOT_LINES;\n let truncated = false;\n function renderNode(nodeId, depth) {\n if (truncated)\n return;\n const node = nodeMap.get(nodeId);\n if (!node)\n return;\n const role = node.role?.value ?? \"\";\n const name = node.name?.value ?? \"\";\n const backendNodeId = node.backendDOMNodeId;\n if (skipRoles.has(role)) {\n const keepGeneric = role === \"generic\" && name && backendNodeId;\n if (!keepGeneric) {\n for (const kid of children.get(nodeId) ?? [])\n renderNode(kid, depth);\n return;\n }\n }\n const isInteractive = interactiveRoles.has(role);\n if (interactiveOnly && !isInteractive) {\n for (const kid of children.get(nodeId) ?? [])\n renderNode(kid, depth);\n return;\n }\n if (lines.length >= lineLimit) {\n truncated = true;\n return;\n }\n let ref = \"\";\n if (relaxed) {\n if (backendNodeId) {\n refCounter++;\n const refId = `e${refCounter}`;\n ref = ` [ref=${refId}]`;\n refMap.set(refId, { role, name, backendNodeId });\n }\n } else if (backendNodeId && (isInteractive || name)) {\n refCounter++;\n const refId = `e${refCounter}`;\n ref = ` [ref=${refId}]`;\n refMap.set(refId, { role, name, backendNodeId });\n }\n const nameStr = name ? ` \"${name}\"` : \"\";\n if (interactiveOnly) {\n lines.push(`- ${role}${nameStr}${ref}`);\n } else {\n lines.push(`${\" \".repeat(depth)}- ${role}${nameStr}${ref}`);\n }\n if (!interactiveOnly) {\n for (const kid of children.get(nodeId) ?? [])\n renderNode(kid, depth + 1);\n }\n }\n const startNode = scopeNodeId ? nodeMap.get(scopeNodeId) : nodes.find((n) => !n.parentId || n.role?.value === \"RootWebArea\");\n if (startNode) {\n if (scopeNodeId) {\n renderNode(scopeNodeId, 0);\n } else {\n for (const kid of children.get(startNode.nodeId) ?? [])\n renderNode(kid, 0);\n }\n }\n if (truncated) {\n lines.push(`\n[...truncated at ${lineLimit} lines — use snapshot with a CSS selector to see specific sections, e.g. selector: \"main\", \"article\", \"#content\"]`);\n }\n return { content: lines.join(`\n`), truncated, refMap };\n }\n async function takeSnapshot(drv, opts) {\n const interactiveOnly = opts?.interactiveOnly ?? false;\n let snap = await buildA11ySnapshot(drv, { interactiveOnly, selector: opts?.selector });\n drv.refMap.clear();\n for (const [k, v] of snap.refMap)\n drv.refMap.set(k, v);\n if (drv.refMap.size === 0) {\n snap = await buildA11ySnapshot(drv, {\n relaxed: true,\n interactiveOnly,\n selector: opts?.selector\n });\n drv.refMap.clear();\n for (const [k, v] of snap.refMap)\n drv.refMap.set(k, v);\n }\n let content = snap.content;\n if (!content && drv.refMap.size === 0) {\n content = \"[No interactive elements found in page accessibility tree. \" + \"Try: snapshot with mode 'full' to see all content, screenshot to see the page visually, \" + \"evaluate to inspect the DOM with JavaScript, or wait and snapshot again if the page is still loading.]\";\n }\n const currentLines = content.split(`\n`);\n const currentStripped = currentLines.map(stripRefs);\n const currentUrl = (await drv.info()).url;\n if (drv.snapshotCache.prevLines && drv.snapshotCache.prevUrl === currentUrl && !opts?.selector) {\n const prevSet = new Set(drv.snapshotCache.prevLines);\n const currSet = new Set(currentStripped);\n const added = [];\n const removed = [];\n for (let i = 0;i < currentLines.length; i++) {\n if (!prevSet.has(currentStripped[i]))\n added.push(currentLines[i]);\n }\n for (const prevLine of drv.snapshotCache.prevLines) {\n if (!currSet.has(prevLine))\n removed.push(prevLine);\n }\n const unchanged = currentLines.length - added.length;\n const isIncremental = unchanged / Math.max(currentLines.length, 1) >= INCREMENTAL_THRESHOLD;\n if (isIncremental && (added.length > 0 || removed.length > 0)) {\n const parts = [];\n parts.push(`[Incremental snapshot — ${unchanged} unchanged, ${added.length} added, ${removed.length} removed]`);\n if (added.length > 0)\n parts.push(\"\", \"Added:\", ...added);\n if (removed.length > 0)\n parts.push(\"\", \"Removed:\", ...removed);\n const interactiveLines = currentLines.filter((_, i) => {\n const line = currentStripped[i];\n return /^-?\\s*- (button|link|textbox|checkbox|radio|combobox|menuitem|tab|switch|slider|searchbox|spinbutton|option)/.test(line.trimStart());\n });\n if (interactiveLines.length > 0)\n parts.push(\"\", \"Interactive elements:\", ...interactiveLines);\n content = parts.join(`\n`);\n }\n }\n drv.snapshotCache.prevLines = currentStripped;\n drv.snapshotCache.prevUrl = currentUrl;\n return content;\n }\n async function snapshotIfNavigated(drv, urlBefore) {\n const urlAfter = (await drv.info()).url;\n if (urlAfter !== urlBefore)\n return takeSnapshot(drv, { interactiveOnly: true });\n return;\n }\n function isStaleNodeError(err) {\n const msg = err instanceof Error ? err.message : String(err);\n return msg.includes(\"does not belong to the document\") || msg.includes(\"No node with given id found\") || msg.includes(\"Could not resolve node\") || msg.includes(\"not found — it may have been removed\");\n }\n async function reResolveRef(drv, ref) {\n await takeSnapshot(drv, { interactiveOnly: true });\n return resolveRef(drv, ref);\n }\n async function executeAction(drv, action) {\n switch (action.type) {\n case \"navigate\": {\n const loaded = drv.once(\"Page.loadEventFired\", 30000);\n try {\n await drv.send(\"Page.navigate\", { url: action.url });\n } catch (err) {\n throw new Error(`navigate failed: ${err instanceof Error ? err.message : String(err)}`);\n }\n await loaded;\n await new Promise((r) => setTimeout(r, 500));\n const info = await drv.info();\n const snapshot = await takeSnapshot(drv, { interactiveOnly: true });\n return { type: \"done\", ...info, message: `Navigated to ${action.url}`, snapshot };\n }\n case \"click\": {\n const urlBefore = (await drv.info()).url;\n let nodeId = resolveRef(drv, action.ref);\n try {\n await clickNode(drv, nodeId);\n } catch (err) {\n if (isStaleNodeError(err)) {\n try {\n nodeId = await reResolveRef(drv, action.ref);\n await clickNode(drv, nodeId);\n } catch {\n throw new Error(`Element [${action.ref}] no longer exists on the page. Take a new snapshot to see current elements.`);\n }\n } else\n throw err;\n }\n await drv.once(\"Page.loadEventFired\", 5000);\n const info = await drv.info();\n const snapshot = await snapshotIfNavigated(drv, urlBefore);\n return { type: \"done\", ...info, message: `Clicked [${action.ref}]`, snapshot };\n }\n case \"type\": {\n const urlBefore = (await drv.info()).url;\n let nodeId = resolveRef(drv, action.ref);\n try {\n await focusAndType(drv, nodeId, action.text);\n } catch (err) {\n if (isStaleNodeError(err)) {\n try {\n nodeId = await reResolveRef(drv, action.ref);\n await focusAndType(drv, nodeId, action.text);\n } catch {\n throw new Error(`Element [${action.ref}] no longer exists on the page. Take a new snapshot to see current elements.`);\n }\n } else\n throw err;\n }\n if (action.submit) {\n await drv.send(\"Input.dispatchKeyEvent\", { type: \"keyDown\", key: \"Enter\", code: \"Enter\", windowsVirtualKeyCode: 13 });\n await drv.send(\"Input.dispatchKeyEvent\", { type: \"keyUp\", key: \"Enter\", code: \"Enter\", windowsVirtualKeyCode: 13 });\n await drv.once(\"Page.loadEventFired\", 5000);\n }\n const info = await drv.info();\n const snapshot = await snapshotIfNavigated(drv, urlBefore);\n return { type: \"done\", ...info, message: `Typed into [${action.ref}]`, snapshot };\n }\n case \"select\": {\n const nodeId = resolveRef(drv, action.ref);\n const objectId = await resolveNode(drv, nodeId);\n await drv.send(\"Runtime.callFunctionOn\", {\n objectId,\n functionDeclaration: `function(v) { this.value = v; this.dispatchEvent(new Event('change', { bubbles: true })); }`,\n arguments: [{ value: action.value }]\n });\n await drv.send(\"Runtime.releaseObject\", { objectId }).catch(() => {});\n const info = await drv.info();\n return { type: \"done\", ...info, message: `Selected ${action.value} in [${action.ref}]` };\n }\n case \"hover\": {\n const nodeId = resolveRef(drv, action.ref);\n await hoverNode(drv, nodeId);\n const info = await drv.info();\n return { type: \"done\", ...info, message: `Hovered [${action.ref}]` };\n }\n case \"scroll\": {\n const amount = action.amount ?? 600;\n const dy = action.direction === \"up\" ? -amount : amount;\n await drv.send(\"Runtime.evaluate\", { expression: `window.scrollBy(0, ${dy})` });\n const info = await drv.info();\n return { type: \"done\", ...info, message: `Scrolled ${action.direction}` };\n }\n case \"back\": {\n await navigateHistory(drv, -1);\n const info = await drv.info();\n return { type: \"done\", ...info, message: \"Went back\" };\n }\n case \"forward\": {\n await navigateHistory(drv, 1);\n const info = await drv.info();\n return { type: \"done\", ...info, message: \"Went forward\" };\n }\n case \"reload\": {\n const loaded = drv.once(\"Page.loadEventFired\", 30000);\n await drv.send(\"Page.reload\", {});\n await loaded;\n const info = await drv.info();\n return { type: \"done\", ...info, message: \"Reloaded\" };\n }\n case \"wait\": {\n await new Promise((r) => setTimeout(r, Math.min(action.ms, 1e4)));\n const info = await drv.info();\n return { type: \"done\", ...info, message: `Waited ${action.ms}ms` };\n }\n case \"snapshot\": {\n const interactiveOnly = action.mode !== \"full\";\n const content = await takeSnapshot(drv, { interactiveOnly, selector: action.selector });\n const info = await drv.info();\n return { type: \"snapshot\", ...info, content };\n }\n case \"screenshot\": {\n const { data } = await drv.send(\"Page.captureScreenshot\", { format: \"jpeg\", quality: 60 });\n const info = await drv.info();\n return { type: \"screenshot\", ...info, base64: data };\n }\n case \"evaluate\": {\n const awaitPromise = action.awaitPromise !== false;\n drv.consoleLogs = [];\n let value;\n let errorStr;\n const result = await drv.send(\"Runtime.evaluate\", {\n expression: action.script,\n awaitPromise,\n returnByValue: true,\n userGesture: true,\n replMode: true\n });\n if (result.exceptionDetails) {\n const ex = result.exceptionDetails;\n const desc = ex.exception?.description ?? ex.text ?? \"Unknown error\";\n errorStr = `${desc} (line ${ex.lineNumber ?? \"?\"}:${ex.columnNumber ?? \"?\"})`;\n } else {\n const r = result.result ?? {};\n value = \"value\" in r ? r.value : r.description;\n }\n const MAX_VALUE_CHARS = action.maxChars ?? 4000;\n try {\n const serialized = typeof value === \"string\" ? value : JSON.stringify(value);\n if (serialized && serialized.length > MAX_VALUE_CHARS) {\n value = (typeof value === \"string\" ? value : serialized).slice(0, MAX_VALUE_CHARS) + `\n[...truncated, ${serialized.length - MAX_VALUE_CHARS} chars omitted]`;\n }\n } catch {}\n const logs = drv.consoleLogs.slice(0, 200);\n drv.consoleLogs = [];\n const info = await drv.info();\n return { type: \"evaluate\", ...info, value, logs: logs.length > 0 ? logs : undefined, error: errorStr };\n }\n case \"tabs\": {\n const tabs = await chrome.tabs.query({ lastFocusedWindow: true });\n return {\n type: \"tabs\",\n tabs: tabs.map((t, i) => ({ index: i, url: t.url ?? \"\", title: t.title ?? \"\", active: !!t.active }))\n };\n }\n default: {\n const info = await drv.info();\n return { type: \"done\", ...info, message: `unsupported action: ${action.type}` };\n }\n }\n }\n async function navigateHistory(drv, delta) {\n const hist = await drv.send(\"Page.getNavigationHistory\", {});\n const target = hist.currentIndex + delta;\n if (target < 0 || target >= hist.entries.length) {\n throw new Error(delta < 0 ? \"No page to go back to\" : \"No page to go forward to\");\n }\n const loaded = drv.once(\"Page.loadEventFired\", 30000);\n await drv.send(\"Page.navigateToHistoryEntry\", { entryId: hist.entries[target].id });\n await loaded;\n }\n var driver = null;\n async function resolveTargetTabId() {\n let tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true });\n let tab = tabs[0];\n if (!tab) {\n tabs = await chrome.tabs.query({ active: true, currentWindow: true });\n tab = tabs[0];\n }\n if (!tab) {\n tabs = await chrome.tabs.query({ active: true });\n tab = tabs[0];\n }\n if (!tab || typeof tab.id !== \"number\")\n throw new Error(\"No active tab to control\");\n const url = tab.url ?? \"\";\n if (/^(chrome|edge|brave|devtools|chrome-extension|about):/i.test(url) || url.startsWith(\"https://chromewebstore.google.com\")) {\n throw new Error(`Can't control this page (${url || \"internal page\"}). Switch to a normal website tab and try again.`);\n }\n return tab.id;\n }\n async function ensureAttached() {\n const tabId = await resolveTargetTabId();\n if (driver && driver.tabId === tabId)\n return driver;\n if (driver) {\n try {\n await chrome.debugger.detach({ tabId: driver.tabId });\n } catch {}\n driver = null;\n }\n try {\n await chrome.debugger.attach({ tabId }, \"1.3\");\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n if (!/already attached/i.test(msg))\n throw err;\n }\n const drv = new TabDriver(tabId);\n await drv.send(\"Page.enable\").catch(() => {});\n await drv.send(\"DOM.enable\").catch(() => {});\n await drv.send(\"Accessibility.enable\").catch(() => {});\n await drv.send(\"Runtime.enable\").catch(() => {});\n driver = drv;\n return drv;\n }\n chrome.debugger.onEvent.addListener((source, method, params) => {\n if (driver && source?.tabId === driver.tabId)\n driver.onEvent(method, params);\n });\n chrome.debugger.onDetach.addListener((source) => {\n if (driver && source?.tabId === driver.tabId)\n driver = null;\n });\n var socket = null;\n var reconnectDelay = RECONNECT_MIN_MS;\n var connecting = false;\n function sendToBridge(msg) {\n try {\n if (socket && socket.readyState === 1)\n socket.send(JSON.stringify(msg));\n } catch {}\n }\n async function handleCommand(id, action) {\n try {\n const drv = await ensureAttached();\n const result = await executeAction(drv, action);\n sendToBridge({ type: \"response\", id, result });\n } catch (err) {\n sendToBridge({ type: \"response\", id, error: err instanceof Error ? err.message : String(err) });\n }\n }\n async function connect() {\n if (connecting || socket && (socket.readyState === 0 || socket.readyState === 1))\n return;\n connecting = true;\n const cfg = await readBridgeConfig();\n if (!cfg) {\n connecting = false;\n scheduleReconnect();\n return;\n }\n try {\n const ws = new WebSocket(`ws://127.0.0.1:${cfg.port}/`);\n socket = ws;\n ws.onopen = () => {\n reconnectDelay = RECONNECT_MIN_MS;\n sendToBridge({ type: \"hello\", secret: cfg.secret, capabilities: { chromeAvailable: true } });\n };\n ws.onmessage = (ev) => {\n let msg;\n try {\n msg = JSON.parse(typeof ev.data === \"string\" ? ev.data : String(ev.data));\n } catch {\n return;\n }\n if (msg.type === \"ping\")\n sendToBridge({ type: \"pong\" });\n else if (msg.type === \"command\")\n handleCommand(msg.id, msg.action);\n };\n ws.onclose = () => {\n if (socket === ws)\n socket = null;\n scheduleReconnect();\n };\n ws.onerror = () => {\n try {\n ws.close();\n } catch {}\n };\n } catch {\n socket = null;\n scheduleReconnect();\n } finally {\n connecting = false;\n }\n }\n function scheduleReconnect() {\n const delay = reconnectDelay;\n reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);\n setTimeout(() => void connect(), delay);\n }\n chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: KEEPALIVE_PERIOD_MIN });\n chrome.alarms.onAlarm.addListener((alarm) => {\n if (alarm.name !== KEEPALIVE_ALARM)\n return;\n if (socket && socket.readyState === 1)\n sendToBridge({ type: \"ping\" });\n else\n connect();\n });\n chrome.runtime.onStartup?.addListener(() => void connect());\n chrome.runtime.onInstalled?.addListener(() => void connect());\n connect();\n})();\n" + "worker.js": "(() => {\n // src/worker.ts\n var RECONNECT_MIN_MS = 1000;\n var RECONNECT_MAX_MS = 5000;\n var KEEPALIVE_ALARM = \"zero-keepalive\";\n var KEEPALIVE_PERIOD_MIN = 0.5;\n var MAX_SNAPSHOT_LINES = 150;\n var INCREMENTAL_THRESHOLD = 0.5;\n async function readBridgeConfig() {\n try {\n const res = await fetch(chrome.runtime.getURL(\"bridge.json\"), { cache: \"no-store\" });\n if (!res.ok)\n return null;\n const cfg = await res.json();\n if (typeof cfg?.port === \"number\" && typeof cfg?.secret === \"string\")\n return cfg;\n return null;\n } catch {\n return null;\n }\n }\n\n class TabDriver {\n tabId;\n refMap = new Map;\n snapshotCache = {};\n consoleLogs = [];\n waiters = new Map;\n constructor(tabId) {\n this.tabId = tabId;\n }\n send(method, params) {\n return chrome.debugger.sendCommand({ tabId: this.tabId }, method, params ?? {});\n }\n onEvent(method, params) {\n if (method === \"Runtime.consoleAPICalled\") {\n try {\n const level = params?.type ?? \"log\";\n const args = (params?.args ?? []).map((a) => {\n if (a == null)\n return String(a);\n if (\"value\" in a)\n return typeof a.value === \"string\" ? a.value : JSON.stringify(a.value);\n return a.description ?? a.unserializableValue ?? \"\";\n }).join(\" \");\n this.consoleLogs.push(`[${level}] ${args}`);\n } catch {}\n return;\n }\n const list = this.waiters.get(method);\n if (list) {\n this.waiters.delete(method);\n for (const fn of list)\n fn();\n }\n }\n once(method, timeout) {\n return new Promise((resolve) => {\n const list = this.waiters.get(method) ?? [];\n let done = false;\n const fire = () => {\n if (done)\n return;\n done = true;\n resolve();\n };\n list.push(fire);\n this.waiters.set(method, list);\n setTimeout(fire, timeout);\n });\n }\n async info() {\n try {\n const tab = await chrome.tabs.get(this.tabId);\n return { url: tab?.url ?? \"\", title: tab?.title ?? \"\" };\n } catch {\n return { url: \"\", title: \"\" };\n }\n }\n }\n function resolveRef(drv, ref) {\n const entry = drv.refMap.get(ref);\n if (!entry) {\n throw new Error(`Element ref [${ref}] not found. Take a snapshot first to get current refs.`);\n }\n return entry.backendNodeId;\n }\n async function resolveNode(drv, backendNodeId) {\n const { object } = await drv.send(\"DOM.resolveNode\", { backendNodeId });\n if (!object?.objectId) {\n throw new Error(\"Could not resolve node — it may have been removed from the DOM. Take a new snapshot.\");\n }\n return object.objectId;\n }\n async function getNodeCenter(drv, backendNodeId) {\n const objectId = await resolveNode(drv, backendNodeId);\n const result = await drv.send(\"Runtime.callFunctionOn\", {\n objectId,\n functionDeclaration: `function() {\n const r = this.getBoundingClientRect();\n return JSON.stringify({ x: r.x + r.width / 2, y: r.y + r.height / 2, width: r.width, height: r.height });\n }`,\n returnByValue: true\n });\n await drv.send(\"Runtime.releaseObject\", { objectId }).catch(() => {});\n return JSON.parse(result.result.value);\n }\n async function clickNode(drv, backendNodeId) {\n const objectId = await resolveNode(drv, backendNodeId);\n await drv.send(\"Runtime.callFunctionOn\", {\n objectId,\n functionDeclaration: `function() { this.scrollIntoViewIfNeeded(); }`\n }).catch(() => {});\n await drv.send(\"Runtime.releaseObject\", { objectId }).catch(() => {});\n const pos = await getNodeCenter(drv, backendNodeId);\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mouseMoved\", x: pos.x, y: pos.y });\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mousePressed\", x: pos.x, y: pos.y, button: \"left\", clickCount: 1 });\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mouseReleased\", x: pos.x, y: pos.y, button: \"left\", clickCount: 1 });\n }\n async function hoverNode(drv, backendNodeId) {\n const pos = await getNodeCenter(drv, backendNodeId);\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mouseMoved\", x: pos.x, y: pos.y });\n }\n async function focusAndType(drv, backendNodeId, text) {\n try {\n await drv.send(\"DOM.focus\", { backendNodeId });\n } catch {\n const pos = await getNodeCenter(drv, backendNodeId);\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mouseMoved\", x: pos.x, y: pos.y });\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mousePressed\", x: pos.x, y: pos.y, button: \"left\", clickCount: 1 });\n await drv.send(\"Input.dispatchMouseEvent\", { type: \"mouseReleased\", x: pos.x, y: pos.y, button: \"left\", clickCount: 1 });\n }\n const objectId = await resolveNode(drv, backendNodeId);\n await drv.send(\"Runtime.callFunctionOn\", {\n objectId,\n functionDeclaration: `function() {\n if ('value' in this) { this.value = ''; this.dispatchEvent(new Event('input', { bubbles: true })); }\n else if (this.isContentEditable) { this.textContent = ''; this.dispatchEvent(new Event('input', { bubbles: true })); }\n }`\n });\n await drv.send(\"Runtime.releaseObject\", { objectId }).catch(() => {});\n await drv.send(\"Input.insertText\", { text });\n }\n function stripRefs(line) {\n return line.replace(/ \\[ref=e\\d+\\]/g, \"\");\n }\n async function buildA11ySnapshot(drv, options) {\n const refMap = new Map;\n let refCounter = 0;\n const relaxed = options?.relaxed ?? false;\n const interactiveOnly = options?.interactiveOnly ?? false;\n let rootBackendNodeId;\n if (options?.selector) {\n try {\n const doc = await drv.send(\"DOM.getDocument\", { depth: 0 });\n const { nodeId } = await drv.send(\"DOM.querySelector\", {\n nodeId: doc.root.nodeId,\n selector: options.selector\n });\n if (nodeId) {\n const { node } = await drv.send(\"DOM.describeNode\", { nodeId });\n rootBackendNodeId = node.backendNodeId;\n }\n } catch {}\n }\n const ax = await drv.send(\"Accessibility.getFullAXTree\", { depth: 50 });\n const nodes = ax.nodes;\n const nodeMap = new Map;\n const children = new Map;\n for (const node of nodes) {\n nodeMap.set(node.nodeId, node);\n if (node.parentId) {\n const kids = children.get(node.parentId) ?? [];\n kids.push(node.nodeId);\n children.set(node.parentId, kids);\n }\n }\n let scopeNodeId;\n if (rootBackendNodeId) {\n for (const node of nodes) {\n if (node.backendDOMNodeId === rootBackendNodeId) {\n scopeNodeId = node.nodeId;\n break;\n }\n }\n }\n const skipRoles = new Set([\n \"none\",\n \"InlineTextBox\",\n \"LineBreak\",\n \"StaticText\",\n \"RootWebArea\",\n \"ignored\",\n ...relaxed ? [] : [\"generic\"]\n ]);\n const interactiveRoles = new Set([\n \"button\",\n \"link\",\n \"textbox\",\n \"checkbox\",\n \"radio\",\n \"combobox\",\n \"menuitem\",\n \"tab\",\n \"switch\",\n \"slider\",\n \"searchbox\",\n \"spinbutton\",\n \"option\",\n \"menuitemcheckbox\",\n \"menuitemradio\",\n \"treeitem\"\n ]);\n const lines = [];\n const lineLimit = interactiveOnly ? Infinity : MAX_SNAPSHOT_LINES;\n let truncated = false;\n function renderNode(nodeId, depth) {\n if (truncated)\n return;\n const node = nodeMap.get(nodeId);\n if (!node)\n return;\n const role = node.role?.value ?? \"\";\n const name = node.name?.value ?? \"\";\n const backendNodeId = node.backendDOMNodeId;\n if (skipRoles.has(role)) {\n const keepGeneric = role === \"generic\" && name && backendNodeId;\n if (!keepGeneric) {\n for (const kid of children.get(nodeId) ?? [])\n renderNode(kid, depth);\n return;\n }\n }\n const isInteractive = interactiveRoles.has(role);\n if (interactiveOnly && !isInteractive) {\n for (const kid of children.get(nodeId) ?? [])\n renderNode(kid, depth);\n return;\n }\n if (lines.length >= lineLimit) {\n truncated = true;\n return;\n }\n let ref = \"\";\n if (relaxed) {\n if (backendNodeId) {\n refCounter++;\n const refId = `e${refCounter}`;\n ref = ` [ref=${refId}]`;\n refMap.set(refId, { role, name, backendNodeId });\n }\n } else if (backendNodeId && (isInteractive || name)) {\n refCounter++;\n const refId = `e${refCounter}`;\n ref = ` [ref=${refId}]`;\n refMap.set(refId, { role, name, backendNodeId });\n }\n const nameStr = name ? ` \"${name}\"` : \"\";\n if (interactiveOnly) {\n lines.push(`- ${role}${nameStr}${ref}`);\n } else {\n lines.push(`${\" \".repeat(depth)}- ${role}${nameStr}${ref}`);\n }\n if (!interactiveOnly) {\n for (const kid of children.get(nodeId) ?? [])\n renderNode(kid, depth + 1);\n }\n }\n const startNode = scopeNodeId ? nodeMap.get(scopeNodeId) : nodes.find((n) => !n.parentId || n.role?.value === \"RootWebArea\");\n if (startNode) {\n if (scopeNodeId) {\n renderNode(scopeNodeId, 0);\n } else {\n for (const kid of children.get(startNode.nodeId) ?? [])\n renderNode(kid, 0);\n }\n }\n if (truncated) {\n lines.push(`\n[...truncated at ${lineLimit} lines — use snapshot with a CSS selector to see specific sections, e.g. selector: \"main\", \"article\", \"#content\"]`);\n }\n return { content: lines.join(`\n`), truncated, refMap };\n }\n async function takeSnapshot(drv, opts) {\n const interactiveOnly = opts?.interactiveOnly ?? false;\n let snap = await buildA11ySnapshot(drv, { interactiveOnly, selector: opts?.selector });\n drv.refMap.clear();\n for (const [k, v] of snap.refMap)\n drv.refMap.set(k, v);\n if (drv.refMap.size === 0) {\n snap = await buildA11ySnapshot(drv, {\n relaxed: true,\n interactiveOnly,\n selector: opts?.selector\n });\n drv.refMap.clear();\n for (const [k, v] of snap.refMap)\n drv.refMap.set(k, v);\n }\n let content = snap.content;\n if (!content && drv.refMap.size === 0) {\n content = \"[No interactive elements found in page accessibility tree. \" + \"Try: snapshot with mode 'full' to see all content, screenshot to see the page visually, \" + \"evaluate to inspect the DOM with JavaScript, or wait and snapshot again if the page is still loading.]\";\n }\n const currentLines = content.split(`\n`);\n const currentStripped = currentLines.map(stripRefs);\n const currentUrl = (await drv.info()).url;\n if (drv.snapshotCache.prevLines && drv.snapshotCache.prevUrl === currentUrl && !opts?.selector) {\n const prevSet = new Set(drv.snapshotCache.prevLines);\n const currSet = new Set(currentStripped);\n const added = [];\n const removed = [];\n for (let i = 0;i < currentLines.length; i++) {\n if (!prevSet.has(currentStripped[i]))\n added.push(currentLines[i]);\n }\n for (const prevLine of drv.snapshotCache.prevLines) {\n if (!currSet.has(prevLine))\n removed.push(prevLine);\n }\n const unchanged = currentLines.length - added.length;\n const isIncremental = unchanged / Math.max(currentLines.length, 1) >= INCREMENTAL_THRESHOLD;\n if (isIncremental && (added.length > 0 || removed.length > 0)) {\n const parts = [];\n parts.push(`[Incremental snapshot — ${unchanged} unchanged, ${added.length} added, ${removed.length} removed]`);\n if (added.length > 0)\n parts.push(\"\", \"Added:\", ...added);\n if (removed.length > 0)\n parts.push(\"\", \"Removed:\", ...removed);\n const interactiveLines = currentLines.filter((_, i) => {\n const line = currentStripped[i];\n return /^-?\\s*- (button|link|textbox|checkbox|radio|combobox|menuitem|tab|switch|slider|searchbox|spinbutton|option)/.test(line.trimStart());\n });\n if (interactiveLines.length > 0)\n parts.push(\"\", \"Interactive elements:\", ...interactiveLines);\n content = parts.join(`\n`);\n }\n }\n drv.snapshotCache.prevLines = currentStripped;\n drv.snapshotCache.prevUrl = currentUrl;\n return content;\n }\n async function snapshotIfNavigated(drv, urlBefore) {\n const urlAfter = (await drv.info()).url;\n if (urlAfter !== urlBefore)\n return takeSnapshot(drv, { interactiveOnly: true });\n return;\n }\n function isStaleNodeError(err) {\n const msg = err instanceof Error ? err.message : String(err);\n return msg.includes(\"does not belong to the document\") || msg.includes(\"No node with given id found\") || msg.includes(\"Could not resolve node\") || msg.includes(\"not found — it may have been removed\");\n }\n async function reResolveRef(drv, ref) {\n await takeSnapshot(drv, { interactiveOnly: true });\n return resolveRef(drv, ref);\n }\n async function executeAction(action) {\n if (action.type === \"navigate\") {\n let tabId;\n try {\n tabId = await navigateAgentTab(action.url);\n } catch (err) {\n throw new Error(`navigate failed: ${err instanceof Error ? err.message : String(err)}`);\n }\n await new Promise((r) => setTimeout(r, 500));\n const drv2 = await ensureAttached(tabId);\n const info = await drv2.info();\n const snapshot = await takeSnapshot(drv2, { interactiveOnly: true });\n return { type: \"done\", ...info, message: `Navigated to ${action.url}`, snapshot };\n }\n const drv = await ensureAttached(requireAgentTab());\n switch (action.type) {\n case \"click\": {\n const urlBefore = (await drv.info()).url;\n let nodeId = resolveRef(drv, action.ref);\n try {\n await clickNode(drv, nodeId);\n } catch (err) {\n if (isStaleNodeError(err)) {\n try {\n nodeId = await reResolveRef(drv, action.ref);\n await clickNode(drv, nodeId);\n } catch {\n throw new Error(`Element [${action.ref}] no longer exists on the page. Take a new snapshot to see current elements.`);\n }\n } else\n throw err;\n }\n await drv.once(\"Page.loadEventFired\", 5000);\n const info = await drv.info();\n const snapshot = await snapshotIfNavigated(drv, urlBefore);\n return { type: \"done\", ...info, message: `Clicked [${action.ref}]`, snapshot };\n }\n case \"type\": {\n const urlBefore = (await drv.info()).url;\n let nodeId = resolveRef(drv, action.ref);\n try {\n await focusAndType(drv, nodeId, action.text);\n } catch (err) {\n if (isStaleNodeError(err)) {\n try {\n nodeId = await reResolveRef(drv, action.ref);\n await focusAndType(drv, nodeId, action.text);\n } catch {\n throw new Error(`Element [${action.ref}] no longer exists on the page. Take a new snapshot to see current elements.`);\n }\n } else\n throw err;\n }\n if (action.submit) {\n await drv.send(\"Input.dispatchKeyEvent\", { type: \"keyDown\", key: \"Enter\", code: \"Enter\", windowsVirtualKeyCode: 13 });\n await drv.send(\"Input.dispatchKeyEvent\", { type: \"keyUp\", key: \"Enter\", code: \"Enter\", windowsVirtualKeyCode: 13 });\n await drv.once(\"Page.loadEventFired\", 5000);\n }\n const info = await drv.info();\n const snapshot = await snapshotIfNavigated(drv, urlBefore);\n return { type: \"done\", ...info, message: `Typed into [${action.ref}]`, snapshot };\n }\n case \"select\": {\n const nodeId = resolveRef(drv, action.ref);\n const objectId = await resolveNode(drv, nodeId);\n await drv.send(\"Runtime.callFunctionOn\", {\n objectId,\n functionDeclaration: `function(v) { this.value = v; this.dispatchEvent(new Event('change', { bubbles: true })); }`,\n arguments: [{ value: action.value }]\n });\n await drv.send(\"Runtime.releaseObject\", { objectId }).catch(() => {});\n const info = await drv.info();\n return { type: \"done\", ...info, message: `Selected ${action.value} in [${action.ref}]` };\n }\n case \"hover\": {\n const nodeId = resolveRef(drv, action.ref);\n await hoverNode(drv, nodeId);\n const info = await drv.info();\n return { type: \"done\", ...info, message: `Hovered [${action.ref}]` };\n }\n case \"scroll\": {\n const amount = action.amount ?? 600;\n const dy = action.direction === \"up\" ? -amount : amount;\n await drv.send(\"Runtime.evaluate\", { expression: `window.scrollBy(0, ${dy})` });\n const info = await drv.info();\n return { type: \"done\", ...info, message: `Scrolled ${action.direction}` };\n }\n case \"back\": {\n await navigateHistory(drv, -1);\n const info = await drv.info();\n return { type: \"done\", ...info, message: \"Went back\" };\n }\n case \"forward\": {\n await navigateHistory(drv, 1);\n const info = await drv.info();\n return { type: \"done\", ...info, message: \"Went forward\" };\n }\n case \"reload\": {\n const loaded = drv.once(\"Page.loadEventFired\", 30000);\n await drv.send(\"Page.reload\", {});\n await loaded;\n const info = await drv.info();\n return { type: \"done\", ...info, message: \"Reloaded\" };\n }\n case \"wait\": {\n await new Promise((r) => setTimeout(r, Math.min(action.ms, 1e4)));\n const info = await drv.info();\n return { type: \"done\", ...info, message: `Waited ${action.ms}ms` };\n }\n case \"snapshot\": {\n const interactiveOnly = action.mode !== \"full\";\n const content = await takeSnapshot(drv, { interactiveOnly, selector: action.selector });\n const info = await drv.info();\n return { type: \"snapshot\", ...info, content };\n }\n case \"screenshot\": {\n const { data } = await drv.send(\"Page.captureScreenshot\", { format: \"jpeg\", quality: 60 });\n const info = await drv.info();\n return { type: \"screenshot\", ...info, base64: data };\n }\n case \"evaluate\": {\n const awaitPromise = action.awaitPromise !== false;\n drv.consoleLogs = [];\n let value;\n let errorStr;\n const result = await drv.send(\"Runtime.evaluate\", {\n expression: action.script,\n awaitPromise,\n returnByValue: true,\n userGesture: true,\n replMode: true\n });\n if (result.exceptionDetails) {\n const ex = result.exceptionDetails;\n const desc = ex.exception?.description ?? ex.text ?? \"Unknown error\";\n errorStr = `${desc} (line ${ex.lineNumber ?? \"?\"}:${ex.columnNumber ?? \"?\"})`;\n } else {\n const r = result.result ?? {};\n value = \"value\" in r ? r.value : r.description;\n }\n const MAX_VALUE_CHARS = action.maxChars ?? 4000;\n try {\n const serialized = typeof value === \"string\" ? value : JSON.stringify(value);\n if (serialized && serialized.length > MAX_VALUE_CHARS) {\n value = (typeof value === \"string\" ? value : serialized).slice(0, MAX_VALUE_CHARS) + `\n[...truncated, ${serialized.length - MAX_VALUE_CHARS} chars omitted]`;\n }\n } catch {}\n const logs = drv.consoleLogs.slice(0, 200);\n drv.consoleLogs = [];\n const info = await drv.info();\n return { type: \"evaluate\", ...info, value, logs: logs.length > 0 ? logs : undefined, error: errorStr };\n }\n case \"tabs\": {\n const tabs = await chrome.tabs.query({ lastFocusedWindow: true });\n return {\n type: \"tabs\",\n tabs: tabs.map((t, i) => ({ index: i, url: t.url ?? \"\", title: t.title ?? \"\", active: !!t.active }))\n };\n }\n default: {\n const info = await drv.info();\n return { type: \"done\", ...info, message: `unsupported action: ${action.type}` };\n }\n }\n }\n async function navigateHistory(drv, delta) {\n const hist = await drv.send(\"Page.getNavigationHistory\", {});\n const target = hist.currentIndex + delta;\n if (target < 0 || target >= hist.entries.length) {\n throw new Error(delta < 0 ? \"No page to go back to\" : \"No page to go forward to\");\n }\n const loaded = drv.once(\"Page.loadEventFired\", 30000);\n await drv.send(\"Page.navigateToHistoryEntry\", { entryId: hist.entries[target].id });\n await loaded;\n }\n var driver = null;\n var agentTabId = null;\n async function tabExists(id) {\n try {\n await chrome.tabs.get(id);\n return true;\n } catch {\n return false;\n }\n }\n async function navigateAgentTab(url) {\n const current = agentTabId;\n if (current != null && await tabExists(current)) {\n const complete = waitForTabComplete(current, 30000);\n await chrome.tabs.update(current, { url, active: true });\n await complete;\n return current;\n }\n const tab = await chrome.tabs.create({ url, active: true });\n if (typeof tab?.id !== \"number\")\n throw new Error(\"could not open a new tab\");\n const newId = tab.id;\n agentTabId = newId;\n if (tab.status !== \"complete\")\n await waitForTabComplete(newId, 30000);\n return newId;\n }\n function requireAgentTab() {\n if (agentTabId == null)\n throw new Error(\"No page open yet — navigate to a URL first.\");\n return agentTabId;\n }\n function isAttachable(url) {\n if (/^(chrome|edge|brave|devtools|chrome-extension|about|view-source):/i.test(url))\n return false;\n if (url.startsWith(\"https://chromewebstore.google.com\"))\n return false;\n if (url.startsWith(\"https://chrome.google.com/webstore\"))\n return false;\n return true;\n }\n function waitForTabComplete(tabId, timeout) {\n return new Promise((resolve) => {\n let done = false;\n const fin = () => {\n if (done)\n return;\n done = true;\n try {\n chrome.tabs.onUpdated.removeListener(listener);\n } catch {}\n resolve();\n };\n const listener = (id, info) => {\n if (id === tabId && info?.status === \"complete\")\n fin();\n };\n chrome.tabs.onUpdated.addListener(listener);\n setTimeout(fin, timeout);\n });\n }\n async function ensureAttached(tabId) {\n let url = \"\";\n try {\n const t = await chrome.tabs.get(tabId);\n url = t?.url ?? \"\";\n } catch {}\n if (!isAttachable(url)) {\n throw new Error(`Can't control this page (${url || \"internal page\"}). Open a normal website tab and try again.`);\n }\n if (driver && driver.tabId === tabId)\n return driver;\n if (driver) {\n try {\n await chrome.debugger.detach({ tabId: driver.tabId });\n } catch {}\n driver = null;\n }\n try {\n await chrome.debugger.attach({ tabId }, \"1.3\");\n } catch (err) {\n const msg = err instanceof Error ? err.message : String(err);\n if (!/already attached/i.test(msg))\n throw err;\n }\n const drv = new TabDriver(tabId);\n await drv.send(\"Page.enable\").catch(() => {});\n await drv.send(\"DOM.enable\").catch(() => {});\n await drv.send(\"Accessibility.enable\").catch(() => {});\n await drv.send(\"Runtime.enable\").catch(() => {});\n driver = drv;\n return drv;\n }\n chrome.debugger.onEvent.addListener((source, method, params) => {\n if (driver && source?.tabId === driver.tabId)\n driver.onEvent(method, params);\n });\n chrome.debugger.onDetach.addListener((source) => {\n if (driver && source?.tabId === driver.tabId)\n driver = null;\n });\n chrome.tabs.onRemoved.addListener((tabId) => {\n if (tabId === agentTabId)\n agentTabId = null;\n if (driver && driver.tabId === tabId)\n driver = null;\n });\n var socket = null;\n var reconnectDelay = RECONNECT_MIN_MS;\n var connecting = false;\n function sendToBridge(msg) {\n try {\n if (socket && socket.readyState === 1)\n socket.send(JSON.stringify(msg));\n } catch {}\n }\n async function handleCommand(id, action) {\n try {\n const result = await executeAction(action);\n sendToBridge({ type: \"response\", id, result });\n } catch (err) {\n sendToBridge({ type: \"response\", id, error: err instanceof Error ? err.message : String(err) });\n }\n }\n async function connect() {\n if (connecting || socket && (socket.readyState === 0 || socket.readyState === 1))\n return;\n connecting = true;\n const cfg = await readBridgeConfig();\n if (!cfg) {\n connecting = false;\n scheduleReconnect();\n return;\n }\n try {\n const ws = new WebSocket(`ws://127.0.0.1:${cfg.port}/`);\n socket = ws;\n ws.onopen = () => {\n reconnectDelay = RECONNECT_MIN_MS;\n sendToBridge({ type: \"hello\", secret: cfg.secret, capabilities: { chromeAvailable: true } });\n };\n ws.onmessage = (ev) => {\n let msg;\n try {\n msg = JSON.parse(typeof ev.data === \"string\" ? ev.data : String(ev.data));\n } catch {\n return;\n }\n if (msg.type === \"ping\")\n sendToBridge({ type: \"pong\" });\n else if (msg.type === \"command\")\n handleCommand(msg.id, msg.action);\n };\n ws.onclose = () => {\n if (socket === ws)\n socket = null;\n scheduleReconnect();\n };\n ws.onerror = () => {\n try {\n ws.close();\n } catch {}\n };\n } catch {\n socket = null;\n scheduleReconnect();\n } finally {\n connecting = false;\n }\n }\n function scheduleReconnect() {\n const delay = reconnectDelay;\n reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);\n setTimeout(() => void connect(), delay);\n }\n chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: KEEPALIVE_PERIOD_MIN });\n chrome.alarms.onAlarm.addListener((alarm) => {\n if (alarm.name !== KEEPALIVE_ALARM)\n return;\n if (socket && socket.readyState === 1)\n sendToBridge({ type: \"ping\" });\n else\n connect();\n });\n chrome.runtime.onStartup?.addListener(() => void connect());\n chrome.runtime.onInstalled?.addListener(() => void connect());\n connect();\n})();\n" };