diff options
| author | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-04-29 01:38:17 +0530 |
|---|---|---|
| committer | amankumarsingh77 <amankumarsingh7702@gmal.com> | 2026-04-29 01:38:17 +0530 |
| commit | 63b16957587fe20566129ffc5191f66f24a70872 (patch) | |
| tree | 8a40fa38e1ae5b4309b1e38d7a3779321f15db4e /src | |
| parent | 5317b32976edce3ab8e2f11d7fb4ff3f16dcac6f (diff) | |
Replace socket daemon with direct CDP WebSocket connection
Drops the Unix socket subprocess in favor of talking CDP directly over
WebSocket from the extension. Removes the protocol's send/health helpers,
simplifies setup to just verifying Chrome's remote debugging port, and
tightens types in dynamic-tools. Bumps Node engine to >=22 and adds the
ws dependency.
Diffstat (limited to 'src')
| -rw-r--r-- | src/daemon.ts | 727 | ||||
| -rw-r--r-- | src/dynamic-tools.ts | 6 | ||||
| -rw-r--r-- | src/index.ts | 9 | ||||
| -rw-r--r-- | src/protocol.ts | 134 | ||||
| -rw-r--r-- | src/setup.ts | 113 | ||||
| -rw-r--r-- | src/tools.ts | 56 |
6 files changed, 522 insertions, 523 deletions
diff --git a/src/daemon.ts b/src/daemon.ts index babae15..7dad1a6 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -1,49 +1,127 @@ /** - * BrowserDaemon — manages the browser-harness daemon process lifecycle. + * BrowserDaemon — manages a direct CDP WebSocket connection to Chrome. * * Architecture: - * Node.js → Unix socket (/tmp/bu-<namespace>.sock) → daemon.py → CDP WS → Chrome + * Node.js → CDP WebSocket (ws://localhost:9222/...) → Chrome * - * The daemon is spawned as a child process on start() and communicates via - * the protocol defined in protocol.ts. All CDP calls go through this class. + * No Python proxy, no Unix socket. Direct CDP over a single persistent + * WebSocket with message-ID multiplexing, auto-reconnect, and event buffering. */ -import { spawn, type ChildProcess } from "node:child_process"; -import { existsSync } from "node:fs"; -import { access, readFile, unlink, writeFile } from "node:fs/promises"; -import { homedir, tmpdir } from "node:os"; -import { basename, join } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { connect as netConnect } from "node:net"; +import WebSocket from "ws"; import { - sendRequest, - daemonAlive, - healthProbe, - isInternalUrl, type CDPEvent, - type DaemonRequest, - type DaemonResponse, type DaemonStatus, type DialogInfo, type PageInfo, type PageInfoResult, type RemoteConfig, type TabInfo, + isInternalUrl, } from "./protocol"; // ── Constants ──────────────────────────────────────────────────────────────── -/** Common locations where browser-harness might be cloned */ -const BH_SEARCH_PATHS = [ - join(homedir(), "Developer", "browser-harness"), - join(homedir(), "src", "browser-harness"), - join(homedir(), "browser-harness"), - join(homedir(), "dev", "browser-harness"), - join(homedir(), "Projects", "browser-harness"), -]; +const CDP_CONNECT_TIMEOUT_MS = 10_000; +const CDP_REQUEST_TIMEOUT_MS = 15_000; +const CDP_RECONNECT_BASE_MS = 500; +const CDP_MAX_RECONNECT_ATTEMPTS = 5; +const PORT_PROBE_DEADLINE_MS = 30_000; +const PORT_PROBE_INTERVAL_MS = 1_000; + +/** + * Discover Chrome's CDP WebSocket URL by reading the per-profile + * `DevToolsActivePort` file written when remote debugging is enabled + * via chrome://inspect/#remote-debugging. Mirrors browser-harness/daemon.py. + */ +async function discoverWsUrl(): Promise<string> { + const dirs = chromeProfileDirs(); + const tried: string[] = []; + for (const base of dirs) { + const portFile = join(base, "DevToolsActivePort"); + let raw: string; + try { + raw = await readFile(portFile, "utf8"); + } catch { + continue; + } + tried.push(base); + const lines = raw.trim().split("\n"); + if (lines.length < 2) continue; + const port = lines[0].trim(); + const path = lines[1].trim(); + if (!port || !path) continue; + + await waitForPort(Number(port)); + return `ws://127.0.0.1:${port}${path}`; + } + throw new Error( + `DevToolsActivePort not found in ${dirs.join(", ")} — open chrome://inspect/#remote-debugging in your browser, tick the checkbox, click Allow, then retry. Or set BU_CDP_WS to a remote browser endpoint.`, + ); +} -const DAEMON_START_TIMEOUT_MS = 30_000; -const DAEMON_POLL_INTERVAL_MS = 200; -const DAEMON_SHUTDOWN_TIMEOUT_MS = 5_000; -const SESSION_ATTACH_TIMEOUT_MS = 5_000; +/** Probe TCP port until reachable or deadline expires. */ +async function waitForPort(port: number): Promise<void> { + const deadline = Date.now() + PORT_PROBE_DEADLINE_MS; + let lastErr: Error | null = null; + while (Date.now() < deadline) { + try { + await new Promise<void>((resolve, reject) => { + const sock = netConnect({ host: "127.0.0.1", port }); + const onError = (err: Error) => { + sock.destroy(); + reject(err); + }; + sock.setTimeout(1000, () => onError(new Error("probe timeout"))); + sock.once("error", onError); + sock.once("connect", () => { + sock.end(); + resolve(); + }); + }); + return; + } catch (err) { + lastErr = err instanceof Error ? err : new Error(String(err)); + await new Promise((r) => setTimeout(r, PORT_PROBE_INTERVAL_MS)); + } + } + throw new Error( + `Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastErr?.message ?? "unknown"})`, + ); +} + +// Chrome / Edge / Chromium profile directories that may contain DevToolsActivePort. +// Mirrors browser-harness/daemon.py PROFILES. +function chromeProfileDirs(): string[] { + const home = homedir(); + return [ + join(home, "Library/Application Support/Google/Chrome"), + join(home, "Library/Application Support/Microsoft Edge"), + join(home, "Library/Application Support/Microsoft Edge Beta"), + join(home, "Library/Application Support/Microsoft Edge Dev"), + join(home, "Library/Application Support/Microsoft Edge Canary"), + join(home, ".config/google-chrome"), + join(home, ".config/chromium"), + join(home, ".config/chromium-browser"), + join(home, ".config/microsoft-edge"), + join(home, ".config/microsoft-edge-beta"), + join(home, ".config/microsoft-edge-dev"), + join(home, ".var/app/org.chromium.Chromium/config/chromium"), + join(home, ".var/app/com.google.Chrome/config/google-chrome"), + join(home, ".var/app/com.brave.Browser/config/BraveSoftware/Brave-Browser"), + join(home, ".var/app/com.microsoft.Edge/config/microsoft-edge"), + join(home, "AppData/Local/Google/Chrome/User Data"), + join(home, "AppData/Local/Chromium/User Data"), + join(home, "AppData/Local/Microsoft/Edge/User Data"), + join(home, "AppData/Local/Microsoft/Edge Beta/User Data"), + join(home, "AppData/Local/Microsoft/Edge Dev/User Data"), + join(home, "AppData/Local/Microsoft/Edge SxS/User Data"), + ]; +} const VIRTUAL_KEY_CODES: Record<string, number> = { Enter: 13, @@ -66,20 +144,23 @@ const VIRTUAL_KEY_CODES: Record<string, number> = { export class BrowserDaemon { readonly namespace: string; - readonly socketPath: string; - readonly pidPath: string; - readonly logPath: string; - private proc: ChildProcess | null = null; + private ws: WebSocket | null = null; private _sessionId: string | null = null; - private _pid: number | null = null; + private _targetId: string | null = null; + private _currentDialog: DialogInfo | null = null; + private _eventBuffer: CDPEvent[] = []; + private _pending = new Map<number, PendingRequest>(); + private _nextId = 1; + private _connectResolve: ((v: void) => void) | null = null; + private _connectReject: ((err: Error) => void) | null = null; + private _connectTimer: ReturnType<typeof setTimeout> | null = null; + private _reconnectAttempts = 0; + private _stopping = false; private remote: RemoteConfig | null = null; constructor(namespace = "default", remote?: RemoteConfig) { this.namespace = namespace; - this.socketPath = `/tmp/bu-${namespace}.sock`; - this.pidPath = `/tmp/bu-${namespace}.pid`; - this.logPath = `/tmp/bu-${namespace}.log`; this.remote = remote ?? null; } @@ -89,17 +170,13 @@ export class BrowserDaemon { return this._sessionId; } - get pid(): number | null { - return this._pid; - } - getStatus(): DaemonStatus { return { - alive: this._sessionId !== null, + alive: this._sessionId !== null && this.ws?.readyState === WebSocket.OPEN, sessionId: this._sessionId, - pid: this._pid, + pid: null, namespace: this.namespace, - socketPath: this.socketPath, + socketPath: "", remoteBrowserId: this.remote?.browserId, }; } @@ -107,153 +184,92 @@ export class BrowserDaemon { // ── Lifecycle ──────────────────────────────────────────────────────────── /** - * Start the daemon process and wait for it to become ready. - * Idempotent — returns immediately if already alive and healthy. + * Connect to Chrome via CDP WebSocket. + * Idempotent — returns immediately if already connected and healthy. */ async start(): Promise<void> { - // Already healthy? - if (await healthProbe(this.socketPath)) { - await this.refreshSessionId(); - return; + if (this._sessionId && this.ws?.readyState === WebSocket.OPEN) { + return; // Already connected } - // Stale socket but daemon alive? Stop it first. - if (await daemonAlive(this.socketPath)) { - await this.stop(); - } - - const bhDir = await findBrowserHarnessDir(); - const env = this.buildEnv(); - - this.proc = spawn("uv", ["run", "daemon.py"], { - cwd: bhDir, - env: { ...process.env, ...env }, - stdio: ["ignore", "ignore", "ignore"], - detached: true, - }); - this.proc.unref(); - - if (this.proc.pid) { - this._pid = this.proc.pid; - } + this._stopping = false; + this._reconnectAttempts = 0; - // Poll until socket accepts connections - const deadline = Date.now() + DAEMON_START_TIMEOUT_MS; - let lastError = ""; - - while (Date.now() < deadline) { - if (this.proc.exitCode !== null) { - lastError = await this.readLogTail(); + try { + await this.connect(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (this.needsChromeSetup(msg)) { throw new Error( - `Browser daemon exited (code ${this.proc.exitCode}): ${lastError || "unknown error"}`, + `Chrome remote debugging not enabled. Open chrome://inspect/#remote-debugging and tick the Allow checkbox, then retry.\n\nDetails: ${msg}`, ); } - - if (await daemonAlive(this.socketPath)) { - // Verify CDP is healthy (not just socket) - if (await healthProbe(this.socketPath)) { - await this.refreshSessionId(); - return; - } - // Socket alive but CDP not ready — keep polling - } - - await sleep(DAEMON_POLL_INTERVAL_MS); - } - - lastError = await this.readLogTail(); - // Check if Chrome needs remote debugging setup - if (this.needsChromeSetup(lastError)) { - throw new Error( - `Chrome remote debugging not enabled. Start Chrome with --remote-debugging-port=9222, then retry.\n\nDaemon log: ${lastError}`, - ); + throw err; } - - throw new Error( - `Daemon "${this.namespace}" failed to start after ${DAEMON_START_TIMEOUT_MS / 1000}s. ` + - `Log: ${lastError || "no log output"}`, - ); } /** - * Gracefully stop the daemon: send shutdown, wait for exit, force-kill if needed. + * Gracefully disconnect from Chrome. */ async stop(): Promise<void> { - // Send shutdown via socket - try { - await sendRequest(this.socketPath, { meta: "shutdown" }, 3000); - } catch { - // Socket may already be dead — that's fine - } + this._stopping = true; - // Wait for process to exit - if (this.proc && this.proc.exitCode === null) { - const deadline = Date.now() + DAEMON_SHUTDOWN_TIMEOUT_MS; - while (Date.now() < deadline) { - if (this.proc.exitCode !== null) break; - await sleep(100); - } + // Reject any pending connect + if (this._connectReject) { + this._connectReject(new Error("Daemon stopped")); + this._connectReject = null; + } + if (this._connectTimer) { + clearTimeout(this._connectTimer); + this._connectTimer = null; + } - // Force kill if still alive - if (this.proc.exitCode === null) { - try { - this.proc.kill("SIGTERM"); - } catch { - // already dead - } - } + // Reject all pending requests + for (const [id, req] of this._pending) { + req.reject(new Error("Daemon stopped")); + this._pending.delete(id); } - // Clean up files - for (const f of [this.socketPath, this.pidPath]) { + // Close the WebSocket + if (this.ws) { try { - await unlink(f); + this.ws.close(1000, "Shutdown"); } catch { - // file doesn't exist + // best-effort } + this.ws = null; } - this.proc = null; this._sessionId = null; - this._pid = null; + this._targetId = null; + this._currentDialog = null; + this._eventBuffer = []; } /** - * Ensure daemon is alive and healthy. Restarts if stale. + * Ensure daemon is alive and healthy. Reconnects if stale. * Call before every tool execution. */ async ensureAlive(): Promise<void> { - if (!(await healthProbe(this.socketPath))) { - await this.stop(); - await this.start(); + const isOpen = this.ws?.readyState === WebSocket.OPEN; + if (isOpen && this._sessionId) { + // Quick health check with a lightweight CDP call + try { + await this.cdp("Target.getTargets", {}); + return; + } catch { + // Connection is stale — reconnect + } } + + await this.stop(); + await this.start(); } // ── CDP Communication ──────────────────────────────────────────────────── /** - * Send a raw request to the daemon and get the response. - */ - async send(request: DaemonRequest): Promise<DaemonResponse> { - await this.ensureAlive(); - - try { - const response = await sendRequest(this.socketPath, request); - return response; - } catch (err) { - // Socket died mid-request — restart and retry once - const message = err instanceof Error ? err.message : String(err); - if (message.includes("ENOENT") || message.includes("ECONNREFUSED") || message.includes("EPIPE")) { - await this.stop(); - await this.start(); - return sendRequest(this.socketPath, request); - } - throw err; - } - } - - /** - * Convenience: send a CDP method and return the result. + * Send a CDP method and return the result. * Handles session management — Target.* methods skip session, * others use explicit sessionId or the daemon's default session. */ @@ -262,50 +278,33 @@ export class BrowserDaemon { params?: Record<string, unknown>, sessionId?: string | null, ): Promise<unknown> { - // Browser-level Target.* calls must NOT use a session - const sid = method.startsWith("Target.") ? null : (sessionId ?? this._sessionId); + // Browser-level Target.* calls must NOT use a session. + // Everything else uses the explicit sessionId (any session attached + // with flatten:true on this connection is reachable via the top-level + // sessionId field — that's what flat-protocol mode means). + const effectiveSid = method.startsWith("Target.") ? null : (sessionId ?? this._sessionId); - const response = await this.send({ - method, - params: params ?? {}, - session_id: sid, - }); - - if (response.error) { - // Auto-recover from stale session - if ( - response.error.includes("Session with given id not found") && - sid === this._sessionId - ) { + try { + return await this.sendRawCdp(method, params ?? {}, effectiveSid); + } catch (err) { + // Auto-recover from stale default session — retry once + if (err instanceof SessionNotFoundError && effectiveSid === this._sessionId) { await this.attachFirstPage(); - const retry = await this.send({ - method, - params: params ?? {}, - session_id: this._sessionId, - }); - if (retry.error) { - throw new Error(`CDP error after session recovery (${method}): ${retry.error}`); - } - return retry.result; + return this.sendRawCdp(method, params ?? {}, this._sessionId); } - throw new Error(`CDP error (${method}): ${response.error}`); + throw err; } - - return response.result; } // ── High-level Browser Helpers ──────────────────────────────────────────── /** Get page state or dialog info */ async getPageInfo(): Promise<PageInfoResult> { - // Check for JS dialog first - try { - const dialogResp = await sendRequest(this.socketPath, { meta: "pending_dialog" }); - if (dialogResp.dialog) { - return { dialog: dialogResp.dialog }; - } - } catch { - // ignore — dialog check is best-effort + // Check for buffered dialog first + if (this._currentDialog) { + const dialog = this._currentDialog; + this._currentDialog = null; // consume it + return { dialog }; } const js = `JSON.stringify({url:location.href,title:document.title,w:innerWidth,h:innerHeight,sx:scrollX,sy:scrollY,pw:document.documentElement.scrollWidth,ph:document.documentElement.scrollHeight})`; @@ -340,9 +339,14 @@ export class BrowserDaemon { return tabs; } - /** Get current tab info */ + /** Get current tab info — uses the targetId we attached to. */ async currentTab(): Promise<TabInfo> { - const result = (await this.cdp("Target.getTargetInfo")) as { + if (!this._targetId) { + throw new Error("No tab attached. Call newTab/switchTab first."); + } + const result = (await this.cdp("Target.getTargetInfo", { + targetId: this._targetId, + })) as { targetInfo: { targetId: string; url: string; title: string }; }; const t = result.targetInfo; @@ -370,12 +374,17 @@ export class BrowserDaemon { flatten: true, })) as { sessionId: string }; - // Tell the daemon to use this as default session this._sessionId = result.sessionId; - await sendRequest(this.socketPath, { - meta: "set_session", - session_id: result.sessionId, - }); + this._targetId = targetId; + + // Enable core domains for the new session + for (const domain of ["Page", "DOM", "Runtime", "Network"]) { + try { + await this.cdp(`${domain}.enable`); + } catch { + // best-effort + } + } // Mark the tab with a green circle for user visibility try { @@ -394,7 +403,7 @@ export class BrowserDaemon { expression = `(function(){${expression}})()`; } - const sid = targetId || null; // explicit target or daemon default + const sid = targetId || null; const result = (await this.cdp( "Runtime.evaluate", { @@ -411,10 +420,11 @@ export class BrowserDaemon { return result.result?.value; } - /** Drain buffered CDP events from the daemon */ + /** Drain buffered CDP events from the internal buffer */ async drainEvents(): Promise<CDPEvent[]> { - const resp = await sendRequest(this.socketPath, { meta: "drain_events" }); - return resp.events ?? []; + const events = this._eventBuffer; + this._eventBuffer = []; + return events; } /** Get virtual key code for a key name or character */ @@ -422,16 +432,206 @@ export class BrowserDaemon { return VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0); } - /** Get the key code string for CDP (uses the key name or the key itself) */ + /** Get the key code string for CDP */ getKeyCode(key: string): string { return key.length === 1 && !VIRTUAL_KEY_CODES[key] ? key : key; } - // ── Internal Helpers ────────────────────────────────────────────────────── + // ── Internal: WebSocket Connection ──────────────────────────────────────── + + private async connect(): Promise<void> { + const timeoutMs = CDP_CONNECT_TIMEOUT_MS; + const wsUrl = this.remote?.cdpUrl + ? this.remote.cdpUrl + : process.env.BU_CDP_WS + ? process.env.BU_CDP_WS + : await discoverWsUrl(); + + if (!this.remote) { + this.remote = { + cdpUrl: wsUrl, + browserId: wsUrl.split("/").pop() || "unknown", + }; + } + + await this.openWebSocket(wsUrl, timeoutMs); + await this.attachFirstPage(); + } + + private openWebSocket(url: string, timeoutMs: number): Promise<void> { + return new Promise((resolve, reject) => { + this._connectResolve = resolve; + this._connectReject = reject; + + this._connectTimer = setTimeout(() => { + this._connectTimer = null; + this._connectReject = null; + if (this.ws) { + try { this.ws.close(); } catch { /* ignore */ } + this.ws = null; + } + reject(new Error(`CDP WebSocket connection timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + try { + this.ws = new WebSocket(url); + } catch (err) { + clearTimeout(this._connectTimer!); + this._connectTimer = null; + reject(err); + return; + } + + this.ws.onopen = () => { + if (this._connectTimer) { + clearTimeout(this._connectTimer); + this._connectTimer = null; + } + this._connectResolve = null; + this._connectReject = null; + this._reconnectAttempts = 0; + resolve(); + }; + + this.ws.onmessage = (event: WebSocket.MessageEvent) => { + const data = event.data; + this.handleMessage(typeof data === "string" ? data : data.toString()); + }; + + this.ws.onerror = (_event: WebSocket.ErrorEvent) => { + // Only reject during the connect phase; otherwise it means a disconnect + if (this._connectReject) { + const err = new Error("CDP WebSocket error during connection"); + this._connectReject(err); + this._connectReject = null; + if (this._connectTimer) { + clearTimeout(this._connectTimer); + this._connectTimer = null; + } + } + }; + + this.ws.onclose = (_event: WebSocket.CloseEvent) => { + // If we're shutting down intentionally, don't reconnect + if (this._stopping) return; + + // Auto-reconnect with backoff + if (this._reconnectAttempts < CDP_MAX_RECONNECT_ATTEMPTS) { + this._sessionId = null; + const delay = Math.min( + CDP_RECONNECT_BASE_MS * 2 ** this._reconnectAttempts, + 15_000, + ); + this._reconnectAttempts++; + setTimeout(() => { + if (!this._stopping) { + this.start().catch(() => { + // Reconnection ultimately failed — tools will report errors + }); + } + }, delay); + } + }; + }); + } + + // ── Internal: Message Handling ──────────────────────────────────────────── + + private handleMessage(data: string): void { + let msg: CDPMessage; + try { + msg = JSON.parse(data); + } catch { + return; // Corrupt frame, ignore + } + + // CDP events have no `id` — they have a `method` field + if (msg.id === undefined) { + // Dialog detection + if (msg.method === "Page.javascriptDialogOpening") { + this._currentDialog = { + type: (msg.params?.type as DialogInfo["type"]) || "alert", + message: (msg.params?.message as string) || "", + defaultPrompt: msg.params?.defaultPrompt as string | undefined, + }; + return; + } + + // Dialog closed — clear the stored dialog + if (msg.method === "Page.javascriptDialogClosed") { + this._currentDialog = null; + return; + } + + // Buffer other events for drainEvents() + this._eventBuffer.push({ + method: msg.method!, + params: msg.params as unknown, + session_id: msg.sessionId as string | undefined, + }); + return; + } + + // It's a response to a pending request + const pending = this._pending.get(msg.id); + if (!pending) return; // Stale response, ignore + + this._pending.delete(msg.id); + clearTimeout(pending.timer); + + if (msg.error) { + // Handle stale session recovery + const errMsg = msg.error.message || String(msg.error); + if ( + errMsg.includes("Session with given id not found") && + pending.sessionId === this._sessionId + ) { + // Schedule recovery and reject — caller can retry + pending.reject(new SessionNotFoundError(errMsg)); + return; + } + pending.reject(new Error(`CDP error (${pending.method}): ${errMsg}`)); + return; + } + + pending.resolve(msg.result); + } + + /** Send a CDP command directly to the browser session */ + private sendRawCdp( + method: string, + params: Record<string, unknown>, + sessionId: string | null, + ): Promise<unknown> { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + return Promise.reject(new Error("Browser not connected. Is Chrome running?")); + } + + const id = this._nextId++; + const payload: Record<string, unknown> = { id, method, params }; + if (sessionId) payload.sessionId = sessionId; + const msg = JSON.stringify(payload); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this._pending.delete(id); + reject(new Error(`CDP timeout after ${CDP_REQUEST_TIMEOUT_MS}ms: ${method}`)); + }, CDP_REQUEST_TIMEOUT_MS); + + this._pending.set(id, { resolve, reject, timer, method, sessionId }); + try { + this.ws!.send(msg); + } catch (err) { + clearTimeout(timer); + this._pending.delete(id); + reject(err); + } + }); + } /** Attach to the first real page target. Creates about:blank if none exist. */ private async attachFirstPage(): Promise<string> { - const result = (await this.cdp("Target.getTargets")) as { + const result = (await this.sendRawCdp("Target.getTargets", {}, null)) as { targetInfos: Array<{ targetId: string; type: string; url: string }>; }; @@ -440,112 +640,91 @@ export class BrowserDaemon { ); if (pages.length === 0) { - const created = (await this.cdp("Target.createTarget", { - url: "about:blank", - })) as { targetId: string }; + const created = (await this.sendRawCdp( + "Target.createTarget", + { url: "about:blank" }, + null, + )) as { targetId: string }; pages.push({ targetId: created.targetId, type: "page", url: "about:blank" }); } - const attachResult = (await this.cdp("Target.attachToTarget", { - targetId: pages[0].targetId, - flatten: true, - })) as { sessionId: string }; + const attachResult = (await this.sendRawCdp( + "Target.attachToTarget", + { targetId: pages[0].targetId, flatten: true }, + null, + )) as { sessionId: string }; this._sessionId = attachResult.sessionId; + this._targetId = pages[0].targetId; // Enable core domains for (const domain of ["Page", "DOM", "Runtime", "Network"]) { try { - await this.cdp(`${domain}.enable`); + await this.sendRawCdp(`${domain}.enable`, {}, this._sessionId); } catch { // best-effort } } - return attachResult.sessionId; - } - - /** Refresh session ID from the daemon's current session */ - private async refreshSessionId(): Promise<void> { + // Mark tab try { - const resp = await sendRequest(this.socketPath, { meta: "session" }); - if (resp.session_id) { - this._sessionId = resp.session_id; - } + await this.sendRawCdp( + "Runtime.evaluate", + { + expression: `if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`, + }, + this._sessionId, + ); } catch { - // will be set on first attach - } - } - - /** Build environment variables for the daemon process */ - private buildEnv(): Record<string, string> { - const env: Record<string, string> = { - BU_NAME: this.namespace, - }; - - if (this.remote) { - env.BU_CDP_WS = this.remote.cdpUrl; - env.BU_BROWSER_ID = this.remote.browserId; + // best-effort } - return env; + return attachResult.sessionId; } - /** Read the last line of the daemon log */ - private async readLogTail(): Promise<string> { - try { - const content = await readFile(this.logPath, "utf-8"); - const lines = content.trim().split("\n"); - return lines[lines.length - 1] || ""; - } catch { - return ""; - } + /** Auto-recover from a stale session error by re-attaching */ + async recoverSession(): Promise<void> { + await this.attachFirstPage(); } - /** Check if the error message suggests Chrome needs remote debugging setup */ + // ── Error diagnostics ──────────────────────────────────────────────────── + private needsChromeSetup(errorMsg: string): boolean { const lower = errorMsg.toLowerCase(); return ( - lower.includes("devtoolsactiveport not found") || - lower.includes("enable chrome://inspect") || - lower.includes("not live yet") || - (lower.includes("ws handshake failed") && + lower.includes("devtoolsactiveport") || + lower.includes("cannot reach chrome") || + lower.includes("ecode") && lower.includes("econnrefused") || + (lower.includes("ws handshake") && (lower.includes("403") || lower.includes("timed out"))) ); } } -// ── Utility ────────────────────────────────────────────────────────────────── +// ── Internal Types ─────────────────────────────────────────────────────────── -function sleep(ms: number): Promise<void> { - return new Promise((resolve) => setTimeout(resolve, ms)); +interface PendingRequest { + resolve: (value: unknown) => void; + reject: (err: Error) => void; + timer: ReturnType<typeof setTimeout>; + method: string; + sessionId: string | null; } -/** - * Find the browser-harness repository directory. - * Searches common locations and falls back to cwd. - */ -async function findBrowserHarnessDir(): Promise<string> { - // Check common locations - for (const dir of BH_SEARCH_PATHS) { - if ( - existsSync(join(dir, "daemon.py")) && - existsSync(join(dir, "helpers.py")) - ) { - return dir; - } - } +interface CDPMessage { + id?: number; + method?: string; + params?: Record<string, unknown>; + result?: unknown; + error?: { message: string; code?: number }; + sessionId?: string; +} - // Check cwd - const cwd = process.cwd(); - if (existsSync(join(cwd, "daemon.py")) && existsSync(join(cwd, "helpers.py"))) { - return cwd; - } +// ── Error Classes ──────────────────────────────────────────────────────────── - throw new Error( - "browser-harness not found. Clone it first:\n" + - " git clone https://github.com/browser-use/browser-harness ~/Developer/browser-harness\n" + - " cd ~/Developer/browser-harness && uv sync\n\n" + - `Searched: ${BH_SEARCH_PATHS.join(", ")}`, - ); +class SessionNotFoundError extends Error { + constructor(msg: string) { + super(msg); + this.name = "SessionNotFoundError"; + } } diff --git a/src/dynamic-tools.ts b/src/dynamic-tools.ts index 5aecaf8..baaf70f 100644 --- a/src/dynamic-tools.ts +++ b/src/dynamic-tools.ts @@ -26,7 +26,7 @@ interface DynamicToolDef { name: string; label: string; description: string; - execute: (params: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, ctx: unknown) => Promise<{ content: Array<{ type: "text"; text: string }>; details?: Record<string, unknown> }>; + execute: (params: Record<string, unknown>, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, ctx: unknown) => Promise<{ content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> | undefined }>; } // ── AsyncFunction constructor ──────────────────────────────────────────────── @@ -247,7 +247,7 @@ export function registerDynamicTools(pi: ExtensionAPI, daemon: BrowserDaemon): v setTimeout, clearTimeout, ); - return result as { content: Array<{ type: "text"; text: string }>; details?: Record<string, unknown> }; + return result as { content: Array<{ type: "text"; text: string }>; details: Record<string, unknown> | undefined }; }, }; @@ -267,7 +267,7 @@ export function registerDynamicTools(pi: ExtensionAPI, daemon: BrowserDaemon): v return tool.execute( (execParams ?? {}) as Record<string, unknown>, execSignal, - execOnUpdate, + execOnUpdate as ((update: unknown) => void) | undefined, execCtx, ); }, diff --git a/src/index.ts b/src/index.ts index 2226cd7..31a7c28 100644 --- a/src/index.ts +++ b/src/index.ts @@ -57,7 +57,7 @@ export default function browserHarnessExtension(pi: ExtensionAPI) { // Commands // ═══════════════════════════════════════════════════════════════════════════ pi.registerCommand("browser-status", { - description: "Show browser daemon status and current page", + description: "Show browser connection status and current page", handler: async (_args, ctx) => { if (!daemon) { ctx.ui.notify("Browser daemon not started. Run /browser-setup first.", "warning"); @@ -66,15 +66,12 @@ export default function browserHarnessExtension(pi: ExtensionAPI) { const status = daemon.getStatus(); const lines = [ - `Browser Daemon: ${status.alive ? "🟢 Connected" : "🔴 Disconnected"}`, - `Namespace: ${status.namespace}`, - `Socket: ${status.socketPath}`, + `Browser: ${status.alive ? "🟢 Connected" : "🔴 Disconnected"}`, `Session: ${status.sessionId || "none"}`, - `PID: ${status.pid || "none"}`, ]; if (status.remoteBrowserId) { - lines.push(`Remote Browser: ${status.remoteBrowserId}`); + lines.push(`Browser ID: ${status.remoteBrowserId}`); } // Try to get current page info diff --git a/src/protocol.ts b/src/protocol.ts index 8edc79d..1b8bf40 100644 --- a/src/protocol.ts +++ b/src/protocol.ts @@ -1,36 +1,10 @@ /** - * Protocol types and low-level socket communication for the browser-harness daemon. + * Protocol types for pi-browser-harness. * - * The daemon exposes a Unix socket at /tmp/bu-<NAME>.sock. - * Protocol: one JSON-line request → one JSON-line response per connection. + * Defines the shape of CDP communication and browser state. + * No socket transport — the daemon talks CDP directly over WebSocket. */ -import { createConnection } from "node:net"; - -// ── Request ────────────────────────────────────────────────────────────────── - -export interface DaemonRequest { - /** CDP method (e.g. "Page.navigate") — absent for meta requests */ - method?: string; - /** CDP parameters */ - params?: Record<string, unknown>; - /** CDP session ID — null/absent for browser-level (Target.*) calls */ - session_id?: string | null; - /** Meta command: "drain_events" | "set_session" | "pending_dialog" | "shutdown" */ - meta?: string; -} - -// ── Response ───────────────────────────────────────────────────────────────── - -export interface DaemonResponse { - result?: unknown; - error?: string; - events?: CDPEvent[]; - session_id?: string; - dialog?: DialogInfo; - ok?: boolean; -} - // ── CDP Event ──────────────────────────────────────────────────────────────── export interface CDPEvent { @@ -75,9 +49,9 @@ export type PageInfoResult = PageInfo | { dialog: DialogInfo }; export interface DaemonStatus { alive: boolean; sessionId: string | null; - pid: number | null; + pid: number | null; // deprecated — always null (no subprocess) namespace: string; - socketPath: string; + socketPath: string; // deprecated — always "" (no socket) remoteBrowserId?: string; } @@ -101,101 +75,3 @@ const INTERNAL_PREFIXES = [ export function isInternalUrl(url: string): boolean { return INTERNAL_PREFIXES.some((p) => url.startsWith(p)); } - -// ── Low-level socket send ──────────────────────────────────────────────────── - -/** - * Send a single JSON-line request to the daemon socket and read one JSON-line response. - * Opens a fresh connection per request (stateless, matches helpers.py _send pattern). - */ -export function sendRequest( - socketPath: string, - request: DaemonRequest, - timeoutMs = 15_000, -): Promise<DaemonResponse> { - return new Promise((resolve, reject) => { - const socket = createConnection(socketPath); - - const timer = setTimeout(() => { - socket.destroy(); - reject(new Error(`Daemon request timed out after ${timeoutMs}ms: ${JSON.stringify(request.method ?? request.meta)}`)); - }, timeoutMs); - - let buffer = ""; - - socket.on("connect", () => { - socket.write(JSON.stringify(request) + "\n"); - }); - - socket.on("data", (chunk: Buffer) => { - buffer += chunk.toString(); - // Response is one JSON line ending with \n - if (buffer.includes("\n")) { - clearTimeout(timer); - const line = buffer.split("\n")[0]; - try { - const response: DaemonResponse = JSON.parse(line); - socket.end(); - resolve(response); - } catch (e) { - socket.end(); - reject(new Error(`Failed to parse daemon response: ${line.slice(0, 200)}`)); - } - } - }); - - socket.on("error", (err: NodeJS.ErrnoException) => { - clearTimeout(timer); - socket.destroy(); - reject(new Error(`Daemon socket error (${socketPath}): ${err.message}`)); - }); - - socket.on("close", () => { - clearTimeout(timer); - if (buffer.length === 0) { - reject(new Error(`Daemon socket closed without response (${socketPath})`)); - } - }); - }); -} - -/** - * Quick check if the daemon socket accepts connections. - */ -export function daemonAlive(socketPath: string, timeoutMs = 1000): Promise<boolean> { - return new Promise((resolve) => { - const socket = createConnection(socketPath); - const timer = setTimeout(() => { - socket.destroy(); - resolve(false); - }, timeoutMs); - - socket.on("connect", () => { - clearTimeout(timer); - socket.end(); - resolve(true); - }); - - socket.on("error", () => { - clearTimeout(timer); - resolve(false); - }); - }); -} - -/** - * True health check: send Target.getTargets and verify "result" key in response. - * A stale daemon can accept socket connections but have a dead CDP websocket. - */ -export async function healthProbe(socketPath: string): Promise<boolean> { - try { - const response = await sendRequest( - socketPath, - { method: "Target.getTargets", params: {} }, - 3000, - ); - return "result" in response && response.result !== undefined; - } catch { - return false; - } -} diff --git a/src/setup.ts b/src/setup.ts index 839a540..f99e9d9 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -1,18 +1,15 @@ /** - * Setup command — guides the user through installing and connecting - * browser-harness to their Chrome browser. + * Setup command — verifies Chrome is running with remote debugging + * and connects the pi agent to it. */ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent"; import { execSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; import type { BrowserDaemon } from "./daemon"; export function registerSetupCommand(pi: ExtensionAPI, daemon: BrowserDaemon): void { pi.registerCommand("browser-setup", { - description: "Install and connect browser-harness to Chrome", + description: "Connect pi to your Chrome browser", handler: async (_args, ctx) => { await runSetup(ctx, daemon); }, @@ -20,30 +17,9 @@ export function registerSetupCommand(pi: ExtensionAPI, daemon: BrowserDaemon): v } async function runSetup(ctx: ExtensionContext, daemon: BrowserDaemon): Promise<void> { - ctx.ui.notify("browser-harness setup: checking installation...", "info"); + ctx.ui.notify("browser setup: checking Chrome...", "info"); - // Step 1: Check for browser-harness installation - const bhInstalled = await checkBrowserHarnessInstalled(); - if (!bhInstalled) { - ctx.ui.notify( - "browser-harness not found. Installing...", - "warning", - ); - const installed = await installBrowserHarness(ctx); - if (!installed) { - ctx.ui.notify( - "Installation failed. Please install manually:\n" + - " git clone https://github.com/browser-use/browser-harness ~/Developer/browser-harness\n" + - " cd ~/Developer/browser-harness && uv sync", - "error", - ); - return; - } - } - - ctx.ui.notify("browser-harness found ✓", "info"); - - // Step 2: Check Chrome is running + // Step 1: Check Chrome is running const chromeRunning = checkChromeRunning(); if (!chromeRunning) { ctx.ui.notify( @@ -54,23 +30,26 @@ async function runSetup(ctx: ExtensionContext, daemon: BrowserDaemon): Promise<v } ctx.ui.notify("Chrome is running ✓", "info"); - // Step 3: Try to start the daemon - ctx.ui.notify("Connecting to Chrome...", "info"); + // Step 2: Try to connect to Chrome DevTools + ctx.ui.notify("Connecting to Chrome DevTools...", "info"); try { await daemon.start(); - ctx.ui.notify("Daemon connected ✓", "info"); + ctx.ui.notify("Connected to Chrome ✓", "info"); } catch (err) { const msg = err instanceof Error ? err.message : String(err); const lower = msg.toLowerCase(); if ( lower.includes("devtoolsactiveport") || - lower.includes("remote debugging") + lower.includes("remote debugging") || + lower.includes("econnrefused") || + lower.includes("cannot reach chrome devtools") ) { ctx.ui.notify( "Chrome remote debugging needs to be enabled.\n\n" + - "Start Chrome with: --remote-debugging-port=9222\n" + - "Then run /browser-setup again.", + "Open chrome://inspect/#remote-debugging in your browser, tick the\n" + + "\"Discover network targets\" / Allow checkbox, then run /browser-setup again.\n\n" + + "Or set BU_CDP_WS to a remote browser WebSocket URL.", "warning", ); return; @@ -80,10 +59,10 @@ async function runSetup(ctx: ExtensionContext, daemon: BrowserDaemon): Promise<v return; } - // Step 4: Verify with test navigation + // Step 3: Verify with test navigation ctx.ui.notify("Testing browser control...", "info"); try { - await daemon.newTab("https://github.com/browser-use/browser-harness"); + await daemon.newTab("https://github.com"); const info = await daemon.getPageInfo(); if ("dialog" in info) { await daemon.cdp("Page.handleJavaScriptDialog", { accept: true }); @@ -100,66 +79,6 @@ async function runSetup(ctx: ExtensionContext, daemon: BrowserDaemon): Promise<v } } -// ── Helpers ────────────────────────────────────────────────────────────────── - -async function checkBrowserHarnessInstalled(): Promise<boolean> { - const searchPaths = [ - join(homedir(), "Developer", "browser-harness"), - join(homedir(), "src", "browser-harness"), - join(homedir(), "browser-harness"), - join(homedir(), "dev", "browser-harness"), - join(homedir(), "Projects", "browser-harness"), - process.cwd(), - ]; - - for (const dir of searchPaths) { - if ( - existsSync(join(dir, "daemon.py")) && - existsSync(join(dir, "helpers.py")) - ) { - return true; - } - } - - // Check if uv tool is installed globally - try { - execSync("uv tool list 2>/dev/null | grep browser-harness", { timeout: 5000 }); - return true; - } catch { - // not found via uv - } - - return false; -} - -async function installBrowserHarness(ctx: ExtensionContext): Promise<boolean> { - // Try uv tool install - try { - ctx.ui.notify("Installing browser-harness via uv...", "info"); - execSync("uv tool install browser-harness", { - timeout: 60_000, - stdio: "pipe", - }); - return true; - } catch { - // uv install failed - } - - // Try git clone - try { - const targetDir = join(homedir(), "Developer", "browser-harness"); - ctx.ui.notify(`Cloning browser-harness to ${targetDir}...`, "info"); - execSync( - `git clone https://github.com/browser-use/browser-harness "${targetDir}"`, - { timeout: 30_000, stdio: "pipe" }, - ); - execSync("uv sync", { cwd: targetDir, timeout: 60_000, stdio: "pipe" }); - return true; - } catch { - return false; - } -} - function checkChromeRunning(): boolean { try { if (process.platform === "darwin" || process.platform === "linux") { diff --git a/src/tools.ts b/src/tools.ts index befd355..6b3bb8d 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -140,6 +140,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { if ("dialog" in info) { return { content: [{ type: "text" as const, text: `Navigated to ${params.url}\nTarget: ${targetId}\n⚠️ A dialog is open: ${info.dialog.type} — "${info.dialog.message}"\nUse browser_handle_dialog to dismiss or accept it.` }], + details: undefined, }; } @@ -149,11 +150,13 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { type: "text" as const, text: `${prefix}Navigated to: ${info.url}\nTitle: ${info.title}\nViewport: ${info.width}x${info.height}\nTarget: ${targetId}`, }], + details: undefined, }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Navigation failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -187,6 +190,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { type: "text" as const, text: `⚠️ DIALOG OPEN: ${info.dialog.type}\nMessage: ${info.dialog.message}${info.dialog.defaultPrompt ? `\nDefault: ${info.dialog.defaultPrompt}` : ""}\n\nUse browser_handle_dialog to accept or dismiss.`, }], + details: undefined, }; } @@ -195,11 +199,13 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { type: "text" as const, text: `URL: ${info.url}\nTitle: ${info.title}\nViewport: ${info.width}x${info.height}\nScroll: (${info.scrollX}, ${info.scrollY})\nPage size: ${info.pageWidth}x${info.pageHeight}`, }], + details: undefined, }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -225,15 +231,16 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { currentIndex: number; }; if (history.currentIndex <= 0) { - return { content: [{ type: "text" as const, text: "Already at the beginning of history." }] }; + return { content: [{ type: "text" as const, text: "Already at the beginning of history." }], details: undefined }; } const targetEntry = history.entries[history.currentIndex - 1]; await daemon.cdp("Page.navigateToHistoryEntry", { entryId: targetEntry.id }); - return { content: [{ type: "text" as const, text: `Navigated back to: ${targetEntry.url}` }] }; + return { content: [{ type: "text" as const, text: `Navigated back to: ${targetEntry.url}` }], details: undefined }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -259,15 +266,16 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { currentIndex: number; }; if (history.currentIndex >= history.entries.length - 1) { - return { content: [{ type: "text" as const, text: "Already at the end of history." }] }; + return { content: [{ type: "text" as const, text: "Already at the end of history." }], details: undefined }; } const targetEntry = history.entries[history.currentIndex + 1]; await daemon.cdp("Page.navigateToHistoryEntry", { entryId: targetEntry.id }); - return { content: [{ type: "text" as const, text: `Navigated forward to: ${targetEntry.url}` }] }; + return { content: [{ type: "text" as const, text: `Navigated forward to: ${targetEntry.url}` }], details: undefined }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -291,13 +299,14 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { await daemon.cdp("Page.reload"); const info = await daemon.getPageInfo(); if ("dialog" in info) { - return { content: [{ type: "text" as const, text: `Reloaded. ⚠️ Dialog open: ${info.dialog.type}` }] }; + return { content: [{ type: "text" as const, text: `Reloaded. ⚠️ Dialog open: ${info.dialog.type}` }], details: undefined }; } - return { content: [{ type: "text" as const, text: `Reloaded: ${info.title} (${info.url})` }] }; + return { content: [{ type: "text" as const, text: `Reloaded: ${info.title} (${info.url})` }], details: undefined }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -341,11 +350,12 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { button: params.button || "left", clickCount: params.clicks || 1, }); - return { content: [{ type: "text" as const, text: `Clicked at (${params.x}, ${params.y})` }] }; + return { content: [{ type: "text" as const, text: `Clicked at (${params.x}, ${params.y})` }], details: undefined }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Click failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -374,11 +384,12 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { try { await daemon.ensureAlive(); await daemon.cdp("Input.insertText", { text: params.text }); - return { content: [{ type: "text" as const, text: `Typed: "${params.text}"` }] }; + return { content: [{ type: "text" as const, text: `Typed: "${params.text}"` }], details: undefined }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Type failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -448,11 +459,12 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { nativeVirtualKeyCode: vk, }); - return { content: [{ type: "text" as const, text: `Pressed: "${k}"${mod ? ` (modifiers: ${mod})` : ""}` }] }; + return { content: [{ type: "text" as const, text: `Pressed: "${k}"${mod ? ` (modifiers: ${mod})` : ""}` }], details: undefined }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Key press failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -508,11 +520,12 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { deltaY: dy, }); - return { content: [{ type: "text" as const, text: `Scrolled by (${dx}, ${dy}) at (${cx}, ${cy})` }] }; + return { content: [{ type: "text" as const, text: `Scrolled by (${dx}, ${dy}) at (${cx}, ${cy})` }], details: undefined }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Scroll failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -549,6 +562,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { return { isError: true, content: [{ type: "text" as const, text: `Screenshot failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -592,6 +606,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { return { isError: true, content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -637,11 +652,13 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { const tab = await daemon.currentTab(); return { content: [{ type: "text" as const, text: `Current tab: ${tab.title || "(no title)"}\nURL: ${tab.url}\nID: ${tab.targetId}` }], + details: undefined, }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -668,11 +685,13 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { const tab = await daemon.currentTab(); return { content: [{ type: "text" as const, text: `Switched to: ${tab.title || "(no title)"}\nURL: ${tab.url}` }], + details: undefined, }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Switch failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -700,11 +719,12 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { const msg = params.url ? `New tab created and navigated to: ${params.url}\nTarget: ${targetId}` : `New tab created: about:blank\nTarget: ${targetId}`; - return { content: [{ type: "text" as const, text: msg }] }; + return { content: [{ type: "text" as const, text: msg }], details: undefined }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -742,6 +762,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { return { isError: true, content: [{ type: "text" as const, text: `JS execution failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -840,6 +861,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { return { isError: true, content: [{ type: "text" as const, text: `HTTP request failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -896,7 +918,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { async execute(_id, params) { const sec = params.seconds || 1; await new Promise((r) => setTimeout(r, sec * 1000)); - return { content: [{ type: "text" as const, text: `Waited ${sec}s.` }] }; + return { content: [{ type: "text" as const, text: `Waited ${sec}s.` }], details: undefined }; }, }); @@ -926,7 +948,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { while (Date.now() < deadline) { const state = await daemon.evaluateJS("document.readyState"); if (state === "complete") { - return { content: [{ type: "text" as const, text: "Page loaded (readyState: complete)." }] }; + return { content: [{ type: "text" as const, text: "Page loaded (readyState: complete)." }], details: undefined }; } await new Promise((r) => setTimeout(r, 300)); } @@ -934,11 +956,13 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { return { isError: true, content: [{ type: "text" as const, text: `Page load timed out after ${timeout}s. Current state unknown.` }], + details: undefined, }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Wait failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -975,11 +999,12 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { await daemon.cdp("Page.handleJavaScriptDialog", { accept: false }); } - return { content: [{ type: "text" as const, text: `Dialog ${params.action}ed.` }] }; + return { content: [{ type: "text" as const, text: `Dialog ${params.action}ed.` }], details: undefined }; } catch (err) { return { isError: true, content: [{ type: "text" as const, text: `Dialog handling failed: ${err instanceof Error ? err.message : String(err)}` }], + details: undefined, }; } }, @@ -1032,6 +1057,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { targetId: "", ok: false as const, error: err instanceof Error ? err.message : String(err), + details: undefined, }; } }); @@ -1067,6 +1093,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { ...tab, ok: false as const, error: err instanceof Error ? err.message : String(err), + details: undefined, }; } finally { completed++; @@ -1079,6 +1106,7 @@ export function registerTools(pi: ExtensionAPI, daemon: BrowserDaemon): void { text: `Opening URLs… ${completed}/${okCount} navigated`, }, ], + details: undefined, }); } }); |
