aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmal.com>2026-04-29 01:38:17 +0530
committeramankumarsingh77 <amankumarsingh7702@gmal.com>2026-04-29 01:38:17 +0530
commit63b16957587fe20566129ffc5191f66f24a70872 (patch)
tree8a40fa38e1ae5b4309b1e38d7a3779321f15db4e /src
parent5317b32976edce3ab8e2f11d7fb4ff3f16dcac6f (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.ts727
-rw-r--r--src/dynamic-tools.ts6
-rw-r--r--src/index.ts9
-rw-r--r--src/protocol.ts134
-rw-r--r--src/setup.ts113
-rw-r--r--src/tools.ts56
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,
});
}
});