aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authoramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-02 16:46:12 +0530
committeramankumarsingh77 <amankumarsingh7702@gmal.com>2026-05-02 16:46:12 +0530
commitbb691459f358d32267c552de643fededf647855e (patch)
tree7dbbbc4f2cfa1f40077b48050cdbf2b96e348102 /src
parentc86b220d46c577441bb0bff7eac49aa80340fc70 (diff)
refactor: delete legacy daemon, tools, protocol, renderers; collapse to single tsconfig
Diffstat (limited to 'src')
-rw-r--r--src/daemon.ts1140
-rw-r--r--src/protocol.ts77
-rw-r--r--src/renderers.ts21
-rw-r--r--src/tools.ts101
4 files changed, 0 insertions, 1339 deletions
diff --git a/src/daemon.ts b/src/daemon.ts
deleted file mode 100644
index 955e79b..0000000
--- a/src/daemon.ts
+++ /dev/null
@@ -1,1140 +0,0 @@
-/**
- * BrowserDaemon — manages a direct CDP WebSocket connection to Chrome.
- *
- * Architecture:
- * Node.js → CDP WebSocket (ws://localhost:9222/...) → Chrome
- *
- * No Python proxy, no Unix socket. Direct CDP over a single persistent
- * WebSocket with message-ID multiplexing, auto-reconnect, and event buffering.
- */
-
-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 {
- type CDPEvent,
- type DaemonStatus,
- type DialogInfo,
- type PageInfo,
- type PageInfoResult,
- type RemoteConfig,
- type TabInfo,
- isInternalUrl,
-} from "./protocol";
-
-// ── Constants ────────────────────────────────────────────────────────────────
-
-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.`,
- );
-}
-
-/** 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,
- Tab: 9,
- Backspace: 8,
- Escape: 27,
- Delete: 46,
- " ": 32,
- ArrowLeft: 37,
- ArrowUp: 38,
- ArrowRight: 39,
- ArrowDown: 40,
- Home: 36,
- End: 35,
- PageUp: 33,
- PageDown: 34,
-};
-
-// ── BrowserDaemon ────────────────────────────────────────────────────────────
-
-export class BrowserDaemon {
- readonly namespace: string;
-
- private ws: WebSocket | null = null;
- private _sessionId: string | 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 _consentObserved = false;
- private remote: RemoteConfig | null = null;
-
- // ── Performance optimizations ──────────────────────────────────────────
- private _lastHealthCheck = 0;
- private readonly HEALTH_CHECK_TTL_MS = 30_000;
- private _cachedPageInfo: PageInfo | null = null;
- private _cachedPageInfoTime = 0;
- private readonly PAGE_INFO_TTL_MS = 1_000;
- private _lastOperationFailed = false;
-
- constructor(namespace = "default", remote?: RemoteConfig) {
- this.namespace = namespace;
- this.remote = remote ?? null;
- }
-
- // ── Public getters ───────────────────────────────────────────────────────
-
- get sessionId(): string | null {
- return this._sessionId;
- }
-
- getStatus(): DaemonStatus {
- return {
- alive: this._sessionId !== null && this.ws?.readyState === WebSocket.OPEN,
- sessionId: this._sessionId,
- pid: null,
- namespace: this.namespace,
- socketPath: "",
- remoteBrowserId: this.remote?.browserId,
- };
- }
-
- // ── Lifecycle ────────────────────────────────────────────────────────────
-
- /**
- * Connect to Chrome via CDP WebSocket.
- * Idempotent — returns immediately if already connected and healthy.
- */
- async start(): Promise<void> {
- if (this._sessionId && this.ws?.readyState === WebSocket.OPEN) {
- return; // Already connected
- }
-
- this._stopping = false;
- this._reconnectAttempts = 0;
-
- try {
- await this.connect();
- } catch (err) {
- const msg = err instanceof Error ? err.message : String(err);
- if (this.needsChromeSetup(msg)) {
- throw new Error(
- `Chrome remote debugging not enabled. Open chrome://inspect/#remote-debugging and tick the Allow checkbox, then retry.\n\nDetails: ${msg}`,
- );
- }
- throw err;
- }
- }
-
- /**
- * Gracefully disconnect from Chrome.
- */
- async stop(): Promise<void> {
- this._stopping = true;
-
- // 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;
- }
-
- // Reject all pending requests
- for (const [id, req] of this._pending) {
- req.reject(new Error("Daemon stopped"));
- this._pending.delete(id);
- }
-
- // Close the WebSocket
- if (this.ws) {
- try {
- this.ws.close(1000, "Shutdown");
- } catch {
- // best-effort
- }
- this.ws = null;
- }
-
- this._sessionId = null;
- this._targetId = null;
- this._currentDialog = null;
- this._eventBuffer = [];
- this._consentObserved = false;
- }
-
- /**
- * Ensure daemon is alive and healthy. Reconnects if stale.
- * Call before every tool execution.
- *
- * OPTIMIZED: Fast path checks WebSocket state + short TTL. Only does
- * a full CDP health check every HEALTH_CHECK_TTL_MS (30s) or after a
- * failed operation. This eliminates ~5ms per tool call.
- */
- async ensureAlive(): Promise<void> {
- const isOpen = this.ws?.readyState === WebSocket.OPEN;
- if (!isOpen || !this._sessionId) {
- await this.stop();
- await this.start();
- return;
- }
-
- // Fast path: if recent health check passed and no failures, skip CDP roundtrip
- const now = Date.now();
- if (!this._lastOperationFailed && (now - this._lastHealthCheck) < this.HEALTH_CHECK_TTL_MS) {
- return;
- }
-
- // Full health check with two-stage probe (as before)
- try {
- await this.sendRawCdp("Target.getTargets", {}, null, 2_000);
- this._lastHealthCheck = now;
- this._lastOperationFailed = false;
- return;
- } catch {
- // probe 1 failed — could be transient, could be consent-pending
- }
- try {
- await this.sendRawCdp("Target.getTargets", {}, null);
- this._lastHealthCheck = now;
- this._lastOperationFailed = false;
- return;
- } catch {
- // genuinely stale — fall through to reconnect
- }
-
- await this.stop();
- await this.start();
- }
-
- // ── CDP Communication ────────────────────────────────────────────────────
-
- /**
- * 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.
- */
- async cdp(
- method: string,
- params?: Record<string, unknown>,
- sessionId?: string | null,
- ): Promise<unknown> {
- // 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);
-
- try {
- const result = await this.sendRawCdp(method, params ?? {}, effectiveSid);
- this._lastOperationFailed = false;
- return result;
- } catch (err) {
- this._lastOperationFailed = true;
- // Auto-recover from stale default session — retry once
- if (err instanceof SessionNotFoundError && effectiveSid === this._sessionId) {
- await this.attachFirstPage();
- return this.sendRawCdp(method, params ?? {}, this._sessionId);
- }
- throw err;
- }
- }
-
- // ── High-level Browser Helpers ────────────────────────────────────────────
-
- /** Get page state or dialog info. Uses short-lived cache to eliminate redundant evaluateJS calls. */
- async getPageInfo(): Promise<PageInfoResult> {
- // Check for buffered dialog first
- if (this._currentDialog) {
- const dialog = this._currentDialog;
- this._currentDialog = null; // consume it
- return { dialog };
- }
-
- // Return cached page info if still fresh (eliminates redundant CDP roundtrips)
- const now = Date.now();
- if (this._cachedPageInfo && (now - this._cachedPageInfoTime) < this.PAGE_INFO_TTL_MS) {
- return this._cachedPageInfo;
- }
-
- 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})`;
- const raw = await this.evaluateJS(js);
- const info = JSON.parse(raw as string) as PageInfo;
-
- // Cache for subsequent calls
- this._cachedPageInfo = info;
- this._cachedPageInfoTime = now;
- return info;
- }
-
- /** Invalidate page info cache (call after navigation). */
- invalidatePageInfoCache(): void {
- this._cachedPageInfo = null;
- this._cachedPageInfoTime = 0;
- }
-
- /** Mark last operation as failed so ensureAlive re-checks health next time. */
- markOperationFailed(): void {
- this._lastOperationFailed = true;
- }
-
- /**
- * Capture a screenshot and write to disk.
- *
- * OPTIMIZED: Supports JPEG with configurable quality for faster CDP transfer.
- * JPEG q80 is 2-5x smaller than PNG for photo-heavy pages with negligible quality loss,
- * which speeds up both the CDP transfer and the LLM reading the image.
- */
- async captureScreenshot(
- outputPath: string,
- fullPage = false,
- format: "png" | "jpeg" = "png",
- quality = 80,
- ): Promise<string> {
- const params: Record<string, unknown> = {
- format,
- captureBeyondViewport: fullPage,
- };
- if (format === "jpeg") {
- params.quality = quality;
- }
- const result = (await this.cdp("Page.captureScreenshot", params)) as { data: string };
-
- const buf = Buffer.from(result.data, "base64");
- await writeFile(outputPath, buf);
- return outputPath;
- }
-
- /**
- * Wait for page load using CDP events instead of polling.
- * Dramatically faster than polling readyState every 300ms — especially
- * for pages that are already loaded (detected instantly via events).
- */
- async waitForLoad(timeoutMs = 15_000): Promise<boolean> {
- // Fast path: check if already loaded
- try {
- const state = await this.evaluateJS("document.readyState");
- if (state === "complete") return true;
- } catch {
- // proceed to event-based wait
- }
-
- // Drain any buffered events from before we started waiting
- await this.drainEvents();
-
- const deadline = Date.now() + timeoutMs;
- while (Date.now() < deadline) {
- const events = await this.drainEvents();
- for (const ev of events) {
- if (ev.method === "Page.loadEventFired") return true;
- if (ev.method === "Page.frameStoppedLoading") {
- // Frame stopped — verify readyState
- try {
- const state = await this.evaluateJS("document.readyState");
- if (state === "complete") return true;
- } catch {
- // keep waiting
- }
- }
- }
- // Short sleep between event drain cycles (50ms vs 300ms baseline)
- await new Promise((r) => setTimeout(r, 50));
- }
-
- // Timeout fallback: final readyState check
- try {
- const state = await this.evaluateJS("document.readyState");
- return state === "complete";
- } catch {
- return false;
- }
- }
-
- /** List browser tabs */
- async listTabs(includeChrome = true): Promise<TabInfo[]> {
- const result = (await this.cdp("Target.getTargets")) as {
- targetInfos: Array<{ targetId: string; type: string; title: string; url: string }>;
- };
-
- const tabs: TabInfo[] = [];
- for (const t of result.targetInfos) {
- if (t.type !== "page") continue;
- if (!includeChrome && isInternalUrl(t.url)) continue;
- tabs.push({ targetId: t.targetId, title: t.title || "", url: t.url || "" });
- }
- return tabs;
- }
-
- /** Get current tab info — uses the targetId we attached to. */
- async currentTab(): Promise<TabInfo> {
- 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;
- return { targetId: t.targetId, url: t.url || "", title: t.title || "" };
- }
-
- /** Create a new tab and switch to it. Optionally navigate. */
- async newTab(url?: string): Promise<string> {
- const result = (await this.cdp("Target.createTarget", {
- url: "about:blank",
- })) as { targetId: string };
-
- await this.switchTab(result.targetId);
- this.invalidatePageInfoCache();
- if (url && url !== "about:blank") {
- await this.cdp("Page.navigate", { url });
- this.invalidatePageInfoCache();
- }
- return result.targetId;
- }
-
- /**
- * Activate and attach to a tab.
- *
- * OPTIMIZED: Enables core domains in parallel (Promise.all) instead of
- * sequential await. Reduces tab-switch latency by ~30%.
- */
- async switchTab(targetId: string): Promise<void> {
- await this.cdp("Target.activateTarget", { targetId });
- const result = (await this.cdp("Target.attachToTarget", {
- targetId,
- flatten: true,
- })) as { sessionId: string };
-
- this._sessionId = result.sessionId;
- this._targetId = targetId;
- this.invalidatePageInfoCache();
-
- // Enable core domains in parallel (pipelined over single WS connection)
- await Promise.all(
- ["Page", "DOM", "Runtime", "Network"].map((domain) =>
- this.cdp(`${domain}.enable`).catch(() => {})
- )
- );
-
- // Mark the tab with a green circle for user visibility
- try {
- await this.cdp("Runtime.evaluate", {
- expression: `if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
- });
- } catch {
- // best-effort
- }
- }
-
- /** Evaluate JavaScript in the page (or a specific iframe target) */
- async evaluateJS(expression: string, targetId?: string): Promise<unknown> {
- // Auto-wrap return statements in IIFE
- if (expression.includes("return ") && !expression.trim().startsWith("(")) {
- expression = `(function(){${expression}})()`;
- }
-
- const sid = targetId || null;
- const result = (await this.cdp(
- "Runtime.evaluate",
- {
- expression,
- returnByValue: true,
- awaitPromise: true,
- },
- targetId ? sid : undefined,
- )) as { result?: { value?: unknown }; exceptionDetails?: unknown };
-
- if (result.exceptionDetails) {
- throw new Error(`JS evaluation failed: ${JSON.stringify(result.exceptionDetails)}`);
- }
- return result.result?.value;
- }
-
- /** Drain buffered CDP events from the internal buffer */
- async drainEvents(): Promise<CDPEvent[]> {
- const events = this._eventBuffer;
- this._eventBuffer = [];
- return events;
- }
-
- /** Find an iframe target by URL substring. Use with evaluateJS(targetId). */
- async findIframeTarget(urlSubstr: string): Promise<string | null> {
- const result = (await this.cdp("Target.getTargets")) as {
- targetInfos: Array<{ targetId: string; type: string; url: string }>;
- };
- for (const t of result.targetInfos) {
- if (t.type === "iframe" && t.url?.includes(urlSubstr)) {
- return t.targetId;
- }
- }
- return null;
- }
-
- /** Set files on a file input element via DOM.setFileInputFiles. */
- async uploadFile(selector: string, filePath: string): Promise<void> {
- // First try CDP-level file input (DOM.setFileInputFiles)
- try {
- const doc = (await this.cdp("DOM.getDocument", { depth: -1 })) as {
- root: { nodeId: number };
- };
- const qResult = (await this.cdp("DOM.querySelector", {
- nodeId: doc.root.nodeId,
- selector,
- })) as { nodeId: number };
-
- if (qResult.nodeId && qResult.nodeId !== 0) {
- await this.cdp("DOM.setFileInputFiles", {
- files: [filePath],
- nodeId: qResult.nodeId,
- });
- // Verify the file was actually set
- const verify = await this.evaluateJS(
- `document.querySelector(${JSON.stringify(selector)})?.files?.length || 0`
- );
- if (verify && Number(verify) > 0) return;
- }
- } catch {
- // CDP approach failed — fall through to JS fallback
- }
-
- // Fallback: try JS-based approach for broader compatibility
- // Read file content from Node.js and inject as a File object via JS
- try {
- const { readFile, stat } = await import("node:fs/promises");
- const { basename } = await import("node:path");
- const fileBuffer = await readFile(filePath);
- const fileStat = await stat(filePath);
- const fileName = basename(filePath);
- const base64 = fileBuffer.toString("base64");
- const mimeType = fileName.endsWith(".png") ? "image/png"
- : fileName.endsWith(".jpg") || fileName.endsWith(".jpeg") ? "image/jpeg"
- : fileName.endsWith(".pdf") ? "application/pdf"
- : fileName.endsWith(".json") ? "application/json"
- : "text/plain";
-
- const js = `
- (() => {
- const input = document.querySelector(${JSON.stringify(selector)});
- if (!input || input.type !== 'file') {
- throw new Error('File input not found for selector: ${selector.replace(/'/g, "\\'")}');
- }
- const binaryStr = atob(${JSON.stringify(base64)});
- const bytes = new Uint8Array(binaryStr.length);
- for (let i = 0; i < binaryStr.length; i++) bytes[i] = binaryStr.charCodeAt(i);
- const file = new File([bytes], ${JSON.stringify(fileName)}, { type: ${JSON.stringify(mimeType)}, lastModified: ${fileStat.mtimeMs} });
- const dt = new DataTransfer();
- dt.items.add(file);
- input.files = dt.files;
- input.dispatchEvent(new Event('change', { bubbles: true }));
- return input.files.length;
- })()
- `;
- const fileCount = await this.evaluateJS(js);
- if (!fileCount || Number(fileCount) === 0) {
- throw new Error("File was not set on input (JS fallback also failed)");
- }
- } catch (jsErr) {
- throw new Error(
- `Upload failed: ${jsErr instanceof Error ? jsErr.message : String(jsErr)}`
- );
- }
- }
-
- /**
- * Configure download behavior: set download directory and disable the
- * save-as prompt. Afterwards, drain Browser.downloadProgress events to
- * confirm downloads started.
- */
- async setDownloadBehavior(downloadPath: string): Promise<void> {
- await this.cdp("Browser.setDownloadBehavior", {
- behavior: "allow",
- downloadPath,
- eventsEnabled: true,
- });
- }
-
- /**
- * Resize the viewport via Emulation.setDeviceMetricsOverride.
- * Adjusts the visible viewport and device pixel ratio.
- */
- async setViewportSize(
- width: number,
- height: number,
- deviceScaleFactor?: number,
- ): Promise<void> {
- await this.cdp("Emulation.setDeviceMetricsOverride", {
- width,
- height,
- deviceScaleFactor: deviceScaleFactor ?? 1,
- mobile: false,
- });
- this.invalidatePageInfoCache();
- }
-
- /**
- * Capture screenshot with an optional debug crosshair overlay at (x, y).
- * When debugClicks is true, overlays a red crosshair to verify click accuracy.
- */
- async captureScreenshotWithDebugOverlay(
- outputPath: string,
- clickX: number,
- clickY: number,
- format: "png" | "jpeg" = "png",
- quality = 80,
- ): Promise<string> {
- await this.captureScreenshot(outputPath, false, format, quality);
- try {
- // sharp is an optional dependency — import at runtime to avoid TS static analysis
- const sharp: any = await new Function("return import('sharp')")()
- .then((m: any) => m.default || m)
- .catch(() => null);
- if (!sharp) return outputPath;
- const dpr = (await this.evaluateJS("window.devicePixelRatio")) as number || 1;
- const metadata = await sharp(outputPath).metadata();
- const w = metadata.width || 0;
- const h = metadata.height || 0;
- const px = Math.round(clickX * dpr);
- const py = Math.round(clickY * dpr);
- const r = Math.round(15 * dpr);
-
- // Create SVG overlay for the crosshair
- const svg = `<svg width="${w}" height="${h}">
- <circle cx="${px}" cy="${py}" r="${r}" fill="none" stroke="red" stroke-width="${Math.max(2, Math.round(3 * dpr))}" opacity="0.8"/>
- <line x1="${px - r - Math.round(5 * dpr)}" y1="${py}" x2="${px + r + Math.round(5 * dpr)}" y2="${py}" stroke="red" stroke-width="${Math.max(1, Math.round(2 * dpr))}" opacity="0.8"/>
- <line x1="${px}" y1="${py - r - Math.round(5 * dpr)}" x2="${px}" y2="${py + r + Math.round(5 * dpr)}" stroke="red" stroke-width="${Math.max(1, Math.round(2 * dpr))}" opacity="0.8"/>
-</svg>`;
- await sharp(outputPath)
- .composite([{ input: Buffer.from(svg), top: 0, left: 0 }])
- .toFile(outputPath + ".debug");
- const { rename } = await import("node:fs/promises");
- await rename(outputPath + ".debug", outputPath);
- } catch {
- // sharp not installed — overlay skipped
- }
- return outputPath;
- }
-
- /**
- * Perform a drag-and-drop sequence via Input.dispatchDragEvent.
- * Moves from (startX, startY) to (endX, endY) with optional DataTransfer.
- */
- async dragAndDrop(
- startX: number,
- startY: number,
- endX: number,
- endY: number,
- dataTransfer?: Record<string, string>,
- ): Promise<void> {
- // CDP expects data.items for DataTransfer content
- const data = dataTransfer
- ? {
- items: Object.entries(dataTransfer).map(([mimeType, data]) => ({
- mimeType,
- data: Buffer.from(data).toString("base64"),
- })),
- dragOperationsMask: 1, // 1 = Copy
- }
- : { items: [], dragOperationsMask: 1 };
-
- // mousePressed to initiate drag
- await this.cdp("Input.dispatchMouseEvent", {
- type: "mousePressed",
- x: startX,
- y: startY,
- button: "left",
- clickCount: 1,
- });
-
- // dragEnter at start
- await this.cdp("Input.dispatchDragEvent", {
- type: "dragEnter",
- x: startX,
- y: startY,
- data,
- modifiers: 0,
- });
-
- // dragOver at intermediate points + destination
- const steps = 5;
- for (let i = 1; i <= steps; i++) {
- const t = i / steps;
- const ix = startX + (endX - startX) * t;
- const iy = startY + (endY - startY) * t;
- await this.cdp("Input.dispatchDragEvent", {
- type: "dragOver",
- x: Math.round(ix),
- y: Math.round(iy),
- data,
- modifiers: 0,
- });
- }
-
- // drop at end position
- await this.cdp("Input.dispatchDragEvent", {
- type: "drop",
- x: endX,
- y: endY,
- data,
- modifiers: 0,
- });
-
- // mouseReleased
- await this.cdp("Input.dispatchMouseEvent", {
- type: "mouseReleased",
- x: endX,
- y: endY,
- button: "left",
- clickCount: 1,
- });
- }
-
- /** Print the current page to PDF. Returns the file path. */
- async printToPDF(outputPath: string): Promise<string> {
- const result = (await this.cdp("Page.printToPDF", {
- printBackground: true,
- preferCSSPageSize: true,
- })) as { data: string };
- const buf = Buffer.from(result.data, "base64");
- await writeFile(outputPath, buf);
- return outputPath;
- }
-
- /**
- * Get buffered network events (Network.requestWillBeSent, Network.responseReceived).
- * Filters to the specified event types and optionally limits the count.
- */
- async getNetworkLog(
- eventTypes: string[] = [
- "Network.requestWillBeSent",
- "Network.responseReceived",
- ],
- limit = 50,
- ): Promise<CDPEvent[]> {
- const events = await this.drainEvents();
- return events
- .filter((e) => eventTypes.includes(e.method))
- .slice(-limit);
- }
-
- /** Get virtual key code for a key name or character */
- getVirtualKeyCode(key: string): number {
- return VIRTUAL_KEY_CODES[key] ?? (key.length === 1 ? key.charCodeAt(0) : 0);
- }
-
- /** Get the key code string for CDP */
- getKeyCode(key: string): string {
- return key.length === 1 && !VIRTUAL_KEY_CODES[key] ? key : key;
- }
-
- // ── 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;
- // NOTE: do not clear _reconnectAttempts here. Chrome accepts the
- // WS upgrade before the user clicks Allow on the remote-debugging
- // consent dialog, so a successful onopen does not prove the
- // connection is usable. We only consider the connection healthy
- // once a CDP roundtrip completes — see attachFirstPage.
- 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;
-
- // If we never saw a successful CDP roundtrip on this connection,
- // the close almost certainly means the user denied (or hasn't yet
- // accepted) Chrome's "Allow remote debugging?" prompt. Reconnecting
- // would just spawn another popup. Bail out and let the next tool
- // call surface a clear error.
- if (!this._consentObserved) {
- this._sessionId = null;
- return;
- }
-
- // Auto-reconnect with backoff
- if (this._reconnectAttempts < CDP_MAX_RECONNECT_ATTEMPTS) {
- this._sessionId = null;
- // Reset for the new connection — it must re-prove itself.
- this._consentObserved = false;
- 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,
- timeoutMs: number = CDP_REQUEST_TIMEOUT_MS,
- ): 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 ${timeoutMs}ms: ${method}`));
- }, timeoutMs);
-
- 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.sendRawCdp("Target.getTargets", {}, null)) as {
- targetInfos: Array<{ targetId: string; type: string; url: string }>;
- };
-
- // First CDP roundtrip succeeded — consent has been granted. Only now
- // is it safe to reset the reconnect counter; otherwise a ws that opens
- // but is silently held by Chrome's consent dialog would let us loop
- // forever stacking popups on top of each other.
- this._consentObserved = true;
- this._reconnectAttempts = 0;
-
- const pages = result.targetInfos.filter(
- (t) => t.type === "page" && !isInternalUrl(t.url),
- );
-
- if (pages.length === 0) {
- 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.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.sendRawCdp(`${domain}.enable`, {}, this._sessionId);
- } catch {
- // best-effort
- }
- }
-
- // Mark tab
- try {
- await this.sendRawCdp(
- "Runtime.evaluate",
- {
- expression: `if(!document.title.startsWith('🟢'))document.title='🟢 '+document.title`,
- },
- this._sessionId,
- );
- } catch {
- // best-effort
- }
-
- this._lastHealthCheck = Date.now();
- this._lastOperationFailed = false;
- return attachResult.sessionId;
- }
-
- /** Auto-recover from a stale session error by re-attaching */
- async recoverSession(): Promise<void> {
- await this.attachFirstPage();
- }
-
- // ── Error diagnostics ────────────────────────────────────────────────────
-
- private needsChromeSetup(errorMsg: string): boolean {
- const lower = errorMsg.toLowerCase();
- return (
- 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")))
- );
- }
-}
-
-// ── Internal Types ───────────────────────────────────────────────────────────
-
-interface PendingRequest {
- resolve: (value: unknown) => void;
- reject: (err: Error) => void;
- timer: ReturnType<typeof setTimeout>;
- method: string;
- sessionId: string | null;
-}
-
-interface CDPMessage {
- id?: number;
- method?: string;
- params?: Record<string, unknown>;
- result?: unknown;
- error?: { message: string; code?: number };
- sessionId?: string;
-}
-
-// ── Error Classes ────────────────────────────────────────────────────────────
-
-class SessionNotFoundError extends Error {
- constructor(msg: string) {
- super(msg);
- this.name = "SessionNotFoundError";
- }
-}
diff --git a/src/protocol.ts b/src/protocol.ts
deleted file mode 100644
index 1b8bf40..0000000
--- a/src/protocol.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * Protocol types for pi-browser-harness.
- *
- * Defines the shape of CDP communication and browser state.
- * No socket transport — the daemon talks CDP directly over WebSocket.
- */
-
-// ── CDP Event ────────────────────────────────────────────────────────────────
-
-export interface CDPEvent {
- method: string;
- params: unknown;
- session_id?: string;
-}
-
-// ── Dialog ───────────────────────────────────────────────────────────────────
-
-export interface DialogInfo {
- type: "alert" | "confirm" | "prompt" | "beforeunload";
- message: string;
- defaultPrompt?: string;
-}
-
-// ── Tab ──────────────────────────────────────────────────────────────────────
-
-export interface TabInfo {
- targetId: string;
- title: string;
- url: string;
-}
-
-// ── Page State ───────────────────────────────────────────────────────────────
-
-export interface PageInfo {
- url: string;
- title: string;
- width: number;
- height: number;
- scrollX: number;
- scrollY: number;
- pageWidth: number;
- pageHeight: number;
-}
-
-export type PageInfoResult = PageInfo | { dialog: DialogInfo };
-
-// ── Daemon Status ────────────────────────────────────────────────────────────
-
-export interface DaemonStatus {
- alive: boolean;
- sessionId: string | null;
- pid: number | null; // deprecated — always null (no subprocess)
- namespace: string;
- socketPath: string; // deprecated — always "" (no socket)
- remoteBrowserId?: string;
-}
-
-// ── Remote Config ────────────────────────────────────────────────────────────
-
-export interface RemoteConfig {
- cdpUrl: string;
- browserId: string;
-}
-
-// ── Internal URL prefixes to filter ──────────────────────────────────────────
-
-const INTERNAL_PREFIXES = [
- "chrome://",
- "chrome-untrusted://",
- "devtools://",
- "chrome-extension://",
- "about:",
-];
-
-export function isInternalUrl(url: string): boolean {
- return INTERNAL_PREFIXES.some((p) => url.startsWith(p));
-}
diff --git a/src/renderers.ts b/src/renderers.ts
deleted file mode 100644
index 5882ff4..0000000
--- a/src/renderers.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-/**
- * TUI renderers for pi-browser-harness.
- *
- * Registers custom message renderers and tool renderCall/renderResult
- * for browser tools.
- */
-
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { Text } from "@mariozechner/pi-tui";
-
-export function registerRenderers(pi: ExtensionAPI): void {
- // Register a message renderer for screenshot notifications
- pi.registerMessageRenderer("browser-screenshot", (message, _options, theme) => {
- const path = (message as { details?: { path?: string } }).details?.path || "unknown";
- return new Text(
- theme.fg("accent", `📸 Screenshot: ${path}`),
- 0,
- 0,
- );
- });
-}
diff --git a/src/tools.ts b/src/tools.ts
deleted file mode 100644
index 0c8a05b..0000000
--- a/src/tools.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * Tool registrations for pi-browser-harness.
- *
- * Maps browser-harness daemon methods to pi tools with TypeBox schemas,
- * prompt snippets, guidelines, and TUI renderers.
- */
-
-import {
- DEFAULT_MAX_BYTES,
- DEFAULT_MAX_LINES,
- formatSize,
- truncateHead,
- withFileMutationQueue,
-} from "@mariozechner/pi-coding-agent";
-import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
-import { Text } from "@mariozechner/pi-tui";
-import { Type } from "typebox";
-import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
-import { tmpdir } from "node:os";
-import { join } from "node:path";
-import type { BrowserDaemon } from "./daemon";
-import type { TabInfo } from "./protocol";
-
-// ── AsyncFunction constructor ────────────────────────────────────────────────
-
-const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor as new (...args: string[]) => (...args: unknown[]) => Promise<unknown>;
-
-// ── Key code map ─────────────────────────────────────────────────────────────
-
-const SPECIAL_KEYS = [
- "Enter", "Tab", "Backspace", "Escape", "Delete",
- "ArrowLeft", "ArrowUp", "ArrowRight", "ArrowDown",
- "Home", "End", "PageUp", "PageDown", " ",
-];
-
-// ── Screenshot path ──────────────────────────────────────────────────────────
-
-let screenshotCounter = 0;
-function nextScreenshotPath(): string {
- screenshotCounter++;
- return join(tmpdir(), `pi-browser-screenshot-${Date.now()}-${screenshotCounter}.png`);
-}
-
-// ── Output truncation ────────────────────────────────────────────────────────
-
-/** Track temp directories for cleanup on session shutdown. */
-const tempDirs: string[] = [];
-
-/** Best-effort cleanup of all tracked temp directories. */
-export async function cleanupTempDirs(): Promise<void> {
- const dirs = tempDirs.splice(0);
- await Promise.all(
- dirs.map((d) => rm(d, { recursive: true, force: true }).catch(() => {})),
- );
-}
-
-/**
- * Truncate output to pi's built-in limits (50KB / 2000 lines).
- * When truncated, writes the full output to a temp file so the LLM
- * can read it via the read tool and so CTRL+O expand can show it.
- */
-async function applyTruncation(
- output: string,
- prefix: string,
-): Promise<{
- text: string;
- fullOutputPath?: string;
- wasTruncated: boolean;
-}> {
- const truncation = truncateHead(output, {
- maxLines: DEFAULT_MAX_LINES,
- maxBytes: DEFAULT_MAX_BYTES,
- });
-
- if (!truncation.truncated) {
- return { text: truncation.content, wasTruncated: false };
- }
-
- const tempDir = await mkdtemp(join(tmpdir(), `pi-bh-${prefix}-`));
- tempDirs.push(tempDir);
-
- const tempFile = join(tempDir, "output.txt");
- await withFileMutationQueue(tempFile, async () => {
- await writeFile(tempFile, output, "utf8");
- });
-
- const omittedBytes = truncation.totalBytes - truncation.outputBytes;
- let text = truncation.content;
- text += `\n\n[Output truncated: ${truncation.outputLines} of ${truncation.totalLines} lines`;
- text += ` (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}).`;
- text += ` ${formatSize(omittedBytes)} omitted. Full output: ${tempFile}]`;
-
- return { text, fullOutputPath: tempFile, wasTruncated: true };
-}
-
-// ── Registration ─────────────────────────────────────────────────────────────
-
-// All tools migrated to src/domains/* in v0.3 — see registerAllTools.
-export function registerTools(_pi: ExtensionAPI, _daemon: BrowserDaemon): void {
- // intentionally empty
-}