aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
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,
});
}
});