Files
oh-my-pi/packages/coding-agent/src/tools/browser/tab-worker.ts
T
can1357 e7009452b4 feat(coding-agent/tools): simplified browser screenshot persistence and return paths
- Remove the per-call `save` option from `tab.screenshot()` to simplify usage.
- Update `tab.screenshot()` to return the saved file path as a promise string.
- Configure screenshot persistence to use daemon path or custom `browser.screenshotDir`.
- Add comprehensive tests verifying temp path return and custom directory saving.
2026-07-28 06:40:31 +02:00

1843 lines
67 KiB
TypeScript

import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { postmortem, Snowflake, untilAborted, withTimeout } from "@oh-my-pi/pi-utils";
import type { HTMLElement } from "linkedom";
import type {
Browser,
CDPSession,
Dialog,
ElementHandle,
ElementScreenshotOptions,
HTTPResponse,
KeyInput,
Page,
SerializedAXNode,
Target,
} from "puppeteer-core";
import { JsRuntime, type RuntimeHooks } from "../../eval/js/shared/runtime";
import { resizeImage } from "../../utils/image-resize";
import { resolveToCwd } from "../path-utils";
import { formatScreenshot } from "../render-utils";
import { ToolAbortError, ToolError, throwIfAborted } from "../tool-errors";
import {
type AriaSnapshotOptions,
assertSelectorString,
captureAriaSnapshot,
parseAriaRefSelector,
resolveAriaRefHandle,
} from "./aria/aria-snapshot";
import {
applyStealthPatches,
applyViewport,
BROWSER_PROTOCOL_TIMEOUT_MS,
DEFAULT_VIEWPORT,
loadPuppeteerInWorker,
} from "./launch";
import { extractReadableFromHtml, type ReadableFormat } from "./readable";
import {
bindBrowserRunFacade,
CELL_BUDGET_SLACK_MS,
markHandled,
resolvePredicateTimeout,
type WaitPredicateOptions,
waitForBrowserRun,
} from "./run-cancellation";
import { cloneSafe, RunOutput } from "./run-output";
import type {
Observation,
ObservationEntry,
ReadyInfo,
RunErrorPayload,
ScreenshotResult,
SessionSnapshot,
ToolReply,
Transport,
WorkerInbound,
WorkerInitPayload,
} from "./tab-protocol";
declare module "puppeteer-core" {
interface Frame {
/** Puppeteer's main JavaScript realm, retained by our pinned runtime patch. */
mainRealm(): Realm;
}
}
declare global {
interface Element extends HTMLElement {}
function getComputedStyle(element: Element): Record<string, unknown>;
var innerWidth: number;
var innerHeight: number;
var document: {
elementFromPoint(x: number, y: number): Element | null;
};
}
const INTERACTIVE_AX_ROLES = new Set([
"button",
"link",
"textbox",
"combobox",
"listbox",
"option",
"checkbox",
"radio",
"switch",
"tab",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"slider",
"spinbutton",
"searchbox",
"treeitem",
]);
const LEGACY_SELECTOR_PREFIXES = ["p-aria/", "p-text/", "p-xpath/", "p-pierce/"] as const;
const SELECTOR_HANDLER_PREFIXES = [
"aria/",
"text/",
"xpath/",
"pierce/",
"aria-ref=",
"aria-ref/",
"ariaref/",
"p-",
] as const;
/**
* Playwright-only selector engines/pseudos puppeteer cannot parse. Without this guard a
* `tab.click(":has-text(...)")` would wait the full action timeout and fail opaquely;
* fail fast instead with a pointer to the puppeteer-native alternative. Skipped for
* explicit query-handler prefixes (`text/`, `aria/`, …) whose payload is literal text.
*/
const PLAYWRIGHT_ONLY_SELECTOR_RE =
/:has-text\(|:text\(|:text-is\(|:text-matches\(|:visible\b|:hidden\b|:nth-match\(|:near\(|:above\(|:below\(|:right-of\(|:left-of\(/;
type DialogPolicy = "accept" | "dismiss";
type DragTarget = string | { readonly x: number; readonly y: number };
type ActionabilityResult = { ok: true; x: number; y: number } | { ok: false; reason: string };
/** Last JS dialog seen on the page; kept for timeout attribution until handled or navigation. */
interface OpenDialogInfo {
type: string;
message: string;
}
/**
* Per-op fail-fast ceilings for `tab.*` helpers. All are kept strictly under the cell
* budget (`timeoutMs - OP_DEADLINE_SLACK_MS`) so a stalled helper rejects with a named,
* attributable error that leaves recovery budget — never the opaque whole-cell
* "Browser code execution timed out" path that consumed the entire run.
*
* - `QUICK_OP_TIMEOUT_MS`: page-coupled reads that should resolve fast (`observe`,
* `screenshot`, `extract`, `ariaSnapshot`).
* - `ACTION_OP_TIMEOUT_MS`: interactive point actions (`click`, `fill`, `type`, …) and
* the default for wait helpers when no explicit `{ timeout }` is given. Selector ops
* additionally fail fast after `ZERO_MATCH_FAIL_FAST_MS` of confirmed zero matches
* (see `#zeroMatchWatchdog`), so the full ceiling is only spent on elements that
* exist but are not yet actionable.
*
* `goto` and `evaluate` stay uncapped (`Number.POSITIVE_INFINITY`): navigation and user
* code legitimately use the full cell budget.
*/
const QUICK_OP_TIMEOUT_MS = 20_000;
const ACTION_OP_TIMEOUT_MS = 8_000;
/** Maximum wait for a renderer acknowledgement after a wheel event is queued. */
const SCROLL_ACK_TIMEOUT_MS = 2_000;
/** Headroom subtracted from the cell budget so a per-op deadline fires before it. */
const OP_DEADLINE_SLACK_MS = CELL_BUDGET_SLACK_MS;
/**
* A selector op whose selector has matched nothing for this long fails fast with the
* zero-match hint instead of burning the rest of its deadline: a wrong selector or a
* wrong page (consent wall, pre-navigation document) is the common agent failure and
* should cost ~2s, not the full action ceiling. Explicit `{ timeout }` waits opt out.
*/
const ZERO_MATCH_FAIL_FAST_MS = 2_000;
/** Poll cadence for the zero-match watchdog. */
const ZERO_MATCH_POLL_MS = 250;
/** Cleanup must settle inside the supervisor's 750ms post-run grace window. */
const REQUEST_INTERCEPTION_CLEANUP_TIMEOUT_MS = 500;
export interface OpTimeouts {
/** Largest per-op deadline allowed — strictly below the cell budget. */
budgetBound: number;
/** Ceiling for quick page reads. */
quickOpMs: number;
/** Ceiling for interactive actions + default for waits. */
actionOpMs: number;
}
/** Resolve the per-op fail-fast ceilings for a given cell budget. */
export function resolveOpTimeouts(cellTimeoutMs: number): OpTimeouts {
const budgetBound = Math.max(1, cellTimeoutMs - OP_DEADLINE_SLACK_MS);
return {
budgetBound,
quickOpMs: Math.min(budgetBound, QUICK_OP_TIMEOUT_MS),
actionOpMs: Math.min(budgetBound, ACTION_OP_TIMEOUT_MS),
};
}
/** Queue a wheel event without treating a delayed renderer acknowledgement as dispatch failure. */
export async function dispatchScroll(
dispatch: () => Promise<void>,
ackTimeoutMs = SCROLL_ACK_TIMEOUT_MS,
): Promise<void> {
const deadline = Promise.withResolvers<void>();
const timer = setTimeout(() => deadline.resolve(), ackTimeoutMs);
timer.unref();
try {
await Promise.race([dispatch(), deadline.promise]);
} finally {
clearTimeout(timer);
}
}
/**
* Effective timeout for a wait helper (`waitFor*`). A positive explicit `{ timeout }` is
* honored but clamped to the cell budget so it still fails fast + named; raising the tool
* `timeout` raises that cap, so a longer budget stays meaningful. No `{ timeout }` → the
* action ceiling. Puppeteer's `{ timeout: 0 }` / `Infinity` ("disable") maps to the largest
* bounded wait (`budgetBound`) — the harness never permits an unbounded wait. Garbage input
* (negative, `NaN`) falls back to the action ceiling rather than the longest wait.
*/
export function resolveWaitTimeout(cellTimeoutMs: number, explicit?: number): number {
const { budgetBound, actionOpMs } = resolveOpTimeouts(cellTimeoutMs);
if (explicit === undefined) return actionOpMs;
// Puppeteer "disable" sentinels — still bounded by the budget here.
if (explicit === 0 || explicit === Number.POSITIVE_INFINITY) return budgetBound;
// Positive finite → honored + clamped. Negative/NaN garbage → default, not the longest wait.
if (Number.isFinite(explicit) && explicit > 0) return Math.min(explicit, budgetBound);
return actionOpMs;
}
interface ScreenshotOptions {
selector?: string;
fullPage?: boolean;
silent?: boolean;
}
interface TabApi {
readonly name: string;
readonly page: Page;
readonly signal?: AbortSignal;
url(): string;
title(): Promise<string>;
goto(
url: string,
opts?: { waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2" },
): Promise<void>;
observe(opts?: { includeAll?: boolean; viewportOnly?: boolean }): Promise<Observation>;
ariaSnapshot(selector?: string, opts?: AriaSnapshotOptions): Promise<string>;
screenshot(opts?: ScreenshotOptions): Promise<string>;
extract(format?: ReadableFormat): Promise<string>;
click(selector: string): Promise<void>;
type(selector: string, text: string): Promise<void>;
fill(selector: string, value: string): Promise<void>;
press(key: KeyInput, opts?: { selector?: string }): Promise<void>;
scroll(deltaX: number, deltaY: number): Promise<void>;
drag(from: DragTarget, to: DragTarget): Promise<void>;
waitFor(selector: string, opts?: { timeout?: number }): Promise<ActionableHandle>;
evaluate<TResult, TArgs extends unknown[]>(
fn: string | ((...args: TArgs) => TResult | Promise<TResult>),
...args: TArgs
): Promise<TResult>;
scrollIntoView(selector: string): Promise<void>;
select(selector: string, ...values: string[]): Promise<string[]>;
uploadFile(selector: string, ...filePaths: string[]): Promise<void>;
waitForUrl(pattern: string | RegExp, opts?: { timeout?: number }): Promise<string>;
waitForResponse(
pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise<boolean>),
opts?: { timeout?: number },
): Promise<HTTPResponse>;
waitForSelector(
selector: string,
opts?: { timeout?: number; visible?: boolean; hidden?: boolean },
): Promise<ActionableHandle | null>;
waitForNavigation(opts?: {
waitUntil?: "load" | "domcontentloaded" | "networkidle0" | "networkidle2";
timeout?: number;
}): Promise<HTTPResponse | null>;
id(n: number): Promise<ActionableHandle>;
ref(id: string): Promise<ActionableHandle>;
}
export function normalizeSelector(selector: string): string {
assertSelectorString(selector);
if (!selector) return selector;
if (
!SELECTOR_HANDLER_PREFIXES.some(prefix => selector.startsWith(prefix)) &&
PLAYWRIGHT_ONLY_SELECTOR_RE.test(selector)
) {
throw new ToolError(
`Playwright-only selector ${JSON.stringify(selector)} is not supported by the browser tool. ` +
`Use a puppeteer text selector ("text/Allow all"), an aria selector ("aria/Name"), CSS, or "xpath/...".`,
);
}
if (selector.startsWith("p-") && !LEGACY_SELECTOR_PREFIXES.some(prefix => selector.startsWith(prefix))) {
throw new ToolError(
`Unsupported selector prefix. Use CSS or puppeteer query handlers (aria/, text/, xpath/, pierce/). Got: ${selector}`,
);
}
if (selector.startsWith("p-text/")) return `text/${selector.slice("p-text/".length)}`;
if (selector.startsWith("p-xpath/")) return `xpath/${selector.slice("p-xpath/".length)}`;
if (selector.startsWith("p-pierce/")) return `pierce/${selector.slice("p-pierce/".length)}`;
if (selector.startsWith("p-aria/")) {
const rest = selector.slice("p-aria/".length);
const nameMatch = rest.match(/\[\s*name\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\]]+))\s*\]/);
const name = nameMatch?.[1] ?? nameMatch?.[2] ?? nameMatch?.[3];
if (name) return `aria/${name.trim()}`;
return `aria/${rest}`;
}
return selector;
}
function isInteractiveNode(node: SerializedAXNode): boolean {
if (INTERACTIVE_AX_ROLES.has(node.role)) return true;
return (
node.checked !== undefined ||
node.pressed !== undefined ||
node.selected !== undefined ||
node.expanded !== undefined ||
node.focused === true
);
}
function asElementHandle(handle: unknown): ElementHandle | null {
return handle ? (handle as ElementHandle) : null;
}
/** ElementHandle enriched with the `fill()` the tool docs promise on handles from `tab.id()`/`tab.ref()`/`tab.waitFor()`. */
export type ActionableHandle = ElementHandle & { fill(value: string): Promise<void> };
/**
* Attach `fill()` to a puppeteer ElementHandle before handing it to user code.
* Puppeteer handles expose `type()` but no `fill()`; the semantics mirror the
* selector-based `tab.fill()`: focus, clear any existing value, then type.
*/
export function toActionableHandle(handle: ElementHandle): ActionableHandle {
const enriched = handle as ActionableHandle;
enriched.fill = value => fillViaHandle(enriched, value);
return enriched;
}
/** Focus, clear any existing value, then retype — shared by `tab.fill(aria-ref)` and enriched handles. */
async function fillViaHandle(handle: ElementHandle, value: string, signal?: AbortSignal): Promise<void> {
await untilAborted(signal, () =>
handle.evaluate(el => {
const node = el as unknown as { value?: string; focus?: () => void };
node.focus?.();
if ("value" in node) node.value = "";
}),
);
await untilAborted(signal, () => handle.type(value, { delay: 0 }));
}
/**
* Strip `user:pass@` from a URL before surfacing it in tool outputs / details
* so Basic Auth credentials don't leak into transcripts. Returns the original
* string verbatim when it doesn't parse as a URL or when there are no
* credentials to redact.
*/
function redactUrlCredentials(url: string): string {
if (!url || (!url.includes("@") && !url.includes("//"))) return url;
try {
const parsed = new URL(url);
if (!parsed.username && !parsed.password) return url;
parsed.username = "";
parsed.password = "";
return parsed.toString();
} catch {
return url;
}
}
class RequestInterceptionCleanupError extends ToolError {}
interface RunPageScope {
page: Page;
cleanup(): Promise<void>;
}
/**
* Expose the tab page while retaining the request handlers created by this run.
* Puppeteer's Page wraps an internal emitter, so `removeAllListeners("request")`
* would also remove its forwarding listener; the facade removes only user handlers.
*/
function createRunPageScope(page: Page): RunPageScope {
const requestHandlers: unknown[] = [];
const on = page.on;
const off = page.off;
const once = page.once;
const removeAllListeners = page.removeAllListeners;
const onDescriptor = Object.getOwnPropertyDescriptor(page, "on");
const offDescriptor = Object.getOwnPropertyDescriptor(page, "off");
const onceDescriptor = Object.getOwnPropertyDescriptor(page, "once");
const removeAllDescriptor = Object.getOwnPropertyDescriptor(page, "removeAllListeners");
Object.defineProperties(page, {
on: {
configurable: true,
value: (type: unknown, handler: unknown): Page => {
Reflect.apply(on, page, [type, handler]);
if (type === "request") requestHandlers.push(handler);
return page;
},
},
once: {
configurable: true,
value: (type: unknown, handler: unknown): Page => {
if (type !== "request" || typeof handler !== "function") {
Reflect.apply(once, page, [type, handler]);
return page;
}
const wrapper = (event: unknown): void => {
const index = requestHandlers.lastIndexOf(wrapper);
if (index >= 0) requestHandlers.splice(index, 1);
Reflect.apply(off, page, ["request", wrapper]);
Reflect.apply(handler, page, [event]);
};
requestHandlers.push(wrapper);
Reflect.apply(on, page, [type, wrapper]);
return page;
},
},
off: {
configurable: true,
value: (type: unknown, handler?: unknown): Page => {
Reflect.apply(off, page, [type, handler]);
if (type === "request") {
if (handler === undefined) requestHandlers.length = 0;
else {
const index = requestHandlers.lastIndexOf(handler);
if (index >= 0) requestHandlers.splice(index, 1);
}
}
return page;
},
},
removeAllListeners: {
configurable: true,
value: (type?: unknown): Page => {
Reflect.apply(removeAllListeners, page, [type]);
if (type === undefined || type === "request") requestHandlers.length = 0;
return page;
},
},
});
return {
page,
async cleanup() {
if (onDescriptor) Object.defineProperty(page, "on", onDescriptor);
else Reflect.deleteProperty(page, "on");
if (offDescriptor) Object.defineProperty(page, "off", offDescriptor);
else Reflect.deleteProperty(page, "off");
if (onceDescriptor) Object.defineProperty(page, "once", onceDescriptor);
else Reflect.deleteProperty(page, "once");
if (removeAllDescriptor) Object.defineProperty(page, "removeAllListeners", removeAllDescriptor);
else Reflect.deleteProperty(page, "removeAllListeners");
for (const handler of requestHandlers) Reflect.apply(off, page, ["request", handler]);
requestHandlers.length = 0;
try {
await withTimeout(
page.setRequestInterception(false),
REQUEST_INTERCEPTION_CLEANUP_TIMEOUT_MS,
"Timed out clearing browser request interception",
);
} catch (error) {
throw new RequestInterceptionCleanupError(
"Failed to clear browser request interception after browser.run",
{
error: error instanceof Error ? error.message : String(error),
},
);
}
},
};
}
function errorPayload(error: unknown): RunErrorPayload {
const recoverTab = error instanceof RequestInterceptionCleanupError || undefined;
if (error instanceof ToolAbortError) {
return { name: error.name, message: error.message, stack: error.stack, isToolError: false, isAbort: true };
}
if (error instanceof ToolError) {
return {
name: error.name,
message: error.message,
stack: error.stack,
isToolError: true,
isAbort: false,
recoverTab,
};
}
if (error instanceof Error) {
return { name: error.name, message: error.message, stack: error.stack, isToolError: false, isAbort: false };
}
return { name: "Error", message: String(error), isToolError: false, isAbort: false };
}
function replyError(payload: RunErrorPayload): Error {
if (payload.isAbort) {
const err = new ToolAbortError(payload.message || "Tool call aborted");
if (payload.stack) err.stack = payload.stack;
return err;
}
const Ctor = payload.isToolError ? ToolError : Error;
const err = new Ctor(payload.message);
if (payload.name) err.name = payload.name;
if (payload.stack) err.stack = payload.stack;
return err;
}
async function targetIdForTarget(target: Target): Promise<string> {
const raw = target as unknown as { _targetId?: unknown };
if (typeof raw._targetId === "string") return raw._targetId;
const session = await target.createCDPSession();
try {
const info = (await session.send("Target.getTargetInfo")) as { targetInfo?: { targetId?: string } };
if (info.targetInfo?.targetId) return info.targetInfo.targetId;
throw new ToolError("Target id unavailable from CDP target info");
} finally {
await session.detach().catch(() => undefined);
}
}
async function targetIdForPage(page: Page): Promise<string> {
return await targetIdForTarget(page.target());
}
async function collectObservationEntries(
core: WorkerCore,
node: SerializedAXNode,
entries: ObservationEntry[],
options: { viewportOnly: boolean; includeAll: boolean },
): Promise<void> {
if (options.includeAll || isInteractiveNode(node)) {
const handle = await node.elementHandle();
if (handle) {
let inViewport = true;
if (options.viewportOnly) {
try {
inViewport = await handle.isIntersectingViewport();
} catch {
inViewport = false;
}
}
if (inViewport) {
const id = core.nextElementId();
const states: string[] = [];
if (node.disabled) states.push("disabled");
if (node.checked !== undefined) states.push(`checked=${String(node.checked)}`);
if (node.pressed !== undefined) states.push(`pressed=${String(node.pressed)}`);
if (node.selected !== undefined) states.push(`selected=${String(node.selected)}`);
if (node.expanded !== undefined) states.push(`expanded=${String(node.expanded)}`);
if (node.required) states.push("required");
if (node.readonly) states.push("readonly");
if (node.multiselectable) states.push("multiselectable");
if (node.multiline) states.push("multiline");
if (node.modal) states.push("modal");
if (node.focused) states.push("focused");
core.cacheElement(id, handle as ElementHandle);
entries.push({
id,
role: node.role,
name: node.name,
value: node.value,
description: node.description,
keyshortcuts: node.keyshortcuts,
states,
});
} else {
await handle.dispose();
}
}
}
for (const child of node.children ?? []) {
await collectObservationEntries(core, child, entries, options);
}
}
async function resolveActionableQueryHandlerClickTarget(handles: ElementHandle[]): Promise<ElementHandle | null> {
const candidates: Array<{
handle: ElementHandle;
rect: { x: number; y: number; w: number; h: number };
ownedProxy?: ElementHandle;
}> = [];
for (const handle of handles) {
let clickable: ElementHandle = handle;
let clickableProxy: ElementHandle | null = null;
try {
const proxy = await handle.evaluateHandle(el => {
const target =
(el as Element).closest(
'a,button,[role="button"],[role="link"],input[type="button"],input[type="submit"]',
) ?? el;
return target;
});
clickableProxy = asElementHandle(proxy.asElement());
if (clickableProxy) clickable = clickableProxy;
} catch {}
try {
const intersecting = await clickable.isIntersectingViewport();
if (!intersecting) continue;
const rect = (await clickable.evaluate(el => {
const r = (el as Element).getBoundingClientRect();
return { x: r.left, y: r.top, w: r.width, h: r.height };
})) as { x: number; y: number; w: number; h: number };
if (rect.w < 1 || rect.h < 1) continue;
candidates.push({ handle: clickable, rect, ownedProxy: clickableProxy ?? undefined });
} catch {
} finally {
if (clickableProxy && clickableProxy !== handle && clickable !== clickableProxy) {
await clickableProxy.dispose().catch(() => undefined);
}
}
}
if (!candidates.length) return null;
candidates.sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x);
const winner = candidates[0]?.handle ?? null;
for (let i = 1; i < candidates.length; i++) {
const candidate = candidates[i]!;
if (candidate.ownedProxy) await candidate.ownedProxy.dispose().catch(() => undefined);
}
return winner;
}
async function isClickActionable(handle: ElementHandle): Promise<ActionabilityResult> {
return (await handle.evaluate(el => {
const element = el as HTMLElement;
const style = globalThis.getComputedStyle(element);
if (style.display === "none") return { ok: false as const, reason: "display:none" };
if (style.visibility === "hidden") return { ok: false as const, reason: "visibility:hidden" };
if (style.pointerEvents === "none") return { ok: false as const, reason: "pointer-events:none" };
if (Number(style.opacity) === 0) return { ok: false as const, reason: "opacity:0" };
const r = element.getBoundingClientRect();
if (r.width < 1 || r.height < 1) return { ok: false as const, reason: "zero-size" };
const left = Math.max(0, Math.min(globalThis.innerWidth, r.left));
const right = Math.max(0, Math.min(globalThis.innerWidth, r.right));
const top = Math.max(0, Math.min(globalThis.innerHeight, r.top));
const bottom = Math.max(0, Math.min(globalThis.innerHeight, r.bottom));
if (right - left < 1 || bottom - top < 1) return { ok: false as const, reason: "off-viewport" };
const x = Math.floor((left + right) / 2);
const y = Math.floor((top + bottom) / 2);
const topEl = globalThis.document.elementFromPoint(x, y);
if (!topEl) return { ok: false as const, reason: "elementFromPoint-null" };
if (topEl === element || element.contains(topEl) || (topEl as Element).contains(element))
return { ok: true as const, x, y };
return { ok: false as const, reason: "obscured" };
})) as ActionabilityResult;
}
async function clickQueryHandlerText(
page: Page,
selector: string,
timeoutMs: number,
signal?: AbortSignal,
): Promise<void> {
const timeoutSignal = AbortSignal.timeout(timeoutMs);
const clickSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
const start = Date.now();
let lastSeen = 0;
let lastReason: string | null = null;
while (Date.now() - start < timeoutMs) {
throwIfAborted(clickSignal);
const handles = (await untilAborted(clickSignal, () => page.$$(selector))) as ElementHandle[];
try {
lastSeen = handles.length;
const target = await resolveActionableQueryHandlerClickTarget(handles);
if (!target) {
lastReason = handles.length ? "no-visible-candidate" : "no-matches";
await untilAborted(clickSignal, () => Bun.sleep(100));
continue;
}
const actionability = await isClickActionable(target);
if (!actionability.ok) {
lastReason = actionability.reason;
await untilAborted(clickSignal, () => Bun.sleep(100));
continue;
}
try {
await untilAborted(clickSignal, () => target.click());
return;
} catch (err) {
lastReason = err instanceof Error ? err.message : String(err);
await untilAborted(clickSignal, () => Bun.sleep(100));
}
} finally {
await Promise.all(handles.map(async handle => handle.dispose().catch(() => undefined)));
}
}
throw new ToolError(
`Timed out clicking ${selector} (seen ${lastSeen} matches; last reason: ${lastReason ?? "unknown"}). ` +
"If there are multiple matching elements, use observe + tab.id() or a more specific selector.",
);
}
/**
* Hint appended to a selector op's fail-fast timeout, given the selector's current
* match count: a missing element (consent wall, wrong page) reads differently from
* a present-but-unactionable one.
*/
export function formatSelectorMatchHint(count: number): string {
return count === 0
? "; selector currently matches no elements — run tab.observe() or tab.ariaSnapshot() to inspect the page"
: `; selector currently matches ${count} element(s) but the action never became possible — the element may be hidden or covered (try tab.scrollIntoView() or a more specific selector)`;
}
export interface InflightOp {
label: string;
startedAt: number;
}
interface ActiveRun {
id: string;
ac: AbortController;
signal: AbortSignal;
output: RunOutput;
screenshots: ScreenshotResult[];
pendingTools: Map<string, { resolve(value: unknown): void; reject(error: Error): void }>;
/** Helper invocations currently awaiting the page/network, keyed by op id. */
inflight: Map<number, InflightOp>;
opCounter: number;
}
/** Human-readable label for a screenshot op, used in op tracking + timeout errors. */
export function describeScreenshot(opts?: ScreenshotOptions): string {
if (opts?.selector) return `tab.screenshot({ selector: ${JSON.stringify(opts.selector)} })`;
if (opts?.fullPage) return "tab.screenshot({ fullPage: true })";
return "tab.screenshot()";
}
/** Summarize still-running helpers (oldest first) so a cell timeout names what stalled. */
export function describeInflight(inflight: Map<number, InflightOp>): string {
const now = Date.now();
return [...inflight.values()]
.sort((a, b) => a.startedAt - b.startedAt)
.map(op => `${op.label} (${((now - op.startedAt) / 1000).toFixed(1)}s)`)
.join(", ");
}
export class WorkerCore {
#transport: Transport;
#browser?: Browser;
#page?: Page;
#targetId?: string;
#elementCache = new Map<number, ElementHandle>();
#elementCounter = 0;
#active: ActiveRun | null = null;
#runtime: JsRuntime | null = null;
#unsub: () => void;
#mode?: WorkerInitPayload["mode"];
#dialogPolicy?: DialogPolicy;
#dialogHandler?: (dialog: Dialog) => void;
#openDialog?: OpenDialogInfo;
constructor(transport: Transport) {
this.#transport = transport;
this.#unsub = this.#transport.onMessage(msg => {
void this.#handleMessage(msg as WorkerInbound);
});
}
nextElementId(): number {
this.#elementCounter += 1;
return this.#elementCounter;
}
cacheElement(id: number, handle: ElementHandle): void {
this.#elementCache.set(id, handle);
}
async #handleMessage(msg: WorkerInbound): Promise<void> {
switch (msg.type) {
case "init":
await this.#init(msg.payload);
return;
case "run":
await this.#run(msg);
return;
case "abort":
if (this.#active?.id === msg.id) {
const reason = msg.expectedCleanup
? postmortem.markExpectedCleanupError(new ToolAbortError())
: new ToolAbortError();
this.#active.ac.abort(reason);
}
return;
case "tool-reply":
this.#deliverToolReply(msg.id, msg.reply);
return;
case "close":
await this.#close();
return;
}
}
async #init(payload: WorkerInitPayload): Promise<void> {
try {
this.#mode = payload.mode;
const puppeteer = await loadPuppeteerInWorker(payload.safeDir);
this.#browser = await puppeteer.connect({
browserWSEndpoint: payload.browserWSEndpoint,
defaultViewport: null,
protocolTimeout: BROWSER_PROTOCOL_TIMEOUT_MS,
});
if (payload.mode === "headless") {
this.#page = await this.#browser.newPage();
this.#observeDialogs();
await applyStealthPatches(this.#browser, this.#page, { browserSession: null, override: null });
await applyViewport(this.#page, payload.viewport);
if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs);
if (payload.url) {
await this.#page.goto(payload.url, {
// Default to "load" because dev servers with HMR/WS never reach networkidle.
waitUntil: payload.waitUntil ?? "load",
timeout: payload.timeoutMs,
});
}
} else {
const target = await this.#findAttachedTarget(payload.targetId);
// Post-timeout recycle: unblock the target BEFORE adopting the page — an open
// modal dialog or hung navigation can stall `target.page()` / ready info, and a
// stalled init used to time out and force-kill the tab.
if (payload.recover) await this.#recoverAttachedTarget(target);
const page = await target.page();
if (!page) throw new ToolError(`Target ${payload.targetId} is no longer available on the attached browser`);
this.#page = page;
this.#observeDialogs();
if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs);
}
this.#targetId = await targetIdForPage(this.#page);
this.#transport.send({ type: "ready", info: await this.#currentReadyInfo() });
} catch (error) {
this.#transport.send({ type: "init-failed", error: errorPayload(error) });
}
}
async #findAttachedTarget(targetId: string): Promise<Target> {
if (!this.#browser) throw new ToolError("Browser is not connected");
for (const target of this.#browser.targets()) {
if ((await targetIdForTarget(target).catch(() => "")) !== targetId) continue;
return target;
}
throw new ToolError(`Target ${targetId} is no longer available on the attached browser`);
}
/**
* Best-effort unblocking of a wedged target during post-timeout recovery: dismiss any
* open JS dialog and stop a pending navigation over a raw CDP session (created on the
* target, not the page, so it works while the page itself is unresponsive). Every step
* tolerates "nothing to do".
*/
async #recoverAttachedTarget(target: Target): Promise<void> {
let session: CDPSession | undefined;
try {
session = await target.createCDPSession();
await session.send("Page.enable").catch(() => undefined);
await session.send("Page.handleJavaScriptDialog", { accept: false }).catch(() => undefined);
await session.send("Page.stopLoading").catch(() => undefined);
await session.send("Fetch.disable").catch(() => undefined);
} catch (error) {
this.#log("debug", "Recovery CDP session failed; proceeding with attach", {
error: error instanceof Error ? error.message : String(error),
});
} finally {
await session?.detach().catch(() => undefined);
}
}
/**
* Record JS dialogs for timeout attribution without handling them (semantics of an
* unset `dialogs` policy are unchanged — the page stays blocked until user code or
* the policy handler acts). Cleared when the policy handler settles the dialog or a
* main-frame navigation proves the modal is gone.
*/
#observeDialogs(): void {
const page = this.#requirePage();
page.on("dialog", dialog => {
this.#openDialog = { type: dialog.type(), message: dialog.message() };
});
page.on("framenavigated", frame => {
if (frame === page.mainFrame()) this.#openDialog = undefined;
});
}
async #currentReadyInfo(): Promise<ReadyInfo> {
const page = this.#requirePage();
const targetId = this.#targetId ?? (await targetIdForPage(page));
this.#targetId = targetId;
return {
url: redactUrlCredentials(page.url()),
title: await page.title().catch(() => undefined),
viewport: page.viewport() ?? DEFAULT_VIEWPORT,
targetId,
};
}
#applyDialogPolicy(policy: DialogPolicy): void {
const page = this.#requirePage();
if (this.#dialogPolicy === policy && this.#dialogHandler) return;
if (this.#dialogHandler) page.off("dialog", this.#dialogHandler);
const handler = (dialog: Dialog): void => {
const action = policy === "accept" ? dialog.accept() : dialog.dismiss();
void action.then(
() => {
this.#openDialog = undefined;
},
err =>
this.#log("debug", "Dialog auto-handler failed", {
policy,
error: err instanceof Error ? err.message : String(err),
}),
);
};
page.on("dialog", handler);
this.#dialogPolicy = policy;
this.#dialogHandler = handler;
}
async #postReadyInfo(): Promise<void> {
try {
this.#transport.send({ type: "ready", info: await this.#currentReadyInfo() });
} catch (error) {
this.#log("debug", "Failed to refresh tab info", {
error: error instanceof Error ? error.message : String(error),
});
}
}
async #run(msg: Extract<WorkerInbound, { type: "run" }>): Promise<void> {
if (this.#active) {
this.#transport.send({
type: "result",
id: msg.id,
ok: false,
error: errorPayload(new ToolError("Tab worker is busy")),
});
return;
}
const timeoutSignal = AbortSignal.timeout(msg.timeoutMs);
const ac = new AbortController();
const runAc = new AbortController();
const signal = AbortSignal.any([timeoutSignal, ac.signal, runAc.signal]);
const output = new RunOutput();
const screenshots: ScreenshotResult[] = [];
const active: ActiveRun = {
id: msg.id,
ac,
signal,
output,
screenshots,
pendingTools: new Map(),
inflight: new Map(),
opCounter: 0,
};
this.#active = active;
let completed = false;
let returnValue: unknown;
let failure: { error: unknown } | undefined;
let runPage: RunPageScope | undefined;
try {
throwIfAborted(signal);
runPage = createRunPageScope(this.#requirePage());
const browser = this.#requireBrowser();
const tabApi = this.#createTabApi(msg.name, msg.timeoutMs, signal, msg.session, output, screenshots, active);
const runtime = this.#ensureRuntime(msg.session);
runtime.setCwd(msg.session.cwd);
runtime.setRunScope({
page: bindBrowserRunFacade(runPage.page, signal),
browser: bindBrowserRunFacade(browser, signal),
tab: bindBrowserRunFacade(tabApi, signal),
assert: (cond: unknown, text?: string): void => {
if (!cond) throw new ToolError(text ?? "Assertion failed");
},
// Both wait forms register in the in-flight map so a cell that dies while
// sleeping/polling names the culprit instead of a bare whole-cell timeout.
wait: (msOrPredicate: number | (() => unknown), opts?: WaitPredicateOptions): Promise<unknown> => {
const label = typeof msOrPredicate === "number" ? `wait(${msOrPredicate}ms)` : "wait(predicate)";
const resolved =
typeof msOrPredicate === "number"
? undefined
: { timeout: resolvePredicateTimeout(msg.timeoutMs, opts?.timeout), interval: opts?.interval };
return markHandled(
this.#runOp(active, label, signal, Number.POSITIVE_INFINITY, sig =>
waitForBrowserRun(msOrPredicate, sig, resolved),
),
);
},
});
const { promise: cancelRejection, reject: rejectCancel } = Promise.withResolvers<never>();
const onCancel = (): void => {
const abortError =
signal.reason instanceof ToolAbortError
? signal.reason
: new ToolAbortError(undefined, { cause: signal.reason });
if (timeoutSignal.aborted) {
const stalled = describeInflight(active.inflight);
const dialog = this.#openDialog;
const dialogNote = dialog
? `; a ${dialog.type}(${JSON.stringify(dialog.message.slice(0, 80))}) dialog opened during this run and may still block the page — reopen the tab with dialogs:"accept"|"dismiss" or handle page.on('dialog')`
: "";
rejectCancel(
new ToolError(
`Browser code execution timed out after ${msg.timeoutMs}ms${stalled ? ` (stalled on ${stalled})` : ""}${dialogNote}`,
),
);
} else {
rejectCancel(abortError);
}
// Cancel in-flight tool calls so user code's awaited proxies reject promptly.
const toolAbort = timeoutSignal.aborted
? postmortem.markExpectedCleanupError(new ToolAbortError(undefined, { cause: timeoutSignal.reason }))
: abortError;
for (const pending of active.pendingTools.values()) {
pending.reject(toolAbort);
}
active.pendingTools.clear();
};
if (signal.aborted) onCancel();
else signal.addEventListener("abort", onCancel, { once: true });
try {
const hooks = this.#hooksForActiveRun();
if (!hooks) throw new ToolError("Browser runtime started without an active run");
returnValue = await Promise.race([
runtime.run(msg.code, `browser-run-${msg.id}.js`, hooks, { runId: msg.id, cwd: msg.session.cwd }),
cancelRejection,
]);
completed = true;
} finally {
signal.removeEventListener("abort", onCancel);
}
} catch (error) {
failure = { error };
} finally {
runAc.abort(postmortem.markExpectedCleanupError(new ToolAbortError("Browser run ended")));
try {
await runPage?.cleanup();
} catch (error) {
failure = { error };
}
if (this.#active?.id === msg.id) this.#active = null;
}
if (failure) {
this.#transport.send({ type: "result", id: msg.id, ok: false, error: errorPayload(failure.error) });
return;
}
if (completed) {
await this.#postReadyInfo();
this.#transport.send({
type: "result",
id: msg.id,
ok: true,
payload: { displays: output.finish(), returnValue: cloneSafe(returnValue), screenshots },
});
}
}
#ensureRuntime(session: SessionSnapshot): JsRuntime {
if (this.#runtime) return this.#runtime;
this.#runtime = new JsRuntime({
initialCwd: session.cwd,
sessionId: `browser-tab-${this.#targetId ?? "unknown"}`,
});
return this.#runtime;
}
#hooksForActiveRun(): RuntimeHooks | null {
const active = this.#active;
if (!active) return null;
return {
onText: chunk => {
throwIfAborted(active.signal);
active.output.pushText(chunk);
this.#log("debug", chunk.replace(/\n$/, ""));
},
onDisplay: output => {
throwIfAborted(active.signal);
active.output.pushDisplay(output);
},
callTool: (name, args) => {
throwIfAborted(active.signal);
return this.#callTool(active, name, args);
},
};
}
async #callTool(active: ActiveRun, name: string, args: unknown): Promise<unknown> {
const id = `tab-tc-${active.id}-${crypto.randomUUID()}`;
const { promise, resolve, reject } = Promise.withResolvers<unknown>();
active.pendingTools.set(id, { resolve, reject });
this.#transport.send({ type: "tool-call", id, runId: active.id, name, args });
return await promise;
}
#deliverToolReply(id: string, reply: ToolReply): void {
const active = this.#active;
if (!active) return;
const pending = active.pendingTools.get(id);
if (!pending) return;
active.pendingTools.delete(id);
if (reply.ok) pending.resolve(reply.value);
else pending.reject(replyError(reply.error));
}
/**
* Wrap a tab helper so it (a) registers in the active run's in-flight map for
* timeout diagnostics and (b) honors an optional per-op deadline that fails fast
* with a named error instead of silently consuming the whole cell budget. Pass
* `Number.POSITIVE_INFINITY` for `perOpTimeoutMs` to bound the op only by the cell
* budget (used for `evaluate` running user code and for locator helpers that already
* carry puppeteer's own `.setTimeout(timeoutMs)`). When the op targets a `selector`,
* the fail-fast timeout carries a best-effort match-count hint, and — when
* `zeroMatchAfterMs` is set — a watchdog aborts the op early once the selector has
* matched nothing for that long.
*/
async #runOp<T>(
active: ActiveRun,
label: string,
cellSignal: AbortSignal,
perOpTimeoutMs: number,
fn: (signal: AbortSignal) => Promise<T>,
opts?: { selector?: string; zeroMatchAfterMs?: number },
): Promise<T> {
const opId = active.opCounter++;
active.inflight.set(opId, { label, startedAt: Date.now() });
const capped = Number.isFinite(perOpTimeoutMs) && perOpTimeoutMs > 0;
const opTimeout = capped ? AbortSignal.timeout(perOpTimeoutMs) : undefined;
const opSignal = opTimeout ? AbortSignal.any([cellSignal, opTimeout]) : cellSignal;
const selector = opts?.selector;
const watchdog =
selector !== undefined && opts?.zeroMatchAfterMs !== undefined && parseAriaRefSelector(selector) === null
? { selector, afterMs: opts.zeroMatchAfterMs }
: undefined;
// Fired when the watchdog wins the race (tears down the in-flight action) and in
// the finally (stops the watchdog's polling once the op settles either way).
const earlyAc = new AbortController();
try {
if (!watchdog) return await fn(opSignal);
const racedSignal = AbortSignal.any([opSignal, earlyAc.signal]);
return await Promise.race([
fn(racedSignal),
this.#zeroMatchWatchdog(watchdog.selector, label, watchdog.afterMs, racedSignal),
]);
} catch (err) {
// Fail fast with a named, attributable error instead of the opaque whole-cell timeout:
// our per-op deadline fired, or puppeteer's own (equal) timeout fired first — having
// already torn down the CDP action via the op signal, so no work is left dangling.
// Cell-budget aborts and uncapped helpers (goto/evaluate) keep their native errors.
if (
capped &&
!cellSignal.aborted &&
(opTimeout?.aborted || (err instanceof Error && err.name === "TimeoutError"))
) {
const hint = selector ? await this.#selectorTimeoutHint(selector) : "";
throw new ToolError(`${label} timed out after ${perOpTimeoutMs}ms${hint}`);
}
throw err;
} finally {
earlyAc.abort();
active.inflight.delete(opId);
}
}
/**
* Fail-fast arm raced against a selector op: rejects once the selector has matched
* nothing for the whole `afterMs` window, so a wrong selector or wrong page (consent
* wall, pre-navigation document) costs ~2s instead of the full action deadline.
* Disarms — hangs until the settled race drops it — the moment at least one element
* matches; an inconclusive probe (mid-navigation, detached frame) never counts
* toward the zero-match window.
*/
async #zeroMatchWatchdog(selector: string, label: string, afterMs: number, signal: AbortSignal): Promise<never> {
const page = this.#requirePage();
const resolved = normalizeSelector(selector);
const deadline = Date.now() + afterMs;
while (!signal.aborted) {
let count: number | null = null;
try {
const handles = await page.$$(resolved);
count = handles.length;
for (const handle of handles) void handle.dispose().catch(() => undefined);
} catch {
// Inconclusive probe — keep polling without advancing toward failure.
}
if (count !== null && count > 0) break;
if (count === 0 && Date.now() >= deadline) {
throw new ToolError(`${label} failed fast after ${afterMs}ms${formatSelectorMatchHint(0)}`);
}
try {
await untilAborted(signal, () => Bun.sleep(ZERO_MATCH_POLL_MS));
} catch {
break;
}
}
return await new Promise<never>(() => {});
}
/**
* Best-effort match-count probe for a timed-out selector op. Never throws;
* empty string when the probe fails, stalls, or the selector is an aria-ref.
*/
async #selectorTimeoutHint(selector: string): Promise<string> {
if (parseAriaRefSelector(selector) !== null) return "";
try {
const handles = await Promise.race([
this.#requirePage().$$(normalizeSelector(selector)),
Bun.sleep(1_000).then(() => null),
]);
if (!handles) return "";
const count = handles.length;
for (const handle of handles) void handle.dispose().catch(() => undefined);
return formatSelectorMatchHint(count);
} catch {
return "";
}
}
#createTabApi(
name: string,
timeoutMs: number,
signal: AbortSignal,
session: SessionSnapshot,
output: RunOutput,
screenshots: ScreenshotResult[],
active: ActiveRun,
): TabApi {
const page = this.#requirePage();
const { budgetBound, quickOpMs, actionOpMs } = resolveOpTimeouts(timeoutMs);
const waitMs = (explicit?: number): number => resolveWaitTimeout(timeoutMs, explicit);
const INF = Number.POSITIVE_INFINITY;
const op = <T>(
label: string,
perOpMs: number,
fn: (sig: AbortSignal) => Promise<T>,
selectorOpts?: { selector?: string; zeroMatchAfterMs?: number },
): Promise<T> => markHandled(this.#runOp(active, label, signal, perOpMs, fn, selectorOpts));
return {
name,
page,
signal,
url: () => page.url(),
title: () => op("tab.title()", INF, sig => untilAborted(sig, () => page.title())),
goto: (url, opts) =>
op(`tab.goto(${JSON.stringify(url)})`, INF, async sig => {
this.#clearElementCache();
try {
// Default to "load" because dev servers with HMR/WS never reach networkidle.
// budgetBound (not the full cell) so a hung navigation fails named and
// catchable inside the run instead of dying with the whole cell.
await untilAborted(sig, () =>
page.goto(url, { waitUntil: opts?.waitUntil ?? "load", timeout: budgetBound }),
);
} catch (err) {
if (err instanceof Error && err.name === "TimeoutError") {
// Abandon the hung navigation NOW — a still-pending load stalls every
// later op on this page and cascades into more opaque timeouts.
await this.#stopLoading();
throw new ToolError(
`tab.goto(${JSON.stringify(url)}) timed out after ${budgetBound}ms; pending navigation stopped — retry with a longer tool timeout or waitUntil:"domcontentloaded"`,
);
}
throw err;
}
}),
observe: opts => op("tab.observe()", quickOpMs, sig => this.#collectObservation({ ...opts, signal: sig })),
ariaSnapshot: (selector, opts) =>
op(
selector ? `tab.ariaSnapshot(${JSON.stringify(selector)})` : "tab.ariaSnapshot()",
quickOpMs,
async sig => {
let root: ElementHandle | null = null;
if (selector) {
root = (await untilAborted(sig, () =>
page.$(normalizeSelector(selector)),
)) as ElementHandle | null;
if (!root)
throw new ToolError(
`tab.ariaSnapshot: selector ${JSON.stringify(selector)} matched no element`,
);
}
try {
return await untilAborted(sig, () => captureAriaSnapshot(page, root, opts));
} finally {
await root?.dispose().catch(() => undefined);
}
},
),
screenshot: opts =>
op(describeScreenshot(opts), quickOpMs, sig =>
this.#captureScreenshot(session, output, screenshots, sig, opts),
),
extract: (format = "markdown") =>
op(`tab.extract(${JSON.stringify(format)})`, quickOpMs, async sig => {
const html = (await untilAborted(sig, () => page.content())) as string;
const result = await extractReadableFromHtml(html, page.url(), format);
if (!result) {
throw new ToolError(
`tab.extract(${JSON.stringify(format)}) found no readable content on ${page.url()}`,
);
}
const content = format === "markdown" ? result.markdown : result.text;
if (!content) {
throw new ToolError(
`tab.extract(${JSON.stringify(format)}) produced empty ${format} content for ${page.url()}`,
);
}
return content;
}),
click: selector =>
op(
`tab.click(${JSON.stringify(selector)})`,
actionOpMs,
async sig => {
if (parseAriaRefSelector(selector) !== null) {
const handle = await this.#resolveAriaRef(selector);
try {
await untilAborted(sig, () => handle.click());
} finally {
await handle.dispose().catch(() => undefined);
}
return;
}
const resolved = normalizeSelector(selector);
if (resolved.startsWith("text/")) await clickQueryHandlerText(page, resolved, actionOpMs, sig);
else
await untilAborted(sig, () =>
page.locator(resolved).setTimeout(actionOpMs).click({ signal: sig }),
);
},
{ selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
),
type: (selector, text) =>
op(
`tab.type(${JSON.stringify(selector)})`,
actionOpMs,
async sig => {
const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
try {
await untilAborted(sig, () => handle.type(text, { delay: 0 }));
} finally {
await handle.dispose().catch(() => undefined);
}
},
{ selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
),
fill: (selector, value) =>
op(
`tab.fill(${JSON.stringify(selector)})`,
actionOpMs,
async sig => {
if (parseAriaRefSelector(selector) !== null) {
const handle = await this.#resolveAriaRef(selector);
try {
await fillViaHandle(handle, value, sig);
} finally {
await handle.dispose().catch(() => undefined);
}
return;
}
await untilAborted(sig, () =>
page.locator(normalizeSelector(selector)).setTimeout(actionOpMs).fill(value, { signal: sig }),
);
},
{ selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
),
press: (key, opts) =>
op(`tab.press(${JSON.stringify(key)})`, actionOpMs, async sig => {
const selector = opts?.selector;
if (selector) {
if (parseAriaRefSelector(selector) !== null) {
const handle = await this.#resolveAriaRef(selector);
try {
await untilAborted(sig, () => handle.focus());
} finally {
await handle.dispose().catch(() => undefined);
}
} else await untilAborted(sig, () => page.focus(normalizeSelector(selector)));
}
await untilAborted(sig, () => page.keyboard.press(key));
}),
scroll: (deltaX, deltaY) =>
op("tab.scroll()", actionOpMs, sig =>
untilAborted(sig, () => dispatchScroll(() => page.mouse.wheel({ deltaX, deltaY }))),
),
drag: (from, to) => op("tab.drag()", actionOpMs, sig => this.#drag(from, to, sig)),
waitFor: (selector, opts) => {
const w = waitMs(opts?.timeout);
return op(
`tab.waitFor(${JSON.stringify(selector)})`,
w,
async sig => toActionableHandle(await this.#resolveActionHandle(selector, w, sig)),
{ selector, zeroMatchAfterMs: opts?.timeout === undefined ? ZERO_MATCH_FAIL_FAST_MS : undefined },
);
},
waitForSelector: (selector, opts) => {
const w = waitMs(opts?.timeout);
return op(
`tab.waitForSelector(${JSON.stringify(selector)})`,
w,
async sig => {
if (parseAriaRefSelector(selector) !== null)
return toActionableHandle(await this.#resolveAriaRef(selector));
const handle = (await untilAborted(sig, () =>
page.waitForSelector(normalizeSelector(selector), {
timeout: w,
visible: opts?.visible,
hidden: opts?.hidden,
signal: sig,
}),
)) as ElementHandle | null;
return handle ? toActionableHandle(handle) : null;
},
{
selector,
// `hidden: true` waits for zero matches — that is success, never a fast-fail.
zeroMatchAfterMs: opts?.timeout === undefined && !opts?.hidden ? ZERO_MATCH_FAIL_FAST_MS : undefined,
},
);
},
waitForNavigation: opts => {
const w = waitMs(opts?.timeout);
return op("tab.waitForNavigation()", w, sig =>
untilAborted(sig, () =>
page.waitForNavigation({ waitUntil: opts?.waitUntil ?? "load", timeout: w, signal: sig }),
),
);
},
evaluate: (fn, ...args) =>
op("tab.evaluate()", INF, sig =>
untilAborted(sig, () =>
typeof fn === "string"
? page.mainFrame().mainRealm().evaluate(fn)
: page
.mainFrame()
.mainRealm()
.evaluate(fn as (...a: unknown[]) => unknown, ...args),
),
) as never,
scrollIntoView: selector =>
op(
`tab.scrollIntoView(${JSON.stringify(selector)})`,
actionOpMs,
async sig => {
const handle = await this.#resolveActionHandle(selector, actionOpMs, sig);
try {
await untilAborted(sig, () =>
handle.evaluate(el => {
const target = el as unknown as {
scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void;
};
target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
}),
);
} finally {
await handle.dispose().catch(() => undefined);
}
},
{ selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
),
select: (selector, ...values) =>
op(
`tab.select(${JSON.stringify(selector)})`,
actionOpMs,
sig => this.#select(selector, values, actionOpMs, sig),
{ selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
),
uploadFile: (selector, ...filePaths) =>
op(
`tab.uploadFile(${JSON.stringify(selector)})`,
actionOpMs,
sig => this.#uploadFile(selector, filePaths, actionOpMs, sig, session),
{ selector, zeroMatchAfterMs: ZERO_MATCH_FAIL_FAST_MS },
),
waitForUrl: (pattern, opts) => {
const w = waitMs(opts?.timeout);
return op("tab.waitForUrl()", w, sig => this.#waitForUrl(pattern, w, sig));
},
waitForResponse: (pattern, opts) => {
const w = waitMs(opts?.timeout);
return op("tab.waitForResponse()", w, sig => this.#waitForResponse(pattern, w, sig));
},
id: async id => toActionableHandle(await this.#resolveCachedHandle(id)),
ref: async id => toActionableHandle(await this.#resolveAriaRef(id)),
};
}
async #collectObservation(options: {
includeAll?: boolean;
viewportOnly?: boolean;
signal?: AbortSignal;
}): Promise<Observation> {
const page = this.#requirePage();
this.#clearElementCache();
const includeAll = options.includeAll ?? false;
const viewportOnly = options.viewportOnly ?? false;
const snapshot = (await untilAborted(options.signal, () =>
page.accessibility.snapshot({ interestingOnly: !includeAll }),
)) as SerializedAXNode | null;
if (!snapshot) throw new ToolError("Accessibility snapshot unavailable");
const entries: ObservationEntry[] = [];
await collectObservationEntries(this, snapshot, entries, { includeAll, viewportOnly });
const scroll = (await untilAborted(options.signal, () =>
page.evaluate(() => {
const win = globalThis as unknown as {
scrollX: number;
scrollY: number;
innerWidth: number;
innerHeight: number;
document: { documentElement: { scrollWidth: number; scrollHeight: number } };
};
const doc = win.document.documentElement;
return {
x: win.scrollX,
y: win.scrollY,
width: win.innerWidth,
height: win.innerHeight,
scrollWidth: doc.scrollWidth,
scrollHeight: doc.scrollHeight,
};
}),
)) as Observation["scroll"];
return {
url: page.url(),
title: (await untilAborted(options.signal, () => page.title())) as string,
viewport: page.viewport() ?? DEFAULT_VIEWPORT,
scroll,
elements: entries,
};
}
async #captureScreenshot(
session: SessionSnapshot,
output: RunOutput,
screenshots: ScreenshotResult[],
signal: AbortSignal | undefined,
opts: ScreenshotOptions = {},
): Promise<string> {
const page = this.#requirePage();
// Multiple tabs can share one Chromium (sibling headless tabs on a shared
// endpoint, cdp/app attach). CDP `Page.captureScreenshot` reads the
// compositor surface, which follows the *active* target — a backgrounded
// page can stall waiting for a fresh frame (the 20s screenshot timeouts)
// or hand back a sibling tab's pixels. Activate first; best-effort so an
// already-active or freshly-closed target never fails the capture.
await untilAborted(signal, () => page.bringToFront()).catch(() => undefined);
const fullPage = opts.selector ? false : (opts.fullPage ?? false);
const captureType = "png";
const captureMime = "image/png" as const;
let buffer: Buffer;
if (opts.selector) {
const handle =
parseAriaRefSelector(opts.selector) !== null
? await this.#resolveAriaRef(opts.selector)
: asElementHandle(await untilAborted(signal, () => page.$(normalizeSelector(opts.selector!))));
if (!handle) throw new ToolError("Screenshot selector did not resolve to an element");
try {
// Bring the element into view with a single instant scroll instead of puppeteer's
// scrollIntoViewIfNeeded(), whose IntersectionObserver promise can stall indefinitely
// on continuously-animating pages (WebGL / backdrop-filter "glass" effects). Best-effort.
await untilAborted(signal, () =>
handle.evaluate(el => {
const target = el as unknown as {
scrollIntoView: (opts: { behavior: string; block: string; inline: string }) => void;
};
target.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
}),
).catch(() => undefined);
// scrollIntoView:false skips the same IntersectionObserver check inside screenshot();
// captureBeyondViewport (puppeteer's default) still renders the clipped region.
const shotOpts: ElementScreenshotOptions = { type: captureType, scrollIntoView: false };
buffer = (await untilAborted(signal, () => handle.screenshot(shotOpts))) as Buffer;
} finally {
await handle.dispose().catch(() => undefined);
}
} else {
buffer = (await untilAborted(signal, () => page.screenshot({ type: captureType, fullPage }))) as Buffer;
}
const resized = await resizeImage(
{ type: "image", data: buffer.toBase64(), mimeType: captureMime },
{ maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70, excludeWebP: session.excludeWebP },
);
const saveFullRes = !!session.browserScreenshotDir;
const savedBuffer = saveFullRes ? buffer : resized.buffer;
const savedMimeType = saveFullRes ? captureMime : resized.mimeType;
const ext = savedMimeType === "image/webp" ? "webp" : savedMimeType === "image/jpeg" ? "jpg" : "png";
const dest = session.browserScreenshotDir
? path.join(
session.browserScreenshotDir,
`screenshot-${new Date().toISOString().replace(/[:.]/g, "-").slice(0, -1)}.${ext}`,
)
: path.join(os.tmpdir(), `omp-sshots-${Snowflake.next()}.${ext}`);
await fs.promises.mkdir(path.dirname(dest), { recursive: true });
await Bun.write(dest, savedBuffer);
const info: ScreenshotResult = {
dest,
mimeType: savedMimeType,
bytes: savedBuffer.length,
width: resized.width,
height: resized.height,
};
screenshots.push(info);
if (!opts.silent) {
const lines = formatScreenshot({
saveFullRes,
savedMimeType,
savedByteLength: savedBuffer.length,
dest,
resized,
});
output.push({ type: "text", text: lines.join("\n") });
output.push({ type: "image", data: resized.data, mimeType: resized.mimeType });
}
return dest;
}
async #drag(from: DragTarget, to: DragTarget, signal: AbortSignal): Promise<void> {
const page = this.#requirePage();
const resolveDragPoint = async (
target: DragTarget,
role: "from" | "to",
): Promise<{ x: number; y: number; handle?: ElementHandle }> => {
if (typeof target === "string") {
const handle =
parseAriaRefSelector(target) !== null
? await this.#resolveAriaRef(target)
: asElementHandle(await untilAborted(signal, () => page.$(normalizeSelector(target))));
if (!handle) throw new ToolError(`Drag ${role} selector did not resolve: ${target}`);
const box = (await untilAborted(signal, () => handle.boundingBox())) as {
x: number;
y: number;
width: number;
height: number;
} | null;
if (!box) {
await handle.dispose().catch(() => undefined);
throw new ToolError(`Drag ${role} element has no bounding box (likely not visible): ${target}`);
}
return { x: box.x + box.width / 2, y: box.y + box.height / 2, handle };
}
if (
target !== null &&
typeof target === "object" &&
typeof (target as { x: unknown }).x === "number" &&
typeof (target as { y: unknown }).y === "number"
) {
return { x: (target as { x: number }).x, y: (target as { y: number }).y };
}
throw new ToolError(
`Drag ${role} must be a selector string or { x: number, y: number } point. Got: ${typeof target}`,
);
};
const start = await resolveDragPoint(from, "from");
let end: { x: number; y: number; handle?: ElementHandle } | undefined;
try {
end = await resolveDragPoint(to, "to");
await untilAborted(signal, () => page.mouse.move(start.x, start.y));
await untilAborted(signal, () => page.mouse.down());
await untilAborted(signal, () => page.mouse.move(end!.x, end!.y, { steps: 12 }));
await untilAborted(signal, () => page.mouse.up());
} finally {
if (start.handle) await start.handle.dispose().catch(() => undefined);
if (end?.handle) await end.handle.dispose().catch(() => undefined);
}
}
async #select(selector: string, values: string[], timeoutMs: number, signal: AbortSignal): Promise<string[]> {
const handle = await this.#resolveActionHandle(selector, timeoutMs, signal);
try {
return (await untilAborted(signal, () =>
handle.evaluate((el, vals) => {
interface SelectOption {
value: string;
selected: boolean;
}
interface SelectLike {
tagName: string;
options: ArrayLike<SelectOption>;
dispatchEvent: (event: unknown) => boolean;
}
const select = el as unknown as SelectLike;
if (select?.tagName !== "SELECT") throw new Error("tab.select() requires a <select> element");
const EventCtor = (
globalThis as unknown as { Event: new (type: string, init?: { bubbles: boolean }) => unknown }
).Event;
const wanted = new Set(vals as string[]);
// Assign the full selection first, then read back: on a single
// <select>, un-selecting the current option mid-loop leaves the
// browser reporting it selected until another option takes over,
// which double-counted the old value in the returned list.
for (let i = 0; i < select.options.length; i++) {
const opt = select.options[i] as SelectOption;
opt.selected = wanted.has(opt.value);
}
const selected: string[] = [];
for (let i = 0; i < select.options.length; i++) {
const opt = select.options[i] as SelectOption;
if (opt.selected) selected.push(opt.value);
}
select.dispatchEvent(new EventCtor("input", { bubbles: true }));
select.dispatchEvent(new EventCtor("change", { bubbles: true }));
return selected;
}, values),
)) as string[];
} finally {
await handle.dispose().catch(() => undefined);
}
}
async #uploadFile(
selector: string,
filePaths: string[],
timeoutMs: number,
signal: AbortSignal,
session: SessionSnapshot,
): Promise<void> {
if (!filePaths.length) throw new ToolError("tab.uploadFile() requires at least one file path");
const handle = await this.#resolveActionHandle(selector, timeoutMs, signal);
try {
const absolute = filePaths.map(filePath => resolveToCwd(filePath, session.cwd));
const upload = handle as unknown as { uploadFile: (...paths: string[]) => Promise<void> };
const tagName = (await untilAborted(signal, () =>
handle.evaluate(el => (el as unknown as { tagName: string }).tagName),
)) as string;
if (tagName !== "INPUT")
throw new ToolError(
`tab.uploadFile() requires an <input type="file"> element (got <${tagName.toLowerCase()}>)`,
);
await untilAborted(signal, () => upload.uploadFile(...absolute));
} finally {
await handle.dispose().catch(() => undefined);
}
}
async #waitForUrl(pattern: string | RegExp, timeout: number, signal: AbortSignal): Promise<string> {
const page = this.#requirePage();
const isRegex = pattern instanceof RegExp;
const matcher = isRegex ? pattern.source : pattern;
const flags = isRegex ? pattern.flags : "";
await untilAborted(signal, () =>
page.waitForFunction(
(m: string, isRe: boolean, fl: string) => {
const url = (globalThis as unknown as { location: { href: string } }).location.href;
return isRe ? new RegExp(m, fl).test(url) : url.includes(m);
},
{ timeout, polling: 200, signal },
matcher,
isRegex,
flags,
),
);
return page.url();
}
async #waitForResponse(
pattern: string | RegExp | ((response: HTTPResponse) => boolean | Promise<boolean>),
timeout: number,
signal: AbortSignal,
): Promise<HTTPResponse> {
const page = this.#requirePage();
const predicate: (response: HTTPResponse) => boolean | Promise<boolean> =
typeof pattern === "function"
? pattern
: pattern instanceof RegExp
? response => pattern.test(response.url())
: response => response.url().includes(pattern);
return (await untilAborted(signal, () => page.waitForResponse(predicate, { timeout, signal }))) as HTTPResponse;
}
async #resolveCachedHandle(id: number): Promise<ElementHandle> {
const handle = this.#elementCache.get(id);
if (!handle) throw new ToolError(`Unknown element id ${id}. Run tab.observe() to refresh the element list.`);
try {
const isConnected = (await handle.evaluate(el => el.isConnected)) as boolean;
if (!isConnected) {
this.#clearElementCache();
throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
}
} catch (err) {
if (err instanceof ToolError) throw err;
this.#clearElementCache();
throw new ToolError(`Element id ${id} is stale. Run tab.observe() again.`);
}
return handle;
}
async #resolveAriaRef(id: string): Promise<ElementHandle> {
const ref = parseAriaRefSelector(id) ?? id.trim();
const handle = await resolveAriaRefHandle(this.#requirePage(), ref);
if (!handle) {
throw new ToolError(
`Unknown ARIA ref ${JSON.stringify(ref)}. Run tab.ariaSnapshot() to refresh refs (they renumber each snapshot).`,
);
}
return handle;
}
/**
* Resolve a selector to an ElementHandle for handle-based actions. An
* `aria-ref=eN` selector resolves against the latest ariaSnapshot's refs
* (main world); anything else goes through the normal locator wait.
*/
async #resolveActionHandle(selector: string, timeoutMs: number, sig: AbortSignal): Promise<ElementHandle> {
if (parseAriaRefSelector(selector) !== null) return this.#resolveAriaRef(selector);
return (await untilAborted(sig, () =>
this.#requirePage().locator(normalizeSelector(selector)).setTimeout(timeoutMs).waitHandle({ signal: sig }),
)) as ElementHandle;
}
#clearElementCache(): void {
if (this.#elementCache.size === 0) {
this.#elementCounter = 0;
return;
}
const handles = [...this.#elementCache.values()];
this.#elementCache.clear();
this.#elementCounter = 0;
for (const handle of handles) void handle.dispose().catch(() => undefined);
}
/** Best-effort `Page.stopLoading` so an abandoned navigation cannot stall later ops. */
async #stopLoading(): Promise<void> {
try {
const session = await this.#requirePage().createCDPSession();
try {
await session.send("Page.stopLoading");
} finally {
await session.detach().catch(() => undefined);
}
} catch (error) {
this.#log("debug", "Page.stopLoading failed", {
error: error instanceof Error ? error.message : String(error),
});
}
}
async #close(): Promise<void> {
this.#unsub();
this.#clearElementCache();
const page = this.#page;
if (this.#dialogHandler && page && !page.isClosed()) page.off("dialog", this.#dialogHandler);
if (this.#mode === "headless" && page && !page.isClosed()) await page.close().catch(() => undefined);
if (this.#browser?.connected) this.#browser.disconnect();
this.#transport.send({ type: "closed" });
this.#transport.close();
}
#requirePage(): Page {
if (!this.#page) throw new ToolError("Tab worker is not initialized");
return this.#page;
}
#requireBrowser(): Browser {
if (!this.#browser) throw new ToolError("Tab worker is not initialized");
return this.#browser;
}
#log(level: "debug" | "warn" | "error", msg: string, meta?: Record<string, unknown>): void {
this.#transport.send({ type: "log", level, msg, meta });
}
}