import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { Browser, CDPSession, CookieParam, Cookie, ElementHandle, Frame, HTTPRequest, HTTPResponse, KeyInput, Page, Target, WaitForOptions } from "puppeteer-core"; import puppeteer from "puppeteer-core"; import { PROBE_JS, FIND_LISTS_JS, foldListsJs, monitorStartJs, MONITOR_STOP_JS, optimizeHtmlForTokens, smartTruncate, diffHtml, type HtmlDiff } from "./simplify.ts"; import { STEALTH_INIT_JS, STEALTH_LAUNCH_ARGS } from "./stealth.ts"; import { runOcr, runDetect, isLocalVisionError, type OcrOutcome, type DetectOutcome } from "../../providers/local-vision.ts"; import { browserBridge } from "./bridge-server.ts"; export type WaitUntil = "load" | "domcontentloaded" | "networkidle0" | "networkidle2"; export interface BrowserOpenOptions { name: string; cwd: string; url?: string; executablePath?: string; cdpUrl?: string; args?: string[]; target?: string; visible?: boolean; viewport?: { width: number; height: number; scale?: number }; waitUntil?: WaitUntil; dialogs?: "accept" | "dismiss"; attachUserProfile?: boolean; userProfileDir?: string; signal?: AbortSignal; timeoutMs: number; } export interface BrowserTabInfo { name: string; kind: "headless" | "headed" | "connected" | "extension"; url: string; title: string; reused: boolean; viewport?: { width: number; height: number; deviceScaleFactor?: number }; } export interface BrowserRunOutput { displays: Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>; returnValue: unknown; screenshots: Array<{ path?: string; mimeType: string; bytes: number }>; url: string; navigated?: boolean; newTabs?: Array<{ url: string }>; } export interface BrowserManagerLike { open(options: BrowserOpenOptions): Promise; run(name: string, code: string, cwd: string, signal: AbortSignal | undefined, timeoutMs: number): Promise; close(name: string): Promise; closeAll(): Promise; } type GenericPageHandler = (...args: never[]) => unknown; type PageEventType = string | symbol; interface RequestListenerScope { cleanup(): void; } interface TabEntry { name: string; key: string; kind: "headless" | "headed" | "connected" | "extension"; browser: Browser; page: Page; owned: boolean; ownedPage: boolean; profileDir?: string; dialogHandler?: (dialog: import("puppeteer-core").Dialog) => Promise; requestScope?: RequestListenerScope; elementSelectors: Map; ownedTempFiles: Set; busy: boolean; cdpSession?: CDPSession; } interface OpeningEntry { key: string; requestKey: string; promise: Promise; abort(): void; } export class BrowserManager implements BrowserManagerLike { #tabs = new Map(); #opening = new Map(); #lifecycle = new AbortController(); async open(options: BrowserOpenOptions): Promise { throwIfAborted(options.signal); const key = browserKey(options); const requestKey = browserOpenRequestKey(options, key); const pending = this.#opening.get(options.name); if (pending) { if (pending.requestKey !== requestKey) throw new Error(`Tab "${options.name}" is already opening with different settings.`); return { ...(await pending.promise), reused: true }; } let existing = this.#tabs.get(options.name); if (existing && (!existing.browser.connected || existing.page.isClosed())) { this.#tabs.delete(options.name); await disposeEntry(existing); existing = undefined; const raced = this.#opening.get(options.name); if (raced) { if (raced.requestKey !== requestKey) throw new Error(`Tab "${options.name}" is already opening with different settings.`); return { ...(await raced.promise), reused: true }; } } if (existing) { if (existing.key !== key) throw new Error(`Tab "${options.name}" already uses a different browser. Close it before changing app settings.`); const combined = combineSignals(options.signal, this.#lifecycle.signal); try { const effective = { ...options, signal: combined.signal }; await this.#configurePage(existing, effective); return this.#info(existing, true, combined.signal, options.timeoutMs); } finally { combined.dispose(); } } const controller = new AbortController(); const combined = combineSignals(options.signal, this.#lifecycle.signal, controller.signal); const effective = { ...options, signal: combined.signal }; let promise: Promise; promise = this.#openNew(effective, key); const opening: OpeningEntry = { key, requestKey, promise, abort: () => controller.abort() }; this.#opening.set(options.name, opening); try { return await promise; } finally { if (this.#opening.get(options.name) === opening) this.#opening.delete(options.name); combined.dispose(); } } async #openNew(options: BrowserOpenOptions, key: string): Promise { const connection = await connectBrowser(options, key); let page: Page | undefined; let ownedPage = false; try { const pickedPage = await raceAbort(pickPage(connection.browser, options.target), options.signal, options.timeoutMs); if (options.target && !pickedPage) throw new Error(`No browser page matched target ${JSON.stringify(options.target)}.`); page = pickedPage ?? await raceAbort(connection.browser.newPage(), options.signal, options.timeoutMs); ownedPage = connection.owned || !pickedPage; const entry: TabEntry = { name: options.name, key, kind: connection.kind, browser: connection.browser, page, owned: connection.owned, ownedPage, profileDir: connection.profileDir, elementSelectors: new Map(), ownedTempFiles: new Set(), busy: false, }; await this.#configurePage(entry, options); throwIfAborted(options.signal); this.#registerEntry(entry); return this.#info(entry, connection.reused, options.signal, options.timeoutMs); } catch (error) { if (ownedPage && page && !page.isClosed()) await completesWithin(page.close(), 2_000).catch(() => false); await disposeBrowser(connection.browser, connection.owned, connection.profileDir); throw error; } } async run(name: string, code: string, cwd: string, signal: AbortSignal | undefined, timeoutMs: number): Promise { const entry = this.#tabs.get(name); if (!entry) throw new Error(`No tab named "${name}". Open it first.`); if (entry.busy) throw new Error(`Tab "${name}" is busy.`); if (!code.trim()) throw new Error("Browser run requires non-empty code."); throwIfAborted(signal); entry.busy = true; const displays: BrowserRunOutput["displays"] = []; const screenshots: BrowserRunOutput["screenshots"] = []; const beforeUrl = entry.page.isClosed() ? "" : entry.page.url(); // Capture new tabs via the targetcreated event instead of a before/after // browser.pages() diff: window.open mid-run can race the after-snapshot and // drop tabs that are still attaching (the race GA's extension explicitly // avoided with chrome.tabs.onCreated). Collect Targets during the run; the // entry page itself is excluded so a same-tab reload is not reported. const ownTarget = entry.page.target(); const createdTargets = new Set(); const onTargetCreated = (target: Target) => { if (target.type() === "page" && target !== ownTarget) createdTargets.add(target); }; // A tab spawned and closed within the same run must not be reported (same // semantics as the old before/after pages diff, which only saw survivors). const onTargetDestroyed = (target: Target) => { createdTargets.delete(target); }; entry.browser.on("targetcreated", onTargetCreated); entry.browser.on("targetdestroyed", onTargetDestroyed); let requestScope: RequestListenerScope | undefined; let runFailed = false; let cleanupFailed = false; try { if (entry.requestScope) { try { entry.requestScope.cleanup(); } catch (error) { cleanupFailed = true; throw error; } entry.requestScope = undefined; } await disablePageRequestInterception(entry.page); requestScope = installRequestListenerScope(entry.page); entry.requestScope = requestScope; const tab = createTabApi(entry, cwd, displays, screenshots, signal, timeoutMs); const assert = (condition: unknown, message = "Browser assertion failed") => { if (!condition) throw new Error(message); }; const wait = (ms: number) => abortableDelay(ms, signal); const display = (value: unknown) => displays.push({ type: "text", text: formatDisplay(value) }); const print = (...values: unknown[]) => displays.push({ type: "text", text: values.map(formatDisplay).join(" ") }); const capturedConsole = { log: print, info: print, warn: print, error: print, debug: print }; const execute = compileRunCode(code); const returnValue = await raceAbort(execute(entry.page, entry.browser, tab, assert, wait, display, print, signal, capturedConsole), signal, timeoutMs); const afterUrl = entry.page.isClosed() ? "" : entry.page.url(); const navigated = Boolean(beforeUrl && afterUrl && beforeUrl !== afterUrl); let newTabs: Array<{ url: string }> | undefined; try { if (createdTargets.size > 0) { const settled = await Promise.all([...createdTargets].map(async (target) => { try { const page = await raceAbort(target.page(), signal, Math.min(2_000, timeoutMs)); return { url: page?.url() ?? target.url() }; } catch { return { url: target.url() }; } })); const added = settled.filter((item) => Boolean(item.url)); if (added.length > 0) newTabs = added; } } catch { /* best-effort */ } return { displays, returnValue, screenshots, url: afterUrl, navigated: navigated || undefined, newTabs }; } catch (error) { runFailed = true; if (isInterruptError(error)) await this.close(name); throw browserRunErrorHint(error); } finally { entry.browser.off("targetcreated", onTargetCreated); entry.browser.off("targetdestroyed", onTargetDestroyed); let cleanupError: unknown; try { requestScope?.cleanup(); if (entry.requestScope === requestScope) entry.requestScope = undefined; await disablePageRequestInterception(entry.page); } catch (error) { cleanupFailed = true; cleanupError = error; } if (cleanupFailed && this.#tabs.get(name) === entry) { await this.close(name).catch(() => {}); } entry.busy = false; if (cleanupError && !runFailed) throw cleanupError; } } async close(name: string): Promise { const entry = this.#tabs.get(name); if (entry) { this.#tabs.delete(name); await disposeEntry(entry); return true; } const opening = this.#opening.get(name); if (!opening) return false; opening.abort(); await opening.promise.catch(() => {}); return true; } async closeAll(): Promise { const entries = [...this.#tabs.values()]; const openings = [...this.#opening.values()]; this.#lifecycle.abort(); this.#lifecycle = new AbortController(); for (const opening of openings) opening.abort(); this.#tabs.clear(); this.#opening.clear(); await Promise.allSettled([...entries.map(disposeEntry), ...openings.map((opening) => opening.promise)]); return entries.length; } has(name: string): boolean { return this.#tabs.has(name); } #registerEntry(entry: TabEntry): void { const discard = () => { void this.#discardEntry(entry); }; entry.page.once("close", discard); entry.browser.once("disconnected", discard); this.#tabs.set(entry.name, entry); } async #discardEntry(entry: TabEntry): Promise { if (this.#tabs.get(entry.name) !== entry) return; this.#tabs.delete(entry.name); await disposeEntry(entry); } async #configurePage(entry: TabEntry, options: BrowserOpenOptions): Promise { // The extension channel drives the user's real browser; stealth is not // injected (it would fight the real fingerprint). Same as `connected`. if (entry.kind !== "connected" && entry.kind !== "extension") await entry.page.evaluateOnNewDocument(STEALTH_INIT_JS); if (options.viewport) { await entry.page.setViewport({ width: options.viewport.width, height: options.viewport.height, deviceScaleFactor: options.viewport.scale, }); } if (entry.dialogHandler) entry.page.off("dialog", entry.dialogHandler); entry.dialogHandler = options.dialogs ? async (dialog) => { if (options.dialogs === "accept") await dialog.accept(); else await dialog.dismiss(); } : undefined; if (entry.dialogHandler) entry.page.on("dialog", entry.dialogHandler); if (options.url) { await raceAbort(entry.page.goto(options.url, { waitUntil: options.waitUntil ?? "load", timeout: options.timeoutMs }), options.signal, options.timeoutMs); entry.elementSelectors.clear(); } } async #info(entry: TabEntry, reused: boolean, signal: AbortSignal | undefined, timeoutMs: number): Promise { return { name: entry.name, kind: entry.kind, url: entry.page.url(), title: await raceAbort(entry.page.title(), signal, timeoutMs), reused, viewport: entry.page.viewport() ?? undefined, }; } } function createTabApi( entry: TabEntry, cwd: string, displays: BrowserRunOutput["displays"], screenshots: BrowserRunOutput["screenshots"], signal: AbortSignal | undefined, timeoutMs: number, ) { const page = entry.page; const deadline = () => Math.max(1, timeoutMs); const resolve = async (selectorOrId: string | number): Promise> => { const selector = typeof selectorOrId === "number" ? entry.elementSelectors.get(selectorOrId) : selectorOrId; if (!selector) throw new Error(`Unknown or stale element id: ${selectorOrId}`); const handle = await page.$(normalizeSelector(selector)); if (!handle) throw new Error(`Element not found: ${selector}`); return handle as ElementHandle; }; const api = { name: entry.name, page, signal, url: () => page.url(), title: () => page.title(), async setViewport(viewport: { width: number; height: number; scale?: number }) { return page.setViewport({ width: viewport.width, height: viewport.height, deviceScaleFactor: viewport.scale }); }, async goto(url: string, options?: { waitUntil?: WaitUntil }) { entry.elementSelectors.clear(); return raceAbort(page.goto(url, { waitUntil: options?.waitUntil ?? "load", timeout: deadline() }), signal, deadline()); }, async observe(options?: { includeAll?: boolean; viewportOnly?: boolean }) { const observed = await page.evaluate(({ includeAll, viewportOnly }) => { const interactive = new Set(["A", "BUTTON", "INPUT", "SELECT", "TEXTAREA", "SUMMARY"]); const selector = includeAll ? "body *" : "a,button,input,select,textarea,summary,[role],[tabindex],[contenteditable='true']"; const elements = Array.from(document.querySelectorAll(selector)); const rows: Array<{ selector: string; role: string; name: string; text: string; box?: { x: number; y: number; width: number; height: number } }> = []; const cssPath = (element: Element): string => { if ((element as HTMLElement).id) return `#${CSS.escape((element as HTMLElement).id)}`; const parts: string[] = []; let current: Element | null = element; while (current && current !== document.body) { const parent: Element | null = current.parentElement; if (!parent) break; const siblings = Array.from(parent.children).filter((item) => item.tagName === current!.tagName); const index = siblings.indexOf(current) + 1; parts.unshift(`${current.tagName.toLowerCase()}${siblings.length > 1 ? `:nth-of-type(${index})` : ""}`); current = parent; } return `body > ${parts.join(" > ")}`; }; for (const element of elements) { const html = element as HTMLElement; const style = getComputedStyle(html); const rect = html.getBoundingClientRect(); if (style.display === "none" || style.visibility === "hidden" || rect.width === 0 || rect.height === 0) continue; if (viewportOnly && (rect.bottom < 0 || rect.right < 0 || rect.top > innerHeight || rect.left > innerWidth)) continue; const role = html.getAttribute("role") || html.tagName.toLowerCase(); const isInteractive = interactive.has(html.tagName) || html.tabIndex >= 0 || html.onclick !== null || ["button", "link", "textbox", "checkbox", "radio", "combobox"].includes(role); if (!includeAll && !isInteractive) continue; rows.push({ selector: cssPath(html), role, name: html.getAttribute("aria-label") || html.getAttribute("title") || (html as HTMLInputElement).placeholder || (html.innerText ?? "").trim().slice(0, 160), text: (html.innerText ?? "").trim().slice(0, 240), box: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }, }); if (rows.length >= 500) break; } return { url: location.href, title: document.title, viewport: { width: innerWidth, height: innerHeight }, scroll: { x: scrollX, y: scrollY }, elements: rows }; }, options ?? {}); entry.elementSelectors.clear(); const elements = observed.elements.map((element, index) => { const id = index + 1; entry.elementSelectors.set(id, element.selector); return { id, role: element.role, name: element.name, text: element.text, box: element.box }; }); return { ...observed, elements }; }, id: (id: number) => resolve(id), ref: (id: number | string) => resolve(typeof id === "string" && /^e?\d+$/.test(id) ? Number(id.replace(/^e/, "")) : id), async screenshot(options?: { selector?: string; fullPage?: boolean; save?: string; silent?: boolean }) { const source = options?.selector ? await resolve(options.selector) : page; const data = await source.screenshot({ type: "png", ...(source === page ? { fullPage: options?.fullPage } : {}) }) as Uint8Array; const buffer = Buffer.from(data); const destination = options?.save ? path.resolve(cwd, options.save) : path.join(os.tmpdir(), `pi-maestro-browser-${Date.now()}-${Math.random().toString(36).slice(2)}.png`); await fs.mkdir(path.dirname(destination), { recursive: true }); await fs.writeFile(destination, buffer, { flag: options?.save ? "w" : "wx", mode: options?.save ? 0o666 : 0o600, }); if (!options?.save) entry.ownedTempFiles.add(destination); const metadata = { path: destination, mimeType: "image/png", bytes: buffer.length }; screenshots.push(metadata); if (!options?.silent) { displays.push({ type: "text", text: `Screenshot saved: ${destination}` }); displays.push({ type: "image", data: buffer.toString("base64"), mimeType: "image/png" }); } return metadata; }, async extract(format: "text" | "html" | "markdown" | "probe" | "list" = "markdown", options?: { fold?: string }) { if (format === "html") return page.content(); if (format === "text" || format === "markdown") return page.evaluate(() => document.body?.innerText ?? ""); if (format === "list") return page.evaluate(FIND_LISTS_JS) as Promise>>; // "probe": simplified, token-optimized structural HTML. const raw = (await page.evaluate(PROBE_JS)) as string; let optimized = optimizeHtmlForTokens(raw); if (options?.fold !== undefined) { const lists = await page.evaluate(FIND_LISTS_JS) as Array>; if (lists.length > 0) { const folded = await page.evaluate(foldListsJs({ html: optimized, lists, instruction: options.fold })) as string; optimized = folded || optimized; } } return smartTruncate(optimized, 35_000); }, async snapshot() { const [html, lists] = await Promise.all([ page.evaluate(PROBE_JS) as Promise, page.evaluate(FIND_LISTS_JS) as Promise>>, ]); return { html: optimizeHtmlForTokens(html), lists }; }, async diff(before: string | { html: string }, after?: string | { html: string }): Promise { const beforeHtml = typeof before === "string" ? before : before.html; const afterHtml = after === undefined ? optimizeHtmlForTokens((await page.evaluate(PROBE_JS)) as string) : typeof after === "string" ? after : after.html; return diffHtml(beforeHtml, afterHtml); }, async monitorStart(intervalMs?: number): Promise { return page.evaluate(monitorStartJs(intervalMs)) as Promise; }, async monitorStop(): Promise { return page.evaluate(MONITOR_STOP_JS) as Promise; }, async tabs(): Promise> { const pages = await raceAbort(entry.browser.pages(), signal, Math.min(2_000, deadline())); return Promise.all(pages.map(async (p) => ({ url: p.url(), title: p.isClosed() ? undefined : await raceAbort(p.title(), signal, Math.min(1_000, deadline())).catch(() => undefined) }))); }, async click(selector: string | number) { await (await resolve(selector)).click(); }, async type(selector: string | number, text: string) { await (await resolve(selector)).type(text); }, async fill(selector: string | number, value: string) { const handle = await resolve(selector); await handle.click({ count: 3 }); await handle.press("Backspace"); await handle.type(value); }, async press(key: KeyInput, options?: { selector?: string | number }) { if (options?.selector !== undefined) await (await resolve(options.selector)).press(key); else await page.keyboard.press(key); }, async scroll(deltaX: number, deltaY: number) { await page.evaluate(({ x, y }) => scrollBy(x, y), { x: deltaX, y: deltaY }); }, async drag(from: { x: number; y: number }, to: { x: number; y: number }) { await page.mouse.move(from.x, from.y); await page.mouse.down(); await page.mouse.move(to.x, to.y, { steps: 8 }); await page.mouse.up(); }, async waitFor(selector: string, options?: { timeout?: number }) { return page.waitForSelector(normalizeSelector(selector), { timeout: options?.timeout ?? deadline() }); }, evaluate: (fn: (...args: unknown[]) => T, ...args: unknown[]) => page.evaluate(fn, ...args), async scrollIntoView(selector: string | number) { await (await resolve(selector)).evaluate((element) => element.scrollIntoView({ block: "center" })); }, async select(selector: string, ...values: string[]) { return page.select(normalizeSelector(selector), ...values); }, async uploadFile(selector: string | number, ...filePaths: string[]) { const handle = await resolve(selector) as ElementHandle; await handle.uploadFile(...filePaths.map((file) => path.resolve(cwd, file))); }, async cdp(method: string, params?: Record) { // Extension channel route (active only when an entry has kind==="extension"; // connectBrowser does not produce such entries yet — see the TODO there and // optional/BROWSER-BRIDGE-SETUP.md NOTES). Dormant until open-channel activation. if (entry.kind === "extension") { // Extension channel: route to chrome.debugger via the bridge instead of a // puppeteer CDPSession (the user's browser has no persistent debug port here). const tabId = browserBridge.resolveTabId(); const res = await browserBridge.cdp(tabId, method, params, timeoutMs); if (!res.ok) throw new Error(res.error ?? "browser-bridge cdp failed"); return res.data as Record; } if (!entry.cdpSession || entry.cdpSession.connection() === null) entry.cdpSession = await page.target().createCDPSession(); return entry.cdpSession.send(method as never, params as never) as Promise>; }, cookies: { async get(filter?: { domain?: string; name?: string }) { let all; if (entry.kind === "extension") { // Extension channel includes partition cookies (chrome.cookies.getAll // with partitionKey) that puppeteer's page.cookies() cannot reach. const tabId = browserBridge.resolveTabId(); const res = await browserBridge.cookies({ tabId }, timeoutMs); if (!res.ok) throw new Error(res.error ?? "browser-bridge cookies failed"); all = res.data as Cookie[]; } else { all = await page.cookies(); } if (!filter) return all; return all.filter((cookie) => (!filter.domain || cookie.domain.includes(filter.domain)) && (!filter.name || cookie.name === filter.name), ); }, async set(cookies: CookieParam | CookieParam[]) { const list = Array.isArray(cookies) ? cookies : [cookies]; await page.setCookie(...list); }, async delete(filter: { domain?: string; name?: string }) { const all = await page.cookies(); for (const cookie of all) { if ((!filter.domain || cookie.domain.includes(filter.domain)) && (!filter.name || cookie.name === filter.name)) { await page.deleteCookie(cookie); } } }, }, // Cross-origin iframe JS execution (B-2.1): puppeteer frames() already holds // cross-origin frames with their own execution context; no need for GA's // createIsolatedWorld dance. async evalInFrame( matcher: string | RegExp | ((frame: Frame) => boolean), fn: (...args: unknown[]) => unknown, ...args: unknown[] ) { const frames = page.frames(); const match = typeof matcher === "function" ? frames.find(matcher) : frames.find((frame) => frame === page.mainFrame() ? false : (typeof matcher === "string" ? frame.url().includes(matcher) : matcher.test(frame.url()))); if (!match) throw new Error(`No iframe matched ${typeof matcher === "string" ? JSON.stringify(matcher) : matcher.toString()}.`); return match.evaluate(fn, ...args); }, // Closed Shadow DOM pierce (B-2.2): puppeteer's `pierce/` engine // crosses open AND closed shadow boundaries (unlike `page.$()`). Returns the // element center so the caller can follow with tab.cdpClick(x, y). Use the // `pierce/` prefix in the selector: tab.pierce('pierce/#shadow-btn'). async pierce(selector: string) { const handle = await page.waitForSelector(`pierce/${selector}`, { timeout: deadline() }); if (!handle) throw new Error(`pierce(${JSON.stringify(selector)}) found no node.`); const box = await handle.evaluate((element: Element) => { const rect = element.getBoundingClientRect(); return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; }); return { nodeId: 0, x: box.x, y: box.y }; }, // Physical-coordinate click (B-2.3): CDP Input.dispatchMouseEvent three-event // sequence (moved -> pressed -> released) with a hover dwell so hover-dependent // components (MUI Tooltip / Ant Dropdown) open before the press. async cdpClick(x: number, y: number, options?: { hoverMs?: number }) { if (!entry.cdpSession || entry.cdpSession.connection() === null) entry.cdpSession = await page.target().createCDPSession(); const session = entry.cdpSession; const hoverMs = options?.hoverMs ?? 80; const moved = session.send("Input.dispatchMouseEvent" as never, { type: "mouseMoved", x, y } as never); await moved; await new Promise((resolve) => setTimeout(resolve, hoverMs)); await session.send("Input.dispatchMouseEvent" as never, { type: "mousePressed", x, y, button: "left", clickCount: 1 } as never); await session.send("Input.dispatchMouseEvent" as never, { type: "mouseReleased", x, y, button: "left", clickCount: 1 } as never); }, // Autofill release (B-2.4): bringToFront (Chrome only releases protected values // in the foreground tab), click the field via physical coords, then re-dispatch // input/change so the framework picks up the now-exposed value. async autofillRelease(selector: string) { await page.bringToFront(); const box = await (await resolve(selector)).evaluate((element: Element) => { const rect = element.getBoundingClientRect(); return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; }); await this.cdpClick(box.x, box.y, { hoverMs: 120 }); await new Promise((resolve) => setTimeout(resolve, 500)); await (await resolve(selector)).evaluate((element) => { element.dispatchEvent(new Event("input", { bubbles: true })); element.dispatchEvent(new Event("change", { bubbles: true })); }); }, // Download-dialog bypass (B-2.5): CDP Browser.setDownloadBehavior sets an // allow path so Chrome does not block on the "download multiple files" prompt. async setDownloadBehavior(downloadPath: string) { if (!entry.cdpSession || entry.cdpSession.connection() === null) entry.cdpSession = await page.target().createCDPSession(); const resolved = path.resolve(cwd, downloadPath); await fs.mkdir(resolved, { recursive: true }); await entry.cdpSession.send("Browser.setDownloadBehavior" as never, { behavior: "allow", downloadPath: resolved } as never); }, // Batched CDP with $N.path chain references (B-2.6): send multiple CDP commands // in one round-trip; later commands may reference earlier results via // "$." strings (recursively resolved from results so far). async cdpBatch(commands: Array<{ method: string; params?: Record }>) { if (entry.kind === "extension") { // Extension channel: the extension resolves $N references server-side. const tabId = browserBridge.resolveTabId(); const res = await browserBridge.batch(commands as Array>, tabId, timeoutMs); return (res.results ?? []) as unknown[]; } if (!entry.cdpSession || entry.cdpSession.connection() === null) entry.cdpSession = await page.target().createCDPSession(); const session = entry.cdpSession; const results: unknown[] = []; for (let i = 0; i < commands.length; i += 1) { const command = commands[i]; const resolvedParams = command.params ? resolveDollarRefs(command.params, results) : undefined; try { const value = await session.send(command.method as never, resolvedParams as never); results.push({ ok: true, value }); } catch (error) { results.push({ ok: false, error: error instanceof Error ? error.message : String(error) }); } } return results; }, // On-page OCR: capture a PNG and pass its bytes plus browser-origin metadata // to the shared RapidOCR/ONNX service. Coordinates stay in screenshot // pixels, rooted at (0, 0); the caller can follow with tab.cdpClick(). async ocr(options?: { region?: { x: number; y: number; w: number; h: number }; fullPage?: boolean; silent?: boolean; langs?: string }) { const shot = await this.screenshot({ fullPage: options?.fullPage, silent: options?.silent ?? true }); const buffer = await fs.readFile(shot.path); const dims = await pngDimensions(buffer); const outcome: OcrOutcome = await runOcr(buffer, dims.width, dims.height, options?.region, options?.langs); return outcome; }, // UI detection uses shared OmniParser/ONNX. Its manifest provenance gate // intentionally fails closed when no verified model is available. async detect(options?: { mode?: "match" | "crop"; fullPage?: boolean; silent?: boolean; langs?: string }) { const shot = await this.screenshot({ fullPage: options?.fullPage, silent: options?.silent ?? true }); const buffer = await fs.readFile(shot.path); const dims = await pngDimensions(buffer); const outcome: DetectOutcome = await runDetect(buffer, dims.width, dims.height, options?.mode ?? "match", options?.langs); return outcome; }, waitForUrl(pattern: string | RegExp, options?: { timeout?: number }) { const descriptor = typeof pattern === "string" ? { kind: "text", value: pattern, flags: "" } : { kind: "regex", value: pattern.source, flags: pattern.flags }; return page.waitForFunction((expected) => expected.kind === "text" ? location.href.includes(expected.value) : new RegExp(expected.value, expected.flags).test(location.href), { timeout: options?.timeout ?? deadline() }, descriptor); }, waitForResponse(pattern: string | RegExp, options?: { timeout?: number }): Promise { return page.waitForResponse((response) => typeof pattern === "string" ? response.url().includes(pattern) : pattern.test(response.url()), { timeout: options?.timeout ?? deadline() }); }, waitForSelector(selector: string, options?: { timeout?: number; visible?: boolean; hidden?: boolean }) { return page.waitForSelector(normalizeSelector(selector), { timeout: options?.timeout ?? deadline(), visible: options?.visible, hidden: options?.hidden }); }, waitForNavigation(options?: WaitForOptions) { return page.waitForNavigation({ timeout: deadline(), ...options }); }, }; return api; } // Returns the pixel dimensions of a PNG buffer by reading the IHDR chunk // (bytes 16..24), avoiding a sharp/zlib dependency just to size a screenshot. function pngDimensions(buffer: Buffer): { width: number; height: number } { if (buffer.length < 24 || buffer.subarray(0, 8).toString("hex") !== "89504e470d0a1a0a") { throw new Error("Not a PNG buffer (screenshot returned an unexpected format)."); } // IHDR width and height are big-endian uint32 at byte offsets 16 and 20. const width = buffer.readUInt32BE(16); const height = buffer.readUInt32BE(20); if (!width || !height) throw new Error("PNG IHDR reported zero dimensions."); return { width, height }; } // Resolves "$N.dotted.path" references inside a cdpBatch command's params against // the results collected so far (0-indexed). A failed prior command yields // undefined for any $N reference (mirroring GA's silent-undefined behavior), so // callers must check results[i].ok before relying on a referenced value. function resolveDollarRefs(value: unknown, results: unknown[]): unknown { if (typeof value === "string" && /^\$\d+(\.[\w$]+)*$/.test(value)) { const [indexPart, ...pathParts] = value.slice(1).split("."); const index = Number(indexPart); const entry = results[index]; if (!entry || typeof entry !== "object" || !("value" in entry)) return undefined; let current: unknown = (entry as { value: unknown }).value; for (const part of pathParts) { if (current == null || typeof current !== "object") return undefined; current = (current as Record)[part]; } return current; } if (Array.isArray(value)) return value.map((item) => resolveDollarRefs(item, results)); if (value && typeof value === "object") { const out: Record = {}; for (const [key, item] of Object.entries(value)) out[key] = resolveDollarRefs(item, results); return out; } return value; } // Managed browser profiles live in a stable per-key directory instead of a fresh // random temp dir per launch. Chrome (not puppeteer) owns the directory's lifecycle: // puppeteer only deletes auto-generated temp profiles, so an explicit userDataDir is // never rm'd — eliminating the EBUSY crash from lingering chrome child processes — // and a leftover browser from a crashed session can be detected and reused. const BROWSER_PROFILE_BASE = process.env.PI_BROWSER_PROFILES_DIR ?? path.join(os.homedir(), ".pi", "browser-profiles"); function profileDirFor(key: string): string { return path.join(BROWSER_PROFILE_BASE, createHash("sha1").update(key).digest("hex").slice(0, 16)); } async function connectBrowser(options: BrowserOpenOptions, key: string): Promise<{ browser: Browser; owned: boolean; kind: "headless" | "headed" | "connected"; reused: boolean; profileDir?: string }> { // TODO(future): the optional browser-bridge extension (installed via // /install browser-bridge) exposes extension-level capabilities over WS. // BrowserBridgeServer is wired and the cdp/cookies.get/cdpBatch helpers in // createTabApi route to it when an entry has kind==="extension" — but this // function never produces such an entry yet (all open flows still go through // CDP attach/launch). Full open-channel activation (a Proxy-Page translating // the ~28 puppeteer Page methods to the extension protocol) is a follow-up; // see optional/BROWSER-BRIDGE-SETUP.md NOTES. Without it the extension // branch helpers are dormant, and browser behavior is unchanged. if (options.attachUserProfile) { if (!options.userProfileDir) throw new Error("attach_user_profile requires app.user_profile_dir pointing at a Chrome user-data-dir whose browser runs with --remote-debugging-port."); let port = await devToolsPortFor(options.userProfileDir); if (!port) { // No live debug port: start the user's own Chrome with remote debugging on // their profile (zero-setup attach). This mirrors GenericAgent's extension // convenience without a second WS control channel — pi still drives via CDP. // We never own this browser (owned=false) so it stays alive after close. const executablePath = await findBrowserExecutable(options.executablePath, options.cwd); if (!executablePath) throw new Error("No Chromium browser found to launch for attach_user_profile. Set app.path, PUPPETEER_EXECUTABLE_PATH, or CHROME_PATH, or start Chrome manually with --remote-debugging-port."); if (await staleProfileProcessesExist(options.userProfileDir)) await reclaimProfileProcesses(options.userProfileDir); port = await launchAttachedChrome(executablePath, options.userProfileDir, options); } const pending = puppeteer.connect({ browserURL: `http://127.0.0.1:${port}` }); const browser = await acquireResource(pending, options.signal, options.timeoutMs, (late) => late.disconnect()); return { browser, owned: false, kind: "connected", reused: false, profileDir: options.userProfileDir }; } if (options.cdpUrl) { const pending = puppeteer.connect({ browserURL: options.cdpUrl.replace(/\/$/, "") }); const browser = await acquireResource(pending, options.signal, options.timeoutMs, (late) => late.disconnect()); return { browser, owned: false, kind: "connected", reused: false }; } const executablePath = await findBrowserExecutable(options.executablePath, options.cwd); if (!executablePath) throw new Error("No Chromium browser found. Set app.path, app.cdp_url, PUPPETEER_EXECUTABLE_PATH, or CHROME_PATH."); const profileDir = profileDirFor(key); // Reuse a still-running browser from this or a previous session: every puppeteer // launch enables remote debugging, so a live instance leaves DevToolsActivePort. const reused = await tryReuseBrowser(profileDir, options); if (reused) return { ...reused, owned: true, reused: true, profileDir }; // A crash can orphan chrome children (e.g. the network service holding // first_party_sets.db) that keep the profile locked; reclaim them before relaunching. if (await staleProfileProcessesExist(profileDir)) await reclaimProfileProcesses(profileDir); const launched = await launchBrowser(executablePath, options, profileDir); return { ...launched, owned: true, reused: false, profileDir }; } async function tryReuseBrowser(profileDir: string, options: BrowserOpenOptions): Promise<{ browser: Browser; kind: "headless" | "headed" } | undefined> { const port = await devToolsPortFor(profileDir); if (!port) return undefined; const pending = puppeteer.connect({ browserURL: `http://127.0.0.1:${port}` }); try { const browser = await acquireResource(pending, options.signal, Math.min(2_000, options.timeoutMs), (late) => late.disconnect()); return { browser, kind: options.visible ? "headed" : "headless" }; } catch { return undefined; } } async function devToolsPortFor(profileDir: string): Promise { try { const contents = await fs.readFile(path.join(profileDir, "DevToolsActivePort"), "utf8"); const port = Number(contents.split(/\r?\n/, 1)[0]); return Number.isInteger(port) && port > 0 ? port : undefined; } catch { return undefined; } } // A live instance leaves a singleton lockfile (Windows: lockfile; POSIX: // SingletonLock) that only a clean shutdown removes. async function staleProfileProcessesExist(profileDir: string): Promise { try { await fs.access(profileDir); } catch { return false; } for (const name of process.platform === "win32" ? ["lockfile"] : ["SingletonLock"]) { try { await fs.access(path.join(profileDir, name)); return true; } catch {} } return false; } async function reclaimProfileProcesses(profileDir: string): Promise { const pids = await findProcessesUsingProfile(profileDir); for (const pid of pids) await killProcessTree(pid); if (pids.length > 0) await abortableDelay(800, undefined); // let file handles release } async function findProcessesUsingProfile(profileDir: string): Promise { try { if (process.platform === "win32") { const script = "Get-CimInstance Win32_Process -Filter \"Name='chrome.exe'\" | Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress"; const output = await runCapture("powershell", ["-NoProfile", "-NonInteractive", "-Command", script], 20_000); const rows = JSON.parse(output) as { ProcessId: number; CommandLine: string } | Array<{ ProcessId: number; CommandLine: string }> | null; const list = Array.isArray(rows) ? rows : rows ? [rows] : []; return list.filter((row) => Boolean(row.CommandLine) && commandLineMentionsProfile(row.CommandLine, profileDir)).map((row) => row.ProcessId); } const output = await runCapture("ps", ["-axo", "pid=,args="], 10_000); const pids: number[] = []; for (const line of output.split(/\r?\n/)) { const match = line.match(/^\s*(\d+)\s+(.*)$/); if (match && commandLineMentionsProfile(match[2], profileDir)) pids.push(Number(match[1])); } return pids; } catch { return []; } } function commandLineMentionsProfile(commandLine: string, profileDir: string): boolean { const lower = process.platform === "win32"; const normalize = (value: string) => { const cleaned = value.replace(/"/g, "").replace(/\\/g, "/").replace(/\/+$/, ""); return lower ? cleaned.toLowerCase() : cleaned; }; return normalize(commandLine).includes(`--user-data-dir=${normalize(profileDir)}`); } async function killProcessTree(pid: number): Promise { try { if (process.platform === "win32") await runCapture("taskkill", ["/PID", String(pid), "/T", "/F"], 15_000); else process.kill(pid, "SIGKILL"); } catch { /* best-effort; leftover processes are reclaimed on the next open */ } } function runCapture(command: string, args: string[], timeoutMs: number): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); const timer = setTimeout(() => { child.kill(); reject(new Error(`${command} timed out`)); }, timeoutMs); child.on("error", (error) => { clearTimeout(timer); reject(error); }); child.on("close", (code) => { clearTimeout(timer); if (code === 0) resolve(stdout); else reject(new Error(`${command} exited with code ${code}: ${stderr.slice(0, 200)}`)); }); }); } // Start the user's own Chrome with remote debugging on their profile, then poll // DevToolsActivePort until the endpoint is ready. The child is detached (unref) // so it survives pi's exit — the user keeps using their own browser. We do not // own it; a later attach call in the same session will reuse the live port via // devToolsPortFor + tryReuseBrowser path. async function launchAttachedChrome(executablePath: string, userProfileDir: string, options: BrowserOpenOptions): Promise { const port = 9222; const args = [ `--remote-debugging-port=${port}`, `--user-data-dir=${userProfileDir}`, "--no-first-run", "--no-default-browser-check", ...(options.visible ? [] : ["--headless=new"]), ...(options.args ?? []), ]; const child = spawn(executablePath, args, { windowsHide: !options.visible, detached: true, stdio: "ignore" }); child.unref(); child.on("error", () => { /* best-effort; port poll will fail and surface a clear error */ }); const deadline = Date.now() + Math.min(options.timeoutMs, 15_000); while (Date.now() < deadline) { if (options.signal?.aborted) throw abortError(); const ready = await devToolsPortFor(userProfileDir); if (ready) return ready; await new Promise((resolve) => setTimeout(resolve, 300)); } throw new Error(`Started ${executablePath} with --remote-debugging-port=${port} but no DevToolsActivePort appeared at ${userProfileDir} within ${Math.min(options.timeoutMs, 15_000) / 1000}s. The profile may be locked by another Chrome instance; close it and retry.`); } async function launchBrowser(executablePath: string, options: BrowserOpenOptions, profileDir: string): Promise<{ browser: Browser; kind: "headless" | "headed" }> { const launch = () => puppeteer.launch({ executablePath, headless: !options.visible, timeout: options.timeoutMs, userDataDir: profileDir, args: ["--no-first-run", "--no-default-browser-check", ...STEALTH_LAUNCH_ARGS, ...(options.args ?? [])], defaultViewport: options.viewport ? { width: options.viewport.width, height: options.viewport.height, deviceScaleFactor: options.viewport.scale } : undefined, }); const kind: "headless" | "headed" = options.visible ? "headed" : "headless"; let pending = launch(); try { const browser = await acquireResource(pending, options.signal, options.timeoutMs, async (late) => { await closeWithin(late, profileDir); }); return { browser, kind }; } catch (error) { // A zombie instance can survive the first reclaim (process still mid-exit); // reclaim again and retry once before failing. if (!(error instanceof Error) || !/already running|ProcessSingleton/i.test(error.message)) throw error; await reclaimProfileProcesses(profileDir); pending = launch(); const browser = await acquireResource(pending, options.signal, options.timeoutMs, async (late) => { await closeWithin(late, profileDir); }); return { browser, kind }; } } async function pickPage(browser: Browser, target?: string): Promise { const pages = await browser.pages(); if (target) { const needle = target.toLowerCase(); for (const page of pages) { if (page.url().toLowerCase().includes(needle) || (await page.title()).toLowerCase().includes(needle)) return page; } return undefined; } return pages.find((page) => page.url() !== "about:blank") ?? pages[0]; } function installRequestListenerScope(page: Page): RequestListenerScope { const mutablePage = page as Page & { on: Page["on"]; off: Page["off"] }; const originalOn = page.on; const originalOff = page.off; const callOn = originalOn.bind(page) as unknown as (type: PageEventType, handler: GenericPageHandler) => Page; const callOff = originalOff.bind(page) as unknown as (type: PageEventType, handler?: GenericPageHandler) => Page; const ownedHandlers: GenericPageHandler[] = []; let active = true; const scopedOn = (type: PageEventType, handler: GenericPageHandler): Page => { const result = callOn(type, handler); if (type === "request") ownedHandlers.push(handler); return result; }; const scopedOff = (type: PageEventType, handler?: GenericPageHandler): Page => { const result = callOff(type, handler); if (type === "request") { if (handler === undefined) { ownedHandlers.length = 0; } else { const index = ownedHandlers.lastIndexOf(handler); if (index >= 0) ownedHandlers.splice(index, 1); } } return result; }; mutablePage.on = scopedOn as unknown as Page["on"]; mutablePage.off = scopedOff as unknown as Page["off"]; return { cleanup() { if (!active) return; active = false; let firstError: unknown; try { for (let index = ownedHandlers.length - 1; index >= 0; index -= 1) { try { callOff("request", ownedHandlers[index]!); } catch (error) { firstError ??= error; } } } finally { ownedHandlers.length = 0; mutablePage.on = originalOn; mutablePage.off = originalOff; } if (firstError) throw firstError; }, }; } async function disablePageRequestInterception(page: Page): Promise { if (page.isClosed()) return; try { await page.setRequestInterception(false); } catch (error) { if (!page.isClosed()) throw error; } } async function disposeEntry(entry: TabEntry): Promise { try { if (entry.cdpSession && entry.cdpSession.connection() !== null) await entry.cdpSession.detach(); } catch {} try { if (entry.ownedPage && !entry.page.isClosed()) await completesWithin(entry.page.close(), 2_000); } catch {} await disposeBrowser(entry.browser, entry.owned, entry.profileDir); await Promise.allSettled([...entry.ownedTempFiles].map((file) => fs.rm(file, { force: true }))); entry.ownedTempFiles.clear(); } async function disposeBrowser(browser: Browser, owned: boolean, profileDir?: string): Promise { try { if (owned) await closeWithin(browser, profileDir); else browser.disconnect(); } catch { if (profileDir) await reclaimProfileProcesses(profileDir).catch(() => {}); } } // Graceful CDP close first; on hang, kill the whole process tree, then sweep any // remaining children by profile marker. Never throws: dispose runs on shutdown // paths where an unhandled rejection would take the whole process down. async function closeWithin(browser: Browser, profileDir?: string): Promise { try { const closed = await completesWithin(browser.close(), 2_000); if (closed) return; } catch {} try { const pid = browser.process()?.pid; if (pid) await killProcessTree(pid); } catch {} if (profileDir) await reclaimProfileProcesses(profileDir).catch(() => {}); } function browserKey(options: BrowserOpenOptions): string { if (options.attachUserProfile) return `attach:${options.userProfileDir ?? ""}`; if (options.cdpUrl) return `cdp:${options.cdpUrl.replace(/\/$/, "")}`; return `launched:${options.visible ? "headed" : "headless"}:${path.resolve(options.cwd, options.executablePath ?? "auto")}:${JSON.stringify(options.args ?? [])}`; } function browserOpenRequestKey(options: BrowserOpenOptions, browser: string): string { return JSON.stringify({ browser, url: options.url ?? "", target: options.target ?? "", viewport: options.viewport ?? null, waitUntil: options.waitUntil ?? "load", dialogs: options.dialogs ?? "accept", visible: options.attachUserProfile || options.cdpUrl ? null : (options.visible ?? false), }); } async function findBrowserExecutable(explicit: string | undefined, cwd: string): Promise { if (explicit) { const resolved = path.resolve(cwd, explicit); try { await fs.access(resolved); return resolved; } catch { throw new Error(`Browser executable does not exist: ${resolved}`); } } const candidates = [ process.env.PUPPETEER_EXECUTABLE_PATH, process.env.CHROME_PATH, process.platform === "win32" ? path.join(process.env.LOCALAPPDATA ?? "", "Google", "Chrome", "Application", "chrome.exe") : undefined, process.platform === "win32" ? path.join(process.env.PROGRAMFILES ?? "", "Google", "Chrome", "Application", "chrome.exe") : undefined, process.platform === "win32" ? path.join(process.env["PROGRAMFILES(X86)"] ?? "", "Microsoft", "Edge", "Application", "msedge.exe") : undefined, process.platform === "darwin" ? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" : undefined, "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser", ].filter((item): item is string => Boolean(item)); for (const candidate of candidates) { try { await fs.access(candidate); return candidate; } catch {} } const executableNames = process.platform === "win32" ? ["chrome.exe", "msedge.exe", "chromium.exe"] : ["google-chrome", "chromium", "chromium-browser"]; for (const directory of (process.env.PATH ?? "").split(path.delimiter)) { for (const name of executableNames) { const candidate = path.join(directory, name); try { await fs.access(candidate); return candidate; } catch {} } } return undefined; } function normalizeSelector(selector: string): string { if (selector.startsWith("p-text/")) return `text/${selector.slice(7)}`; if (selector.startsWith("p-xpath/")) return `xpath/${selector.slice(8)}`; if (selector.startsWith("p-pierce/")) return `pierce/${selector.slice(9)}`; if (selector.startsWith("p-aria/")) return `aria/${selector.slice(7)}`; if (/^p-[^/]+\//.test(selector)) throw new Error(`Unsupported selector engine: ${selector.split("/")[0]}`); return selector; } async function raceAbort(promise: Promise, signal: AbortSignal | undefined, timeoutMs: number): Promise { if (signal?.aborted) throw abortError(); let timeout: NodeJS.Timeout | undefined; let onAbort: (() => void) | undefined; const interrupt = new Promise((_resolve, reject) => { timeout = setTimeout(() => reject(new Error(`Browser operation timed out after ${timeoutMs}ms.`)), timeoutMs); onAbort = () => reject(abortError()); signal?.addEventListener("abort", onAbort, { once: true }); }); try { return await Promise.race([promise, interrupt]); } finally { if (timeout) clearTimeout(timeout); if (onAbort) signal?.removeEventListener("abort", onAbort); } } async function acquireResource(promise: Promise, signal: AbortSignal | undefined, timeoutMs: number, cleanup: (resource: T) => void | Promise): Promise { try { return await raceAbort(promise, signal, timeoutMs); } catch (error) { void promise.then(cleanup, () => {}); throw error; } } async function completesWithin(promise: Promise, timeoutMs: number): Promise { let timer: NodeJS.Timeout; try { return await Promise.race([promise.then(() => true), new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); })]); } finally { clearTimeout(timer!); } } function abortableDelay(ms: number, signal?: AbortSignal): Promise { return raceAbort(new Promise((resolve) => setTimeout(resolve, ms)), signal, Math.max(ms + 1_000, 1_000)); } function throwIfAborted(signal?: AbortSignal): void { if (signal?.aborted) throw abortError(); } function combineSignals(...signals: Array): { signal: AbortSignal; dispose(): void } { const controller = new AbortController(); const abort = () => controller.abort(); for (const signal of signals) { if (signal?.aborted) controller.abort(); else signal?.addEventListener("abort", abort, { once: true }); } return { signal: controller.signal, dispose() { for (const signal of signals) signal?.removeEventListener("abort", abort); }, }; } function abortError(): Error { const error = new Error("Browser operation aborted."); error.name = "AbortError"; return error; } function formatDisplay(value: unknown): string { if (typeof value === "string") return value; const serialized = JSON.stringify(value, null, 2); return serialized ?? String(value); } function isInterruptError(error: unknown): boolean { return error instanceof Error && (error.name === "AbortError" || /timed out/i.test(error.message)); } const RUN_HELPER_NAMES = ["page", "browser", "tab", "assert", "wait", "display", "print", "signal", "console"] as const; const RUN_PARAM_NAMES = RUN_HELPER_NAMES.map((name) => `__pi_${name}`); // Augments common user run-code errors with actionable browser-context guidance so // the agent can self-correct instead of repeatedly failing: the top cause of // `X is not defined` is referencing a Node-side variable inside a // page.evaluate/tab.evaluate callback (which runs in the page context), and // tab.click()/type()/fill() return void, so `const x = await tab.click(...)` // yields undefined rather than a boolean. Unmatched errors pass through untouched. export function browserRunErrorHint(error: unknown): unknown { if (!(error instanceof Error)) return error; if (error.name === "ReferenceError") { const match = error.message.match(/^([A-Za-z_$][\w$]*) is not defined/); if (match) { error.message += `\nBrowser run hint: \`${match[1]}\` is referenced where it is not defined. ` + `Code inside page.evaluate()/tab.evaluate() callbacks runs in the browser page context, ` + `so Node-side variables declared in this run are invisible there. ` + `Pass values explicitly: await tab.evaluate((v) => …, v), or compute the value inside the callback. ` + `Note: tab.click()/tab.type()/tab.fill() return undefined; test element existence with tab.observe() or tab.waitFor().`; return error; } } if (error instanceof SyntaxError) { error.message += "\nBrowser run hint: the run code failed to parse. Fix the syntax error above and retry."; } return error; } // Wraps user run code so the injected helpers (page, browser, tab, ...) never // collide with a top-level const/let/class/function the user declares. Helpers // are bound to collision-resistant parameters and re-exposed as var aliases in // an outer scope; user code runs in an async IIFE, so its top-level bindings // live in their own scope (shadowing a helper if reused) while top-level // return/await keep working. export function compileRunCode(code: string): (...values: unknown[]) => Promise { const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as new (...args: string[]) => (...values: unknown[]) => Promise; const aliases = RUN_HELPER_NAMES.map((name, index) => `${name} = ${RUN_PARAM_NAMES[index]}`).join(", "); const body = `"use strict";\nvar ${aliases};\nreturn await (async () => {\n${code}\n})();`; return new AsyncFunction(...RUN_PARAM_NAMES, body); } export const browserManager = new BrowserManager();