diff --git a/README.md b/README.md
index 4d69a27..858d8ba 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
@@ -44,6 +44,12 @@ It already has your browser open right there. It just can't see it.
- **Real, trusted input.** Clicks, typing and key presses go through the Chrome DevTools Protocol, so the page sees `isTrusted` events, focus really moves, default actions run (Tab moves focus, Enter submits, arrows drive autocomplete menus) and focus/blur fire even while the window is in the background — legacy grids and lookup widgets behave as they do for a person. One debugger session per tab is reused and detached after 30 s idle (the yellow "being debugged" banner shows only while it is attached). Pass `trusted: false` for the old synthetic events with no banner; they are also the automatic fallback when the debugger can't attach.
- **Batches.** `browser_batch` runs a list of tool calls in one round-trip and stops at the first failure — a click → type → Tab → wait → read sequence is one call instead of five.
- **Console-style JavaScript.** `browser_evaluate` accepts code as you'd type it in DevTools: top-level `await`, several statements, the last expression's value is returned, DOM nodes come back as readable descriptions — and page CSP doesn't block it.
+- **Refs that stay right.** Snapshot/find refs resolve through one shared page runtime: the registered element first, then the first *visible* selector match (across open and closed shadow roots and same-origin iframes), then a verified fallback that only re-binds when role, tag and name identify one element — an ambiguous match is reported as gone instead of clicked. Hidden duplicates are skipped.
+- **Shadow DOM everywhere.** `snapshot`, `text`, `find`, `click_text`, `wait` and every locator see web components (open and closed roots, slots), so sites like caniuse read like any other page.
+- **Frozen tabs don't freeze the agent.** Every page call has an 8 s budget; a tab that stops answering is reported as `TAB_WEDGED` in seconds, later calls fail fast after a 1.5 s probe, and `browser_navigate` / `browser_tabs reload` replace the frozen tab in place (the result carries the new `tabId`).
+- **Coordinates when you need them.** Click, hover and wheel-scroll at `x`/`y`, triple-click, ctrl/shift-click, key sequences with `repeat`, and zoomed `region` screenshots that tell you how image pixels map to those coordinates.
+- **Record and replay.** `browser_gif` records a flow as an animated GIF (clicks marked) for the user; `browser_shortcuts` saves a flow with `{{variables}}` and replays it in one call.
+- **Several browsers.** Every connected Chrome profile is its own connection; `browser_list_browsers` / `browser_select_browser` pick one per session (one browser behaves exactly as before).
- **Honest errors.** Every tool failure reaches your agent as a real `isError` result with the full payload — no "success" responses hiding failures mid-workflow.
---
@@ -284,35 +290,36 @@ See [`agent-config/`](agent-config/) for manual installation or to customize the
## What It Can Do
-25 tools. Every page-interaction tool takes a **`tabId`** (the one exception is `browser_navigate`, where it's optional).
+30 tools. Every page-interaction tool takes a **`tabId`** (the one exception is `browser_navigate`, where it's optional).
**See**
| Tool | What it does |
|------|-------------|
| `browser_observe` | Compact atomic semantic observation with snapshot/document identity, geometry, state, and dynamic allowed actions |
-| `browser_snapshot` | Accessibility tree with element refs. Compact mode (default) returns only interactive elements. Traverses open shadow DOM + same-origin iframes. |
-| `browser_screenshot` | Capture a tab as an image over CDP — `maxWidth` / `scale` / `jpeg` to cut tokens, `fullPage` for the whole page |
-| `browser_text` | Extract raw text from page or element |
-| `browser_find` | Query elements by natural language — walks same-origin iframes too |
+| `browser_snapshot` | Accessibility tree with element refs. Compact mode (default) returns only interactive elements; `filter` / `depth` / `ref` (subtree) / `maxChars` (default 20k) keep it small. Traverses open + closed shadow DOM, slots and same-origin iframes. |
+| `browser_screenshot` | Capture a tab as an image over CDP — `maxWidth` / `scale` / `jpeg` to cut tokens, `fullPage` for the whole page, `region` to zoom; reports the pixel → x/y mapping |
+| `browser_text` | Extract text from page or element (incl. shadow DOM); `mode:"article"` = main content only; `offset` paging |
+| `browser_find` | Query elements by natural language ("search input", "Save button") — tokenized, role-aware, shadow DOM + same-origin iframes |
**Interact**
| Tool | What it does |
|------|-------------|
| `browser_act` | Safely click/type/select/focus/hover/keypress/scroll/upload against a `browser_observe` snapshot |
-| `browser_click` | Real (trusted) click by ref or CSS selector — pierces same-origin iframes |
-| `browser_click_text` | Click by visible text. Works through React portals and overlays |
-| `browser_type` | Real key presses into inputs and contenteditable fields; returns the resulting value |
-| `browser_press_key` | Real key presses and combos (`Enter`, `Tab`, `ctrl+a`) |
+| `browser_click` | Real (trusted) click by ref, CSS selector or `x`/`y` — `clickCount` 1-3, `modifiers`; pierces same-origin iframes and shadow DOM |
+| `browser_click_text` | Click by visible text (case-insensitive, shadow DOM too) with a real click on the owning control. Works through React portals and overlays |
+| `browser_type` | Real key presses into inputs and contenteditable fields (or the focused field); returns the resulting value |
+| `browser_press_key` | Real key presses, combos (`Enter`, `Tab`, `ctrl+a`), sequences (`"ArrowDown ArrowDown Enter"`) and `repeat` |
| `browser_batch` | Run several tool calls in one round-trip; stops at the first failure |
-| `browser_scroll` | Scroll pages and virtual containers |
-| `browser_hover` | Trigger tooltips and dropdowns |
+| `browser_shortcuts` | Save a flow with `{{variables}}`, replay it in one call |
+| `browser_scroll` | Scroll pages and virtual containers, or wheel-scroll at `x`/`y` |
+| `browser_hover` | Trigger tooltips and dropdowns (ref, selector or `x`/`y`) |
| `browser_select` | Pick from native `` dropdowns |
-| `browser_wait` | Wait for elements to appear or disappear |
-| `browser_fill_form` | Fill multiple form fields in one call (React/Vue-safe setters) |
+| `browser_wait` | Wait for elements (any visible match), text, a URL change, or a delay |
+| `browser_fill_form` | Fill multiple form fields in one call (React/Vue-safe setters; selects by value or label) |
| `browser_drag` | Drag element-to-element (uses CDP for reliability) |
-| `browser_upload_file` | Upload files through ` ` (uses CDP, strict-CSP safe) |
+| `browser_upload_file` | Upload files through ` ` (CDP, strict-CSP safe), or bytes / a screenshot into an input or a drop zone |
Uploading files — no file dialog
@@ -332,15 +339,18 @@ Paths are absolute and local to the machine running the browser. Omit `ref`/`sel
| Tool | What it does |
|------|-------------|
-| `browser_navigate` | Go to a URL in a tab (`tabId` optional, defaults to active) |
-| `browser_tabs` | List / create / close / focus / **lock** / **unlock** tabs |
+| `browser_navigate` | Go to a URL in a tab (`tabId` optional, defaults to active); replaces a frozen tab |
+| `browser_tabs` | List / create (`active:false` for background) / close / focus / reload / **lock** / **unlock** tabs |
+| `browser_resize_window` | Resize or maximize the window holding a tab (responsive testing) |
+| `browser_list_browsers` / `browser_select_browser` | See the connected browsers (profiles) and route this session to one |
**Debug & Advanced**
| Tool | What it does |
|------|-------------|
-| `browser_console` | Console output (log, warn, error) — per-tab, capped at 200 entries |
-| `browser_network` | XHR/fetch requests with status codes — per-tab, optional `limit` |
+| `browser_console` | The page's console output (log, info, warn, error, debug, uncaught errors) — per-tab, capped at 200 entries; `pattern` / `level` / `limit` |
+| `browser_network` | Requests with status codes and failures — per-tab; `urlPattern`, regex `filter`, `failed`, `limit` |
+| `browser_gif` | Record the agent's actions in a tab as an animated GIF (clicks marked); export writes the file |
| `browser_evaluate` | Run JavaScript like the DevTools console: top-level `await`, last value returned, not blocked by CSP |
| `browser_handle_dialog` | Dismiss/accept an open alert/confirm/prompt via CDP (works on frozen pages) |
| `browser_run_action` | Run a self-contained JS action object via CDP |
diff --git a/eslint.config.js b/eslint.config.js
index 3ae527d..0406d3f 100644
--- a/eslint.config.js
+++ b/eslint.config.js
@@ -27,7 +27,8 @@ export default [
parserOptions: {
requireConfigFile: false,
babelOptions: {
- presets: [['@babel/preset-typescript', { allowDeclareFields: true }]],
+ // Babel 8 always allows `declare` fields (the option was removed).
+ presets: ['@babel/preset-typescript'],
},
},
},
diff --git a/extension/console-main.js b/extension/console-main.js
new file mode 100644
index 0000000..3cc1301
--- /dev/null
+++ b/extension/console-main.js
@@ -0,0 +1,36 @@
+/**
+ * MAIN-world console capture. content.js runs in the extension's isolated
+ * world, where patching `console` only sees the extension's own calls — the
+ * page's console.log/warn/info/debug/error never reached browser_console.
+ * This script patches the PAGE's console and hands each entry to content.js
+ * as a JSON string on a private DOM event (object details don't cross
+ * worlds). Uncaught errors / rejections are still captured by content.js.
+ */
+(function () {
+ 'use strict';
+ if (window.__bcConsoleMain) return;
+ Object.defineProperty(window, '__bcConsoleMain', { value: true });
+ const EVENT = '__bc_console_entry';
+ const LEVELS = ['log', 'info', 'warn', 'error', 'debug'];
+ const fmt = (a) => {
+ if (typeof a === 'string') return a;
+ if (a instanceof Error) return `${a.name}: ${a.message}`;
+ if (a && typeof a === 'object') {
+ try { return JSON.stringify(a); } catch { return Object.prototype.toString.call(a); }
+ }
+ return String(a);
+ };
+ for (const level of LEVELS) {
+ const orig = console[level];
+ if (typeof orig !== 'function') continue;
+ const patched = function (...args) {
+ try {
+ let text = args.map(fmt).join(' ');
+ if (text.length > 2000) text = text.slice(0, 2000) + '…[truncated]';
+ document.dispatchEvent(new CustomEvent(EVENT, { detail: JSON.stringify({ level, text }) }));
+ } catch { /* never break the page's logging */ }
+ return orig.apply(this, args);
+ };
+ try { console[level] = patched; } catch { /* frozen console */ }
+ }
+})();
diff --git a/extension/content.js b/extension/content.js
index 8e38748..95daab3 100644
--- a/extension/content.js
+++ b/extension/content.js
@@ -37,6 +37,15 @@
console.info = (...a) => capture('info', ...a);
console.debug = (...a) => capture('debug', ...a);
+ // Page console entries from console-main.js (MAIN world), JSON on a DOM event.
+ document.addEventListener('__bc_console_entry', (e) => {
+ let entry;
+ try { entry = JSON.parse(e.detail); } catch { return; }
+ if (!entry || typeof entry.text !== 'string') return;
+ if (entry.text.indexOf('ResizeObserver loop') !== -1) return;
+ try { chrome.runtime.sendMessage({ type: 'console', level: String(entry.level || 'log'), text: entry.text.slice(0, 2100) }); } catch {}
+ });
+
window.addEventListener('error', (e) => {
// Silence the well-known ResizeObserver loop warning: it's a benign browser
// notice (element resized during its own observation callback), not a real
diff --git a/extension/events.js b/extension/events.js
index 58fadf2..2b67b86 100644
--- a/extension/events.js
+++ b/extension/events.js
@@ -117,6 +117,23 @@ export function registerEventListeners() {
{ urls: [''] },
);
+ // Requests that never completed (DNS failure, blocked, aborted, CORS…):
+ // onCompleted never fires for them, so without this they were invisible.
+ chrome.webRequest.onErrorOccurred.addListener(
+ (details) => {
+ if (details.tabId == null || details.tabId < 0) return;
+ const buf = getTabBuffer(networkByTab, details.tabId);
+ pushCapped(buf, {
+ method: details.method,
+ url: details.url,
+ error: details.error,
+ type: details.type,
+ timestamp: details.timeStamp,
+ });
+ },
+ { urls: [''] },
+ );
+
// A tab closing should release its lock and drop its buffers.
// NOTE: no hideLockShield here — the tab/page is already gone, so a shield
// inject would just throw (swallowed) and there is nothing to remove.
diff --git a/extension/handlers/cdp.js b/extension/handlers/cdp.js
index cf1a3d7..457b7bd 100644
--- a/extension/handlers/cdp.js
+++ b/extension/handlers/cdp.js
@@ -3,9 +3,10 @@
* upload_file — the two tools that cannot be implemented with
* chrome.scripting (CSP bypass / DOM.setFileInputFiles).
*/
-import { resolveTab, safeExec } from '../lib/page-exec.js';
+import { resolveTab, execDom, getFallback } from '../lib/page-exec.js';
import { MAX_RESULT_CHARS } from '../lib/state.js';
import { ensureCdp } from '../lib/cdp-session.js';
+import { handleScreenshot } from './tabs.js';
export async function handleRunAction(params, _sessionId, _agentName, signal) {
const { tabId, code, actionParams = {} } = params;
@@ -67,71 +68,135 @@ export async function handleRunAction(params, _sessionId, _agentName, signal) {
}
}
+/** Main-world expression returning the node marked data-bc-upload=token (pierces open shadow roots / same-origin frames). */
+export function findMarkedExpression(token) {
+ return `(() => { const s = '[data-bc-upload="${token}"]';
+ const q = (root, d) => { const hit = root.querySelector(s); if (hit || d > 6) return hit;
+ for (const el of root.querySelectorAll('*')) {
+ if (el.shadowRoot) { const h = q(el.shadowRoot, d + 1); if (h) return h; }
+ if (el.tagName === 'IFRAME') { try { const h = el.contentDocument && q(el.contentDocument, d + 1); if (h) return h; } catch (e) {} }
+ }
+ return null; };
+ return q(document, 0); })()`;
+}
+
+/**
+ * Page-side: build a File from base64 bytes and hand it to the page — into an
+ * (files + input/change events) or, for any other target,
+ * as a drag-and-drop (dragenter/dragover/drop with a DataTransfer), which is
+ * what upload drop zones listen for. No temp files, no file dialog.
+ */
+function pagePutFile(ref, sel, fb, b64, mime, name, x, y) {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ let target;
+ if (ref || sel) target = D.resolve(ref, sel, fb).el;
+ else if (Number.isFinite(x) && Number.isFinite(y)) target = D.elementAt(x, y);
+ else target = (D.queryAll('input[type="file"]', true) || [])[0] || null;
+ if (!target) return { success: false, error: 'Upload target not found' };
+ let bytes;
+ try {
+ const bin = atob(b64);
+ bytes = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
+ } catch { return { success: false, error: 'imageBase64 is not valid base64' }; }
+ const file = new File([bytes], name, { type: mime });
+ const dt = new DataTransfer();
+ dt.items.add(file);
+ if (target.tagName === 'INPUT' && target.type === 'file') {
+ target.files = dt.files;
+ target.dispatchEvent(new Event('input', { bubbles: true }));
+ target.dispatchEvent(new Event('change', { bubbles: true }));
+ return { success: true, mode: 'input', file: name, size: file.size };
+ }
+ const r = target.getBoundingClientRect();
+ const init = { bubbles: true, cancelable: true, composed: true, dataTransfer: dt, clientX: r.left + r.width / 2, clientY: r.top + r.height / 2 };
+ for (const type of ['dragenter', 'dragover', 'drop']) target.dispatchEvent(new DragEvent(type, init));
+ return { success: true, mode: 'drop', file: name, size: file.size, target: D.describe(target) };
+}
+
+/** Upload bytes (base64 or a fresh screenshot) instead of a local path. */
+async function uploadBytes(tab, params) {
+ let b64 = params.imageBase64 || null;
+ let mime = params.mimeType || 'image/png';
+ let name = params.fileName || 'image.png';
+ if (params.fromScreenshot) {
+ const shot = await handleScreenshot({
+ tabId: params.screenshotTabId ?? tab.id, format: 'png',
+ ...(params.region ? { region: params.region } : {}),
+ });
+ if (!shot?.data) throw new Error('Screenshot for upload returned no data');
+ b64 = shot.data;
+ mime = 'image/png';
+ name = params.fileName || 'screenshot.png';
+ }
+ if (!b64) throw new Error('imageBase64 or fromScreenshot required');
+ const res = await execDom(tab.id, pagePutFile, [
+ params.ref || null, params.selector || null, getFallback(tab.id, params.ref),
+ b64, mime, name, params.x ?? null, params.y ?? null,
+ ]);
+ return res;
+}
+
export async function handleUploadFile(params) {
const { tabId, ref, selector, filePath, files: fileList } = params;
const tab = await resolveTab(tabId);
+ if (params.imageBase64 || params.fromScreenshot) return uploadBytes(tab, params);
const filePaths = fileList || (filePath ? [filePath] : []);
- if (filePaths.length === 0) throw new Error('filePath or files required');
+ if (filePaths.length === 0) throw new Error('filePath, files, imageBase64 or fromScreenshot required');
- let sel = 'input[type="file"]';
- if (ref) sel = `[data-mcp-ref="${ref}"]`;
- else if (selector) sel = selector;
-
- // Verify the target BEFORE the CDP round-trip: CDP's DOM.querySelector
- // happily resolves any node, and DOM.setFileInputFiles on a non-file input
- // fails with an opaque protocol error (or worse, on some Chrome versions,
- // appears to succeed). React onChange handlers also require a change/input
- // event after the files are set — CDP doesn't fire one.
- const check = await safeExec(tab.id, (s) => {
- const el = document.querySelector(s);
+ // Resolve in the page with the shared resolver (ref registry, visible-first
+ // selector across shadow roots / same-origin frames, verified fallback), then
+ // hand the node to CDP through a one-shot marker attribute.
+ const sel = selector || (ref ? null : 'input[type="file"]');
+ const what = selector || (ref ? `ref ${ref}` : 'input[type="file"]');
+ const token = `u${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
+ const check = await execDom(tab.id, (_ref, _sel, _fb, _token) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const el = D.resolve(_ref, _sel, _fb).el;
if (!el) return { found: false };
+ el.setAttribute('data-bc-upload', _token);
return {
found: true,
isFileInput: el.tagName === 'INPUT' && el.type === 'file',
multiple: !!el.multiple,
};
- }, [sel]).catch(() => null);
- if (check && check.found) {
- if (!check.isFileInput) throw new Error(`Element matching ${sel} is not an .`);
- if (filePaths.length > 1 && !check.multiple) {
- throw new Error(`File input matching ${sel} does not accept multiple files.`);
- }
+ }, [ref || null, sel, getFallback(tab.id, ref), token]).catch(() => null);
+ if (!check || !check.found) throw new Error(`File input not found: ${what}`);
+ if (!check.isFileInput) throw new Error(`Element matching ${what} is not an .`);
+ if (filePaths.length > 1 && !check.multiple) {
+ throw new Error(`File input matching ${what} does not accept multiple files.`);
}
// upload_file stays on CDP (DOM.setFileInputFiles is CDP-only).
let uploaded = false;
try {
const send = await ensureCdp(tab.id);
- await send('DOM.enable');
- const { root } = await send('DOM.getDocument');
-
- const { nodeId } = await send('DOM.querySelector', {
- nodeId: root.nodeId,
- selector: sel,
- });
-
- if (!nodeId) throw new Error(`File input not found with selector: ${sel}`);
-
- await send('DOM.setFileInputFiles', {
- files: filePaths,
- nodeId,
- });
+ // Find the marked node wherever it lives (open shadow roots, same-origin frames).
+ const { result } = await send('Runtime.evaluate', { expression: findMarkedExpression(token) });
+ if (!result || !result.objectId) throw new Error(`File input not found: ${what}`);
+ await send('DOM.setFileInputFiles', { files: filePaths, objectId: result.objectId });
uploaded = true;
} finally {
// Fire the events React/Vue file inputs listen for after a successful set,
- // and always remove the short-lived Observation V2 handoff marker.
+ // and always remove the one-shot marker (and the Observation V2 handoff marker).
try {
- await safeExec(tab.id, (s, notify) => {
- const el = document.querySelector(s);
- if (!el) return;
+ await execDom(tab.id, (_token, notify) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const el = (D.queryAll(`[data-bc-upload="${_token}"]`, true) || [])[0];
+ if (!el) return null;
if (notify) {
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
+ el.removeAttribute('data-bc-upload');
el.removeAttribute('data-bc-v2-upload');
- }, [sel, uploaded]);
+ return null;
+ }, [token, uploaded]);
} catch { /* page changed — CDP outcome still determines the tool result */ }
}
- return { success: true, files: filePaths, selector: sel };
+ return { success: true, files: filePaths, selector: what };
}
diff --git a/extension/handlers/find.js b/extension/handlers/find.js
new file mode 100644
index 0000000..d93aca0
--- /dev/null
+++ b/extension/handlers/find.js
@@ -0,0 +1,128 @@
+/**
+ * browser_find: natural-language element search (tokenized, role-aware,
+ * shadow DOM + same-origin iframes, wrapper/echo suppression). Refs go into
+ * the shared page registry so every ref tool can use them.
+ */
+import { safeExec, execDom, resolveTab } from '../lib/page-exec.js';
+import { fallbackByTab, persistSessionState, nextRefPrefix } from '../lib/state.js';
+import { PAGE_FALLBACK_INSTALL } from '../utils/smart-selector.js';
+
+export async function handleFind(params) {
+ const { tabId, query, limit = 10, role } = params;
+ await resolveTab(tabId);
+ await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
+ const refPrefix = nextRefPrefix('f');
+
+ return execDom(tabId, (_q, _lim, _refPrefix, _role) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const genFallback = (globalThis.__browserControllerFallbackRuntime || {}).generateFallback || null;
+ const fallbacks = {};
+
+ // Words that describe the KIND of element, mapped to the roles they mean.
+ const ROLE_WORDS = {
+ button: ['button'], btn: ['button'], link: ['link'], anchor: ['link'],
+ input: ['textbox', 'searchbox', 'combobox', 'spinbutton'], field: ['textbox', 'searchbox', 'combobox', 'spinbutton'],
+ textbox: ['textbox', 'searchbox'], box: ['textbox', 'searchbox', 'combobox', 'checkbox'], textarea: ['textbox'],
+ searchbox: ['searchbox'], checkbox: ['checkbox'], check: ['checkbox'], radio: ['radio'],
+ dropdown: ['combobox', 'listbox', 'button'], select: ['combobox', 'listbox'], combobox: ['combobox'],
+ tab: ['tab'], menu: ['menu', 'menubar', 'button'], menuitem: ['menuitem'], option: ['option'],
+ heading: ['heading'], title: ['heading'], image: ['img'], img: ['img'], icon: ['img', 'button'],
+ dialog: ['dialog', 'alertdialog'], modal: ['dialog', 'alertdialog'], switch: ['switch'], toggle: ['switch', 'button', 'checkbox'],
+ slider: ['slider'], list: ['list', 'listbox'], table: ['table', 'grid'], row: ['row'], cell: ['cell', 'gridcell'],
+ };
+ const STOP = new Set(['the', 'a', 'an', 'to', 'of', 'for', 'on', 'in', 'with', 'and', 'that', 'this', 'element', 'please']);
+ const words = String(_q).toLowerCase().split(/[^\p{L}\p{N}_-]+/u).filter((w) => w && !STOP.has(w));
+ const roleHints = new Set();
+ const content = [];
+ for (const w of words) {
+ if (ROLE_WORDS[w]) ROLE_WORDS[w].forEach((r) => roleHints.add(r));
+ else content.push(w);
+ }
+ // "search" names the purpose AND a role.
+ if (words.includes('search')) roleHints.add('searchbox');
+ const phrase = content.join(' ');
+ const wantRole = _role ? String(_role).toLowerCase() : null;
+
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'META', 'LINK', 'HEAD', 'HTML', 'BODY', 'BR', 'PATH']);
+ const cands = [];
+ for (const root of D.allRoots(true)) {
+ let els = [];
+ try { els = root.querySelectorAll('*'); } catch {}
+ for (const el of els) {
+ if (SKIP.has(el.tagName)) continue;
+ const r = D.roleOf(el);
+ if (r === 'none') continue;
+ if (wantRole && r !== wantRole) continue;
+ const name = D.nameOf(el).toLowerCase();
+ const attrs = [el.id, D.attr(el, 'name'), D.attr(el, 'type'), D.attr(el, 'placeholder'), D.attr(el, 'data-testid'),
+ D.attr(el, 'title'), typeof el.className === 'string' ? el.className : ''].join(' ').toLowerCase();
+ const interactive = D.isInteractive(el);
+ let score = 0;
+ let covered = 0;
+ for (const w of content) {
+ const inName = name.includes(w);
+ const inAttr = attrs.includes(w);
+ if (inName) score += new RegExp(`(^|[^\\p{L}\\p{N}])${w.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}([^\\p{L}\\p{N}]|$)`, 'u').test(name) ? 6 : 4;
+ else if (inAttr) score += 3;
+ if (inName || inAttr) covered++;
+ }
+ if (content.length && covered === 0) continue;
+ if (phrase && name === phrase) score += 12;
+ else if (phrase && content.length > 1 && name.includes(phrase)) score += 6;
+ if (roleHints.size) {
+ if (roleHints.has(r) || (roleHints.has('searchbox') && /search/.test(attrs) && ['textbox', 'searchbox', 'combobox'].includes(r))) score += 8;
+ else if (!content.length) continue;
+ else score -= 2;
+ }
+ if (interactive) score += 4;
+ else if (r === 'generic') score -= 3;
+ // A container whose text merely CONTAINS the words is a weak match.
+ if (name.length > 120) score -= 4;
+ const coverage = content.length ? covered / content.length : 1;
+ if (coverage < 0.5) continue;
+ score = Math.round(score * coverage * 10) / 10;
+ if (score <= 0) continue;
+ cands.push({ el, r, name, score, interactive });
+ }
+ }
+ cands.sort((a, b) => b.score - a.score);
+ // Visibility is the expensive check: only for the best-scoring pool.
+ const pool = [];
+ for (const c of cands) {
+ if (pool.length >= _lim * 6) break;
+ if (D.isVisible(c.el)) pool.push(c);
+ }
+ // Drop wrappers (an ancestor scoring no better than a descendant) and echoes
+ // (a descendant repeating the name of the control that contains it).
+ const kept = pool.filter((c) => !pool.some((o) => o !== c && (
+ (o.score >= c.score && D.composedContains(c.el, o.el))
+ || (o.interactive && !c.interactive && o.score >= c.score && o.name === c.name && D.composedContains(o.el, c.el)))));
+
+ const matches = [];
+ kept.slice(0, _lim).forEach((c, i) => {
+ const ref = `${_refPrefix}${i}`;
+ D.registry.set(ref, c.el);
+ try { if (genFallback) fallbacks[ref] = genFallback(c.el); } catch {}
+ const rect = D.centerOf(c.el).rect;
+ matches.push({
+ ref, role: c.r, name: D.nameOf(c.el).slice(0, 80), tag: c.el.tagName.toLowerCase(), score: c.score,
+ bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
+ });
+ });
+ return {
+ success: true, query: _q, matches,
+ ...(matches.length === 0 ? { hint: 'No match. Try fewer/other words, a role filter, browser_snapshot, or browser_text.' } : {}),
+ __fallbacks: fallbacks,
+ };
+ }, [query, limit, refPrefix, role || null]).then((res) => {
+ if (res && res.__fallbacks) {
+ const map = fallbackByTab.get(tabId) || new Map();
+ for (const [ref, fbEntry] of Object.entries(res.__fallbacks)) map.set(ref, fbEntry);
+ fallbackByTab.set(tabId, map);
+ delete res.__fallbacks;
+ persistSessionState();
+ }
+ return res;
+ });
+}
diff --git a/extension/handlers/gif.js b/extension/handlers/gif.js
new file mode 100644
index 0000000..a376c36
--- /dev/null
+++ b/extension/handlers/gif.js
@@ -0,0 +1,154 @@
+/**
+ * browser_gif: record what the agent does in a tab as an animated GIF
+ * (Claude-in-Chrome gif_creator). While recording, the router captures a
+ * downscaled frame after every page-changing action; export encodes the
+ * frames (lib/gif-encoder.js) with a red ring where clicks landed. The MCP
+ * server writes the file — the GIF bytes never go to the agent.
+ */
+import { resolveTab } from '../lib/page-exec.js';
+import { encodeGif, drawMarker } from '../lib/gif-encoder.js';
+import { handleScreenshot } from './tabs.js';
+
+/** Tools after which a frame is captured. Reads (text/snapshot/find…) don't change the page. */
+export const GIF_FRAME_TOOLS = new Set([
+ 'browser_navigate', 'browser_click', 'browser_type', 'browser_press_key', 'browser_scroll', 'browser_hover',
+ 'browser_select', 'browser_click_text', 'browser_drag', 'browser_fill_form', 'browser_act', 'browser_upload_file',
+ 'browser_handle_dialog', 'browser_run_action', 'browser_evaluate', 'browser_wait',
+]);
+
+const MAX_FRAMES_CAP = 500;
+/** Export travels in parts: the daemon's WebSocket frames are capped at 1 MB. */
+export const GIF_PART_BYTES = 600_000;
+/** tabId -> { frames, width, maxFrames, activate, recording, skipped, startedAt } */
+const recordings = new Map();
+
+export function isRecording(tabId) {
+ const r = recordings.get(tabId);
+ return !!(r && r.recording);
+}
+
+/** Capture one frame (after an action). Never throws: a failed frame is just skipped. */
+export async function recordFrame(tabId, label, result) {
+ const rec = recordings.get(tabId);
+ if (!rec || !rec.recording) return;
+ if (rec.frames.length >= rec.maxFrames) { rec.skipped++; return; }
+ try {
+ const tab = await chrome.tabs.get(tabId);
+ // Hidden tabs don't paint; activating one flashes it for ~150 ms (like browser_screenshot).
+ if (!tab.active && !rec.activate) { rec.skipped++; return; }
+ const shot = await handleScreenshot({ tabId, format: 'jpeg', quality: 70, maxWidth: rec.width });
+ if (!shot?.data) { rec.skipped++; return; }
+ const f = shot.frame;
+ const at = result && result.at && f && !f.page
+ ? [(result.at.x - f.origin[0]) * f.scale, (result.at.y - f.origin[1]) * f.scale]
+ : null;
+ rec.frames.push({ data: shot.data, t: Date.now(), label, ...(at ? { at } : {}) });
+ } catch {
+ rec.skipped++;
+ }
+}
+
+function b64ToBytes(b64) {
+ const bin = atob(b64);
+ const out = new Uint8Array(bin.length);
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
+ return out;
+}
+
+function bytesToB64(bytes) {
+ let s = '';
+ for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode.apply(null, bytes.subarray(i, i + 0x8000));
+ return btoa(s);
+}
+
+async function encodeRecording(rec) {
+ const decoded = [];
+ let W = 0;
+ let H = 0;
+ for (const fr of rec.frames) {
+ const bmp = await createImageBitmap(new Blob([b64ToBytes(fr.data)], { type: 'image/jpeg' }));
+ if (!W) { W = bmp.width; H = bmp.height; }
+ const canvas = new OffscreenCanvas(W, H);
+ const ctx = canvas.getContext('2d');
+ ctx.drawImage(bmp, 0, 0, W, H);
+ bmp.close?.();
+ const rgba = ctx.getImageData(0, 0, W, H).data;
+ if (fr.at) drawMarker(rgba, W, H, fr.at[0] * (W / (bmp.width || W)), fr.at[1] * (H / (bmp.height || H)));
+ decoded.push({ rgba, t: fr.t });
+ }
+ const frames = decoded.map((d, i) => ({
+ rgba: d.rgba,
+ // Real pacing, clamped so the GIF is watchable: 0.4 s … 2.5 s, 1.5 s on the last frame.
+ delayMs: i + 1 < decoded.length ? Math.min(2500, Math.max(400, decoded[i + 1].t - d.t)) : 1500,
+ }));
+ return { bytes: encodeGif(W, H, frames), width: W, height: H };
+}
+
+export async function handleGif(params) {
+ const { tabId, action } = params;
+ await resolveTab(tabId);
+ switch (action) {
+ case 'start': {
+ const rec = {
+ frames: [],
+ width: Math.min(Math.max(Number(params.width) || 800, 200), 1600),
+ maxFrames: Math.min(Math.max(Number(params.maxFrames) || 300, 1), MAX_FRAMES_CAP),
+ activate: params.activate !== false,
+ recording: true,
+ skipped: 0,
+ startedAt: Date.now(),
+ };
+ recordings.set(tabId, rec);
+ await recordFrame(tabId, 'start', null); // the first frame: how the page looked
+ return { success: true, recording: true, frames: rec.frames.length, width: rec.width, maxFrames: rec.maxFrames };
+ }
+ case 'frame': {
+ const rec = recordings.get(tabId);
+ if (!rec) throw new Error('Not recording this tab — call browser_gif action:"start" first.');
+ const was = rec.recording;
+ rec.recording = true;
+ await recordFrame(tabId, 'frame', null);
+ rec.recording = was;
+ return { success: true, frames: rec.frames.length };
+ }
+ case 'stop': {
+ const rec = recordings.get(tabId);
+ if (!rec) throw new Error('Not recording this tab.');
+ rec.recording = false;
+ return { success: true, recording: false, frames: rec.frames.length, skipped: rec.skipped, seconds: Math.round((Date.now() - rec.startedAt) / 1000) };
+ }
+ case 'status': {
+ const rec = recordings.get(tabId);
+ return rec
+ ? { success: true, recording: rec.recording, frames: rec.frames.length, skipped: rec.skipped }
+ : { success: true, recording: false, frames: 0 };
+ }
+ case 'clear': {
+ recordings.delete(tabId);
+ return { success: true, cleared: true };
+ }
+ case 'export': {
+ const rec = recordings.get(tabId);
+ if (!rec || (rec.frames.length === 0 && !rec.encoded)) throw new Error('No frames recorded for this tab.');
+ rec.recording = false;
+ // Encode once (part 0), then hand the bytes out part by part.
+ const part = Number.isInteger(params.part) && params.part > 0 ? params.part : 0;
+ if (part === 0 || !rec.encoded) rec.encoded = await encodeRecording(rec);
+ const { bytes, width, height } = rec.encoded;
+ const parts = Math.max(1, Math.ceil(bytes.length / GIF_PART_BYTES));
+ if (part >= parts) throw new Error(`part ${part} out of range (${parts} parts)`);
+ const chunk = bytes.subarray(part * GIF_PART_BYTES, (part + 1) * GIF_PART_BYTES);
+ const frames = rec.frames.length;
+ if (part === parts - 1) {
+ rec.encoded = null;
+ if (params.clear !== false) recordings.delete(tabId);
+ }
+ return { success: true, frames, width, height, bytes: bytes.length, part, parts, gifBase64: bytesToB64(chunk) };
+ }
+ default:
+ throw new Error(`Unknown action: ${action}`);
+ }
+}
+
+/** Test hook. */
+export function _recordings() { return recordings; }
diff --git a/extension/handlers/inspection.js b/extension/handlers/inspection.js
index 3bea899..f189c06 100644
--- a/extension/handlers/inspection.js
+++ b/extension/handlers/inspection.js
@@ -2,15 +2,18 @@
* Inspection handlers (extracted from background.js): wait, scroll, snapshot,
* find, text, evaluate — the read side of the toolset.
*/
-import { safeExec, resolveTab, getFallback } from '../lib/page-exec.js';
-import { fallbackByTab, lastSnapshotFingerprints, MAX_RESULT_CHARS, persistSessionState } from '../lib/state.js';
+import { safeExec, execDom, resolveTab, getFallback, assertResponsive, hasPoint } from '../lib/page-exec.js';
+import { trustedSender, pointInfo, releaseShield } from '../lib/trusted-input.js';
+import { fallbackByTab, lastSnapshotFingerprints, MAX_RESULT_CHARS, persistSessionState, nextRefPrefix } from '../lib/state.js';
import { PAGE_FALLBACK_INSTALL } from '../utils/smart-selector.js';
-import { PAGE_LEGACY_REF_INSTALL } from '../utils/legacy-refs.js';
import { withCdp } from '../lib/cdp-session.js';
import { cdpEvaluate } from '../lib/cdp-evaluate.js';
+/** Default output cap for snapshots (chars of serialized tree). */
+export const SNAPSHOT_MAX_CHARS = 20_000;
+
export async function handleWait(params, _sessionId, _agentName, signal) {
- const { tabId, selector, state = 'visible', timeout = 10000, delay } = params;
+ const { tabId, selector, state = 'visible', timeout = 10000, delay, text, urlIncludes } = params;
// A promise that rejects when this call is cancelled (client gone / bridge
// timeout forwarded). Long waits race against it so a cancelled call releases
@@ -22,6 +25,7 @@ export async function handleWait(params, _sessionId, _agentName, signal) {
})
: null;
+ const hasCondition = !!selector || text != null || !!urlIncludes;
if (delay) {
const sleep = new Promise((r) => setTimeout(r, Math.min(delay, 30000)));
try {
@@ -29,55 +33,94 @@ export async function handleWait(params, _sessionId, _agentName, signal) {
} catch {
return { success: false, error: 'aborted', waited: 0 };
}
- return { success: true, waited: delay };
+ return { success: true, waited: delay }; // documented: a delay ignores the conditions
}
- if (!selector) return { success: false, error: 'Need selector or delay' };
+ if (!hasCondition) return { success: false, error: 'Need selector, text, urlIncludes or delay' };
await resolveTab(tabId);
const start = Date.now();
+ const what = selector || (text != null ? `text "${text}"` : `url containing "${urlIncludes}"`);
while (Date.now() - start < timeout) {
// Bail the moment the caller is gone so we don't pin the tab mutex for the
// full timeout window after the originating agent was evicted (consistent
// with handleNavigate / handleRunAction).
if (signal?.aborted) return { success: false, error: 'aborted', selector, state };
- const found = await safeExec(tabId, (_sel, _state) => {
- const el = document.querySelector(_sel);
- if (_state === 'hidden') return !el || el.offsetParent === null;
- if (_state === 'attached') return !!el;
- return el && el.offsetParent !== null;
- }, [selector, state]);
-
- if (found) return { success: true, selector, state, elapsed: Date.now() - start };
+ let found;
+ try {
+ found = await execDom(tabId, (_sel, _state, _text, _url) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const hidden = _state === 'hidden';
+ if (_url != null && !location.href.includes(_url)) return false;
+ if (_text != null) {
+ const has = D.pageText(document.body).toLowerCase().includes(String(_text).toLowerCase());
+ if (hidden ? has : !has) return false;
+ }
+ if (_sel) {
+ // Every match across shadow roots / same-origin frames, not just the first.
+ const all = D.queryAll(_sel, true);
+ if (all === null) return { error: `Invalid CSS selector: ${_sel}` };
+ if (_state === 'attached') return all.length > 0;
+ const anyVisible = all.some((el) => D.isVisible(el));
+ return hidden ? !anyVisible : anyVisible;
+ }
+ return true;
+ }, [selector ?? null, state, text ?? null, urlIncludes ?? null]);
+ } catch { found = false; /* navigating: the next document isn't ready yet */ }
+ if (found && found.error) return { success: false, error: found.error };
+
+ if (found === true) {
+ return {
+ success: true,
+ ...(selector ? { selector } : {}),
+ ...(text != null ? { text } : {}),
+ ...(urlIncludes ? { urlIncludes } : {}),
+ state,
+ elapsed: Date.now() - start,
+ };
+ }
await new Promise((r) => setTimeout(r, 200));
}
- return { success: false, error: `Timeout waiting for ${selector} to be ${state}` };
+ return { success: false, error: `Timeout waiting for ${what} to be ${state}` };
}
export async function handleScroll(params) {
const { tabId, direction = 'down', amount = 500, selector, toElement, position } = params;
await resolveTab(tabId);
+ // x/y: a real mouse-wheel event at that point — scrolls whatever is under
+ // it (inner panels, maps, virtual lists) exactly like a user's wheel.
+ if (hasPoint(params) && !toElement && !position && !selector) {
+ const send = await trustedSender(tabId, true);
+ if (!send) throw new Error(`Scrolling at x/y needs the debugger (CDP), which could not attach to tab ${tabId}. Use selector/toElement instead.`);
+ const deltaX = direction === 'right' ? amount : direction === 'left' ? -amount : 0;
+ const deltaY = direction === 'down' ? amount : direction === 'up' ? -amount : 0;
+ const info = await pointInfo(tabId, params.x, params.y);
+ try {
+ await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: params.x, y: params.y, deltaX, deltaY });
+ } finally {
+ await releaseShield(tabId);
+ }
+ return { success: true, input: 'cdp', at: { x: params.x, y: params.y }, deltaX, deltaY, refsMayBeStale: true, ...(info.hit ? { over: info.hit } : {}) };
+ }
const fb = getFallback(tabId, toElement);
- if (fb) await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
- await safeExec(tabId, PAGE_LEGACY_REF_INSTALL, []);
- return safeExec(tabId, (_dir, _amt, _sel, _toEl, _pos, _fb) => {
+ return execDom(tabId, (_dir, _amt, _sel, _toEl, _pos, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
if (_toEl) {
- const resolveFallback = (globalThis.__browserControllerFallbackRuntime || {}).resolveFallback || null;
- const resolveRef = (globalThis.__browserControllerLegacyRefRuntime || {}).resolveRef || null;
- const el = (resolveRef ? resolveRef(_toEl) : null) ||
- document.querySelector(`[data-mcp-ref="${_toEl}"]`) ||
- document.querySelector(_toEl) ||
- (_fb && resolveFallback ? resolveFallback(_fb) : null);
+ // toElement accepts a ref or a CSS selector (first visible match).
+ let el = D.resolve(_toEl, null, _fb).el;
+ if (!el) { try { el = D.resolve(null, _toEl, null).el; } catch { el = null; } }
if (el) {
- el.scrollIntoView({ behavior: 'smooth', block: 'center' });
+ el.scrollIntoView({ behavior: 'instant', block: 'center' });
return { success: true, scrolledTo: 'element' };
}
return { success: false, error: 'Element not found' };
}
- const target = _sel ? document.querySelector(_sel) : window;
+ const target = _sel ? D.resolve(null, _sel, null).el : window;
if (!target) return { success: false, error: 'Scroll container not found' };
if (_pos === 'top') {
@@ -118,7 +161,9 @@ export async function handleScroll(params) {
* DOM with permanent data-mcp-ref attributes.
*/
export async function handleSnapshot(params) {
- const { tabId, selector, compact = true } = params;
+ const { tabId, selector, ref: rootRef, depth, maxChars = SNAPSHOT_MAX_CHARS } = params;
+ // filter:"interactive"|"all" (Claude-in-Chrome naming) is an alias of compact.
+ const compact = params.filter === 'all' ? false : params.filter === 'interactive' ? true : params.compact !== false;
await resolveTab(tabId);
// Install the fallback page runtime first (v2 install-once pattern): the
@@ -127,196 +172,185 @@ export async function handleSnapshot(params) {
// extension CSP (script-src 'self', no unsafe-eval) throws in every
// isolated world, which silently killed fallback capture before this fix.
await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
- await safeExec(tabId, PAGE_LEGACY_REF_INSTALL, []);
// isNew feature: pass the fingerprints seen in the PREVIOUS snapshot so the
// page function can mark newly-appeared elements. Array is serializable.
- const prevFingerprints = lastSnapshotFingerprints.get(tabId) || [];
- const refPrefix = `e-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-`;
+ const prevFingerprints = lastSnapshotFingerprints.get(tabId) || null;
+ const refPrefix = nextRefPrefix('s');
- return safeExec(tabId, (_sel, _compact, _prevFingerprints, _refPrefix) => {
+ return execDom(tabId, (_sel, _compact, _prevFingerprints, _refPrefix, _rootRef, _depth, _maxChars) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
let refCount = 0;
/** @type {Record} ref -> fallback, returned to background */
const fallbacks = {};
/** @type {string[]} fingerprints of THIS snapshot (role|name), returned to background */
const fingerprints = [];
- const prevSet = new Set(_prevFingerprints);
+ // No previous snapshot → nothing is "new" (marking every node wasted tokens).
+ const prevSet = _prevFingerprints ? new Set(_prevFingerprints) : null;
// Descriptor generator comes from the pre-installed page runtime.
const genFallback = (globalThis.__browserControllerFallbackRuntime || {}).generateFallback || null;
- const registerRef = (globalThis.__browserControllerLegacyRefRuntime || {}).registerRef || null;
const skipTags = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'SVG', 'PATH', 'BR', 'HR', 'WBR', 'META', 'LINK']);
+ const maxDepth = Number.isInteger(_depth) && _depth >= 0 ? _depth : Infinity;
+ // Output budget: stop emitting nodes once the serialized size reaches it.
+ let budget = Number.isInteger(_maxChars) && _maxChars > 0 ? _maxChars : Infinity;
+ let truncated = false;
+ // 'show' = render normally, 'pass' = no box of its own (display:contents,
+ // slots) but its children may render, false = hidden subtree.
function vis(el) {
- const s = getComputedStyle(el);
- if (s.display === 'none' || s.visibility === 'hidden' || parseFloat(s.opacity) === 0) return false;
+ const s = D.styleOf(el);
+ if (!s || s.display === 'none') return false;
+ if (s.display === 'contents' || el.tagName === 'SLOT') return 'pass';
+ if (s.visibility === 'hidden' || s.visibility === 'collapse' || parseFloat(s.opacity) === 0) {
+ // visibility is inherited but can be re-enabled below; keep walking.
+ return 'pass';
+ }
const r = el.getBoundingClientRect();
- return r.width > 0 && r.height > 0;
+ if (r.width > 0 && r.height > 0) return 'show';
+ // Zero-size wrappers (custom-element hosts, overflow containers) can still hold visible children.
+ return el.childElementCount > 0 || D.shadowOf(el) ? 'pass' : false;
}
- function role(el) {
- const r = el.getAttribute('role');
- if (r) return r;
- const map = {
- A: 'link', BUTTON: 'button', SELECT: 'combobox', TEXTAREA: 'textbox', IMG: 'img',
- H1: 'heading', H2: 'heading', H3: 'heading', H4: 'heading', H5: 'heading', H6: 'heading',
- NAV: 'navigation', MAIN: 'main', HEADER: 'banner', FOOTER: 'contentinfo', FORM: 'form',
- TABLE: 'table', UL: 'list', OL: 'list', LI: 'listitem',
- };
- if (el.tagName === 'INPUT') {
- const t = el.type?.toLowerCase();
- if (t === 'checkbox') return 'checkbox';
- if (t === 'radio') return 'radio';
- return 'textbox';
- }
- return map[el.tagName] || 'generic';
- }
+ const role = (el) => D.roleOf(el);
+ // Landmarks/regions are named only by an explicit label: their text is just
+ // their children's names again (token noise).
+ const elName = (el, r) => (landmarkRoles.has(r) && r !== 'dialog'
+ ? D.clean(D.attr(el, 'aria-label') || D.attr(el, 'title'))
+ : D.nameOf(el)).slice(0, 80);
+ const isInteractive = (el) => D.isInteractive(el);
- function elName(el) {
- const raw = (
- el.getAttribute('aria-label') || el.getAttribute('alt') ||
- el.getAttribute('title') || el.getAttribute('placeholder') ||
- ''
- ).trim();
- if (raw) return raw.slice(0, 80);
- const text = el.innerText;
- if (!text) return '';
- const first = text.split('\n')[0].trim();
- return first.slice(0, 80);
- }
+ const landmarkRoles = new Set(['navigation', 'main', 'banner', 'contentinfo', 'form', 'search', 'complementary', 'region', 'dialog']);
- function isInteractive(el) {
- const tags = ['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA'];
- return tags.includes(el.tagName) || el.onclick || el.getAttribute('tabindex') !== null ||
- el.getAttribute('role') === 'button' || el.getAttribute('role') === 'link' ||
- el.getAttribute('role') === 'tab' || el.getAttribute('role') === 'menuitem' ||
- el.getAttribute('role') === 'option' || el.getAttribute('role') === 'switch' ||
- el.getAttribute('contenteditable') === 'true';
+ // Flat-tree children: open AND closed shadow roots, slotted content,
+ // same-origin iframe bodies (lib/page-dom.js flatChildren).
+ function childrenOf(el) {
+ return D.flatChildren(el).filter((c) => c.nodeType === 1);
}
- const landmarkRoles = new Set(['navigation', 'main', 'banner', 'contentinfo', 'form', 'search', 'complementary', 'region']);
+ const origin = location.origin;
+ function hrefOf(el) {
+ const h = el.href;
+ if (!h || typeof h !== 'string') return null;
+ if (h.startsWith(origin + '/')) return h.slice(origin.length); // same-origin: path only
+ return h;
+ }
- // Children including shadow DOM (open roots) and same-origin iframes.
- function childrenOf(el) {
- const out = [];
- for (const c of el.children) out.push(c);
- if (el.shadowRoot) {
- for (const c of el.shadowRoot.children) out.push(c);
- }
- // same-origin iframes: expose their document body children too.
- if (el.tagName === 'IFRAME') {
- try {
- const doc = el.contentDocument;
- if (doc && doc.body) for (const c of doc.body.children) out.push(c);
- } catch { /* cross-origin: skip */ }
- }
- return out;
+ function emit(el, r, n, extra, isNewCheck) {
+ const ref = `${_refPrefix}${refCount++}`;
+ D.registry.set(ref, el);
+ try { if (genFallback) fallbacks[ref] = genFallback(el); } catch {}
+ const fp = `${r}|${n}`;
+ fingerprints.push(fp);
+ const node = { ref, role: r, ...extra };
+ if (n) node.name = n;
+ if (isNewCheck && prevSet && !prevSet.has(fp)) node.isNew = true;
+ if (el.value !== undefined && el.value !== '' && typeof el.value !== 'object') node.value = String(el.value).slice(0, 200);
+ if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) node.checked = el.checked;
+ else if (D.attr(el, 'aria-checked')) node.checked = D.attr(el, 'aria-checked') === 'true';
+ if (D.attr(el, 'aria-expanded')) node.expanded = D.attr(el, 'aria-expanded') === 'true';
+ if (D.attr(el, 'aria-selected') === 'true') node.selected = true;
+ if (el.disabled) node.disabled = true;
+ if (el.tagName === 'A') { const h = hrefOf(el); if (h) node.href = h; }
+ budget -= JSON.stringify(node).length + 16;
+ return node;
}
- function buildCompact(el) {
+ function buildCompact(el, d) {
if (!el || el.nodeType !== 1) return null;
if (skipTags.has(el.tagName)) return null;
- if (!vis(el)) return null;
+ if (budget <= 0) { truncated = true; return null; }
+ const v = vis(el);
+ if (!v) return null;
- const ia = isInteractive(el);
+ const ia = v === 'show' && isInteractive(el);
const r = role(el);
- const isLandmark = landmarkRoles.has(r);
+ const isLandmark = v === 'show' && (landmarkRoles.has(r) || (r === 'heading'));
+ const own = ia || isLandmark;
- const kids = [];
- for (const c of childrenOf(el)) {
- const cn = buildCompact(c);
- if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn);
+ let node = null;
+ if (own) {
+ if (d > maxDepth) { truncated = true; return null; }
+ node = emit(el, r, elName(el, r), {}, true);
}
+ const kids = [];
+ if (!(own && d >= maxDepth)) {
+ for (const c of childrenOf(el)) {
+ const cn = buildCompact(c, own ? d + 1 : d);
+ if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn);
+ }
+ } else if (childrenOf(el).length) truncated = true;
- if (!ia && !isLandmark && r !== 'heading') {
- return kids.length === 0 ? null : kids.length === 1 ? kids[0] : kids;
- }
-
- const ref = `${_refPrefix}${refCount++}`;
- if (registerRef) registerRef(ref, el);
- const n = elName(el);
- try { if (genFallback) fallbacks[ref] = genFallback(el); } catch {}
-
- // isNew: mark elements whose (role|name) wasn't in the previous snapshot.
- const fp = `${r}|${n}`;
- fingerprints.push(fp);
- const isNew = !prevSet.has(fp);
-
- const node = { ref, role: r };
- if (n) node.name = n;
- if (isNew) node.isNew = true;
- if (el.value !== undefined && el.value !== '') node.value = String(el.value);
- if (el.checked !== undefined) node.checked = el.checked;
- if (el.disabled) node.disabled = true;
- if (el.href && el.tagName === 'A') node.href = el.href;
+ if (!own) return kids.length === 0 ? null : kids.length === 1 ? kids[0] : kids;
if (kids.length) node.children = kids;
-
return node;
}
- function buildFull(el, depth) {
+ function buildFull(el, d) {
if (!el || el.nodeType !== 1) return null;
if (skipTags.has(el.tagName)) return null;
- if (!vis(el)) return null;
+ if (budget <= 0) { truncated = true; return null; }
+ const v = vis(el);
+ if (!v) return null;
const r = role(el);
- const n = elName(el);
- const ia = isInteractive(el);
+ const ia = v === 'show' && isInteractive(el);
+ const n = v === 'show' ? elName(el, r) : '';
- if (r === 'generic' && !n && !ia && depth > 1) {
+ if (v !== 'show' || (r === 'generic' && !n && !ia && d > 1)) {
const kids = [];
for (const c of childrenOf(el)) {
- const cn = buildFull(c, depth + 1);
+ const cn = buildFull(c, d + (v === 'show' ? 1 : 0));
if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn);
}
return kids.length === 0 ? null : kids.length === 1 ? kids[0] : kids;
}
+ if (d > maxDepth) { truncated = true; return null; }
- const ref = `${_refPrefix}${refCount++}`;
- if (registerRef) registerRef(ref, el);
- try { if (genFallback) fallbacks[ref] = genFallback(el); } catch {}
-
- // isNew: mark elements whose (role|name) wasn't in the previous snapshot.
- const fp = `${r}|${n}`;
- fingerprints.push(fp);
- const isNew = !prevSet.has(fp);
-
- const node = { ref, role: r };
- if (r === 'generic') node.tag = el.tagName.toLowerCase();
- if (n) node.name = n;
- if (isNew) node.isNew = true;
- if (el.value !== undefined && el.value !== '') node.value = String(el.value);
- if (el.checked !== undefined) node.checked = el.checked;
- if (el.disabled) node.disabled = true;
- if (el.href && el.tagName === 'A') node.href = el.href;
-
+ const node = emit(el, r, n, r === 'generic' ? { tag: el.tagName.toLowerCase() } : {}, true);
const kids = [];
for (const c of childrenOf(el)) {
- const cn = buildFull(c, depth + 1);
+ const cn = buildFull(c, d + 1);
if (cn) Array.isArray(cn) ? kids.push(...cn) : kids.push(cn);
}
if (kids.length) node.children = kids;
-
return node;
}
- const root = _sel ? document.querySelector(_sel) : document.body;
+ let root = document.body;
+ if (_rootRef) {
+ root = D.registry.get(_rootRef);
+ if (!D.connected(root)) return { success: false, error: `ref ${_rootRef} is gone — take a new snapshot` };
+ } else if (_sel) {
+ const hit = D.resolve(null, _sel, null);
+ if (hit.error === 'INVALID_SELECTOR') return { success: false, error: `Invalid CSS selector: ${_sel}` };
+ root = hit.el;
+ }
if (!root) return { success: false, error: 'Root element not found' };
- const tree = _compact ? buildCompact(root) : buildFull(root, 0);
+ const tree = _compact ? buildCompact(root, 0) : buildFull(root, 0);
return {
success: true,
url: location.href,
title: document.title,
compact: _compact,
tree,
+ ...(truncated ? {
+ truncated: true,
+ hint: 'Output capped (maxChars/depth). Scope it with selector or ref (a subtree), or raise maxChars.',
+ } : {}),
// internal: background stores these per-tab; never sent to the agent.
__fallbacks: fallbacks,
__fingerprints: fingerprints,
};
- }, [selector, compact, prevFingerprints, refPrefix]).then((res) => {
+ }, [selector ?? null, compact, prevFingerprints, refPrefix, rootRef ?? null, depth ?? null, maxChars]).then((res) => {
// Store the fallbacks per-tab so click/type can resolve stale refs, and
- // persist them across service-worker recycles (MV3 lifetime).
+ // persist them across service-worker recycles (MV3 lifetime). Merged, not
+ // replaced: a scoped snapshot must not invalidate refs from the full one.
if (res && res.__fallbacks) {
- const map = new Map(Object.entries(res.__fallbacks));
+ const map = fallbackByTab.get(tabId) || new Map();
+ for (const [ref, fbEntry] of Object.entries(res.__fallbacks)) map.set(ref, fbEntry);
+ // Bound the map: keep the most recent entries.
+ while (map.size > 3000) map.delete(map.keys().next().value);
fallbackByTab.set(tabId, map);
delete res.__fallbacks; // keep it out of the agent-visible payload
persistSessionState();
@@ -330,104 +364,49 @@ export async function handleSnapshot(params) {
});
}
-export async function handleFind(params) {
- const { tabId, query, limit = 10 } = params;
- await resolveTab(tabId);
- await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
- await safeExec(tabId, PAGE_LEGACY_REF_INSTALL, []);
- const refPrefix = `f-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}-`;
-
- return safeExec(tabId, (_q, _lim, _refPrefix) => {
- const qLow = _q.toLowerCase();
- const matches = [];
- const fallbacks = {};
- const genFallback = (globalThis.__browserControllerFallbackRuntime || {}).generateFallback || null;
- const registerRef = (globalThis.__browserControllerLegacyRefRuntime || {}).registerRef || null;
-
- function aName(el) {
- return (el.getAttribute('aria-label') || el.getAttribute('alt') || el.getAttribute('title') ||
- el.getAttribute('placeholder') || el.innerText?.slice(0, 200) || '').trim();
- }
-
- function aRole(el) {
- const r = el.getAttribute('role');
- if (r) return r;
- const map = { A: 'link', BUTTON: 'button', INPUT: 'input', SELECT: 'combobox', TEXTAREA: 'textbox', IMG: 'image' };
- return map[el.tagName] || el.tagName.toLowerCase();
- }
-
- // Same-origin iframe piercing (field report: legacy UIs live entirely
- // inside #mainFrame — the top-document walk saw none of it).
- const roots = [document.body];
- (function collectFrames(doc, depth) {
- if (depth >= 3) return;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d && d.body) { roots.push(d.body); collectFrames(d, depth + 1); } } catch {}
- }
- })(document, 0);
- let rc = 0;
- let node;
- for (const root of roots) {
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
- while ((node = walker.nextNode()) && matches.length < _lim * 3) {
- const s = getComputedStyle(node);
- const rect = node.getBoundingClientRect();
- if (s.display === 'none' || s.visibility === 'hidden' || rect.width === 0) continue;
-
- const n = aName(node).toLowerCase();
- const r = aRole(node).toLowerCase();
- const id = (node.id || '').toLowerCase();
- let score = 0;
- if (n.includes(qLow)) score += 10;
- if (r.includes(qLow)) score += 5;
- if (id.includes(qLow)) score += 3;
- if (score === 0) continue;
-
- const ref = `${_refPrefix}${rc++}`;
- if (registerRef) registerRef(ref, node);
- try { if (genFallback) fallbacks[ref] = genFallback(node); } catch {}
- matches.push({
- ref, role: r, name: n.slice(0, 100), tag: node.tagName.toLowerCase(), score,
- bounds: { x: Math.round(rect.x), y: Math.round(rect.y), width: Math.round(rect.width), height: Math.round(rect.height) },
- });
- }
- }
-
- matches.sort((a, b) => b.score - a.score);
- return { success: true, query: _q, matches: matches.slice(0, _lim), __fallbacks: fallbacks };
- }, [query, limit, refPrefix]).then((res) => {
- if (res && res.__fallbacks) {
- const map = fallbackByTab.get(tabId) || new Map();
- for (const [ref, fbEntry] of Object.entries(res.__fallbacks)) map.set(ref, fbEntry);
- fallbackByTab.set(tabId, map);
- delete res.__fallbacks;
- persistSessionState();
- }
- return res;
- });
-}
-
export async function handleGetPageText(params) {
// Default must match the MCP schema (text.ts: maxLength .default(5000)) —
// it drifted 10x here once, so direct-WS callers got 50000 while MCP callers
// got 5000 from the same knob.
- const { tabId, selector, maxLength = 5000 } = params;
+ const { tabId, selector, maxLength = 5000, mode = 'all', offset = 0 } = params;
await resolveTab(tabId);
- const args = selector === undefined ? [null, maxLength] : [selector, maxLength];
-
- return safeExec(tabId, (_sel, _max) => {
- const root = _sel ? document.querySelector(_sel) : document.body;
+ const max = Math.min(Math.max(1, Number(maxLength) || 5000), 100_000);
+ const from = Math.max(0, Number(offset) || 0);
+
+ return execDom(tabId, (_sel, _max, _mode, _from) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const article = _mode === 'article';
+ let root = document.body;
+ if (_sel) {
+ const hit = D.resolve(null, _sel, null);
+ if (hit.error === 'INVALID_SELECTOR') return { success: false, error: `Invalid CSS selector: ${_sel}` };
+ root = hit.el;
+ } else if (article) {
+ root = D.articleRoot();
+ }
if (!root) return { success: false, error: 'Element not found' };
- let text = root.innerText || root.textContent || '';
- text = text.replace(/\t/g, ' ').replace(/\n\s*\n/g, '\n\n').replace(/ +/g, ' ').trim();
+ // Composed text: includes open/closed shadow roots and same-origin frames
+ // (innerText alone misses web-component content such as caniuse's tables).
+ let text = D.pageText(root, { article, max: _from + _max + 1000 });
+ const total = text.length;
+ if (_from) text = text.slice(_from);
const truncated = text.length > _max;
if (truncated) text = text.slice(0, _max) + '...';
- return { success: true, url: location.href, title: document.title, text, length: text.length, truncated };
- }, args);
+ return {
+ success: true, url: location.href, title: document.title, text, length: text.length, truncated,
+ ...(_from ? { offset: _from } : {}),
+ ...(truncated ? { nextOffset: _from + _max } : {}),
+ ...(article ? { mode: 'article' } : {}),
+ ...(total && _from >= total ? { note: `offset ${_from} is past the end (${total} chars)` } : {}),
+ };
+ }, [selector ?? null, max, mode, from]);
}
+export { handleFind } from './find.js';
+
/**
* evaluate (task 1.5): runs in the page's MAIN world via chrome.scripting — no
* chrome.debugger, so no yellow "is being debugged" banner. Replaces the old
@@ -444,6 +423,7 @@ export async function handleEvaluate(params, _sessionId, _agentName, signal) {
// Default: REPL semantics over CDP (top-level await, last expression is the
// result, not blocked by CSP). mode:"scripting" (or no debugger available)
// keeps the banner-free chrome.scripting path below.
+ await assertResponsive(tabId);
if (mode !== 'scripting') {
let attached = false;
try {
diff --git a/extension/handlers/interaction-advanced.js b/extension/handlers/interaction-advanced.js
index 6fe73ef..61c94fa 100644
--- a/extension/handlers/interaction-advanced.js
+++ b/extension/handlers/interaction-advanced.js
@@ -3,7 +3,7 @@
* orchestration. Kept separate from the common pointer/keyboard handlers so
* each module stays focused and reviewable.
*/
-import { resolveTab, safeExec } from '../lib/page-exec.js';
+import { resolveTab, safeExec, execDom, getFallback } from '../lib/page-exec.js';
import { withCdp } from '../lib/cdp-session.js';
import { openShield, releaseShield } from '../lib/trusted-input.js';
@@ -64,32 +64,19 @@ export async function handleDrag(params) {
let sx = startX, sy = startY, ex = endX, ey = endY;
if (sx == null || sy == null || ex == null || ey == null) {
- const coords = await safeExec(tabId, (_sRef, _sSel, _eRef, _eSel) => {
- function deepQuery(sel) {
- const query = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const frame of doc.querySelectorAll('iframe')) {
- try {
- const child = frame.contentDocument;
- if (child) { const el = query(child, depth + 1); if (el) return el; }
- } catch {}
- }
- return null;
- };
- return query(document, 0);
- }
-
- function find(ref, selector) {
- let el = ref ? deepQuery(`[data-mcp-ref="${ref}"]`) : null;
- if (!el && selector) el = deepQuery(selector);
+ const coords = await execDom(tabId, (_sRef, _sSel, _eRef, _eSel, _sFb, _eFb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ function find(ref, selector, fb) {
+ if (!ref && !selector) return null;
+ const el = D.resolve(ref, selector, fb).el;
if (!el) return null;
el.scrollIntoView({ behavior: 'instant', block: 'center' });
- const rect = el.getBoundingClientRect();
- return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
+ const { x, y } = D.centerOf(el);
+ return { x, y };
}
- return { start: find(_sRef, _sSel), end: find(_eRef, _eSel) };
- }, [startRef, startSelector, endRef, endSelector]);
+ return { start: find(_sRef, _sSel, _sFb), end: find(_eRef, _eSel, _eFb) };
+ }, [startRef, startSelector, endRef, endSelector, getFallback(tabId, startRef), getFallback(tabId, endRef)]);
if (coords.start) { sx = coords.start.x; sy = coords.start.y; }
if (coords.end) { ex = coords.end.x; ey = coords.end.y; }
@@ -127,21 +114,12 @@ export async function handleFillForm(params) {
}
await resolveTab(tabId);
- return safeExec(tabId, (_fields, _submit) => {
- function deepQuery(sel) {
- const query = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const frame of doc.querySelectorAll('iframe')) {
- try {
- const child = frame.contentDocument;
- if (child) { const el = query(child, depth + 1); if (el) return el; }
- } catch {}
- }
- return null;
- };
- return query(document, 0);
- }
+ // Attach each ref's snapshot descriptor so stale refs re-resolve (verified) in the page.
+ const withFb = fields.map((f) => (f && f.ref ? { ...f, fb: getFallback(tabId, f.ref) } : f));
+
+ return execDom(tabId, (_fields, _submit) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
const setNativeValue = (target, nextValue) => {
const prototype = target instanceof HTMLTextAreaElement
@@ -154,9 +132,8 @@ export async function handleFillForm(params) {
const results = [];
let containingForm = null;
for (const field of _fields) {
- const { ref, selector, value, clear } = field;
- let el = ref ? deepQuery(`[data-mcp-ref="${ref}"]`) : null;
- if (!el && selector) el = deepQuery(selector);
+ const { ref, selector, value, clear, fb } = field;
+ const el = D.resolve(ref, selector, fb).el;
if (!el) {
results.push({ selector: selector || ref, success: false, error: 'Not found' });
continue;
@@ -164,20 +141,27 @@ export async function handleFillForm(params) {
el.focus();
if (el.form && !containingForm) containingForm = el.form;
- if (clear !== false) {
+ const isChoice = el.tagName === 'SELECT' || el.type === 'checkbox' || el.type === 'radio';
+ if (clear !== false && !isChoice) {
if (el.isContentEditable) el.textContent = '';
else setNativeValue(el, '');
el.dispatchEvent(new Event('input', { bubbles: true }));
}
if (el.tagName === 'SELECT') {
- const option = Array.from(el.options).find((candidate) => candidate.value === String(value));
+ // Match the option's value first, then its visible label.
+ const want = String(value);
+ const options = Array.from(el.options);
+ const option = options.find((candidate) => candidate.value === want)
+ || options.find((candidate) => candidate.textContent.trim() === want.trim())
+ || options.find((candidate) => candidate.textContent.trim().toLowerCase() === want.trim().toLowerCase());
if (!option) {
results.push({ selector: selector || ref, success: false, error: `Option "${value}" not found` });
continue;
}
- setNativeValue(el, String(value));
- el.dispatchEvent(new Event('change', { bubbles: true }));
+ const setter = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value')?.set;
+ if (setter) setter.call(el, option.value); else el.value = option.value;
+ el.dispatchEvent(new Event('input', { bubbles: true }));
} else if (el.type === 'checkbox' || el.type === 'radio') {
const checked = value === true || value === 'true';
if (el.checked !== checked) el.click();
@@ -205,5 +189,5 @@ export async function handleFillForm(params) {
return failed === 0
? { success: true, fields: results }
: { success: false, error: `${failed} of ${results.length} fields failed`, fields: results };
- }, [fields, submit]);
+ }, [withFb, submit]);
}
diff --git a/extension/handlers/interaction.js b/extension/handlers/interaction.js
index af074db..386c6ba 100644
--- a/extension/handlers/interaction.js
+++ b/extension/handlers/interaction.js
@@ -3,15 +3,19 @@
* hover, select, click_text, dialog, drag, fill_form — the write side that
* drives the page's event system (synthetic events) or CDP when required.
*/
-import { resolveTab, requireTarget, safeExec, getFallback } from '../lib/page-exec.js';
+import { resolveTab, requireTarget, hasPoint, execDom, getFallback } from '../lib/page-exec.js';
import { autoReSnapshot } from './inspection.js';
-import { PAGE_FALLBACK_INSTALL } from '../utils/smart-selector.js';
-import { trustedSender, locateTarget, releaseShield, cdpClickAt, cdpKeyPress, cdpTypeText, keyDefinition } from '../lib/trusted-input.js';
+import { trustedSender, locateTarget, releaseShield, cdpClickAt, cdpKeyPress, cdpTypeText, keyDefinition, modifierBits, pointInfo } from '../lib/trusted-input.js';
export { handleDialog, handleDrag, handleFillForm } from './interaction-advanced.js';
/** Shared REF_GONE recovery: re-snapshot and hand fresh refs back (no auto-retry). */
-async function refGone(tabId, res, ref) {
+async function refGone(tabId, res, ref, selector) {
+ // A selector that matches nothing is usually the wrong page (navigation,
+ // postback), not a virtualized feed — say which locator failed.
+ if (!(res._ref || ref) && selector) {
+ return { success: false, error: `No element matches selector ${selector} on the current page (${res.url || 'navigated?'}).` };
+ }
const fresh = await autoReSnapshot(tabId);
return {
success: false,
@@ -21,25 +25,63 @@ async function refGone(tabId, res, ref) {
}
const BUTTONS = new Set(['left', 'right', 'middle']);
+const MODS = new Set(['ctrl', 'alt', 'shift', 'meta']);
+
+/** clickCount from the params (1–3; doubleClick = 2). */
+function clickCountOf(params) {
+ const n = Number(params.clickCount);
+ if (Number.isInteger(n) && n >= 1) return Math.min(n, 3);
+ return params.doubleClick ? 2 : 1;
+}
+
+/** Modifier names held during a click ("ctrl+click" opens links in a new tab). */
+function clickModifiers(params) {
+ const mods = Array.isArray(params.modifiers) ? params.modifiers.filter((m) => MODS.has(m)) : [];
+ return modifierBits(mods);
+}
+
+/** Coordinate actions need CDP: there is no element to dispatch synthetic events on. */
+async function requireCdp(tabId, what) {
+ const send = await trustedSender(tabId, true);
+ if (!send) throw new Error(`${what} at x/y needs the debugger (CDP), which could not attach to tab ${tabId}. Use ref or selector instead.`);
+ return send;
+}
+
+/** Real mouse click at viewport coordinates (the same CSS-pixel frame as browser_screenshot). */
+async function clickAtPoint(tabId, params) {
+ const { x, y, button = 'left' } = params;
+ if (!BUTTONS.has(button)) throw new Error(`Unknown button ${button}`);
+ const send = await requireCdp(tabId, 'Clicking');
+ const info = await pointInfo(tabId, x, y);
+ try {
+ await cdpClickAt(send, x, y, { button, clickCount: clickCountOf(params), modifiers: clickModifiers(params) });
+ } finally {
+ await releaseShield(tabId);
+ }
+ return {
+ success: true, input: 'cdp', at: { x, y },
+ ...(info.hit ? { hit: info.hit } : {}),
+ ...(info.inView === false ? { warning: 'point is outside the viewport' } : {}),
+ };
+}
export async function handleClick(params) {
const { tabId, ref, selector, button = 'left', doubleClick = false, trusted } = params;
await resolveTab(tabId);
- requireTarget(params);
+ requireTarget(params, { allowPoint: true });
+ if (!ref && !selector) return clickAtPoint(tabId, params);
+ // Snapshot-time descriptor used by the shared resolver when the ref is stale.
const fb = getFallback(tabId, ref);
- // Install the fallback page runtime only when a descriptor exists (v2
- // install-once pattern — eval rebuilding is impossible under MV3 CSP).
- if (fb) await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
// Trusted path: a real mouse click at the element's centre over CDP, so
// focus moves, default actions run and the page sees isTrusted:true.
const send = await trustedSender(tabId, trusted);
if (send && BUTTONS.has(button)) {
const loc = await locateTarget(tabId, { ref, selector, fb });
- if (loc && loc.success === false && loc.error === 'REF_GONE') return refGone(tabId, loc, ref);
+ if (loc && loc.success === false && loc.error === 'REF_GONE') return refGone(tabId, loc, ref, selector);
if (loc?.success && loc.visible) {
try {
- await cdpClickAt(send, loc.x, loc.y, { button, clickCount: doubleClick ? 2 : 1 });
+ await cdpClickAt(send, loc.x, loc.y, { button, clickCount: clickCountOf(params), modifiers: clickModifiers(params) });
} finally {
await releaseShield(tabId);
}
@@ -54,33 +96,19 @@ export async function handleClick(params) {
// Zero-size element: no point to hit — fall through to the synthetic path.
}
- const res = await safeExec(tabId, async (_ref, _sel, _btn, _dbl, _fb) => {
- // Same-origin iframe piercing (field report: legacy UIs live inside
- // #mainFrame — top-document lookups missed every element).
- function deepQuery(sel) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
+ const res = await execDom(tabId, async (_ref, _sel, _btn, _dbl, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
- let el = _ref ? deepQuery(`[data-mcp-ref="${_ref}"]`) : null;
- let via = 'ref';
- if (!el && _sel) { el = deepQuery(_sel); via = 'selector'; }
- // Resolver comes from the pre-installed page runtime (no eval).
- const resolveFallback = (globalThis.__browserControllerFallbackRuntime || {}).resolveFallback || null;
- // Smart-selector fallback (plan task 3): ref broke → try robust selector,
- // then text+role+tag scan. The agent doesn't request this; it's automatic.
- if (!el && _fb && resolveFallback) { el = resolveFallback(_fb); if (el) via = 'fallback'; }
+ // ref registry → first visible selector match → verified fallback (lib/page-dom.js).
+ const found = D.resolve(_ref, _sel, _fb);
+ if (found.error === 'INVALID_SELECTOR') return { success: false, error: `Invalid CSS selector: ${_sel}` };
+ let el = found.el || null;
+ const via = found.via || 'ref';
if (!el) {
// Element is gone (likely virtualized away on scroll). Abort WITHOUT
// clicking — the background auto-re-snapshots and embeds fresh refs.
- return { success: false, error: 'REF_GONE', _ref };
+ return { success: false, error: 'REF_GONE', _ref, url: location.href };
}
el.scrollIntoView({ behavior: 'instant', block: 'center' });
@@ -95,7 +123,7 @@ export async function handleClick(params) {
if (!visible0) {
await new Promise((r) => setTimeout(r, 200));
// re-resolve the element (it may have been re-rendered with a new node)
- el = _ref ? deepQuery(`[data-mcp-ref="${_ref}"]`) : el;
+ el = D.resolve(_ref, _sel, _fb).el || el;
if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
}
if (!el) return { success: false, error: 'REF_GONE', _ref };
@@ -133,26 +161,31 @@ export async function handleClick(params) {
// Auto-re-snapshot and embed fresh refs so the agent retries in ONE step.
// We do NOT auto-retry the click: it's non-idempotent and the element that
// re-appears may be a different post after the scroll shifted the feed.
- if (res && res.success === false && res.error === 'REF_GONE') return refGone(tabId, res, ref);
+ if (res && res.success === false && res.error === 'REF_GONE') return refGone(tabId, res, ref, selector);
return res;
}
export async function handleType(params) {
const { tabId, ref, selector, text, clear = false, trusted } = params;
await resolveTab(tabId);
- requireTarget(params);
+ // No ref/selector: type into the element that has focus (like a user
+ // typing after clicking a field).
+ const focusedOnly = !ref && !selector;
+ // Snapshot-time descriptor used by the shared resolver when the ref is stale.
const fb = getFallback(tabId, ref);
- // Install the fallback page runtime only when a descriptor exists (v2
- // install-once pattern — eval rebuilding is impossible under MV3 CSP).
- if (fb) await safeExec(tabId, PAGE_FALLBACK_INSTALL, []);
// Trusted path: focus the field, then real key presses over CDP (keydown /
// keypress / input / keyup per character). Like a user, this does NOT fire
// `change` until focus leaves the field — press Tab to commit.
const send = await trustedSender(tabId, trusted);
if (send) {
- const loc = await locateTarget(tabId, { ref, selector, fb, mode: clear ? 'clear' : 'focus' });
- if (loc && loc.success === false && loc.error === 'REF_GONE') return refGone(tabId, loc, ref);
+ const mode = focusedOnly ? (clear ? 'focused-clear' : 'focused') : clear ? 'clear' : 'focus';
+ const loc = await locateTarget(tabId, { ref, selector, fb, mode });
+ if (loc && loc.error === 'NO_FOCUS') {
+ await releaseShield(tabId);
+ return { success: false, error: 'No field has focus: pass ref/selector, or click the field first.' };
+ }
+ if (loc && loc.success === false && loc.error === 'REF_GONE') return refGone(tabId, loc, ref, selector);
if (loc?.success && (loc.focused || loc.visible)) {
let after;
try {
@@ -175,30 +208,25 @@ export async function handleType(params) {
await releaseShield(tabId);
}
- const res = await safeExec(tabId, (_ref, _sel, _text, _clear, _fb) => {
- // Same-origin iframe piercing (field report: legacy UIs live inside
- // #mainFrame — top-document lookups missed every element).
- function deepQuery(sel) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
+ const res = await execDom(tabId, (_ref, _sel, _text, _clear, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
- let el = _ref ? deepQuery(`[data-mcp-ref="${_ref}"]`) : null;
- let via = 'ref';
- if (!el && _sel) { el = deepQuery(_sel); via = 'selector'; }
- const resolveFallback = (globalThis.__browserControllerFallbackRuntime || {}).resolveFallback || null;
- if (!el && _fb && resolveFallback) { el = resolveFallback(_fb); if (el) via = 'fallback'; }
+ let found;
+ if (!_ref && !_sel) {
+ const a = document.activeElement;
+ if (!a || a === document.body) return { success: false, error: 'No field has focus: pass ref/selector, or click the field first.' };
+ found = { el: a, via: 'active' };
+ } else {
+ found = D.resolve(_ref, _sel, _fb);
+ }
+ if (found.error === 'INVALID_SELECTOR') return { success: false, error: `Invalid CSS selector: ${_sel}` };
+ const el = found.el || null;
+ const via = found.via || 'ref';
if (!el) {
// Element gone (virtualized feed) — abort WITHOUT typing; background
// auto-re-snapshots and embeds fresh refs for a one-step retry.
- return { success: false, error: 'REF_GONE', _ref };
+ return { success: false, error: 'REF_GONE', _ref, url: location.href };
}
el.focus();
@@ -235,7 +263,7 @@ export async function handleType(params) {
// Virtualization recovery (same as click): type target is gone, so
// auto-re-snapshot and embed fresh refs. No auto-retry (non-idempotent).
- if (res && res.success === false && res.error === 'REF_GONE') return refGone(tabId, res, ref);
+ if (res && res.success === false && res.error === 'REF_GONE') return refGone(tabId, res, ref, selector);
return res;
}
@@ -258,55 +286,54 @@ export async function handlePressKey(params) {
const { tabId, ref, selector, trusted } = params;
const { key, mods: modifiers } = parseKeyCombo(params.key, params.modifiers || []);
await resolveTab(tabId);
+ const fb = getFallback(tabId, ref);
+
+ // "ArrowDown ArrowDown Enter" / "ctrl+a Backspace": a space-separated key
+ // sequence; `repeat` presses the whole sequence N times.
+ const raw = String(params.key ?? '');
+ const seq = raw.length > 1 && /\s/.test(raw.trim()) ? raw.trim().split(/\s+/) : [raw];
+ const combos = seq.map((k) => parseKeyCombo(k, params.modifiers || []));
+ const repeat = Math.min(Math.max(1, Number.isInteger(params.repeat) ? params.repeat : 1), 100);
// Trusted path: a real key press, so default actions run (Tab moves focus
// and fires blur/focusout, Enter submits, arrows drive autocomplete menus).
let knownKey = true;
- try { keyDefinition(key); } catch { knownKey = false; }
+ for (const c of combos) { try { keyDefinition(c.key); } catch { knownKey = false; } }
+ if (!knownKey && (combos.length > 1 || repeat > 1)) throw new Error(`Unknown key in "${raw}"`);
const send = knownKey ? await trustedSender(tabId, trusted) : null;
if (send) {
- const loc = await locateTarget(tabId, { ref, selector, mode: ref || selector ? 'focus' : 'active' });
+ const loc = await locateTarget(tabId, { ref, selector, fb, mode: ref || selector ? 'focus' : 'active' });
if (!loc || loc.success === false) {
await releaseShield(tabId);
if (ref || selector) return { success: false, error: `Element ${ref ? `with ref ${ref}` : `with selector ${selector}`} not found` };
}
let after;
try {
- await cdpKeyPress(send, key, modifiers);
+ for (let r = 0; r < repeat; r++) {
+ for (const c of combos) await cdpKeyPress(send, c.key, c.mods);
+ }
} finally {
after = await releaseShield(tabId);
}
- return { success: true, key, ...(modifiers.length ? { modifiers } : {}), input: 'cdp', ...(after?.focusedTag ? { focused: after.focusedTag } : {}) };
+ return {
+ success: true, key: combos.length > 1 ? raw : key, ...(modifiers.length && combos.length === 1 ? { modifiers } : {}),
+ ...(repeat > 1 ? { repeat } : {}), input: 'cdp', ...(after?.focusedTag ? { focused: after.focusedTag } : {}),
+ };
}
- return safeExec(tabId, (_key, _mods, _ref, _sel) => {
- // Same-origin iframe piercing (field report: legacy UIs live inside
- // #mainFrame — top-document lookups missed every element).
- function deepQuery(sel) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
+ if (combos.length > 1 || repeat > 1) throw new Error('Key sequences and repeat need the debugger (CDP); press keys one at a time with trusted:false.');
+ return execDom(tabId, (_key, _mods, _ref, _sel, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
let target = document.activeElement || document.body;
// When the caller names a target, an unresolved ref/selector must FAIL —
// silently falling back to activeElement sent Enter to the wrong control
// with a success result. (Omitting both is still legitimate: intentional
// activeElement targeting.)
- if (_ref) {
- const el = deepQuery(`[data-mcp-ref="${_ref}"]`);
- if (!el) return { success: false, error: `Element with ref ${_ref} not found` };
- el.focus();
- target = el;
- } else if (_sel) {
- const el = deepQuery(_sel);
- if (!el) return { success: false, error: `Element with selector ${_sel} not found` };
+ if (_ref || _sel) {
+ const el = D.resolve(_ref, _sel, _fb).el;
+ if (!el) return { success: false, error: _ref ? `Element with ref ${_ref} not found` : `Element with selector ${_sel} not found` };
el.focus();
target = el;
}
@@ -327,17 +354,28 @@ export async function handlePressKey(params) {
target.dispatchEvent(new KeyboardEvent('keyup', init));
return { success: true, key: _key };
- }, [key, modifiers, ref, selector]);
+ }, [key, modifiers, ref, selector, fb]);
}
export async function handleHover(params) {
const { tabId, ref, selector, trusted } = params;
await resolveTab(tabId);
- requireTarget(params);
+ requireTarget(params, { allowPoint: true });
+ if (!ref && !selector && hasPoint(params)) {
+ const sendAt = await requireCdp(tabId, 'Hovering');
+ const info = await pointInfo(tabId, params.x, params.y);
+ try {
+ await sendAt('Input.dispatchMouseEvent', { type: 'mouseMoved', x: params.x, y: params.y });
+ } finally {
+ await releaseShield(tabId);
+ }
+ return { success: true, input: 'cdp', at: { x: params.x, y: params.y }, ...(info.hit ? { hit: info.hit } : {}) };
+ }
+ const fb = getFallback(tabId, ref);
const send = await trustedSender(tabId, trusted);
if (send) {
- const loc = await locateTarget(tabId, { ref, selector });
+ const loc = await locateTarget(tabId, { ref, selector, fb });
if (loc?.success && loc.visible) {
try {
await send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: loc.x, y: loc.y });
@@ -350,23 +388,11 @@ export async function handleHover(params) {
if (loc && loc.success === false) return { success: false, error: 'Element not found' };
}
- return safeExec(tabId, (_ref, _sel) => {
- // Same-origin iframe piercing (field report: legacy UIs live inside
- // #mainFrame — top-document lookups missed every element).
- function deepQuery(sel) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
+ return execDom(tabId, (_ref, _sel, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
- let el = _ref ? deepQuery(`[data-mcp-ref="${_ref}"]`) : null;
- if (!el && _sel) el = deepQuery(_sel);
+ const el = D.resolve(_ref, _sel, _fb).el;
if (!el) return { success: false, error: 'Element not found' };
el.scrollIntoView({ behavior: 'instant', block: 'center' });
@@ -380,7 +406,7 @@ export async function handleHover(params) {
el.dispatchEvent(new MouseEvent('mousemove', init));
return { success: true };
- }, [ref, selector]);
+ }, [ref, selector, fb]);
}
export async function handleSelect(params) {
@@ -390,24 +416,13 @@ export async function handleSelect(params) {
if (value === undefined && label === undefined && index === undefined) {
throw new Error('One of value, label, or index is required to pick an option.');
}
+ const fb = getFallback(tabId, ref);
- return safeExec(tabId, (_ref, _sel, _val, _lbl, _idx) => {
- // Same-origin iframe piercing (field report: legacy UIs live inside
- // #mainFrame — top-document lookups missed every element).
- function deepQuery(sel) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(sel); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
+ return execDom(tabId, (_ref, _sel, _val, _lbl, _idx, _fb) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
- let el = _ref ? deepQuery(`[data-mcp-ref="${_ref}"]`) : null;
- if (!el && _sel) el = deepQuery(_sel);
+ const el = D.resolve(_ref, _sel, _fb).el;
if (!el) return { success: false, error: 'Element not found' };
if (el.tagName !== 'SELECT') return { success: false, error: 'Not a select element' };
@@ -424,69 +439,98 @@ export async function handleSelect(params) {
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new Event('input', { bubbles: true }));
return { success: true, selected: el.value };
- }, [ref, selector, value, label, index]);
+ }, [ref, selector, value, label, index, fb]);
}
export async function handleClickByText(params) {
- const { tabId, text, index = 0, exact = false } = params;
+ const { tabId, text, index = 0, exact = false, trusted } = params;
await resolveTab(tabId);
-
- return safeExec(tabId, (_text, _index, _exact) => {
- const textLower = _text.toLowerCase();
- const candidates = [];
- // Same-origin iframe piercing — walk every frame body, not just the top.
- const roots = [document.body];
- (function collectFrames(doc, depth) {
- if (depth >= 3) return;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d && d.body) { roots.push(d.body); collectFrames(d, depth + 1); } } catch {}
- }
- })(document, 0);
- let node;
- for (const root of roots) {
- const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
- while ((node = walker.nextNode())) {
- const s = getComputedStyle(node);
- if (s.display === 'none' || s.visibility === 'hidden') continue;
- const r = node.getBoundingClientRect();
- if (r.width === 0 || r.height === 0) continue;
-
- const nodeText = (node.innerText || node.textContent || '').trim();
- const firstLine = nodeText.split('\n')[0].trim();
- const match = _exact
- ? firstLine === _text
- : firstLine.toLowerCase().includes(textLower);
-
- if (match) {
- candidates.push({ el: node, text: firstLine, depth: getDepth(node) });
+ const tempRef = `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 5)}`;
+
+ // Page side: find the element by accessible name / composed text (shadow
+ // roots + same-origin frames), climb to the control that owns it, and park
+ // it in the ref registry so the click itself goes through the normal path.
+ const found = await execDom(tabId, (_text, _index, _exact, _ref) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const want = D.clean(_text).toLowerCase();
+ if (!want) return { success: false, error: 'text is required' };
+ const SKIP = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'HEAD', 'HTML', 'BODY', 'META', 'LINK']);
+ const hits = [];
+ const seen = new Set();
+ const matches = (s) => {
+ const t = D.clean(s).toLowerCase();
+ if (!t) return false;
+ return _exact ? t === want : t.includes(want);
+ };
+ for (const root of D.allRoots(true)) {
+ let els = [];
+ try { els = root.querySelectorAll('*'); } catch {}
+ for (const el of els) {
+ if (SKIP.has(el.tagName)) continue;
+ const own = D.isInteractive(el) ? D.nameOf(el) : D.composedText(el, 200);
+ // aria-label / title / value also count as the element's text.
+ if (!matches(own) && !matches(D.attr(el, 'aria-label')) && !matches(D.attr(el, 'title'))
+ && !(el.tagName === 'INPUT' && matches(el.value))) continue;
+ // Climb to the control that owns this text (MUI: inside ).
+ let target = el;
+ let cur = el;
+ for (let i = 0; i < 6 && cur; i++) {
+ if (D.isInteractive(cur)) { target = cur; break; }
+ let r = null;
+ try { r = cur.getRootNode(); } catch {}
+ cur = cur.parentElement || (r && r.host) || null;
+ }
+ if (seen.has(target) || !D.isVisible(target)) continue;
+ seen.add(target);
+ hits.push({ el: target, interactive: D.isInteractive(target), len: D.clean(own).length });
}
}
+ // Prefer controls over plain text; then the most specific (shortest) text; keep document order otherwise.
+ hits.forEach((h, i) => { h.i = i; });
+ hits.sort((a, b) => (b.interactive - a.interactive) || (a.len - b.len) || (a.i - b.i));
+ // Drop a candidate that merely contains a better one (wrapper rows).
+ const best = hits.filter((h) => !hits.some((o) => o !== h && o.i !== h.i && D.composedContains(h.el, o.el) && o.interactive >= h.interactive));
+ if (best.length === 0) return { success: false, error: `No element found with text "${_text}"` };
+ if (!Number.isInteger(_index) || _index < 0 || _index >= best.length) {
+ return { success: false, error: `Only ${best.length} matches, index ${_index} out of range` };
}
+ const chosen = best[_index].el;
+ D.registry.set(_ref, chosen);
+ return { success: true, clicked: D.nameOf(chosen).slice(0, 80) || D.composedText(chosen, 80), role: D.roleOf(chosen), matchCount: best.length };
+ }, [text, index, exact, tempRef]);
+ if (!found || found.success === false) return found;
- function getDepth(el) { let d = 0; let p = el; while ((p = p.parentElement)) d++; return d; }
-
- candidates.sort((a, b) => b.depth - a.depth);
-
- if (candidates.length === 0) return { success: false, error: `No element found with text "${_text}"` };
- // Guard the full range: a negative index used to read candidates[-1] and
- // crash with a raw TypeError (schema bounds only protect MCP callers).
- if (!Number.isInteger(_index) || _index < 0 || _index >= candidates.length) {
- return { success: false, error: `Only ${candidates.length} matches, index ${_index} out of range` };
+ // Trusted click on the parked element (same path as browser_click).
+ const send = await trustedSender(tabId, trusted);
+ if (send) {
+ const loc = await locateTarget(tabId, { ref: tempRef });
+ if (loc?.success && loc.visible) {
+ try {
+ await cdpClickAt(send, loc.x, loc.y);
+ } finally {
+ await releaseShield(tabId);
+ }
+ return { ...found, input: 'cdp', ...(loc.occludedBy ? { warning: `click point is covered by ${loc.occludedBy}` } : {}) };
}
+ await releaseShield(tabId);
+ }
- const target = candidates[_index].el;
+ return execDom(tabId, (_ref, _found) => {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ const target = D.registry.get(_ref);
+ if (!D.connected(target)) return { success: false, error: 'Element disappeared before the click' };
target.scrollIntoView({ behavior: 'instant', block: 'center' });
const rect = target.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
- const init = { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y, button: 0 };
-
+ const init = { bubbles: true, cancelable: true, composed: true, view: target.ownerDocument.defaultView, clientX: x, clientY: y, button: 0 };
target.dispatchEvent(new MouseEvent('mouseover', init));
target.dispatchEvent(new MouseEvent('mousedown', init));
if (target.focus) target.focus();
target.dispatchEvent(new MouseEvent('mouseup', init));
target.dispatchEvent(new MouseEvent('click', init));
-
- return { success: true, clicked: candidates[_index].text, matchCount: candidates.length };
- }, [text, index, exact]);
+ return _found;
+ }, [tempRef, found]);
}
diff --git a/extension/handlers/navigation.js b/extension/handlers/navigation.js
index 7d9b69f..3d9d73e 100644
--- a/extension/handlers/navigation.js
+++ b/extension/handlers/navigation.js
@@ -2,7 +2,7 @@
* Navigation handler (extracted from background.js): the one page tool allowed
* to omit tabId (documented active-tab fallback).
*/
-import { resolveTab, safeExec } from '../lib/page-exec.js';
+import { resolveTab, safeExec, replaceFrozenTab } from '../lib/page-exec.js';
import { isHashOnlyChange } from '../utils/navigation.js';
import { handleSnapshot } from './inspection.js';
@@ -18,11 +18,11 @@ export async function getActiveTab() {
return tab;
}
-export async function handleNavigate(params, _sessionId, _agentName, signal) {
+export async function handleNavigate(params, sessionId, _agentName, signal) {
let { url } = params;
const { waitUntil = 'load', tabId, snapshot: wantSnapshot = true } = params;
// navigate is the one page tool allowed to omit tabId → active tab fallback.
- const tab = tabId != null ? await resolveTab(tabId) : await getActiveTab();
+ let tab = tabId != null ? await resolveTab(tabId) : await getActiveTab();
// Fix #2 (hash-aware): a hash-only navigation does NOT reload the document,
// so `chrome.tabs.onUpdated` never fires `status === 'complete'` and the wait
@@ -35,6 +35,10 @@ export async function handleNavigate(params, _sessionId, _agentName, signal) {
: historyStep === 'forward' ? chrome.tabs.goForward(tab.id)
: chrome.tabs.update(tab.id, { url });
+ // A frozen page (TAB_WEDGED) would hold the navigation hostage: replace the tab.
+ let replacedTabId = null;
+ const fresh = !historyStep ? await replaceFrozenTab(tab, null, sessionId) : null;
+ if (fresh) { replacedTabId = tab.id; tab = fresh; }
const currentTab = await chrome.tabs.get(tab.id);
const hashOnly = !historyStep && isHashOnlyChange(currentTab.url, url);
@@ -123,7 +127,7 @@ export async function handleNavigate(params, _sessionId, _agentName, signal) {
if (historyStep) url = (await chrome.tabs.get(tab.id)).url;
if (!wantSnapshot) {
- return { url, status: 'navigated', tabId: tab.id };
+ return { url, status: 'navigated', tabId: tab.id, ...(replacedTabId ? { replacedTabId, note: `tab ${replacedTabId} was frozen and has been replaced by tab ${tab.id}` } : {}) };
}
try {
const snap = await handleSnapshot({ tabId: tab.id, compact: true });
@@ -132,10 +136,11 @@ export async function handleNavigate(params, _sessionId, _agentName, signal) {
url,
status: 'navigated',
tabId: tab.id,
+ ...(replacedTabId ? { replacedTabId, note: `tab ${replacedTabId} was frozen and has been replaced by tab ${tab.id}` } : {}),
snapshot: snapObj && snapObj.content ? snapObj.content : snapObj,
};
} catch {
// snapshot failed (protected page / 401 / etc) — navigation still succeeded.
- return { url, status: 'navigated', tabId: tab.id };
+ return { url, status: 'navigated', tabId: tab.id, ...(replacedTabId ? { replacedTabId } : {}) };
}
}
diff --git a/extension/handlers/tabs.js b/extension/handlers/tabs.js
index 96f69b3..a9e990b 100644
--- a/extension/handlers/tabs.js
+++ b/extension/handlers/tabs.js
@@ -3,9 +3,10 @@
* lifecycle (list/create/close/focus/lock/unlock), console/network reads,
* screenshot.
*/
-import { resolveTab } from '../lib/page-exec.js';
+import { resolveTab, replaceFrozenTab, safeExec } from '../lib/page-exec.js';
import {
tabLocks,
+ wedgedTabs,
windowCaptureMutex,
consoleByTab,
networkByTab,
@@ -27,12 +28,35 @@ function withTimeout(promise, ms, what) {
]).finally(() => clearTimeout(timer));
}
+/** Pixel size of a base64 PNG/JPEG (null if it can't be read). */
+export function imageSize(b64) {
+ let bin;
+ try { bin = atob(String(b64).slice(0, 87_384)); } catch { return null; }
+ const at = (i) => bin.charCodeAt(i);
+ if (bin.length > 24 && at(0) === 0x89 && bin.slice(1, 4) === 'PNG') {
+ return { width: ((at(16) << 24) | (at(17) << 16) | (at(18) << 8) | at(19)) >>> 0, height: ((at(20) << 24) | (at(21) << 16) | (at(22) << 8) | at(23)) >>> 0 };
+ }
+ if (at(0) === 0xff && at(1) === 0xd8) {
+ let i = 2;
+ while (i + 9 < bin.length) {
+ if (at(i) !== 0xff) { i++; continue; }
+ const marker = at(i + 1);
+ const len = (at(i + 2) << 8) | at(i + 3);
+ if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
+ return { width: (at(i + 7) << 8) | at(i + 8), height: (at(i + 5) << 8) | at(i + 6) };
+ }
+ i += 2 + len;
+ }
+ }
+ return null;
+}
+
/**
* CDP capture (Page.captureScreenshot): works on a tab that is NOT the active
* one in its window, so the user's view is never switched, and can downscale
* (`scale`) or cap the width (`maxWidth`) to save image tokens.
*/
-async function cdpScreenshot(tabId, { format, quality, scale, maxWidth, fullPage }) {
+async function cdpScreenshot(tabId, { format, quality, scale, maxWidth, fullPage, region }) {
return withCdp(tabId, async (send) => {
let metrics = await send('Page.getLayoutMetrics');
if (!(metrics?.cssVisualViewport?.clientWidth > 0)) {
@@ -41,22 +65,52 @@ async function cdpScreenshot(tabId, { format, quality, scale, maxWidth, fullPage
}
const vv = metrics.cssVisualViewport;
const content = metrics.cssContentSize || metrics.contentSize;
- const width = fullPage ? Math.ceil(content.width) : vv.clientWidth;
- const height = fullPage ? Math.min(Math.ceil(content.height), 16_000) : vv.clientHeight;
- let s = Math.min(1, Math.max(0.05, scale ?? 1));
- if (maxWidth && width * s > maxWidth) s = maxWidth / width;
+ // region: a viewport rectangle (CSS px, same frame as click/hover x/y) —
+ // zoom into small UI with scale > 1.
+ let originX = 0;
+ let originY = 0;
+ let width = fullPage ? Math.ceil(content.width) : vv.clientWidth;
+ let height = fullPage ? Math.min(Math.ceil(content.height), 16_000) : vv.clientHeight;
+ if (region) {
+ originX = Math.max(0, Math.min(region.x, vv.clientWidth - 1));
+ originY = Math.max(0, Math.min(region.y, vv.clientHeight - 1));
+ width = Math.max(1, Math.min(region.width, vv.clientWidth - originX));
+ height = Math.max(1, Math.min(region.height, vv.clientHeight - originY));
+ }
+ let s = Math.min(region ? 4 : 1, Math.max(0.05, scale ?? (region ? 2 : 1)));
+ // The capture comes out at clip.scale × devicePixelRatio (device pixels):
+ // the deprecated device-pixel metrics against the CSS ones give the ratio.
+ const dpr = metrics.visualViewport?.clientWidth > 0 ? metrics.visualViewport.clientWidth / vv.clientWidth : 1;
+ if (maxWidth && width * s * dpr > maxWidth) s = maxWidth / (width * dpr);
const { data } = await withTimeout(send('Page.captureScreenshot', {
format,
...(format === 'jpeg' ? { quality } : {}),
- captureBeyondViewport: !!fullPage,
- clip: { x: fullPage ? 0 : vv.pageX, y: fullPage ? 0 : vv.pageY, width, height, scale: s },
+ captureBeyondViewport: !!fullPage && !region,
+ clip: {
+ x: fullPage && !region ? 0 : vv.pageX + originX,
+ y: fullPage && !region ? 0 : vv.pageY + originY,
+ width, height, scale: s,
+ },
}), CDP_CAPTURE_TIMEOUT_MS, 'Page.captureScreenshot');
- return { data, width: Math.round(width * s), height: Math.round(height * s) };
+ // How image pixels map to the viewport coordinates click/hover/scroll take:
+ // viewportX = origin[0] + imageX / scale (fullPage: page coordinates instead).
+ // The real image size is the ground truth (it includes the device pixel
+ // ratio: an 800 px viewport at DPR 2 is a 1600 px image, scale 2).
+ const size = imageSize(data);
+ const imgW = size?.width || Math.round(width * s * dpr);
+ const imgH = size?.height || Math.round(height * s * dpr);
+ const frame = {
+ scale: Math.round((imgW / width) * 1000) / 1000,
+ origin: [Math.round(originX), Math.round(originY)],
+ viewport: [Math.round(vv.clientWidth), Math.round(vv.clientHeight)],
+ ...(fullPage && !region ? { page: true, scrollY: Math.round(vv.pageY) } : {}),
+ };
+ return { data, width: imgW, height: imgH, frame };
});
}
export async function handleScreenshot(params) {
- const { tabId, format = 'png', quality = 80, scale, maxWidth, fullPage = false } = params;
+ const { tabId, format = 'png', quality = 80, scale, maxWidth, fullPage = false, region } = params;
const tab = await resolveTab(tabId);
const protectedPage = /^(chrome|chrome-extension|devtools|edge|about):/i.test(tab.url || '');
let cdpError = null;
@@ -65,8 +119,8 @@ export async function handleScreenshot(params) {
// locked — always take it out of the picture; the router restores it.
const wasLocked = !!tabLocks.owner(tabId);
await hideLockShield(tabId);
- const opts = { format, quality, scale, maxWidth, fullPage };
- const done = (shot, via) => ({ success: true, format, via, width: shot.width, height: shot.height, data: shot.data });
+ const opts = { format, quality, scale, maxWidth, fullPage, region };
+ const done = (shot, via) => ({ success: true, format, via, width: shot.width, height: shot.height, frame: shot.frame, data: shot.data });
try {
if (tab.active) {
const shot = await cdpScreenshot(tabId, opts);
@@ -125,20 +179,35 @@ export async function handleScreenshot(params) {
}
export async function handleConsole(params) {
- const { tabId, clear = false } = params;
+ const { tabId, clear = false, pattern, level, limit } = params;
// Validate the tab (audit finding, seen live): a wrong tabId used to return
// an empty success instead of an actionable error.
await resolveTab(tabId);
const buf = getTabBuffer(consoleByTab, tabId);
- const msgs = [...buf];
+ let msgs = [...buf];
+ const total = msgs.length;
+ if (level) {
+ const want = new Set((Array.isArray(level) ? level : [level]).map((l) => String(l).toLowerCase()));
+ msgs = msgs.filter((m) => want.has(String(m.level).toLowerCase()));
+ }
+ if (pattern) {
+ let re;
+ try { re = new RegExp(pattern, 'i'); } catch (err) { throw new Error(`Invalid pattern regex: ${err?.message || err}`, { cause: err }); }
+ msgs = msgs.filter((m) => re.test(m.text));
+ }
+ if (Number.isInteger(limit) && limit > 0 && msgs.length > limit) msgs = msgs.slice(-limit);
if (clear) consoleByTab.set(tabId, []);
- return { success: true, messages: msgs };
+ return { success: true, messages: msgs, ...(msgs.length !== total ? { total } : {}) };
}
export async function handleNetwork(params) {
- const { tabId, filter, clear = false, limit } = params;
+ const { tabId, clear = false, limit, filter, urlPattern, failed } = params;
await resolveTab(tabId); // same as handleConsole — no empty fake successes
let reqs = [...getTabBuffer(networkByTab, tabId)];
+ // urlPattern: plain substring (Claude-in-Chrome style); filter: regex.
+ if (urlPattern) reqs = reqs.filter((r) => String(r.url).includes(urlPattern));
+ // failed:true = only requests that errored (DNS, blocked, aborted…) or got a 4xx/5xx.
+ if (failed === true) reqs = reqs.filter((r) => r.error || (typeof r.status === 'number' && r.status >= 400));
if (filter) {
// An invalid pattern used to throw a raw SyntaxError out of the handler;
// surface it as an actionable error instead.
@@ -146,7 +215,7 @@ export async function handleNetwork(params) {
try {
re = new RegExp(filter);
} catch (err) {
- throw new Error(`Invalid filter regex: ${err?.message || err}`);
+ throw new Error(`Invalid filter regex: ${err?.message || err}`, { cause: err });
}
reqs = reqs.filter((r) => re.test(r.url));
}
@@ -172,7 +241,7 @@ export async function handleTabs(params, sessionId) {
tabs: tabs.map((t) => {
const entry = { id: t.id, windowId: t.windowId, title: t.title, active: t.active };
const url = String(t.url || '');
- entry.url = url.length > 80 ? url.slice(0, 77) + '...' : url;
+ entry.url = params.fullUrls || url.length <= 80 ? url : url.slice(0, 77) + '...';
const owner = tabLocks.owner(t.id);
if (owner) entry.lockedBy = owner; // omit when null — saves tokens
return entry;
@@ -180,8 +249,41 @@ export async function handleTabs(params, sessionId) {
};
}
case 'create': {
- const t = await chrome.tabs.create({ url: url || 'about:blank' });
- return { success: true, tabId: t.id, url: t.url };
+ // active:false opens it in the background: the user's current tab stays in front.
+ const t = await chrome.tabs.create({ url: url || 'about:blank', ...(params.active === false ? { active: false } : {}) });
+ return { success: true, tabId: t.id, url: t.url || t.pendingUrl || url || 'about:blank', ...(params.active === false ? { active: false } : {}) };
+ }
+ case 'reload': {
+ if (!tabId) throw new Error('tabId required');
+ const reloadOwner = tabLocks.owner(tabId);
+ if (reloadOwner && reloadOwner !== sessionId) {
+ throw new Error(`Tab ${tabId} is locked by ${reloadOwner} — unlock it from that session before reloading.`);
+ }
+ const current = await resolveTab(tabId);
+ // A frozen page would block the reload until it frees up: replace the tab.
+ const fresh = await replaceFrozenTab(current, current.url, sessionId);
+ if (fresh) {
+ return { success: true, reloaded: fresh.id, replacedTabId: tabId, url: current.url, note: `tab ${tabId} was frozen and has been replaced by tab ${fresh.id}` };
+ }
+ const done = new Promise((resolve) => {
+ const timer = setTimeout(() => { chrome.tabs.onUpdated.removeListener(listener); resolve(false); }, 30_000);
+ function listener(id, info) {
+ if (id === tabId && info.status === 'complete') {
+ clearTimeout(timer);
+ chrome.tabs.onUpdated.removeListener(listener);
+ resolve(true);
+ }
+ }
+ chrome.tabs.onUpdated.addListener(listener);
+ });
+ await chrome.tabs.reload(tabId, { bypassCache: params.bypassCache === true });
+ const loaded = await done;
+ wedgedTabs.delete(tabId);
+ const t = await chrome.tabs.get(tabId);
+ return {
+ success: true, reloaded: tabId, url: t.url,
+ ...(loaded ? {} : { warning: 'load did not complete within 30s' }),
+ };
}
case 'close': {
if (!tabId) throw new Error('tabId required');
@@ -202,8 +304,12 @@ export async function handleTabs(params, sessionId) {
if (focusOwner && focusOwner !== sessionId) {
throw new Error(`Tab ${tabId} is locked by ${focusOwner} — unlock it from that session before focusing.`);
}
- await chrome.tabs.update(tabId, { active: true });
- return { success: true, focused: tabId };
+ const focusedTab = await chrome.tabs.update(tabId, { active: true });
+ // window:true also brings its window to the front (OS focus).
+ if (params.window === true && focusedTab?.windowId != null) {
+ await chrome.windows.update(focusedTab.windowId, { focused: true }).catch(() => {});
+ }
+ return { success: true, focused: tabId, ...(params.window === true ? { windowFocused: true } : {}) };
}
case 'lock': {
if (!tabId) throw new Error('tabId required');
@@ -232,3 +338,29 @@ export async function handleTabs(params, sessionId) {
throw new Error(`Unknown action: ${action}`);
}
}
+
+/** Resize / change the state of the window that holds a tab. */
+export async function handleResizeWindow(params, sessionId) {
+ const { tabId, width, height, state } = params;
+ const tab = await resolveTab(tabId);
+ const owner = tabLocks.owner(tabId);
+ if (owner && owner !== sessionId) throw new Error(`Tab ${tabId} is locked by ${owner} — unlock it from that session first.`);
+ const update = {};
+ if (width != null) update.width = Math.round(width);
+ if (height != null) update.height = Math.round(height);
+ if (state) update.state = state;
+ // Sizes only apply to a normal window; a maximized one must be restored first.
+ if (update.width != null || update.height != null) {
+ if (update.state && update.state !== 'normal') { delete update.width; delete update.height; }
+ else update.state = 'normal';
+ }
+ if (Object.keys(update).length === 0) throw new Error('width, height or state is required');
+ const win = await chrome.windows.update(tab.windowId, update);
+ await new Promise((r) => setTimeout(r, 200)); // let the page re-layout
+ let viewport = null;
+ try { viewport = await safeExec(tabId, () => [window.innerWidth, window.innerHeight], [], { timeoutMs: 2000 }); } catch { /* protected page */ }
+ return {
+ success: true, windowId: win.id, state: win.state, width: win.width, height: win.height,
+ ...(Array.isArray(viewport) ? { viewport: { width: viewport[0], height: viewport[1] } } : {}),
+ };
+}
diff --git a/extension/lib/cdp-session.js b/extension/lib/cdp-session.js
index c531a39..97ea91a 100644
--- a/extension/lib/cdp-session.js
+++ b/extension/lib/cdp-session.js
@@ -13,6 +13,16 @@
*/
export const IDLE_MS = 30_000;
+/** A CDP command on a frozen renderer never answers: bound the setup probes. */
+const SETUP_MS = 5_000;
+
+function bounded(promise, what) {
+ let timer;
+ return Promise.race([
+ promise,
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`CDP ${what} timed out (page not responding)`)), SETUP_MS); }),
+ ]).finally(() => clearTimeout(timer));
+}
/** tabId -> { ready: Promise, timer } */
const sessions = new Map();
@@ -33,9 +43,9 @@ async function attach(tabId) {
// A service-worker restart forgets the map but Chrome may keep our
// attachment: probe it and reuse instead of failing.
if (!/already attached/i.test(String(err?.message || err))) throw err;
- await chrome.debugger.sendCommand(target, 'Runtime.evaluate', { expression: '1' });
+ await bounded(chrome.debugger.sendCommand(target, 'Runtime.evaluate', { expression: '1' }), 'attach probe');
}
- await chrome.debugger.sendCommand(target, 'Emulation.setFocusEmulationEnabled', { enabled: true }).catch(() => {});
+ await bounded(chrome.debugger.sendCommand(target, 'Emulation.setFocusEmulationEnabled', { enabled: true }), 'focus emulation').catch(() => {});
// Can fail while the page is still loading; locateTarget retries it.
await ensureViewport(tabId).catch(() => {});
}
@@ -48,9 +58,9 @@ async function attach(tabId) {
*/
export async function ensureViewport(tabId) {
const target = { tabId };
- const { result } = await chrome.debugger.sendCommand(target, 'Runtime.evaluate', {
+ const { result } = await bounded(chrome.debugger.sendCommand(target, 'Runtime.evaluate', {
expression: 'innerWidth * innerHeight', returnByValue: true,
- });
+ }), 'viewport probe');
if (result?.value > 0) return;
const tab = await chrome.tabs.get(tabId);
const win = await chrome.windows.get(tab.windowId);
diff --git a/extension/lib/connection.js b/extension/lib/connection.js
index b7783e3..9e49578 100644
--- a/extension/lib/connection.js
+++ b/extension/lib/connection.js
@@ -91,12 +91,28 @@ async function autoPairToken() {
return '';
}
+/** This browser profile's identity for multi-browser routing (persisted). */
+let browserIdentity = {};
+
+function defaultBrowserLabel(id) {
+ const ua = navigator.userAgentData;
+ const brand = ua?.brands?.find((b) => !/Not.?A.?Brand|Chromium/i.test(b.brand))?.brand || 'Chrome';
+ const platform = ua?.platform || navigator.platform || '';
+ return `${brand}${platform ? ` on ${platform}` : ''} (${id.slice(0, 4)})`;
+}
+
export async function initConnection() {
try {
- const stored = await chrome.storage.local.get(['wsPort', 'wsToken', 'enrollmentSecret']);
+ const stored = await chrome.storage.local.get(['wsPort', 'wsToken', 'enrollmentSecret', 'bcBrowserId', 'bcBrowserLabel']);
if (stored.wsPort) wsPort = stored.wsPort;
if (stored.wsToken) wsToken = stored.wsToken;
if (stored.enrollmentSecret) enrollmentSecret = stored.enrollmentSecret;
+ let id = stored.bcBrowserId;
+ if (!id) {
+ id = (crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2)).replace(/-/g, '').slice(0, 12);
+ chrome.storage.local.set({ bcBrowserId: id });
+ }
+ browserIdentity = { browserId: id, browserLabel: stored.bcBrowserLabel || defaultBrowserLabel(id) };
} catch {}
// Restore lock ownership + fallbacks BEFORE connecting: the shield sweep
@@ -251,7 +267,7 @@ export async function connect() {
socket.close(1002, 'incompatible protocol');
return;
}
- socket.send(JSON.stringify(buildExtensionHelloAck(chrome.runtime.getManifest().version)));
+ socket.send(JSON.stringify(buildExtensionHelloAck(chrome.runtime.getManifest().version, browserIdentity)));
clearTimeout(handshakeTimeout);
handshakeTimeout = null;
connected = true;
diff --git a/extension/lib/gif-encoder.js b/extension/lib/gif-encoder.js
new file mode 100644
index 0000000..1ac366f
--- /dev/null
+++ b/extension/lib/gif-encoder.js
@@ -0,0 +1,130 @@
+/**
+ * Minimal animated-GIF encoder (GIF89a) for action recordings — no
+ * dependencies, runs in the service worker and in node tests.
+ *
+ * Palette: a fixed 256-colour table (6×6×6 colour cube + 40 greys). UI
+ * screenshots are dominated by greys/white and flat colours, so a fixed
+ * palette looks fine, needs no per-frame quantisation and keeps encoding
+ * fast. Pixel data is LZW-compressed as the format requires.
+ */
+
+const GREYS = 40;
+
+/** 256×RGB fixed palette: 216-colour cube followed by 40 greys. */
+export function buildPalette() {
+ const pal = new Uint8Array(256 * 3);
+ let i = 0;
+ for (let r = 0; r < 6; r++) for (let g = 0; g < 6; g++) for (let b = 0; b < 6; b++) {
+ pal[i++] = r * 51; pal[i++] = g * 51; pal[i++] = b * 51;
+ }
+ for (let k = 0; k < GREYS; k++) {
+ const v = Math.round((k * 255) / (GREYS - 1));
+ pal[i++] = v; pal[i++] = v; pal[i++] = v;
+ }
+ return pal;
+}
+
+/** RGBA pixels → palette indices (greyish pixels use the finer grey ramp). */
+export function indexPixels(rgba, count) {
+ const out = new Uint8Array(count);
+ for (let p = 0, q = 0; p < count; p++, q += 4) {
+ const r = rgba[q];
+ const g = rgba[q + 1];
+ const b = rgba[q + 2];
+ const max = r > g ? (r > b ? r : b) : (g > b ? g : b);
+ const min = r < g ? (r < b ? r : b) : (g < b ? g : b);
+ if (max - min < 14) {
+ out[p] = 216 + Math.round((((r + g + b) / 3) * (GREYS - 1)) / 255);
+ } else {
+ out[p] = Math.round(r / 51) * 36 + Math.round(g / 51) * 6 + Math.round(b / 51);
+ }
+ }
+ return out;
+}
+
+/** GIF LZW compression of palette indices (min code size 8), as sub-blocks. */
+export function lzwEncode(indices) {
+ const MIN = 8;
+ const CLEAR = 1 << MIN;
+ const EOI = CLEAR + 1;
+ const bytes = [];
+ let cur = 0;
+ let bits = 0;
+ let codeSize = MIN + 1;
+ const emit = (code) => {
+ cur |= code << bits;
+ bits += codeSize;
+ while (bits >= 8) { bytes.push(cur & 0xff); cur >>>= 8; bits -= 8; }
+ };
+ let dict = new Map();
+ let next = EOI + 1;
+ emit(CLEAR);
+ let prefix = indices.length ? indices[0] : 0;
+ for (let i = 1; i < indices.length; i++) {
+ const k = indices[i];
+ const key = prefix * 256 + k;
+ const hit = dict.get(key);
+ if (hit !== undefined) { prefix = hit; continue; }
+ emit(prefix);
+ if (next < 4096) {
+ dict.set(key, next++);
+ if (next > (1 << codeSize) && codeSize < 12) codeSize++;
+ } else {
+ emit(CLEAR);
+ dict = new Map();
+ next = EOI + 1;
+ codeSize = MIN + 1;
+ }
+ prefix = k;
+ }
+ if (indices.length) emit(prefix);
+ emit(EOI);
+ if (bits > 0) bytes.push(cur & 0xff);
+ // Split into ≤255-byte sub-blocks.
+ const out = [MIN];
+ for (let i = 0; i < bytes.length; i += 255) {
+ const chunk = bytes.slice(i, i + 255);
+ out.push(chunk.length, ...chunk);
+ }
+ out.push(0);
+ return out;
+}
+
+/**
+ * frames: [{ rgba: Uint8ClampedArray|Uint8Array (width*height*4), delayMs }]
+ * All frames share width×height. Returns the GIF file bytes.
+ */
+export function encodeGif(width, height, frames, { loop = 0 } = {}) {
+ const pal = buildPalette();
+ const out = [];
+ const u16 = (v) => { out.push(v & 0xff, (v >> 8) & 0xff); };
+ const str = (s) => { for (const ch of s) out.push(ch.charCodeAt(0)); };
+ str('GIF89a');
+ u16(width); u16(height);
+ out.push(0xf7, 0, 0); // global colour table, 8 bits/channel, 256 entries
+ for (const v of pal) out.push(v);
+ // NETSCAPE2.0 loop extension
+ out.push(0x21, 0xff, 0x0b); str('NETSCAPE2.0'); out.push(0x03, 0x01); u16(loop); out.push(0);
+ for (const f of frames) {
+ const delay = Math.max(2, Math.round((f.delayMs ?? 500) / 10));
+ out.push(0x21, 0xf9, 0x04, 0x00); u16(delay); out.push(0, 0); // graphic control
+ out.push(0x2c); u16(0); u16(0); u16(width); u16(height); out.push(0); // image descriptor
+ const data = lzwEncode(indexPixels(f.rgba, width * height));
+ for (const b of data) out.push(b);
+ }
+ out.push(0x3b);
+ return Uint8Array.from(out);
+}
+
+/** Paint a red ring (click marker) into RGBA pixels. */
+export function drawMarker(rgba, width, height, cx, cy, radius = 9) {
+ for (let y = Math.max(0, Math.floor(cy - radius - 2)); y <= Math.min(height - 1, Math.ceil(cy + radius + 2)); y++) {
+ for (let x = Math.max(0, Math.floor(cx - radius - 2)); x <= Math.min(width - 1, Math.ceil(cx + radius + 2)); x++) {
+ const d = Math.hypot(x - cx, y - cy);
+ if (d <= radius + 1.5 && d >= radius - 1.5) {
+ const q = (y * width + x) * 4;
+ rgba[q] = 255; rgba[q + 1] = 0; rgba[q + 2] = 0; rgba[q + 3] = 255;
+ }
+ }
+ }
+}
diff --git a/extension/lib/page-dom.js b/extension/lib/page-dom.js
new file mode 100644
index 0000000..00cd0a2
--- /dev/null
+++ b/extension/lib/page-dom.js
@@ -0,0 +1,437 @@
+/**
+ * Shared page-side DOM runtime: one element resolver and one composed-tree
+ * walker for every tool, installed once per document (v2 install-once pattern,
+ * see observation-v2.js — injected source can't import modules).
+ *
+ * Why: refs used to be looked up by a `[data-mcp-ref]` attribute nothing
+ * writes any more, so every ref action fell through to the smart-selector
+ * fallback, whose first step returned the FIRST querySelector match — clicks
+ * "succeeded" on the wrong element. Selectors also acted on the first match
+ * even when it was hidden, and nothing looked inside shadow roots.
+ *
+ * Resolution order: ref registry → selector (first VISIBLE match across the
+ * composed tree: open/closed shadow roots + same-origin iframes) → verified
+ * fallback (unique, or nth among exact role/tag/name matches). Anything
+ * ambiguous is reported as gone instead of guessed.
+ */
+
+export const PAGE_DOM_VERSION = 1;
+
+export function PAGE_DOM_INSTALL(version) {
+ if (globalThis.__bcDom && globalThis.__bcDom.v === version) return false;
+ const REGISTRY_KEY = '__browserControllerLegacyRefRegistry';
+ const registry = globalThis[REGISTRY_KEY] instanceof Map ? globalThis[REGISTRY_KEY] : new Map();
+ globalThis[REGISTRY_KEY] = registry;
+
+ /** Computed style from the element's own window (frames have their own). */
+ function styleOf(el) {
+ try { return ((el.ownerDocument && el.ownerDocument.defaultView) || globalThis).getComputedStyle(el); } catch { return null; }
+ }
+ const connected = (el) => !!el && el.isConnected !== false;
+
+ const clean = (v, max = 160) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
+ const attr = (el, n) => (el && el.getAttribute ? el.getAttribute(n) || '' : '');
+
+ /** Open or closed shadow root (closed ones via chrome.dom in the isolated world). */
+ function shadowOf(el) {
+ if (!el || el.nodeType !== 1) return null;
+ if (el.shadowRoot) return el.shadowRoot;
+ try {
+ if (typeof chrome !== 'undefined' && chrome.dom && chrome.dom.openOrClosedShadowRoot) return chrome.dom.openOrClosedShadowRoot(el) || null;
+ } catch { /* not an element that can host a root */ }
+ return null;
+ }
+
+ function frameDoc(el) {
+ if (!el || el.tagName !== 'IFRAME' && el.tagName !== 'FRAME') return null;
+ try { return el.contentDocument || null; } catch { return null; }
+ }
+
+ /** Every search root: documents (top + same-origin frames) and shadow roots. */
+ function allRoots(withShadow) {
+ const roots = [];
+ const seen = new Set();
+ const visit = (root, depth) => {
+ if (!root || seen.has(root) || depth > 6) return;
+ seen.add(root);
+ roots.push(root);
+ let els = [];
+ try { els = root.querySelectorAll(withShadow ? '*' : 'iframe,frame'); } catch {}
+ for (const el of els) {
+ const d = frameDoc(el);
+ if (d) visit(d, depth + 1);
+ if (withShadow) { const s = shadowOf(el); if (s) visit(s, depth + 1); }
+ }
+ };
+ visit(document, 0);
+ return roots;
+ }
+
+ function queryAll(sel, withShadow) {
+ const out = [];
+ for (const root of allRoots(withShadow)) {
+ try { for (const el of root.querySelectorAll(sel)) out.push(el); } catch { return null; /* invalid selector */ }
+ }
+ return out;
+ }
+
+ /** Composed-ancestor aware: display/visibility/opacity/content-visibility + a real box. */
+ function isVisible(el) {
+ if (!connected(el)) return false;
+ // An element in a hidden/transparent/zero-size iframe is not visible either.
+ let frameEl;
+ try { frameEl = el.ownerDocument && el.ownerDocument.defaultView ? el.ownerDocument.defaultView.frameElement : null; } catch { frameEl = null; }
+ if (frameEl && !isVisible(frameEl)) return false;
+ try {
+ if (typeof el.checkVisibility === 'function'
+ && !el.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, contentVisibilityAuto: true })) return false;
+ } catch { /* old engine */ }
+ let r;
+ try { r = el.getBoundingClientRect(); } catch { return false; }
+ if (r.width > 0 && r.height > 0) return true;
+ // display:contents hosts / slots have no box of their own: visible if a child is.
+ try {
+ const st = styleOf(el);
+ if (st && st.display === 'contents') {
+ for (const c of flatChildren(el)) if (c.nodeType === 1 && isVisible(c)) return true;
+ }
+ } catch {}
+ return false;
+ }
+
+ /** Flat-tree children: shadow content replaces light children, slots show what is assigned. */
+ function flatChildren(node) {
+ if (!node) return [];
+ if (node.nodeType === 1) {
+ const s = shadowOf(node);
+ if (s) return Array.from(s.childNodes);
+ if (node.tagName === 'SLOT' && typeof node.assignedNodes === 'function') {
+ const assigned = node.assignedNodes({ flatten: true });
+ if (assigned.length) return assigned;
+ }
+ const d = frameDoc(node);
+ if (d) return d.body ? [d.body] : [];
+ }
+ return Array.from(node.childNodes || node.children || []);
+ }
+
+ function hasShadowHosts() {
+ for (const root of allRoots(false)) {
+ let els = [];
+ try { els = root.querySelectorAll('*'); } catch {}
+ for (const el of els) if (shadowOf(el)) return true;
+ }
+ return false;
+ }
+
+ const INPUT_BUTTONS = ['button', 'submit', 'reset', 'image'];
+ function roleOf(el) {
+ const explicit = clean(attr(el, 'role')).split(' ')[0];
+ if (explicit) return explicit;
+ const tag = String(el.tagName || '').toLowerCase();
+ const type = String(el.type || attr(el, 'type') || '').toLowerCase();
+ if (tag === 'button' || tag === 'summary') return 'button';
+ if (tag === 'a') return el.hasAttribute('href') ? 'link' : 'generic';
+ if (tag === 'textarea') return 'textbox';
+ if (tag === 'select') return el.multiple ? 'listbox' : 'combobox';
+ if (tag === 'option') return 'option';
+ if (tag === 'img') return 'img';
+ if (/^h[1-6]$/.test(tag)) return 'heading';
+ if (tag === 'input') {
+ if (INPUT_BUTTONS.includes(type)) return 'button';
+ if (type === 'checkbox') return 'checkbox';
+ if (type === 'radio') return 'radio';
+ if (type === 'range') return 'slider';
+ if (type === 'search') return 'searchbox';
+ if (type === 'hidden') return 'none';
+ return 'textbox';
+ }
+ if (el.isContentEditable) return 'textbox';
+ const map = { nav: 'navigation', main: 'main', header: 'banner', footer: 'contentinfo', form: 'form', dialog: 'dialog', table: 'table', ul: 'list', ol: 'list', li: 'listitem', aside: 'complementary' };
+ return map[tag] || 'generic';
+ }
+
+ /** Composed text (includes shadow content) — innerText misses shadow roots. */
+ function composedText(el, max = 300) {
+ let out = '';
+ let budget = max * 4; // raw chars incl. whitespace; stops long before a big container's full text
+ const walk = (n) => {
+ if (budget <= 0) return;
+ if (n.nodeType === 3) { out += n.nodeValue; budget -= n.nodeValue.length; return; }
+ if (n.nodeType === 1 && n.childNodes === undefined && !shadowOf(n)) {
+ const t = String(n.textContent ?? n.innerText ?? ''); out += t; budget -= t.length; return; // minimal DOMs
+ }
+ if (n.nodeType !== 1 && n.nodeType !== 11) return;
+ if (n.nodeType === 1 && (n.tagName === 'SCRIPT' || n.tagName === 'STYLE')) return;
+ for (const c of flatChildren(n)) walk(c);
+ };
+ walk(el);
+ return clean(out, max);
+ }
+
+ function nameOf(el) {
+ const labelledBy = attr(el, 'aria-labelledby');
+ if (labelledBy) {
+ let root = null;
+ try { root = el.getRootNode(); } catch {}
+ const t = labelledBy.split(/\s+/).map((id) => {
+ const l = (root && root.getElementById && root.getElementById(id)) || el.ownerDocument.getElementById(id);
+ return l ? clean(l.textContent) : '';
+ }).filter(Boolean).join(' ');
+ if (t) return clean(t);
+ }
+ const aria = clean(attr(el, 'aria-label'));
+ if (aria) return aria;
+ try {
+ const labels = Array.from(el.labels || []).map((l) => clean(l.textContent)).filter(Boolean);
+ if (labels.length) return clean(labels.join(' '));
+ } catch {}
+ const tag = String(el.tagName || '').toLowerCase();
+ const type = String(el.type || '').toLowerCase();
+ if (tag === 'input' && INPUT_BUTTONS.includes(type) && el.value) return clean(el.value);
+ for (const a of ['alt', 'title', 'placeholder']) { const v = clean(attr(el, a)); if (v) return v; }
+ if (tag === 'input' || tag === 'textarea' || tag === 'select') return '';
+ // Bounded composed text: CSS-independent (innerText applies text-transform:
+ // uppercase), includes shadow content, and never materialises a huge
+ // container's whole textContent.
+ return composedText(el, 200);
+ }
+
+ const INTERACTIVE_ROLES = new Set(['button', 'link', 'textbox', 'searchbox', 'combobox', 'listbox', 'checkbox', 'radio', 'switch', 'tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'slider', 'spinbutton', 'treeitem']);
+ function isInteractive(el) {
+ const tag = el.tagName;
+ if (['A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'SUMMARY'].includes(tag)) return tag !== 'A' || el.hasAttribute('href') || el.hasAttribute('onclick');
+ if (INTERACTIVE_ROLES.has(roleOf(el))) return true;
+ if (el.isContentEditable) return true;
+ const ti = attr(el, 'tabindex');
+ if (ti !== '' && Number(ti) >= 0) return true;
+ return typeof el.onclick === 'function';
+ }
+
+ /** Exact, or wanted + non-letter suffix ("Login »"). Mirrors isPreciseTextMatch. */
+ function preciseMatch(candidate, wanted) {
+ const c = clean(candidate).toLowerCase();
+ const w = clean(wanted).toLowerCase();
+ if (!w) return false;
+ if (c === w) return true;
+ return w.length > 2 && c.length > w.length && c.startsWith(w) && !/[a-zà-ÿ-ۿ]/i.test(c.slice(w.length));
+ }
+
+ function firstVisible(list) {
+ if (!list || !list.length) return null;
+ for (const el of list) if (isVisible(el)) return el;
+ return null;
+ }
+
+ function bySelector(sel) {
+ let all = queryAll(sel, false);
+ if (all === null) return { error: 'INVALID_SELECTOR' };
+ let el = firstVisible(all);
+ if (el) return { el, count: all.length };
+ const deep = queryAll(sel, true) || [];
+ el = firstVisible(deep) || deep[0] || all[0] || null;
+ return el ? { el, count: deep.length || all.length, hidden: !isVisible(el) } : null;
+ }
+
+ /** Fallback descriptor → element, only when the match is unambiguous. */
+ function byFallback(fb) {
+ if (!fb) return null;
+ const wantTag = fb.tag || null;
+ const wantRole = fb.role || null;
+ const wantNth = typeof fb.nth === 'number' && fb.nth >= 0 ? fb.nth : 0;
+ const effRole = (el) => attr(el, 'role') || el.tagName.toLowerCase();
+ const textOk = (el) => !fb.text || preciseMatch(nameOf(el), fb.text)
+ || preciseMatch(clean(el.textContent).split('\n')[0], fb.text)
+ || preciseMatch(attr(el, 'aria-label') || attr(el, 'alt') || attr(el, 'title') || attr(el, 'placeholder'), fb.text);
+ const same = (el) => (!wantTag || el.tagName === wantTag) && (!wantRole || effRole(el) === wantRole);
+
+ if (fb.robustSelector) {
+ const cands = (queryAll(fb.robustSelector, true) || []).filter((el) => same(el) && isVisible(el));
+ const named = fb.text ? cands.filter(textOk) : cands;
+ if (named.length === 1) return named[0];
+ if (named.length > wantNth) return named[wantNth];
+ if (named.length > 1) return null; // several look-alikes and the ordinal no longer fits: don't guess
+ }
+ if (!fb.text) return null;
+ const matches = [];
+ const sel = wantTag ? wantTag.toLowerCase() : '*';
+ for (const el of queryAll(sel, true) || []) {
+ if (!same(el) || !isVisible(el)) continue;
+ if (textOk(el)) matches.push(el);
+ }
+ if (matches.length > wantNth) return matches[wantNth];
+ if (matches.length === 1) return matches[0];
+ return null;
+ }
+
+ /**
+ * ref → selector → verified fallback. Returns { el, via } or { error, url }.
+ * via: 'ref' | 'selector' | 'fallback'.
+ */
+ function resolve(ref, sel, fb) {
+ if (ref) {
+ const el = registry.get(ref);
+ if (connected(el)) return { el, via: 'ref' };
+ if (el) registry.delete(ref);
+ }
+ if (sel) {
+ const hit = bySelector(sel);
+ if (hit && hit.error) return { error: hit.error, url: location.href };
+ if (hit) return { el: hit.el, via: 'selector', ...(hit.hidden ? { hidden: true } : {}) };
+ }
+ if (fb) {
+ const el = byFallback(fb);
+ if (el) {
+ if (ref) registry.set(ref, el); // re-bind so the next call is a direct hit
+ return { el, via: 'fallback' };
+ }
+ }
+ return { error: 'REF_GONE', url: location.href };
+ }
+
+ /** Top-level viewport centre of an element (adds same-origin iframe offsets). */
+ function centerOf(el) {
+ const rect = el.getBoundingClientRect();
+ let x = rect.left + rect.width / 2;
+ let y = rect.top + rect.height / 2;
+ let win = el.ownerDocument ? el.ownerDocument.defaultView : null;
+ while (win && win !== globalThis.window && win.frameElement) {
+ const fr = win.frameElement.getBoundingClientRect();
+ const cs = win.frameElement.ownerDocument.defaultView.getComputedStyle(win.frameElement);
+ x += fr.left + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0);
+ y += fr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0);
+ win = win.parent;
+ }
+ return { x, y, rect };
+ }
+
+ /** Composed hit-test: the deepest element at a top-level point (pierces shadow roots and same-origin frames). */
+ function elementAt(x, y) {
+ let doc = document;
+ let px = x;
+ let py = y;
+ let hit = null;
+ for (let guard = 0; guard < 8; guard++) {
+ let h = doc.elementFromPoint(px, py);
+ while (h) {
+ const s = shadowOf(h);
+ const inner = s && s.elementFromPoint ? s.elementFromPoint(px, py) : null;
+ if (!inner || inner === h) break;
+ h = inner;
+ }
+ if (!h) break;
+ hit = h;
+ const d = frameDoc(h);
+ if (!d) break;
+ const fr = h.getBoundingClientRect();
+ px -= fr.left; py -= fr.top;
+ doc = d;
+ }
+ return hit;
+ }
+
+ function composedContains(a, b) {
+ let cur = b;
+ const seen = new Set();
+ while (cur && !seen.has(cur)) {
+ if (cur === a) return true;
+ seen.add(cur);
+ let root = null;
+ try { root = cur.getRootNode(); } catch {}
+ let frameEl = null;
+ try { frameEl = root && root.nodeType === 9 && root.defaultView ? root.defaultView.frameElement : null; } catch {}
+ cur = cur.parentElement || (root && root.host) || frameEl || null;
+ }
+ return false;
+ }
+
+ function describe(el) {
+ if (!el) return null;
+ return el.tagName.toLowerCase() + (el.id ? `#${el.id}` : '')
+ + (typeof el.className === 'string' && el.className.trim() ? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '');
+ }
+
+ const BLOCK_DISPLAY = /^(block|flex|grid|list-item|table|table-row|table-caption|flow-root|inline-block)$/;
+ const ARTICLE_SKIP_ROLES = new Set(['navigation', 'banner', 'contentinfo', 'complementary', 'search', 'menu', 'menubar', 'toolbar', 'dialog', 'alertdialog']);
+ const ARTICLE_SKIP_TAGS = new Set(['NAV', 'FOOTER', 'ASIDE', 'HEADER', 'FORM', 'BUTTON', 'DIALOG']);
+ const ARTICLE_SKIP_HINT = /(^|[-_ ])(cookie|consent|sidebar|side-bar|menu|navbar|nav|footer|breadcrumbs?|share|social|advert|ads|promo|newsletter|related|comments?)([-_ ]|$)/i;
+
+ /**
+ * Visible text of the flat tree (shadow roots, slots, same-origin frames),
+ * block elements on their own lines. `article` skips navigation, headers,
+ * footers, sidebars, banners and similar page chrome.
+ */
+ function flatText(root, { article = false, max = 1e7 } = {}) {
+ const parts = [];
+ let len = 0;
+ const push = (t) => { parts.push(t); len += t.length; };
+ // pre: inside white-space:pre* the text keeps its own line breaks.
+ const walk = (n, pre) => {
+ if (len > max) return;
+ if (n.nodeType === 3) {
+ if (pre) { if (n.nodeValue.trim()) push(n.nodeValue.replace(/\n/g, '\u2029')); return; }
+ const v = n.nodeValue.replace(/\s+/g, ' ');
+ if (v.trim()) push(v);
+ return;
+ }
+ if (n.nodeType === 11 || n.nodeType === 9) { for (const c of flatChildren(n)) walk(c, pre); return; }
+ if (n.nodeType !== 1) return;
+ const tag = n.tagName;
+ if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'NOSCRIPT' || tag === 'TEMPLATE' || tag === 'svg' || tag === 'SVG') return;
+ if (tag === 'BR') { push('\n'); return; }
+ if (article && n !== root) {
+ if (ARTICLE_SKIP_TAGS.has(tag) && !(tag === 'HEADER' && n.closest && n.closest('article, main'))) return;
+ if (ARTICLE_SKIP_ROLES.has(roleOf(n))) return;
+ const hint = `${n.id || ''} ${typeof n.className === 'string' ? n.className : ''}`;
+ if (hint.trim() && ARTICLE_SKIP_HINT.test(hint)) return;
+ if (attr(n, 'aria-hidden') === 'true') return;
+ }
+ const st = styleOf(n);
+ if (st && (st.display === 'none' || st.visibility === 'hidden' || st.contentVisibility === 'hidden')) return;
+ const block = st ? BLOCK_DISPLAY.test(st.display) : false;
+ const inPre = st ? /^pre/.test(st.whiteSpace || '') : pre;
+ if (block) push('\n');
+ for (const c of flatChildren(n)) walk(c, inPre);
+ if (st && st.display === 'table-cell') push(' \t ');
+ if (block) push('\n');
+ };
+ walk(root, false);
+ return parts.join('')
+ .replace(/[ \t]*\n[ \t]*/g, '\n')
+ .replace(/\n{3,}/g, '\n\n')
+ .replace(/[ \t]{2,}/g, ' ')
+ .replace(/\u2029/g, '\n') // preformatted line breaks survive the collapsing above
+ .trim();
+ }
+
+ /** Main-content root for article mode: the visible //[role=main] with the most text. */
+ function articleRoot() {
+ let best = null;
+ let bestLen = 0;
+ for (const root of allRoots(false)) {
+ let els = [];
+ try { els = root.querySelectorAll('article, main, [role="main"], [itemprop="articleBody"]'); } catch {}
+ for (const el of els) {
+ const l = (el.textContent || '').length;
+ if (l > bestLen && isVisible(el)) { best = el; bestLen = l; }
+ }
+ }
+ return best || document.body;
+ }
+
+ /** Page text: native innerText when there is no shadow DOM (fast), the flat-tree walker otherwise. */
+ function pageText(root, { article = false, max = 1e7 } = {}) {
+ if (!article && root.ownerDocument === document && typeof root.innerText === 'string' && !hasShadowHosts()) {
+ return root.innerText.replace(/\t/g, ' ').replace(/\n\s*\n/g, '\n\n').replace(/ +/g, ' ').trim();
+ }
+ return flatText(root, { article, max });
+ }
+
+ globalThis.__bcDom = Object.freeze({
+ v: version,
+ clean, attr, shadowOf, frameDoc, allRoots, queryAll, isVisible, flatChildren, hasShadowHosts,
+ roleOf, nameOf, composedText, isInteractive, preciseMatch, resolve, centerOf, elementAt,
+ composedContains, describe, registry, flatText, articleRoot, pageText, styleOf, connected,
+ });
+ return true;
+}
diff --git a/extension/lib/page-exec.js b/extension/lib/page-exec.js
index f6c7a02..958840e 100644
--- a/extension/lib/page-exec.js
+++ b/extension/lib/page-exec.js
@@ -2,7 +2,8 @@
* Page-execution primitives (extracted from background.js): tab resolution,
* the locator guard, and safeExec. Everything a handler needs to touch a page.
*/
-import { fallbackByTab } from './state.js';
+import { fallbackByTab, wedgedTabs, tabLocks, persistSessionState } from './state.js';
+import { PAGE_DOM_INSTALL, PAGE_DOM_VERSION } from './page-dom.js';
/**
* Resolve a tab by id, throwing a clear, actionable error if it's gone.
@@ -18,7 +19,7 @@ export async function resolveTab(tabId) {
if (!tab) throw new Error(`Tab ${tabId} not found, call browser_tabs list first.`);
return tab;
} catch (err) {
- throw new Error(`Tab ${tabId} not found, call browser_tabs list first. (${err.message || err})`);
+ throw new Error(`Tab ${tabId} not found, call browser_tabs list first. (${err.message || err})`, { cause: err });
}
}
@@ -29,12 +30,20 @@ export async function resolveTab(tabId) {
* misleading "Element undefined is gone from the DOM" after a wasted
* round-trip (critical audit #10).
*/
-export function requireTarget(params) {
+export function requireTarget(params, { allowPoint = false } = {}) {
+ if (allowPoint && hasPoint(params)) return;
if (!params.ref && !params.selector) {
- throw new Error('ref or selector is required (get refs from browser_snapshot / browser_find).');
+ throw new Error(allowPoint
+ ? 'ref or selector is required, or x+y viewport coordinates (get refs from browser_snapshot / browser_find).'
+ : 'ref or selector is required (get refs from browser_snapshot / browser_find).');
}
}
+/** Viewport coordinates given (and no element locator): act at that point. */
+export function hasPoint(params) {
+ return Number.isFinite(params.x) && Number.isFinite(params.y);
+}
+
/**
* Get the stored smart-selector fallback for a (tabId, ref). Returns null if
* the ref was never snapshotted or the snapshot pre-dates the fallback feature.
@@ -46,6 +55,71 @@ export function getFallback(tabId, ref) {
return map.get(ref) || null;
}
+/** Default budget for one page function (below every tool's own timeout). */
+export const PAGE_EXEC_TIMEOUT_MS = 8_000;
+/** Probe budget for a tab already known to be unresponsive. */
+export const WEDGE_PROBE_MS = 1_500;
+
+/** Race a promise against a timer; the timer's error comes from makeError(). */
+export function withTimeout(promise, ms, makeError) {
+ let timer;
+ const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(makeError()), ms); });
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
+}
+
+export function wedgedError(tabId, ms) {
+ const err = new Error(`TAB_WEDGED: tab ${tabId} did not respond within ${(ms / 1000).toFixed(1)}s `
+ + '(frozen main thread or a huge document). browser_navigate it elsewhere, reload it '
+ + '(browser_tabs action:"reload") or close it; other tabs are unaffected.');
+ err.code = 'TAB_WEDGED';
+ return err;
+}
+
+/** Is a previously wedged tab answering again? Clears the mark when it is. */
+export async function probeResponsive(tabId, ms = WEDGE_PROBE_MS) {
+ try {
+ await withTimeout(chrome.scripting.executeScript({ target: { tabId }, func: () => 1 }), ms, () => wedgedError(tabId, ms));
+ wedgedTabs.delete(tabId);
+ return true;
+ } catch (err) {
+ if (err && err.code === 'TAB_WEDGED') return false;
+ wedgedTabs.delete(tabId); // a different failure (protected page, gone): not a wedge
+ return true;
+ }
+}
+
+/**
+ * A frozen page holds every navigation/reload of its tab hostage until its
+ * main thread frees up (57 s measured on a busy loop; CDP Page.crash and
+ * tabs.discard don't help — discard even changes the tab id). Closing a tab
+ * never waits for the page, so replace it: a new tab at the same position,
+ * then close the frozen one. Returns the new tab, or null when the tab was
+ * not wedged. Callers report `replacedTabId` so the agent switches ids.
+ */
+export async function replaceFrozenTab(tab, url, sessionId = null) {
+ if (!wedgedTabs.has(tab.id)) return null;
+ // Defence in depth (the router checks too): never replace another session's tab.
+ const owner = tabLocks.owner(tab.id);
+ if (owner && owner !== sessionId) {
+ throw new Error(`Tab ${tab.id} is locked by ${owner} — unlock it from that session first.`);
+ }
+ const fresh = await chrome.tabs.create({ windowId: tab.windowId, index: tab.index, url: url || 'about:blank', active: !!tab.active });
+ wedgedTabs.delete(tab.id);
+ // The replacement keeps the caller's lock on the tab it is replacing.
+ if (owner) {
+ tabLocks.release(tab.id);
+ tabLocks.lock(fresh.id, owner);
+ persistSessionState();
+ }
+ chrome.tabs.remove(tab.id).catch(() => { /* already gone */ });
+ return fresh;
+}
+
+/** Fail fast when the tab is known to be frozen (one short probe, no queueing). */
+export async function assertResponsive(tabId) {
+ if (wedgedTabs.has(tabId) && !(await probeResponsive(tabId))) throw wedgedError(tabId, WEDGE_PROBE_MS);
+}
+
/**
* safeExec (task 2.5): run chrome.scripting.executeScript against a tab,
* turning "can't access chrome:// / webstore / devtools pages" into a clear
@@ -60,19 +134,42 @@ export async function safeExec(tabId, func, args = [], opts = {}) {
throw new Error(`Cannot access protected page (${tab.url}). Tab ${tabId} is a browser-internal page.`);
}
const sanitized = args.map((a) => (a === undefined ? null : a));
+ await assertResponsive(tabId);
+ // An in-flight executeScript can't be aborted: without a timer a frozen page
+ // pinned the tab's mutex until the caller's own timeout, and every queued
+ // call after it waited too (benchmark: 2 × 125 s on one JSON page).
+ const ms = opts.timeoutMs ?? PAGE_EXEC_TIMEOUT_MS;
try {
- const results = await chrome.scripting.executeScript({
+ const results = await withTimeout(chrome.scripting.executeScript({
target: { tabId },
func,
args: sanitized,
...(opts.world ? { world: opts.world } : {}),
+ }), ms, () => {
+ wedgedTabs.set(tabId, Date.now());
+ return wedgedError(tabId, ms);
});
return results[0]?.result;
} catch (err) {
+ if (err && err.code === 'TAB_WEDGED') throw err;
const msg = err?.message || String(err);
if (/cannot access|Cannot access|not allowed|No tab with id/i.test(msg)) {
- throw new Error(`Cannot execute on tab ${tabId}: ${msg}`);
+ throw new Error(`Cannot execute on tab ${tabId}: ${msg}`, { cause: err });
}
throw err;
}
}
+
+/**
+ * Run a page function that uses the shared DOM runtime (globalThis.__bcDom,
+ * lib/page-dom.js). Page functions start with
+ * `if (!globalThis.__bcDom) return { __needDom: true };`
+ * so the steady state costs one executeScript; the runtime is installed and
+ * the call repeated only when the document doesn't have it yet.
+ */
+export async function execDom(tabId, func, args = [], opts = {}) {
+ const res = await safeExec(tabId, func, args, opts);
+ if (!res || res.__needDom !== true) return res;
+ await safeExec(tabId, PAGE_DOM_INSTALL, [PAGE_DOM_VERSION], opts);
+ return safeExec(tabId, func, args, opts);
+}
diff --git a/extension/lib/protocol.js b/extension/lib/protocol.js
index 7e2fc69..2aaaafd 100644
--- a/extension/lib/protocol.js
+++ b/extension/lib/protocol.js
@@ -39,11 +39,15 @@ export function validateDaemonHello(message) {
return { ok: true, legacy: false };
}
-export function buildExtensionHelloAck(appVersion) {
+export function buildExtensionHelloAck(appVersion, identity = {}) {
return {
type: 'helloAck',
protocolVersion: PROTOCOL_VERSION,
...(appVersion ? { appVersion } : {}),
capabilities: EXTENSION_PROTOCOL_CAPABILITIES,
+ // Multi-browser: a stable id per browser profile (+ a human label) so the
+ // daemon can keep several browsers connected and route sessions to one.
+ ...(identity.browserId ? { browserId: identity.browserId } : {}),
+ ...(identity.browserLabel ? { browserLabel: identity.browserLabel } : {}),
};
}
diff --git a/extension/lib/router.js b/extension/lib/router.js
index 5eca362..735c90b 100644
--- a/extension/lib/router.js
+++ b/extension/lib/router.js
@@ -5,15 +5,16 @@
* dispatch() rebuilt the tool map on every call.
*/
import { runOnTab as runOnTabLib } from './tab-concurrency.js';
-import { tabLocks, tabMutex, observationSnapshots, persistSessionState } from './state.js';
+import { tabLocks, tabMutex, observationSnapshots, persistSessionState, wedgedTabs } from './state.js';
import { sendJson, updateBadge, broadcastStatus, isWsConnected, setCurrentActivity } from './connection.js';
import { showLockShield, hideLockShield } from './overlay.js';
import { getActiveTab, handleNavigate } from '../handlers/navigation.js';
import { handleClick, handleType, handlePressKey, handleHover, handleSelect, handleClickByText, handleDialog, handleDrag, handleFillForm } from '../handlers/interaction.js';
import { handleWait, handleScroll, handleSnapshot, handleFind, handleGetPageText, handleEvaluate } from '../handlers/inspection.js';
-import { handleTabs, handleConsole, handleNetwork, handleScreenshot } from '../handlers/tabs.js';
+import { handleTabs, handleConsole, handleNetwork, handleScreenshot, handleResizeWindow } from '../handlers/tabs.js';
import { handleRunAction, handleUploadFile } from '../handlers/cdp.js';
import { handleObserve, handleAct } from '../handlers/agent-api.js';
+import { handleGif, isRecording, recordFrame, GIF_FRAME_TOOLS } from '../handlers/gif.js';
// sessionId arrives as a first-class top-level field on the WS message (audit
// M1) — the daemon no longer injects it into params. We read it here so the
@@ -61,6 +62,8 @@ const HANDLERS = {
browser_text: handleGetPageText,
browser_observe: handleObserve,
browser_act: handleAct,
+ browser_resize_window: handleResizeWindow,
+ browser_gif: handleGif,
};
/** All tool names the router can dispatch (exported for the drift-guard test). */
@@ -72,6 +75,14 @@ export async function dispatch(tool, params, sessionId, agentName, signal) {
return handler(params, sessionId, agentName, signal);
}
+const OVERLAY_MS = 1_500;
+function overlayStep(promise) {
+ let timer;
+ return Promise.race([promise, new Promise((r) => { timer = setTimeout(r, OVERLAY_MS); })])
+ .catch(() => {})
+ .finally(() => clearTimeout(timer));
+}
+
function sendResponse(id, response) {
sendJson({ id, ...response });
}
@@ -170,8 +181,21 @@ export async function handleMessage(msg) {
// and even handle_dialog. Close/focus retain their ownership checks inside
// handleTabs; dialog handling uses CDP directly and must reach the native
// prompt without waiting for page execution to settle.
- const bypassesMutex = (tool === 'browser_tabs' && (p.action === 'close' || p.action === 'focus'))
- || tool === 'browser_handle_dialog';
+ // A wedged tab's queue may still hold calls waiting on a frozen page:
+ // navigate/reload are the recovery path and need no page cooperation.
+ const bypassesMutex = (tool === 'browser_tabs' && (p.action === 'close' || p.action === 'focus' || p.action === 'reload'))
+ || tool === 'browser_handle_dialog'
+ || (tool === 'browser_navigate' && wedgedTabs.has(tabId));
+
+ // Skipping the queue must not skip lock ownership: runOnTab enforces it for
+ // queued calls, so the frozen-tab navigate path checks it here.
+ if (tool === 'browser_navigate' && bypassesMutex) {
+ const owner = tabLocks.owner(tabId);
+ if (owner && owner !== sessionId) {
+ sendResponse(id, { success: false, error: `Tab ${tabId} is locked by ${owner} — unlock it from that session first.` });
+ return;
+ }
+ }
// Tools without a tabId (tabs list/create, console-less) run directly.
if (tabId == null || bypassesMutex) {
@@ -203,10 +227,18 @@ export async function handleMessage(msg) {
// the AGENT (user request: "agent {name} controlling the tab"), not the
// running tool. agentName is a top-level WS field (audit M1); fall back
// to a generic label for anonymous direct-WS callers.
- await showLockShield(tabId, agentName ? `agent ${agentName} controlling the tab` : 'agent controlling the tab');
+ // Best effort and bounded: the shield is cosmetic, a frozen page must not block the tool.
+ if (!wedgedTabs.has(tabId)) {
+ await overlayStep(showLockShield(tabId, agentName ? `agent ${agentName} controlling the tab` : 'agent controlling the tab'));
+ }
try {
const result = await dispatch(tool, p, sessionId, agentName, controller.signal);
sendToolResponse(id, result);
+ // GIF recording: capture the page after the action (the reply is already
+ // sent; the tab mutex keeps the next call from racing the capture).
+ if (GIF_FRAME_TOOLS.has(tool) && isRecording(tabId) && !(result && result.success === false)) {
+ await recordFrame(tabId, tool, result);
+ }
} catch (err) {
sendResponse(id, { success: false, error: err.message || String(err) });
} finally {
@@ -214,8 +246,10 @@ export async function handleMessage(msg) {
updateBadge(isWsConnected() ? 'connected' : 'disconnected');
// A locked tab keeps a plain frame (no label) for the lock's lifetime;
// an unlocked tab loses the frame once this action completes.
- if (tabLocks.owner(tabId)) await showLockShield(tabId);
- else await hideLockShield(tabId);
+ if (!wedgedTabs.has(tabId)) {
+ if (tabLocks.owner(tabId)) await overlayStep(showLockShield(tabId));
+ else await overlayStep(hideLockShield(tabId));
+ }
}
},
)
diff --git a/extension/lib/state.js b/extension/lib/state.js
index 7068654..8c86463 100644
--- a/extension/lib/state.js
+++ b/extension/lib/state.js
@@ -33,6 +33,14 @@ export const networkByTab = new Map();
* @type {Map>}
*/
export const fallbackByTab = new Map();
+/**
+ * Tabs whose page stopped answering chrome.scripting (frozen main thread, a
+ * giant document). Map. While a tab is here every page call
+ * first probes it with a short timeout and fails fast with TAB_WEDGED instead
+ * of queueing behind executeScript calls that can never be aborted.
+ * Cleared when the tab starts a new navigation or is closed.
+ */
+export const wedgedTabs = new Map();
/**
* isNew feature: Map of "fingerprints" (role|name) from the
* PREVIOUS snapshot. The next snapshot marks any ref whose fingerprint isn't
@@ -120,6 +128,7 @@ export async function loadSessionState() {
/** Invalidate document-bound refs without releasing the tab's durable lock. */
export function dropDocumentState(tabId) {
+ wedgedTabs.delete(tabId);
fallbackByTab.delete(tabId);
lastSnapshotFingerprints.delete(tabId);
observationSnapshots.invalidateTab(tabId);
@@ -127,6 +136,7 @@ export function dropDocumentState(tabId) {
/** Drop one tab's durable state (tab closed). */
export function dropTabState(tabId) {
+ wedgedTabs.delete(tabId);
consoleByTab.delete(tabId);
networkByTab.delete(tabId);
fallbackByTab.delete(tabId);
@@ -134,3 +144,12 @@ export function dropTabState(tabId) {
observationSnapshots.dropTab(tabId);
tabLocks.release(tabId);
}
+
+// Short refs ("s4k2-17"): a per-worker salt keeps refs from a recycled service
+// worker from colliding with live ones in the page registry.
+const REF_SALT = Math.random().toString(36).slice(2, 4);
+let refSeq = 0;
+export function nextRefPrefix(kind) {
+ refSeq = (refSeq + 1) % 1296;
+ return `${kind}${REF_SALT}${refSeq.toString(36)}-`;
+}
diff --git a/extension/lib/trusted-input.js b/extension/lib/trusted-input.js
index 1c8fd72..7ee78c6 100644
--- a/extension/lib/trusted-input.js
+++ b/extension/lib/trusted-input.js
@@ -9,7 +9,7 @@
* The synthetic path stays as the fallback when CDP can't attach.
*/
import { ensureCdp, ensureViewport, hasCdp } from './cdp-session.js';
-import { safeExec } from './page-exec.js';
+import { safeExec, execDom } from './page-exec.js';
const MOD_BITS = { alt: 1, ctrl: 2, meta: 4, shift: 8 };
@@ -107,42 +107,51 @@ export async function cdpClickAt(send, x, y, { button = 'left', clickCount = 1,
}
/**
- * Page-side: resolve the target (ref → selector → smart fallback, piercing
- * same-origin iframes), scroll it into view, optionally focus/select it, and
+ * Page-side: resolve the target through the shared DOM runtime (ref registry →
+ * first visible selector match across shadow roots and same-origin iframes →
+ * verified fallback), scroll it into view, optionally focus/select it, and
* return its centre in TOP-level viewport coordinates (what CDP expects).
* Also opens the lock shield for the agent's own trusted input for a few
* seconds, since trusted events are otherwise blocked by it.
* Kept self-contained: it is serialized into the page by chrome.scripting.
*/
function pageLocate(ref, sel, fb, mode) {
- function deepQuery(s) {
- const q = (doc, depth) => {
- try { const el = doc.querySelector(s); if (el) return el; } catch {}
- if (depth >= 3) return null;
- for (const f of doc.querySelectorAll('iframe')) {
- try { const d = f.contentDocument; if (d) { const el = q(d, depth + 1); if (el) return el; } } catch {}
- }
- return null;
- };
- return q(document, 0);
- }
- let el = ref ? deepQuery(`[data-mcp-ref="${ref}"]`) : null;
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ let el = null;
let via = 'ref';
- if (!el && sel) { el = deepQuery(sel); via = 'selector'; }
- const resolveFallback = (globalThis.__browserControllerFallbackRuntime || {}).resolveFallback || null;
- if (!el && fb && resolveFallback) { el = resolveFallback(fb); if (el) via = 'fallback'; }
- if (!el && mode === 'active') { el = document.activeElement; via = 'active'; }
- if (!el) return { success: false, error: 'REF_GONE', _ref: ref };
+ if (ref || sel || fb) {
+ const r = D.resolve(ref, sel, fb);
+ if (r.error === 'INVALID_SELECTOR') return { success: false, error: `Invalid CSS selector: ${sel}` };
+ if (r.el) { el = r.el; via = r.via; }
+ }
+ if (!el && (mode === 'active' || mode === 'focused' || mode === 'focused-clear')) {
+ el = document.activeElement;
+ // Descend into focused shadow roots / same-origin frames.
+ for (let i = 0; el && i < 10; i++) {
+ const s = D.shadowOf(el);
+ if (s && s.activeElement) { el = s.activeElement; continue; }
+ const d = D.frameDoc(el);
+ if (d && d.activeElement) { el = d.activeElement; continue; }
+ break;
+ }
+ via = 'active';
+ // type without a target needs a real field, not the page body.
+ if (mode !== 'active' && (!el || el === document.body || el === document.documentElement)) {
+ return { success: false, error: 'NO_FOCUS' };
+ }
+ }
+ if (!el) return { success: false, error: 'REF_GONE', _ref: ref, url: location.href };
// Agent input pass-through for the lock shield (see overlay.js).
window.__bcAgentInputUntil = Date.now() + 8000;
const shield = document.getElementById('__bc-lock-shield');
if (shield) shield.style.pointerEvents = 'none';
- if (mode !== 'active') el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
- if (mode === 'focus' || mode === 'clear') {
+ if (via !== 'active') el.scrollIntoView({ behavior: 'instant', block: 'center', inline: 'center' });
+ if (mode === 'focus' || mode === 'clear' || mode === 'focused-clear') {
if (typeof el.focus === 'function') el.focus();
- if (mode === 'clear') {
+ if (mode === 'clear' || mode === 'focused-clear') {
if (el.isContentEditable) {
const r = el.ownerDocument.createRange();
r.selectNodeContents(el);
@@ -155,32 +164,22 @@ function pageLocate(ref, sel, fb, mode) {
}
}
- const rect = el.getBoundingClientRect();
- let x = rect.left + rect.width / 2;
- let y = rect.top + rect.height / 2;
- // Add the offsets of every enclosing same-origin iframe.
- let win = el.ownerDocument.defaultView;
- while (win && win !== window && win.frameElement) {
- const fr = win.frameElement.getBoundingClientRect();
- const cs = win.frameElement.ownerDocument.defaultView.getComputedStyle(win.frameElement);
- x += fr.left + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0);
- y += fr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0);
- win = win.parent;
+ const { x, y, rect } = D.centerOf(el);
+ let focusedEl = el.ownerDocument.activeElement;
+ for (let i = 0; focusedEl && i < 10; i++) {
+ const s = D.shadowOf(focusedEl);
+ if (s && s.activeElement) focusedEl = s.activeElement; else break;
}
- const doc = el.ownerDocument;
- const focused = doc.activeElement === el || (el.contains && el.contains(doc.activeElement));
+ const focused = focusedEl === el || D.composedContains(el, focusedEl);
const hasValue = 'value' in el && !el.isContentEditable && typeof el.value === 'string';
let fullySelected = false;
try { fullySelected = el.selectionStart === 0 && el.selectionEnd === el.value.length; } catch { /* number/email inputs */ }
- // What a real click at (x, y) would hit (top document only).
+ // What a real click at (x, y) would hit (pierces shadow roots and same-origin frames).
let occludedBy = null;
- if (win === window || !el.ownerDocument.defaultView.frameElement) {
- const hit = document.elementFromPoint(x, y);
- if (hit && hit !== el && !el.contains(hit) && !hit.contains(el)) {
- occludedBy = hit.tagName.toLowerCase() + (hit.id ? `#${hit.id}` : '')
- + (typeof hit.className === 'string' && hit.className.trim() ? `.${hit.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '');
- }
- }
+ try {
+ const hit = D.elementAt(x, y);
+ if (hit && !D.composedContains(el, hit) && !D.composedContains(hit, el)) occludedBy = D.describe(hit);
+ } catch { /* detached mid-measure */ }
return {
success: true,
x, y,
@@ -192,6 +191,8 @@ function pageLocate(ref, sel, fb, mode) {
hasText: hasValue ? el.value.length > 0 : (el.textContent || '').length > 0,
...(occludedBy ? { occludedBy } : {}),
...(via !== 'ref' ? { via } : {}),
+ // A fallback re-resolution says what it actually hit, so a wrong guess is visible.
+ ...(via === 'fallback' ? { target: { role: D.roleOf(el), name: D.nameOf(el).slice(0, 60) } } : {}),
};
}
@@ -201,7 +202,9 @@ function pageRelease() {
const shield = document.getElementById('__bc-lock-shield');
if (shield) shield.style.pointerEvents = 'auto';
let a = document.activeElement;
- while (a && a.tagName === 'IFRAME') {
+ for (let i = 0; a && i < 10; i++) {
+ if (a.shadowRoot && a.shadowRoot.activeElement) { a = a.shadowRoot.activeElement; continue; }
+ if (a.tagName !== 'IFRAME') break;
try { a = a.contentDocument.activeElement; } catch { break; }
}
if (!a || a === document.body) return { value: null };
@@ -209,12 +212,42 @@ function pageRelease() {
return { value: value == null ? null : value.slice(0, 500), focusedTag: a.tagName.toLowerCase() + (a.id ? `#${a.id}` : '') };
}
+/**
+ * Page-side: what is at a top-level viewport point (pierces shadow roots and
+ * same-origin frames), and open the shield pass-through for the agent's input.
+ */
+function pagePointInfo(x, y) {
+ const D = globalThis.__bcDom;
+ if (!D) return { __needDom: true };
+ window.__bcAgentInputUntil = Date.now() + 8000;
+ const shield = document.getElementById('__bc-lock-shield');
+ if (shield) shield.style.pointerEvents = 'none';
+ const inView = x >= 0 && y >= 0 && x <= window.innerWidth && y <= window.innerHeight;
+ const el = D.elementAt(x, y);
+ if (!el) return { inView };
+ // Report the control that owns the point (e.g. the around an ).
+ let owner = el;
+ for (let cur = el, i = 0; cur && i < 6; i++) {
+ if (D.isInteractive(cur)) { owner = cur; break; }
+ let r = null;
+ try { r = cur.getRootNode(); } catch {}
+ cur = cur.parentElement || (r && r.host) || null;
+ }
+ const name = D.nameOf(owner).slice(0, 60);
+ return { inView, hit: { role: D.roleOf(owner), ...(name ? { name } : {}), tag: owner.tagName.toLowerCase() } };
+}
+
+/** Describe the element at (x, y) and let trusted input through the shield. */
+export async function pointInfo(tabId, x, y) {
+ try { return (await execDom(tabId, pagePointInfo, [x, y])) || {}; } catch { return {}; /* protected page: input still works */ }
+}
+
export async function locateTarget(tabId, { ref, selector, fb, mode = 'none' }) {
- const loc = await safeExec(tabId, pageLocate, [ref, selector, fb, mode]);
+ const loc = await execDom(tabId, pageLocate, [ref, selector, fb, mode]);
// Never-shown background tab: size its viewport, then measure again.
if (loc?.success && loc.zeroViewport && hasCdp(tabId)) {
await ensureViewport(tabId).catch(() => {});
- return safeExec(tabId, pageLocate, [ref, selector, fb, mode]);
+ return execDom(tabId, pageLocate, [ref, selector, fb, mode]);
}
return loc;
}
diff --git a/extension/manifest.json b/extension/manifest.json
index f6954e1..9da4add 100644
--- a/extension/manifest.json
+++ b/extension/manifest.json
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Browser Controller",
- "version": "2.3.0",
+ "version": "2.4.0",
"description": "Let AI agents control your real browser - your tabs, your sessions, your logins",
"icons": {
"16": "icons/icon16.png",
@@ -32,6 +32,13 @@
"js": ["content.js"],
"run_at": "document_start",
"all_frames": true
+ },
+ {
+ "matches": [""],
+ "js": ["console-main.js"],
+ "run_at": "document_start",
+ "all_frames": true,
+ "world": "MAIN"
}
],
"action": {
diff --git a/mcp-server/src/bridge-connections.ts b/mcp-server/src/bridge-connections.ts
new file mode 100644
index 0000000..1f4c701
--- /dev/null
+++ b/mcp-server/src/bridge-connections.ts
@@ -0,0 +1,120 @@
+import { WebSocket } from 'ws';
+
+/**
+ * One connected extension = one browser (profile). Several can be connected
+ * at once (multi-browser); a reconnect of the same browser replaces its old
+ * socket. Legacy extensions that send no browserId all count as "default".
+ */
+export interface ExtensionConnection {
+ ws: WebSocket;
+ browserId: string;
+ label: string;
+ state: 'pending' | 'ready' | 'legacy' | 'incompatible';
+ handshakeTimer: ReturnType | null;
+ missedPongs: number;
+ connectedAt: number;
+}
+
+/** Tools the bridge answers itself (browser selection), never forwarded to an extension. */
+export const BRIDGE_TOOLS = new Set(['browser_list_browsers', 'browser_select_browser']);
+
+export function newConnection(ws: WebSocket): ExtensionConnection {
+ return {
+ ws, browserId: 'default', label: 'default', state: 'pending',
+ handshakeTimer: null, missedPongs: 0, connectedAt: Date.now(),
+ };
+}
+
+/** Normalised identity from an extension helloAck (absent = legacy "default"). */
+export function identityOf(info?: { browserId?: unknown; browserLabel?: unknown }): { browserId: string; label: string } {
+ const browserId = typeof info?.browserId === 'string' && info.browserId.trim() ? info.browserId.trim().slice(0, 64) : 'default';
+ const label = typeof info?.browserLabel === 'string' && info.browserLabel.trim() ? info.browserLabel.trim().slice(0, 120) : browserId;
+ return { browserId, label };
+}
+
+/**
+ * The set of extension connections plus each session's browser choice.
+ * Routing: a session's selected browser, else the default — the most
+ * recently connected live browser (so one browser behaves exactly as before).
+ */
+export class ExtensionConnections {
+ private conns = new Set();
+ /** sessionId -> browserId chosen with browser_select_browser. */
+ private sessionBrowser = new Map();
+
+ add(conn: ExtensionConnection): void { this.conns.add(conn); }
+ has(conn: ExtensionConnection): boolean { return this.conns.has(conn); }
+ delete(conn: ExtensionConnection): boolean { return this.conns.delete(conn); }
+ all(): ExtensionConnection[] { return [...this.conns]; }
+
+ static isLive(conn: ExtensionConnection): boolean {
+ return (conn.state === 'ready' || conn.state === 'legacy') && conn.ws.readyState === WebSocket.OPEN;
+ }
+
+ live(): ExtensionConnection[] {
+ return this.all().filter((c) => ExtensionConnections.isLive(c));
+ }
+
+ /** The default browser: the most recently connected live one. */
+ primary(): ExtensionConnection | null {
+ let best: ExtensionConnection | null = null;
+ for (const c of this.live()) if (!best || c.connectedAt >= best.connectedAt) best = c;
+ return best;
+ }
+
+ /** Where a session's calls go: its selected browser, else the default one. */
+ forSession(sessionId?: string): ExtensionConnection {
+ const want = sessionId ? this.sessionBrowser.get(sessionId) : undefined;
+ if (want) {
+ const chosen = this.live().find((c) => c.browserId === want);
+ if (chosen) return chosen;
+ throw new Error(`Selected browser "${want}" is not connected. browser_list_browsers shows the connected ones (browser_select_browser "auto" = default).`);
+ }
+ const primary = this.primary();
+ if (!primary) throw new Error('Chrome extension not connected. Make sure the Browser Controller extension is installed and enabled.');
+ return primary;
+ }
+
+ /** Other sockets of the same browser (a reconnect replaces them). */
+ siblingsOf(conn: ExtensionConnection): ExtensionConnection[] {
+ return this.all().filter((c) => c !== conn && c.browserId === conn.browserId);
+ }
+
+ /** browser_list_browsers: every connected extension, with this session's choice. */
+ list(sessionId?: string): Record {
+ const primary = this.primary();
+ const selected = sessionId ? this.sessionBrowser.get(sessionId) : undefined;
+ return {
+ success: true,
+ browsers: this.live().map((c) => ({
+ browserId: c.browserId,
+ label: c.label,
+ connectedAt: new Date(c.connectedAt).toISOString(),
+ ...(c === primary ? { default: true } : {}),
+ ...((selected ? selected === c.browserId : c === primary) ? { selected: true } : {}),
+ })),
+ ...(selected ? { selectedBrowserId: selected } : {}),
+ };
+ }
+
+ /** browser_select_browser: route this session's calls to one browser ("auto" = default). */
+ select(sessionId: string | undefined, browserId: unknown): Record {
+ if (!sessionId) throw new Error('Selecting a browser needs a client session (connect through the Browser Controller MCP server).');
+ const id = typeof browserId === 'string' ? browserId.trim() : '';
+ if (!id || id === 'auto') {
+ this.sessionBrowser.delete(sessionId);
+ return { success: true, selected: 'auto', browserId: this.primary()?.browserId ?? null };
+ }
+ const conn = this.live().find((c) => c.browserId === id || c.label === id);
+ if (!conn) throw new Error(`No connected browser "${id}". browser_list_browsers shows the connected ones.`);
+ this.sessionBrowser.set(sessionId, conn.browserId);
+ return { success: true, selected: conn.browserId, label: conn.label };
+ }
+
+ releaseSession(sessionId: string): void { this.sessionBrowser.delete(sessionId); }
+
+ clear(): void {
+ this.conns.clear();
+ this.sessionBrowser.clear();
+ }
+}
diff --git a/mcp-server/src/bridge.ts b/mcp-server/src/bridge.ts
index a8e11c9..efb3789 100644
--- a/mcp-server/src/bridge.ts
+++ b/mcp-server/src/bridge.ts
@@ -15,8 +15,15 @@ import {
isDaemonResponsiveOnPort,
tokensMatch,
} from './bridge-security.js';
+import {
+ ExtensionConnections,
+ identityOf,
+ newConnection,
+ type ExtensionConnection,
+} from './bridge-connections.js';
export { isDaemonResponsiveOnPort } from './bridge-security.js';
+export { BRIDGE_TOOLS } from './bridge-connections.js';
/** Handler for daemon-owned HTTP endpoints served on the bridge port. */
export type HttpRequestHandler = (
@@ -39,6 +46,8 @@ interface PendingRequest {
/** Abort listener registered for this request (audit C2); removed on settle. */
onAbort?: (() => void) | null;
signal?: AbortSignal | null;
+ /** The extension connection (browser) this call was sent to. */
+ conn?: ExtensionConnection;
}
interface BridgeOptions {
@@ -64,7 +73,7 @@ interface BridgeOptions {
export class ExtensionBridge {
private httpServer: http.Server | null = null;
private wss: WebSocketServer | null = null;
- private client: WebSocket | null = null;
+ private conns = new ExtensionConnections();
private pendingRequests = new Map();
private requestId = 0;
private port: number;
@@ -76,11 +85,9 @@ export class ExtensionBridge {
private defaultTimeoutMs: number;
private maxWsPayloadBytes: number;
private handshakeGraceMs: number;
- private handshakeState: 'disconnected' | 'pending' | 'ready' | 'legacy' | 'incompatible' = 'disconnected';
+ /** Reason of the latest failed extension handshake, until some browser connects fine. */
private handshakeError: string | null = null;
- private handshakeTimer: ReturnType | null = null;
private pingTimer: ReturnType | null = null;
- private missedPongs = 0;
private connectionWaiters: Array<{ resolve: () => void; reject: (err: Error) => void }> = [];
/** Optional HTTP handler (set by the daemon) for /pair, /status, etc. */
private httpHandler: HttpRequestHandler | null = null;
@@ -106,30 +113,47 @@ export class ExtensionBridge {
this.handshakeGraceMs = options.handshakeGraceMs ?? 25;
}
- private clearHandshakeTimer(): void {
- if (this.handshakeTimer) clearTimeout(this.handshakeTimer);
- this.handshakeTimer = null;
- }
-
- private markExtensionReady(state: 'ready' | 'legacy'): void {
- this.clearHandshakeTimer();
- this.handshakeState = state;
+ private markExtensionReady(conn: ExtensionConnection, state: 'ready' | 'legacy', info?: { browserId?: unknown; browserLabel?: unknown }): void {
+ if (conn.handshakeTimer) clearTimeout(conn.handshakeTimer);
+ conn.handshakeTimer = null;
+ conn.state = state;
+ conn.missedPongs = 0;
+ const { browserId, label } = identityOf(info);
+ conn.browserId = browserId;
+ conn.label = label;
+ // A reconnect of the same browser replaces its old socket.
+ for (const other of this.conns.siblingsOf(conn)) {
+ this.dropConn(other, 'Extension reconnected');
+ try { other.ws.close(); } catch { /* already closing */ }
+ }
this.handshakeError = null;
- this.missedPongs = 0;
this.startPingLoop();
this.connectionWaiters.forEach((waiter) => waiter.resolve());
this.connectionWaiters = [];
- console.error(`[Bridge] Extension connected (${state} protocol)`);
+ console.error(`[Bridge] Extension connected (${state} protocol, browser ${conn.label})`);
}
- private rejectExtensionHandshake(reason: string): void {
- this.clearHandshakeTimer();
- this.handshakeState = 'incompatible';
+ private rejectExtensionHandshake(conn: ExtensionConnection, reason: string): void {
+ if (conn.handshakeTimer) clearTimeout(conn.handshakeTimer);
+ conn.handshakeTimer = null;
+ conn.state = 'incompatible';
this.handshakeError = reason;
- const error = new Error(reason);
- this.connectionWaiters.forEach((waiter) => waiter.reject(error));
- this.connectionWaiters = [];
- this.rejectAllPending(reason);
+ if (this.conns.live().length === 0) {
+ const error = new Error(reason);
+ this.connectionWaiters.forEach((waiter) => waiter.reject(error));
+ this.connectionWaiters = [];
+ }
+ this.rejectAllPending(reason, conn);
+ }
+
+ /** Forget a connection and fail the calls that were waiting on it. */
+ private dropConn(conn: ExtensionConnection, reason: string): void {
+ if (!this.conns.has(conn)) return;
+ this.conns.delete(conn);
+ if (conn.handshakeTimer) clearTimeout(conn.handshakeTimer);
+ conn.handshakeTimer = null;
+ this.rejectAllPending(reason, conn);
+ if (this.conns.live().length === 0) this.stopPingLoop();
}
/**
@@ -160,6 +184,7 @@ export class ExtensionBridge {
throw new Error(
`Cannot listen on ${this.host}:${this.port}: ${owner} already owns the port. ` +
'Refusing to terminate another process automatically.',
+ { cause: err },
);
}
}
@@ -284,16 +309,11 @@ export class ExtensionBridge {
});
});
- this.wss.on('connection', (ws: WebSocket, req) => {
- if (this.client && this.client.readyState === WebSocket.OPEN) {
- this.client.close();
- }
-
- this.client = ws;
- this.clearHandshakeTimer();
- this.handshakeState = 'pending';
- this.handshakeError = null;
- this.missedPongs = 0;
+ this.wss.on('connection', (ws: WebSocket, _req) => {
+ // Every socket is its own connection (browser). A reconnect of the same
+ // browser replaces the old socket once the new one finished its handshake.
+ const conn = newConnection(ws);
+ this.conns.add(conn);
ws.on('message', (data: Buffer) => {
try {
@@ -303,15 +323,15 @@ export class ExtensionBridge {
const capabilities = validateCapabilities(msg.capabilities, ['tool-dispatch', 'ping-pong']);
if (!version.ok || !capabilities.ok) {
const reason = version.reason || capabilities.reason || 'Extension protocol handshake failed.';
- this.rejectExtensionHandshake(reason);
+ this.rejectExtensionHandshake(conn, reason);
ws.close(1002, 'incompatible protocol');
return;
}
- this.markExtensionReady(version.legacy || capabilities.legacy ? 'legacy' : 'ready');
+ this.markExtensionReady(conn, version.legacy || capabilities.legacy ? 'legacy' : 'ready', msg);
return;
}
if (msg.type === 'pong') {
- this.missedPongs = 0;
+ conn.missedPongs = 0;
return;
}
this.handleResponse(msg);
@@ -321,16 +341,9 @@ export class ExtensionBridge {
});
ws.on('close', () => {
- if (this.client !== ws) return;
- console.error('[Bridge] Extension disconnected');
- this.client = null;
- this.clearHandshakeTimer();
- if (this.handshakeState !== 'incompatible') {
- this.handshakeState = 'disconnected';
- this.handshakeError = null;
- }
- this.stopPingLoop();
- this.rejectAllPending('Extension disconnected');
+ if (!this.conns.has(conn)) return; // replaced or already dropped
+ console.error(`[Bridge] Extension disconnected (browser ${conn.label})`);
+ this.dropConn(conn, 'Extension disconnected');
});
ws.on('error', (err: Error) => {
@@ -340,11 +353,11 @@ export class ExtensionBridge {
// Modern extensions acknowledge immediately. The short fallback keeps
// pre-handshake extension builds usable during a rolling local upgrade.
setTimeout(() => {
- if (this.client !== ws || ws.readyState !== WebSocket.OPEN) return;
+ if (!this.conns.has(conn) || ws.readyState !== WebSocket.OPEN) return;
ws.send(JSON.stringify(buildExtensionHello(APP_VERSION)));
- this.handshakeTimer = setTimeout(() => {
- if (this.client === ws && this.handshakeState === 'pending') {
- this.markExtensionReady('legacy');
+ conn.handshakeTimer = setTimeout(() => {
+ if (this.conns.has(conn) && conn.state === 'pending') {
+ this.markExtensionReady(conn, 'legacy');
}
}, this.handshakeGraceMs);
}, 0);
@@ -367,14 +380,15 @@ export class ExtensionBridge {
private startPingLoop(): void {
this.stopPingLoop();
this.pingTimer = setInterval(() => {
- if (!this.isConnected()) return;
- this.missedPongs++;
- if (this.missedPongs >= 3) {
- console.error('[Bridge] Extension unresponsive (3 missed pongs), closing');
- this.client?.close();
- return;
+ for (const conn of this.conns.live()) {
+ conn.missedPongs++;
+ if (conn.missedPongs >= 3) {
+ console.error(`[Bridge] Extension unresponsive (3 missed pongs), closing (browser ${conn.label})`);
+ conn.ws.close();
+ continue;
+ }
+ try { conn.ws.send(JSON.stringify({ type: 'ping' })); } catch { /* close path handles it */ }
}
- this.client?.send(JSON.stringify({ type: 'ping' }));
}, this.pingIntervalMs);
}
@@ -406,9 +420,7 @@ export class ExtensionBridge {
}
isConnected(): boolean {
- return this.client !== null
- && this.client.readyState === WebSocket.OPEN
- && (this.handshakeState === 'ready' || this.handshakeState === 'legacy');
+ return this.conns.live().length > 0;
}
/**
@@ -417,11 +429,15 @@ export class ExtensionBridge {
* forget: control messages carry no reply. Used by the daemon's close handler.
*/
sendControl(type: string, payload: Record = {}): void {
- if (!this.isConnected()) return; // extension gone — nothing to notify
- try {
- this.client!.send(JSON.stringify({ type, ...payload }));
- } catch {
- // socket gone — close path will fire
+ // A gone session no longer has a browser choice.
+ if (type === 'releaseSession' && typeof payload.sessionId === 'string') this.conns.releaseSession(payload.sessionId);
+ // Every browser gets control messages (session release, cancel of an id it may own).
+ for (const conn of this.conns.live()) {
+ try {
+ conn.ws.send(JSON.stringify({ type, ...payload }));
+ } catch {
+ // socket gone — close path will fire
+ }
}
}
@@ -451,14 +467,17 @@ export class ExtensionBridge {
}
async callTool(tool: string, params: Record, sessionId?: string, signal?: AbortSignal, agentName?: string): Promise {
+ // Browser selection is answered here, not by an extension.
+ if (tool === 'browser_list_browsers') return this.conns.list(sessionId);
+ if (tool === 'browser_select_browser') return this.conns.select(sessionId, params.browserId);
if (!this.isConnected()) {
try {
await this.waitForConnection(5_000);
} catch (error) {
- if (this.handshakeError) throw new Error(this.handshakeError);
+ if (this.handshakeError) throw new Error(this.handshakeError, { cause: error });
throw new Error(error instanceof Error && /protocol|capabilit/i.test(error.message)
? error.message
- : 'Chrome extension not connected. Make sure the Browser Controller extension is installed and enabled.');
+ : 'Chrome extension not connected. Make sure the Browser Controller extension is installed and enabled.', { cause: error });
}
}
@@ -471,6 +490,12 @@ export class ExtensionBridge {
if (signal?.aborted) {
return Promise.reject(new Error(`Call aborted before send: ${tool}`));
}
+ let conn: ExtensionConnection;
+ try {
+ conn = this.conns.forSession(sessionId);
+ } catch (err) {
+ return Promise.reject(err);
+ }
return new Promise((resolve, reject) => {
const id = String(++this.requestId);
// Timeout policy (audit M3): the tool registry is the single source of
@@ -523,14 +548,14 @@ export class ExtensionBridge {
signal.addEventListener('abort', onAbort, { once: true });
}
- this.pendingRequests.set(id, { resolve, reject, timeout, tool, retries: retryCount, params, onAbort, signal });
+ this.pendingRequests.set(id, { resolve, reject, timeout, tool, retries: retryCount, params, onAbort, signal, conn });
try {
// sessionId + agentName travel as top-level WS fields (audit M1), not
// injected into params — the daemon stays a pure {tool, params} multiplexer.
// agentName is the STABLE identity for tab locks (survives reconnects);
// sessionId is transient (s3→s4) and used only for logging/UI.
- this.client!.send(JSON.stringify({ id, tool, params, sessionId, agentName }));
+ conn.ws.send(JSON.stringify({ id, tool, params, sessionId, agentName }));
} catch (err) {
clearTimeout(timeout);
if (signal) signal.removeEventListener('abort', onAbort);
@@ -544,8 +569,10 @@ export class ExtensionBridge {
});
}
- private rejectAllPending(reason: string): void {
+ /** Fail waiting calls: all of them, or only those sent to one browser. */
+ private rejectAllPending(reason: string, onlyConn?: ExtensionConnection): void {
for (const [id, pending] of this.pendingRequests) {
+ if (onlyConn && pending.conn !== onlyConn) continue;
clearTimeout(pending.timeout);
if (pending.onAbort && pending.signal) pending.signal.removeEventListener('abort', pending.onAbort);
pending.reject(new Error(reason));
@@ -554,13 +581,15 @@ export class ExtensionBridge {
}
stop(): void {
- this.clearHandshakeTimer();
this.stopPingLoop();
this.rejectAllPending('Server shutting down');
this.connectionWaiters.forEach(w => w.reject(new Error('Server shutting down')));
this.connectionWaiters = [];
- this.client?.close();
- this.client = null;
+ for (const conn of this.conns.all()) {
+ if (conn.handshakeTimer) clearTimeout(conn.handshakeTimer);
+ try { conn.ws.close(); } catch { /* already closed */ }
+ }
+ this.conns.clear();
this.wss?.close();
this.wss = null;
this.httpServer?.close();
diff --git a/mcp-server/src/register-tools.ts b/mcp-server/src/register-tools.ts
index 691b838..cb7e437 100644
--- a/mcp-server/src/register-tools.ts
+++ b/mcp-server/src/register-tools.ts
@@ -44,7 +44,7 @@ function checkPayloadLimits(value: unknown, ctx: z.RefinementCtx, path: Array {
- let bytes = 0;
+ let bytes: number;
try {
bytes = Buffer.byteLength(JSON.stringify(value), 'utf8');
} catch {
@@ -88,7 +88,7 @@ export function parseToolParams(tool: ToolDefinition, params: Record new Promise((resolve) => setTimeout(resolve, ms));
@@ -33,6 +33,19 @@ async function runStep(def: ToolDefinition, host: ToolHost, params: Record }>;
continueOnError: boolean;
output: 'all' | 'last' | 'errors';
};
+ // The default tab follows a frozen tab's replacement (navigate/reload report replacedTabId).
+ let tabId = (params as { tabId?: number }).tabId;
const content: ToolResult['content'] = [];
let failed = 0;
let ran = 0;
@@ -94,6 +109,10 @@ export const batchTool: ToolDefinition = {
}
}
ran++;
+ if (!result.isError && tabId !== undefined) {
+ const replacement = replacedBy(result, tabId);
+ if (replacement !== null) tabId = replacement;
+ }
const isLast = i === actions.length - 1;
if (output === 'all' || result.isError || (output === 'last' && isLast)) {
content.push({ type: 'text', text: `${label} ${result.isError ? 'FAILED' : 'ok'}` });
diff --git a/mcp-server/src/tools/browsers.ts b/mcp-server/src/tools/browsers.ts
new file mode 100644
index 0000000..4d46dc6
--- /dev/null
+++ b/mcp-server/src/tools/browsers.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod';
+import type { ToolDefinition } from './types.js';
+import { forwardHandler } from './types.js';
+
+// Answered by the bridge itself (it holds every extension connection), not by
+// an extension — see BRIDGE_TOOLS in bridge.ts.
+
+export const listBrowsersTool: ToolDefinition = {
+ name: 'browser_list_browsers',
+ summary: 'List the connected browsers (Chrome profiles)',
+ description:
+ 'List every browser (Chrome profile / instance with the extension) connected to Browser Controller: browserId, label, which one is the default and which one this session uses. With one browser connected you never need this.',
+ inputSchema: z.object({}),
+ idempotent: true,
+ timeoutMs: 5_000,
+ handler: forwardHandler('browser_list_browsers'),
+};
+
+export const selectBrowserTool: ToolDefinition = {
+ name: 'browser_select_browser',
+ summary: 'Send this session\'s browser calls to one browser',
+ description:
+ 'Route all of this session\'s browser tool calls to one connected browser (by browserId or label from browser_list_browsers). "auto" goes back to the default (the most recently connected browser). Tab ids belong to their browser — list tabs again after switching.',
+ inputSchema: z.object({
+ browserId: z.string().describe('browserId or label from browser_list_browsers, or "auto"'),
+ }),
+ timeoutMs: 5_000,
+ handler: forwardHandler('browser_select_browser'),
+};
diff --git a/mcp-server/src/tools/click-text.ts b/mcp-server/src/tools/click-text.ts
index c8c27ce..9c8d0c3 100644
--- a/mcp-server/src/tools/click-text.ts
+++ b/mcp-server/src/tools/click-text.ts
@@ -5,12 +5,13 @@ import { requireTabId, forwardHandler } from './types.js';
export const clickTextTool: ToolDefinition = {
name: 'browser_click_text',
summary: 'Click an element by its visible text', description:
- 'Click an element by its visible text content. Works on React dropdowns, portals, and overlays that may not appear in snapshots. CSP-safe (no eval). Prefers deepest matching element.',
+ 'Click an element by its visible text content (case-insensitive, CSS text-transform does not matter). Matches accessible names, aria-label/title and composed text including shadow DOM and same-origin iframes, then clicks the control that owns the text (e.g. the around a ) with a real (trusted) mouse click. Works on React dropdowns, portals, and overlays that may not appear in snapshots. CSP-safe (no eval).',
inputSchema: z.object({
tabId: requireTabId(),
- text: z.string().describe('Text to match against element content (first line)'),
+ text: z.string().describe('Text to match against the element name or visible text'),
index: z.number().int().min(0).optional().describe('Which match to click if multiple (0-based, default 0)'),
- exact: z.boolean().optional().describe('Require exact match instead of substring (default false)'),
+ exact: z.boolean().optional().describe('Require the whole text to match (case-insensitive) instead of a substring (default false)'),
+ trusted: z.boolean().optional().describe('Real CDP mouse click (default true). false = synthetic DOM events.'),
}),
timeoutMs: 10_000,
handler: forwardHandler('browser_click_text'),
diff --git a/mcp-server/src/tools/click.ts b/mcp-server/src/tools/click.ts
index 8e4db7d..88c52d9 100644
--- a/mcp-server/src/tools/click.ts
+++ b/mcp-server/src/tools/click.ts
@@ -5,17 +5,22 @@ import { requireTabId, forwardHandler } from './types.js';
export const clickTool: ToolDefinition = {
name: 'browser_click',
summary: 'Click an element by ref or CSS selector',
- description: 'Click an element on the page using a ref from snapshot or a CSS selector. Uses a real mouse click over CDP (isTrusted events, real focus, default actions — works in background windows); falls back to synthetic DOM events if the debugger cannot attach.',
+ description: 'Click an element on the page using a ref from snapshot or a CSS selector, or at x/y viewport coordinates (e.g. read off a screenshot). clickCount 3 = triple click (select a line); modifiers hold keys (ctrl+click opens a link in a new tab). Uses a real mouse click over CDP (isTrusted events, real focus, default actions — works in background windows); falls back to synthetic DOM events if the debugger cannot attach.',
inputSchema: z.object({
tabId: requireTabId(),
ref: z.string().optional().describe('Element reference from snapshot (e.g. "e12")'),
selector: z.string().optional().describe('CSS selector for the element'),
button: z.enum(['left', 'right', 'middle']).optional().default('left'),
doubleClick: z.boolean().optional().default(false),
+ clickCount: z.number().int().min(1).max(3).optional().describe('1 = click, 2 = double, 3 = triple click (overrides doubleClick)'),
+ modifiers: z.array(z.enum(['ctrl', 'alt', 'shift', 'meta'])).optional().describe('Keys held during the click'),
+ x: z.number().optional().describe('Viewport x in CSS px (from browser_screenshot / find bounds) — use with y instead of ref/selector'),
+ y: z.number().optional().describe('Viewport y in CSS px — use with x'),
trusted: z.boolean().optional().describe('Real (isTrusted) input over CDP — default. false = synthetic DOM events, no debugger banner.'),
}).superRefine((params, ctx) => {
- if (!params.ref && !params.selector) {
- ctx.addIssue({ code: 'custom', message: 'ref or selector is required', path: ['ref'] });
+ const point = Number.isFinite(params.x) && Number.isFinite(params.y);
+ if (!params.ref && !params.selector && !point) {
+ ctx.addIssue({ code: 'custom', message: 'ref or selector (or x and y) is required', path: ['ref'] });
}
}),
timeoutMs: 10_000,
diff --git a/mcp-server/src/tools/console.ts b/mcp-server/src/tools/console.ts
index a384eed..8418d77 100644
--- a/mcp-server/src/tools/console.ts
+++ b/mcp-server/src/tools/console.ts
@@ -4,10 +4,13 @@ import { requireTabId, forwardHandler } from './types.js';
export const consoleTool: ToolDefinition = {
name: 'browser_console',
- summary: 'Read console messages from a tab', description: 'Read console messages (log, warn, error) captured from a specific tab',
+ summary: 'Read console messages from a tab', description: 'Read console messages (log, info, warn, error, debug, uncaught errors) the page produced in a specific tab. Filter with pattern (regex) / level, and keep only the latest with limit.',
inputSchema: z.object({
tabId: requireTabId(),
clear: z.boolean().optional().default(false).describe('Clear this tab\'s messages after reading'),
+ pattern: z.string().optional().describe('Case-insensitive regex the message text must match, e.g. "error|fail"'),
+ level: z.union([z.enum(['log', 'info', 'warn', 'error', 'debug']), z.array(z.enum(['log', 'info', 'warn', 'error', 'debug']))]).optional().describe('Only these levels'),
+ limit: z.number().int().min(1).max(200).optional().describe('Return only the most recent N matching messages'),
}),
// NOT idempotent: `clear:true` mutates the buffer. A timeout-retry would
// return an empty buffer (first call already cleared it) and silently lose
diff --git a/mcp-server/src/tools/find.ts b/mcp-server/src/tools/find.ts
index 5fa78ef..f2fc613 100644
--- a/mcp-server/src/tools/find.ts
+++ b/mcp-server/src/tools/find.ts
@@ -5,11 +5,12 @@ import { requireTabId, forwardHandler } from './types.js';
export const findTool: ToolDefinition = {
name: 'browser_find',
summary: 'Find elements by natural language description', description:
- 'Find elements on the page using natural language (e.g. "login button", "search input"). Returns refs you can use with click/type.',
+ 'Find elements on the page using natural language (e.g. "login button", "search input", "Open alert dialog"). Matches every word against accessible names, labels, placeholders and attributes, understands role words (button, link, input, checkbox, tab, menu...), looks inside shadow DOM and same-origin iframes, and prefers the control over its wrappers. Returns refs you can use with click/type/every ref tool.',
inputSchema: z.object({
tabId: requireTabId(),
query: z.string().describe('Natural language description of what to find'),
limit: z.number().optional().default(10).describe('Max matches to return'),
+ role: z.string().optional().describe('Only return elements with this ARIA role (button, link, textbox, searchbox, checkbox, tab, menuitem, combobox, heading...)'),
}),
// Read-only: safe to retry on timeout. (Fixes the prior wire-name drift where
// callTool('find') disagreed with .name 'browser_find' and silently disabled
diff --git a/mcp-server/src/tools/gif.ts b/mcp-server/src/tools/gif.ts
new file mode 100644
index 0000000..9f111d3
--- /dev/null
+++ b/mcp-server/src/tools/gif.ts
@@ -0,0 +1,56 @@
+import { z } from 'zod';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import type { ToolDefinition } from './types.js';
+import { requireTabId, textResult, jsonError, payloadOf } from './types.js';
+
+/** Default place for an exported recording: ~/Downloads if it exists, else the temp dir. */
+function defaultGifPath(): string {
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const downloads = path.join(os.homedir(), 'Downloads');
+ const dir = fs.existsSync(downloads) ? downloads : os.tmpdir();
+ return path.join(dir, `browser-recording-${stamp}.gif`);
+}
+
+export const gifTool: ToolDefinition = {
+ name: 'browser_gif',
+ summary: 'Record the agent\'s actions in a tab as an animated GIF',
+ description:
+ 'Record what happens in a tab as an animated GIF (to show the user what was done). start begins recording: a frame is captured after every page-changing action (navigate, click, type, keys, scroll, hover, select, drag, forms…), clicks are marked with a red ring. frame adds one now, stop pauses, status/clear, export writes the .gif file (default ~/Downloads) and returns its path — the image itself is not returned. A background tab is shown for a moment per frame (Chrome does not paint hidden tabs); activate:false records only while the tab is visible.',
+ inputSchema: z.object({
+ tabId: requireTabId(),
+ action: z.enum(['start', 'frame', 'stop', 'status', 'export', 'clear']).describe('Recording action'),
+ width: z.number().int().min(200).max(1600).optional().describe('start: frame width in px (default 800)'),
+ maxFrames: z.number().int().min(1).max(500).optional().describe('start: stop capturing after this many frames (default 300)'),
+ activate: z.boolean().optional().describe('start: briefly show a background tab to capture it (default true)'),
+ path: z.string().optional().describe('export: where to write the .gif (default ~/Downloads/browser-recording-.gif)'),
+ clear: z.boolean().optional().describe('export: drop the frames afterwards (default true)'),
+ }),
+ timeoutMs: 120_000,
+ async handler(host, params) {
+ const call = (p: Record) => host.callTool('browser_gif', p) as Promise>;
+ let result: Record;
+ try {
+ result = await call(params);
+ } catch (err) {
+ const payload = payloadOf(err);
+ if (payload !== undefined) return jsonError(payload);
+ throw err;
+ }
+ if (params.action !== 'export' || typeof result.gifBase64 !== 'string') return textResult(JSON.stringify(result));
+ // The GIF arrives in parts (WebSocket frames are capped at 1 MB).
+ const chunks = [Buffer.from(result.gifBase64, 'base64')];
+ const parts = typeof result.parts === 'number' ? result.parts : 1;
+ for (let part = 1; part < parts; part++) {
+ const next = await call({ ...params, part });
+ if (typeof next.gifBase64 !== 'string') throw new Error(`GIF export part ${part} returned no data`);
+ chunks.push(Buffer.from(next.gifBase64, 'base64'));
+ }
+ const file = path.resolve(typeof params.path === 'string' && params.path ? params.path : defaultGifPath());
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(file, Buffer.concat(chunks));
+ const { gifBase64: _drop, part: _part, parts: _parts, ...rest } = result;
+ return textResult(JSON.stringify({ ...rest, path: file }));
+ },
+};
diff --git a/mcp-server/src/tools/hover.ts b/mcp-server/src/tools/hover.ts
index 1b5180c..705f794 100644
--- a/mcp-server/src/tools/hover.ts
+++ b/mcp-server/src/tools/hover.ts
@@ -4,11 +4,13 @@ import { requireTabId, forwardHandler } from './types.js';
export const hoverTool: ToolDefinition = {
name: 'browser_hover',
- summary: 'Hover over an element', description: 'Hover over an element to trigger tooltips, dropdown menus, or hover states',
+ summary: 'Hover over an element', description: 'Hover over an element (ref/selector) or a viewport point (x/y) to trigger tooltips, dropdown menus, or hover states. Real mouse move over CDP.',
inputSchema: z.object({
tabId: requireTabId(),
ref: z.string().optional().describe('Element reference from snapshot'),
selector: z.string().optional().describe('CSS selector for the element'),
+ x: z.number().optional().describe('Viewport x in CSS px (from browser_screenshot / find bounds) — use with y instead of ref/selector'),
+ y: z.number().optional().describe('Viewport y in CSS px — use with x'),
trusted: z.boolean().optional().describe('Real (isTrusted) mouse move over CDP — default. false = synthetic DOM events, no debugger banner.'),
}),
timeoutMs: 5_000,
diff --git a/mcp-server/src/tools/index.ts b/mcp-server/src/tools/index.ts
index daca021..ac298e6 100644
--- a/mcp-server/src/tools/index.ts
+++ b/mcp-server/src/tools/index.ts
@@ -25,6 +25,10 @@ import { fillFormTool } from './fill-form.js';
import { observeTool } from './observe.js';
import { actTool } from './act.js';
import { batchTool } from './batch.js';
+import { resizeWindowTool } from './resize-window.js';
+import { gifTool } from './gif.js';
+import { listBrowsersTool, selectBrowserTool } from './browsers.js';
+import { shortcutsTool } from './shortcuts.js';
export const allTools: ToolDefinition[] = [
navigateTool,
@@ -52,6 +56,11 @@ export const allTools: ToolDefinition[] = [
observeTool,
actTool,
batchTool,
+ resizeWindowTool,
+ gifTool,
+ listBrowsersTool,
+ selectBrowserTool,
+ shortcutsTool,
];
export const toolMap = new Map(
diff --git a/mcp-server/src/tools/meta.ts b/mcp-server/src/tools/meta.ts
index 4bc663f..d0e3265 100644
--- a/mcp-server/src/tools/meta.ts
+++ b/mcp-server/src/tools/meta.ts
@@ -61,7 +61,7 @@ const TASK_PREAMBLE =
*/
const TOOL_GUIDANCE: Record = {
browser_click:
- 'Use for ANY click — a real (trusted) mouse click over CDP with a smart-selector fallback; works in background windows. Prefer over JS .click().',
+ 'Use for ANY click — a real (trusted) mouse click over CDP with a verified smart-selector fallback; works in background windows. Also clicks at x/y read off a screenshot, triple-clicks (clickCount 3) and ctrl/shift-clicks. Prefer over JS .click().',
browser_type:
'Use for typing into inputs — real key presses over CDP, so autocomplete/lookup widgets react like for a user. change/blur fire when focus leaves: follow with browser_press_key Tab. Prefer over JS .value= .',
browser_batch:
@@ -75,9 +75,9 @@ const TOOL_GUIDANCE: Record = {
browser_snapshot:
'Use to understand page structure and get element refs (e1, e2…) for subsequent click/type calls. Returns the accessibility tree (semantic), not raw DOM.',
browser_text:
- 'Use to read visible text on the page. Cheapest read tool. Returns {text, title, url}.',
+ 'Use to read visible text on the page (incl. shadow DOM). Cheapest read tool. mode:"article" = main content only; page long text with offset/nextOffset. Returns {text, title, url}.',
browser_find:
- 'Use to locate elements by natural-language description when you don\'t have a snapshot yet. Returns refs for click/type.',
+ 'Use to locate elements by natural-language description ("search input", "Save button") when you don\'t have a snapshot yet — cheaper than a snapshot. Sees shadow DOM and same-origin iframes. Returns refs for every ref tool.',
browser_screenshot:
'Use to capture a visual image (PNG/JPEG). Cannot be done via JS — this is the only way to see the page.',
browser_evaluate:
@@ -89,13 +89,23 @@ const TOOL_GUIDANCE: Record = {
browser_scroll:
'Use to scroll the page or a specific element (pixel offset, to-element, or top/bottom). Works with virtualized feeds.',
browser_hover:
- 'Use to trigger tooltips / dropdown menus / hover-only UI states.',
+ 'Use to trigger tooltips / dropdown menus / hover-only UI states (ref, selector or x/y).',
+ browser_shortcuts:
+ 'Use for a workflow you repeat (login-free form fill, report export…): save it once with {{variables}}, then run it in ONE call.',
+ browser_list_browsers:
+ 'Use only when several browsers/profiles are connected: shows browserIds and which one this session uses.',
+ browser_select_browser:
+ 'Use to work in another connected browser/profile (then list its tabs). "auto" = default.',
+ browser_gif:
+ 'Use to show the user what you did: start before a flow, export after — writes an animated .gif (clicks marked) and returns its path.',
+ browser_resize_window:
+ 'Use to test responsive layouts or maximize/restore the window holding a tab. Resizes the user\'s window — prefer a separate window for experiments.',
browser_select:
'Use to pick an option in a native dropdown.',
browser_press_key:
- 'Use for keyboard input (Enter, Tab, Escape, ArrowDown, Ctrl+A, …).',
+ 'Use for keyboard input (Enter, Tab, Escape, ArrowDown, Ctrl+A, …), key sequences ("ArrowDown ArrowDown Enter") and repeat.',
browser_wait:
- 'Use to wait for an element to appear/disappear, or a fixed delay. Avoids fragile sleep loops.',
+ 'Use to wait for an element to appear/disappear, text to appear/disappear, a URL change, or a fixed delay. Avoids fragile sleep loops.',
browser_console:
'Use to read console messages (log/warn/error) from a tab. Useful for debugging.',
browser_network:
diff --git a/mcp-server/src/tools/network.ts b/mcp-server/src/tools/network.ts
index 8401181..6207526 100644
--- a/mcp-server/src/tools/network.ts
+++ b/mcp-server/src/tools/network.ts
@@ -4,10 +4,12 @@ import { requireTabId, forwardHandler } from './types.js';
export const networkTool: ToolDefinition = {
name: 'browser_network',
- summary: 'Read network requests captured from a tab', description: 'Read network requests made by a specific tab. Filter by URL pattern.',
+ summary: 'Read network requests captured from a tab', description: 'Read network requests made by a specific tab (method, url, status, type — failed ones carry error). Filter by urlPattern (substring) or filter (regex); failed:true = only errors and 4xx/5xx.',
inputSchema: z.object({
tabId: requireTabId(),
filter: z.string().optional().describe('URL regex pattern to filter requests'),
+ urlPattern: z.string().optional().describe('URL substring to filter requests (e.g. "/api/")'),
+ failed: z.boolean().optional().describe('Only failed requests (network errors and HTTP 4xx/5xx)'),
limit: z.number().int().min(1).max(200).optional().describe('Return only the most recent N requests (default: all buffered, up to 200)'),
clear: z.boolean().optional().default(false).describe('Clear this tab\'s requests after reading'),
}),
diff --git a/mcp-server/src/tools/press-key.ts b/mcp-server/src/tools/press-key.ts
index cee74cd..668d717 100644
--- a/mcp-server/src/tools/press-key.ts
+++ b/mcp-server/src/tools/press-key.ts
@@ -5,10 +5,11 @@ import { requireTabId, forwardHandler } from './types.js';
export const pressKeyTool: ToolDefinition = {
name: 'browser_press_key',
summary: 'Press a keyboard key (Enter, Tab, Escape, etc.)', description:
- 'Press a keyboard key or combination (Enter, Escape, Tab, ArrowDown, etc). Accepts combos as "ctrl+a" or via modifiers. Real key press over CDP: Tab moves focus (fires blur/focusout), Enter submits, arrows drive autocomplete menus.',
+ 'Press a keyboard key or combination (Enter, Escape, Tab, ArrowDown, etc). Accepts combos as "ctrl+a" or via modifiers, space-separated sequences ("ArrowDown ArrowDown Enter", "ctrl+a Backspace") and repeat. Real key press over CDP: Tab moves focus (fires blur/focusout), Enter submits, arrows drive autocomplete menus.',
inputSchema: z.object({
tabId: requireTabId(),
- key: z.string().describe('Key name (e.g. "Enter", "Escape", "Tab", "ArrowDown", "a")'),
+ key: z.string().describe('Key name (e.g. "Enter", "Escape", "Tab", "ArrowDown", "a"), a combo ("ctrl+a") or a space-separated sequence ("ArrowDown ArrowDown Enter")'),
+ repeat: z.number().int().min(1).max(100).optional().describe('Press the key (or the whole sequence) this many times'),
modifiers: z
.array(z.enum(['ctrl', 'alt', 'shift', 'meta']))
.optional()
diff --git a/mcp-server/src/tools/resize-window.ts b/mcp-server/src/tools/resize-window.ts
new file mode 100644
index 0000000..666a08d
--- /dev/null
+++ b/mcp-server/src/tools/resize-window.ts
@@ -0,0 +1,22 @@
+import { z } from 'zod';
+import type { ToolDefinition } from './types.js';
+import { forwardHandler } from './types.js';
+
+export const resizeWindowTool: ToolDefinition = {
+ name: 'browser_resize_window',
+ summary: 'Resize/maximize the window that holds a tab',
+ description:
+ 'Resize the browser window that contains a tab (e.g. to test a responsive layout at 390x844), or set it to normal/maximized/minimized/fullscreen. Affects the whole window the user sees — prefer a separate window for experiments. Returns the resulting window and viewport size.',
+ inputSchema: z.object({
+ tabId: z.number().int().describe('A tab in the window to resize (from browser_tabs list)'),
+ width: z.number().int().min(200).max(10000).optional().describe('Window width in px'),
+ height: z.number().int().min(200).max(10000).optional().describe('Window height in px'),
+ state: z.enum(['normal', 'maximized', 'minimized', 'fullscreen']).optional().describe('Window state (width/height apply to "normal")'),
+ }).superRefine((p, ctx) => {
+ if (p.width === undefined && p.height === undefined && p.state === undefined) {
+ ctx.addIssue({ code: 'custom', path: ['width'], message: 'width, height or state is required' });
+ }
+ }),
+ timeoutMs: 10_000,
+ handler: forwardHandler('browser_resize_window'),
+};
diff --git a/mcp-server/src/tools/screenshot.ts b/mcp-server/src/tools/screenshot.ts
index ea72ebc..161bbda 100644
--- a/mcp-server/src/tools/screenshot.ts
+++ b/mcp-server/src/tools/screenshot.ts
@@ -5,12 +5,15 @@ import { requireTabId, imageResult, jsonError, payloadOf } from './types.js';
export const screenshotTool: ToolDefinition = {
name: 'browser_screenshot',
summary: 'Capture a screenshot of a tab',
- description: 'Capture a screenshot of a tab over CDP. Use maxWidth / scale and format:"jpeg" to shrink the image (far fewer tokens); fullPage captures the whole scrollable page. A background tab is shown for a moment and the user\'s tab is switched straight back (Chrome does not paint hidden tabs). The agent\'s blue control frame is never in the picture.',
+ description: 'Capture a screenshot of a tab over CDP. Use maxWidth / scale and format:"jpeg" to shrink the image (far fewer tokens); fullPage captures the whole scrollable page; region zooms into a rectangle. The result says how image pixels map to the x/y that click/hover/scroll take. A background tab is shown for a moment and the user\'s tab is switched straight back (Chrome does not paint hidden tabs). The agent\'s blue control frame is never in the picture.',
inputSchema: z.object({
tabId: requireTabId(),
format: z.enum(['png', 'jpeg']).optional().default('png'),
quality: z.number().min(0).max(100).optional().default(80).describe('JPEG quality (ignored for PNG)'),
- scale: z.number().min(0.05).max(1).optional().describe('Downscale factor, e.g. 0.5 = half size'),
+ scale: z.number().min(0.05).max(4).optional().describe('Downscale factor, e.g. 0.5 = half size (max 1 for the viewport/page; up to 4 to zoom into a region, default 2 there)'),
+ region: z.object({
+ x: z.number(), y: z.number(), width: z.number().positive(), height: z.number().positive(),
+ }).optional().describe('Capture only this viewport rectangle (CSS px, the same coordinates click/hover/scroll x/y use) — zoom in on small UI'),
maxWidth: z.number().int().min(100).max(4000).optional().describe('Cap the image width in pixels (keeps aspect ratio), e.g. 1024'),
fullPage: z.boolean().optional().default(false).describe('Capture the whole scrollable page, not just the viewport'),
}),
@@ -24,6 +27,9 @@ export const screenshotTool: ToolDefinition = {
success: boolean;
format: string;
data?: string;
+ width?: number;
+ height?: number;
+ frame?: { scale: number; origin: [number, number]; viewport: [number, number]; page?: boolean };
};
} catch (err) {
// Unified error channel: surface a payload-carrying rejection intact.
@@ -33,7 +39,16 @@ export const screenshotTool: ToolDefinition = {
}
if (result.data) {
const mimeType = result.format === 'jpeg' ? 'image/jpeg' : 'image/png';
- return imageResult(result.data, mimeType);
+ const image = imageResult(result.data, mimeType);
+ // Coordinate frame so a point seen in the image maps to click/hover/scroll x/y.
+ if (result.frame) {
+ const f = result.frame;
+ const map = f.page
+ ? `page coords = imagePx / ${f.scale} (scroll first; viewport y = pageY - ${(f as { scrollY?: number }).scrollY ?? 0})`
+ : `x = ${f.origin[0]} + imageX / ${f.scale}, y = ${f.origin[1]} + imageY / ${f.scale}`;
+ image.content.push({ type: 'text', text: JSON.stringify({ image: [result.width, result.height], viewport: f.viewport, toViewport: map }) });
+ }
+ return image;
}
// "Captured but no data" is a failure — the agent must not treat an
// empty screenshot as success (audit: misleading success-shaped error).
diff --git a/mcp-server/src/tools/scroll.ts b/mcp-server/src/tools/scroll.ts
index c6eca36..95b646f 100644
--- a/mcp-server/src/tools/scroll.ts
+++ b/mcp-server/src/tools/scroll.ts
@@ -5,7 +5,7 @@ import { requireTabId, forwardHandler } from './types.js';
export const scrollTool: ToolDefinition = {
name: 'browser_scroll',
summary: 'Scroll a tab up/down/left/right', description:
- 'Scroll the page or an element. Supports pixel offsets, scrolling to elements, and named positions (top/bottom). Works with virtual scroll containers used by social media sites.',
+ 'Scroll the page or an element. Supports pixel offsets, scrolling to elements, and named positions (top/bottom). Works with virtual scroll containers used by social media sites. With x/y it sends a real mouse-wheel event at that point, scrolling whatever is under it (inner panels, maps, virtual lists).',
inputSchema: z.object({
tabId: requireTabId(),
direction: z.enum(['up', 'down', 'left', 'right']).optional().default('down'),
@@ -13,6 +13,8 @@ export const scrollTool: ToolDefinition = {
selector: z.string().optional().describe('CSS selector of scroll container (for virtual scroll)'),
toElement: z.string().optional().describe('Ref or CSS selector to scroll into view'),
position: z.enum(['top', 'bottom']).optional().describe('Scroll to top or bottom of page'),
+ x: z.number().optional().describe('Viewport x in CSS px: wheel-scroll at this point (with y)'),
+ y: z.number().optional().describe('Viewport y in CSS px'),
}),
timeoutMs: 10_000,
handler: forwardHandler('browser_scroll'),
diff --git a/mcp-server/src/tools/shortcuts.ts b/mcp-server/src/tools/shortcuts.ts
new file mode 100644
index 0000000..c10e726
--- /dev/null
+++ b/mcp-server/src/tools/shortcuts.ts
@@ -0,0 +1,158 @@
+import { z } from 'zod';
+import fs from 'node:fs';
+import path from 'node:path';
+import type { ToolDefinition, ToolResult } from './types.js';
+import { optionalTabId, textResult, jsonError } from './types.js';
+import { STATE_DIR } from '../daemon-config.js';
+
+/**
+ * Saved, replayable action sequences — Browser Controller's counterpart of
+ * Claude-in-Chrome's shortcuts. A shortcut is a named browser_batch with
+ * {{variables}}: save once, then `run` it in ONE call with different values.
+ * Stored locally in ~/.browser-controller/shortcuts.json (BC_SHORTCUTS_FILE
+ * overrides); nothing leaves the machine.
+ */
+
+interface Shortcut {
+ name: string;
+ description?: string;
+ actions: Array<{ tool: string; params?: Record }>;
+ variables: string[];
+ createdAt: string;
+ updatedAt: string;
+}
+
+const NAME = /^[\w.-]{1,64}$/;
+const VAR = /\{\{\s*([\w.-]+)\s*\}\}/g;
+
+export function shortcutsFile(): string {
+ return process.env.BC_SHORTCUTS_FILE || path.join(STATE_DIR, 'shortcuts.json');
+}
+
+function load(): Record {
+ try {
+ const data = JSON.parse(fs.readFileSync(shortcutsFile(), 'utf8')) as { shortcuts?: Record };
+ return data && typeof data.shortcuts === 'object' && data.shortcuts ? data.shortcuts : {};
+ } catch {
+ return {};
+ }
+}
+
+function save(all: Record): void {
+ const file = shortcutsFile();
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const tmp = `${file}.tmp`;
+ fs.writeFileSync(tmp, JSON.stringify({ version: 1, shortcuts: all }, null, 2));
+ fs.renameSync(tmp, file);
+}
+
+/** Every {{variable}} used anywhere in the actions. */
+export function variablesOf(actions: unknown): string[] {
+ const found = new Set();
+ const walk = (v: unknown) => {
+ if (typeof v === 'string') for (const m of v.matchAll(VAR)) found.add(m[1]!);
+ else if (Array.isArray(v)) v.forEach(walk);
+ else if (v && typeof v === 'object') Object.values(v).forEach(walk);
+ };
+ walk(actions);
+ return [...found].sort();
+}
+
+/** Replace {{variables}}. A string that is exactly one variable keeps the value's type (numbers, booleans). */
+export function substitute(value: unknown, vars: Record): unknown {
+ if (typeof value === 'string') {
+ const whole = value.match(/^\{\{\s*([\w.-]+)\s*\}\}$/);
+ if (whole && whole[1]! in vars) return vars[whole[1]!];
+ return value.replace(VAR, (m, k: string) => (k in vars ? String(vars[k]) : m));
+ }
+ if (Array.isArray(value)) return value.map((v) => substitute(v, vars));
+ if (value && typeof value === 'object') {
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, substitute(v, vars)]));
+ }
+ return value;
+}
+
+const summaryOf = (s: Shortcut) => ({
+ name: s.name, ...(s.description ? { description: s.description } : {}), steps: s.actions.length,
+ ...(s.variables.length ? { variables: s.variables } : {}), updatedAt: s.updatedAt,
+});
+
+export const shortcutsTool: ToolDefinition = {
+ name: 'browser_shortcuts',
+ summary: 'Save and replay named action sequences (with {{variables}})',
+ description:
+ 'Saved, replayable browser workflows. save stores a named list of steps (same format as browser_batch actions; put {{variable}} placeholders in any string param), run replays it in ONE call with vars filled in (it runs as a browser_batch: stops at the first failing step unless continueOnError), list/show/delete manage them. Stored locally in ~/.browser-controller/shortcuts.json.',
+ inputSchema: z.object({
+ action: z.enum(['list', 'show', 'save', 'run', 'delete']).describe('Shortcut action'),
+ name: z.string().optional().describe('Shortcut name (letters, digits, _ . -)'),
+ description: z.string().max(500).optional().describe('save: what it does / when to use it'),
+ actions: z.array(z.object({
+ tool: z.string(),
+ params: z.record(z.string(), z.unknown()).optional(),
+ })).max(200).optional().describe('save: the steps, like browser_batch actions'),
+ vars: z.record(z.string(), z.unknown()).optional().describe('run: values for the {{variables}}'),
+ tabId: optionalTabId().describe('run: default tab for every step without its own tabId'),
+ continueOnError: z.boolean().optional().describe('run: keep going after a failing step'),
+ output: z.enum(['all', 'last', 'errors']).optional().describe('run: like browser_batch output (default last)'),
+ }),
+ // A run can hold up to 200 steps, like browser_batch.
+ timeoutMs: 300_000,
+ async handler(host, params): Promise {
+ const p = params as {
+ action: 'list' | 'show' | 'save' | 'run' | 'delete'; name?: string; description?: string;
+ actions?: Shortcut['actions']; vars?: Record; tabId?: number;
+ continueOnError?: boolean; output?: 'all' | 'last' | 'errors';
+ };
+ const all = load();
+ if (p.action === 'list') {
+ return textResult(JSON.stringify({ success: true, shortcuts: Object.values(all).map(summaryOf) }));
+ }
+ if (!p.name || !NAME.test(p.name)) return jsonError({ success: false, error: 'name is required (letters, digits, _ . -, max 64)' });
+ const existing = all[p.name];
+ switch (p.action) {
+ case 'show':
+ if (!existing) return jsonError({ success: false, error: `No shortcut "${p.name}"` });
+ return textResult(JSON.stringify({ success: true, shortcut: existing }));
+ case 'delete':
+ if (!existing) return jsonError({ success: false, error: `No shortcut "${p.name}"` });
+ delete all[p.name];
+ save(all);
+ return textResult(JSON.stringify({ success: true, deleted: p.name }));
+ case 'save': {
+ if (!p.actions || p.actions.length === 0) return jsonError({ success: false, error: 'actions (non-empty) are required to save a shortcut' });
+ const bad = p.actions.find((a) => a.tool === 'browser_batch' || a.tool === 'browser_shortcuts' || a.tool === 'browser_tools');
+ if (bad) return jsonError({ success: false, error: `${bad.tool} cannot be a shortcut step` });
+ const now = new Date().toISOString();
+ all[p.name] = {
+ name: p.name,
+ ...(p.description ? { description: p.description } : existing?.description ? { description: existing.description } : {}),
+ actions: p.actions,
+ variables: variablesOf(p.actions),
+ createdAt: existing?.createdAt ?? now,
+ updatedAt: now,
+ };
+ save(all);
+ return textResult(JSON.stringify({ success: true, saved: summaryOf(all[p.name]!), file: shortcutsFile() }));
+ }
+ case 'run': {
+ if (!existing) return jsonError({ success: false, error: `No shortcut "${p.name}"` });
+ const vars = p.vars ?? {};
+ const missing = existing.variables.filter((v) => !(v in vars));
+ if (missing.length) return jsonError({ success: false, error: `Missing vars: ${missing.join(', ')}`, variables: existing.variables });
+ const actions = substitute(existing.actions, vars) as Shortcut['actions'];
+ // Lazy, through the registry: batch.ts and the registry import each
+ // other, and the registry imports this module.
+ const { toolMap } = await import('./index.js');
+ const batchTool = toolMap.get('browser_batch')!;
+ return batchTool.handler(host, {
+ ...(p.tabId !== undefined ? { tabId: p.tabId } : {}),
+ actions,
+ continueOnError: p.continueOnError ?? false,
+ output: p.output ?? 'last',
+ });
+ }
+ default:
+ return jsonError({ success: false, error: `Unknown action ${String(p.action)}` });
+ }
+ },
+};
diff --git a/mcp-server/src/tools/snapshot.ts b/mcp-server/src/tools/snapshot.ts
index 1060466..3c0ef49 100644
--- a/mcp-server/src/tools/snapshot.ts
+++ b/mcp-server/src/tools/snapshot.ts
@@ -5,11 +5,15 @@ import { requireTabId, forwardHandler } from './types.js';
export const snapshotTool: ToolDefinition = {
name: 'browser_snapshot',
summary: 'Get the accessibility tree with element refs', description:
- 'Get an accessibility tree snapshot of the page. Returns element refs you can use with click, type, and other tools. Use compact mode (default) for smaller output - only interactive elements.',
+ 'Get an accessibility tree snapshot of the page, including shadow DOM (web components), slotted content and same-origin iframes. Returns element refs you can use with click, type, and every other ref tool. Use compact mode (default) for smaller output - only interactive elements, landmarks and headings. Output is capped by maxChars (default 20000); scope big pages with selector or ref.',
inputSchema: z.object({
tabId: requireTabId(),
selector: z.string().optional().describe('CSS selector to scope the snapshot'),
+ ref: z.string().optional().describe('Snapshot only the subtree of this ref (from an earlier snapshot/find)'),
compact: z.boolean().optional().default(true).describe('When true (default), returns only interactive elements with minimal nesting. Set false for full tree.'),
+ filter: z.enum(['interactive', 'all']).optional().describe('Alias of compact: "interactive" = compact, "all" = full tree. Wins over compact when given.'),
+ depth: z.number().int().min(0).optional().describe('Max nesting depth of returned nodes (0 = top level only)'),
+ maxChars: z.number().int().min(500).max(200_000).optional().describe('Cap on the serialized tree size (default 20000). The result says truncated:true when hit.'),
}),
// Read-only (refs are deterministic given a stable DOM): safe to retry.
idempotent: true,
diff --git a/mcp-server/src/tools/tabs.ts b/mcp-server/src/tools/tabs.ts
index 06dd704..d5037d1 100644
--- a/mcp-server/src/tools/tabs.ts
+++ b/mcp-server/src/tools/tabs.ts
@@ -4,20 +4,25 @@ import { forwardHandler } from './types.js';
export const tabsTool: ToolDefinition = {
name: 'browser_tabs',
- summary: 'List, create, close, focus, lock, or unlock tabs', description:
- 'Manage browser tabs: list, create, close, focus, or lock. list requires no tabId. lock/unlock claim a tab for the calling agent so other agents queue behind it instead of racing (see browser_tabs lock).',
+ summary: 'List, create, close, focus, reload, lock, or unlock tabs', description:
+ 'Manage browser tabs: list, create, close, focus, reload, or lock. list requires no tabId. reload also recovers a frozen (TAB_WEDGED) tab. lock/unlock claim a tab for the calling agent so other agents queue behind it instead of racing (see browser_tabs lock).',
inputSchema: z.object({
action: z
- .enum(['list', 'create', 'close', 'focus', 'lock', 'unlock'])
+ .enum(['list', 'create', 'close', 'focus', 'reload', 'lock', 'unlock'])
.describe('Tab action'),
- tabId: z.number().int().optional().describe('Tab ID (required for close/focus/lock/unlock)'),
+ tabId: z.number().int().optional().describe('Tab ID (required for close/focus/reload/lock/unlock)'),
+ bypassCache: z.boolean().optional().describe('reload: skip the HTTP cache'),
url: z.string().optional().describe('URL for create action'),
+ active: z.boolean().optional().describe('create: false opens the tab in the background (the user keeps their current tab)'),
+ window: z.boolean().optional().describe('focus: also bring the tab\'s window to the front'),
+ fullUrls: z.boolean().optional().describe('list: do not shorten long URLs'),
}).superRefine((params, ctx) => {
- const targetedActions = ['close', 'focus', 'lock', 'unlock'];
+ const targetedActions = ['close', 'focus', 'reload', 'lock', 'unlock'];
if (targetedActions.includes(params.action) && params.tabId === undefined) {
ctx.addIssue({ code: 'custom', path: ['tabId'], message: `tabId is required for ${params.action}` });
}
}),
- timeoutMs: 5_000,
+ // reload waits for the new document (up to 30s); the other actions return at once.
+ timeoutMs: 35_000,
handler: forwardHandler('browser_tabs'),
};
diff --git a/mcp-server/src/tools/text.ts b/mcp-server/src/tools/text.ts
index d908925..aaea2c9 100644
--- a/mcp-server/src/tools/text.ts
+++ b/mcp-server/src/tools/text.ts
@@ -4,11 +4,13 @@ import { requireTabId, forwardHandler } from './types.js';
export const textTool: ToolDefinition = {
name: 'browser_text',
- summary: 'Extract raw text content from a page', description: 'Extract raw text content from the page or a specific element',
+ summary: 'Extract raw text content from a page', description: 'Extract raw text content from the page or a specific element. Includes shadow-DOM (web component) content. mode:"article" returns only the main content (skips nav, header, footer, sidebars). Page long texts with offset.',
inputSchema: z.object({
tabId: requireTabId(),
selector: z.string().optional().describe('CSS selector to scope text extraction'),
- maxLength: z.number().optional().default(5000).describe('Max text length to return (default 5000 chars ≈ 1250 tokens; raise only when you need more)'),
+ maxLength: z.number().int().min(1).max(100_000).optional().default(5000).describe('Max text length to return (default 5000 chars ≈ 1250 tokens; raise only when you need more, max 100000)'),
+ mode: z.enum(['all', 'article']).optional().describe('"all" (default): all visible text. "article": main content only (article/main), without navigation, headers, footers, sidebars and banners.'),
+ offset: z.number().int().min(0).optional().describe('Start at this character (use nextOffset from a truncated result to read the next page).'),
}),
// Read-only: safe to retry on timeout. (Fixes prior wire-name drift — C1.)
idempotent: true,
diff --git a/mcp-server/src/tools/type.ts b/mcp-server/src/tools/type.ts
index 7fad852..b85f378 100644
--- a/mcp-server/src/tools/type.ts
+++ b/mcp-server/src/tools/type.ts
@@ -5,11 +5,11 @@ import { requireTabId, forwardHandler } from './types.js';
export const typeTool: ToolDefinition = {
name: 'browser_type',
summary: 'Type text into an input element',
- description: 'Focus an input and type text with real key presses over CDP (keydown/keypress/input/keyup per character, like a user). Like a user, `change`/blur fire only when focus leaves — follow with browser_press_key Tab to commit. Returns the field value after typing. Falls back to synthetic events if the debugger cannot attach.',
+ description: 'Focus an input (ref/selector — or, with neither, the element that already has focus) and type text with real key presses over CDP (keydown/keypress/input/keyup per character, like a user). Like a user, `change`/blur fire only when focus leaves — follow with browser_press_key Tab to commit. Returns the field value after typing. Falls back to synthetic events if the debugger cannot attach.',
inputSchema: z.object({
tabId: requireTabId(),
ref: z.string().optional().describe('Element reference from snapshot'),
- selector: z.string().optional().describe('CSS selector for the input'),
+ selector: z.string().optional().describe('CSS selector for the input (omit both ref and selector to type into the focused field)'),
text: z.string().describe('Text to type'),
clear: z.boolean().optional().default(false).describe('Clear the field before typing'),
trusted: z.boolean().optional().describe('Real (isTrusted) input over CDP — default. false = synthetic DOM events, no debugger banner.'),
diff --git a/mcp-server/src/tools/upload-file.ts b/mcp-server/src/tools/upload-file.ts
index 3c13224..3dc04a5 100644
--- a/mcp-server/src/tools/upload-file.ts
+++ b/mcp-server/src/tools/upload-file.ts
@@ -5,7 +5,7 @@ import { requireTabId, forwardHandler } from './types.js';
export const uploadFileTool: ToolDefinition = {
name: 'browser_upload_file',
summary: 'Upload a file to a file input element', description:
- 'Upload a local file into an WITHOUT opening the file dialog: CDP DOM.setFileInputFiles sets the files as if the user picked them, then input+change events fire so React/Vue handlers react. Works even on strict-CSP pages. Paths are absolute and local to the machine running the browser. Target the input with a ref from snapshot, a CSS selector, or omit both to auto-find the first input[type="file"]. Use files (array) for multiple uploads — requires an input that allows multiple.',
+ 'Upload a local file into an WITHOUT opening the file dialog: CDP DOM.setFileInputFiles sets the files as if the user picked them, then input+change events fire so React/Vue handlers react. Works even on strict-CSP pages. Paths are absolute and local to the machine running the browser. Target the input with a ref from snapshot, a CSS selector, or omit both to auto-find the first input[type="file"]. Use files (array) for multiple uploads — requires an input that allows multiple. Without a local file: imageBase64 (+fileName/mimeType) or fromScreenshot:true uploads bytes directly — into a file input, or as a drag-and-drop onto a drop zone (ref/selector/x+y).',
inputSchema: z.object({
tabId: requireTabId(),
ref: z.string().optional().describe('Element reference from snapshot (e.g. "e12")'),
@@ -15,6 +15,15 @@ export const uploadFileTool: ToolDefinition = {
.array(z.string())
.optional()
.describe('Array of local file paths to upload (multiple files)'),
+ imageBase64: z.string().optional().describe('File bytes as base64 (no data: prefix) — uploads without a local file'),
+ fileName: z.string().optional().describe('Name for imageBase64 / screenshot uploads (default image.png / screenshot.png)'),
+ mimeType: z.string().optional().describe('MIME type for imageBase64 (default image/png)'),
+ fromScreenshot: z.boolean().optional().describe('Upload a fresh PNG screenshot (of screenshotTabId, default this tab; optional region)'),
+ screenshotTabId: z.number().int().optional().describe('Tab to screenshot for fromScreenshot'),
+ region: z.object({ x: z.number(), y: z.number(), width: z.number().positive(), height: z.number().positive() }).optional()
+ .describe('fromScreenshot: only this viewport rectangle'),
+ x: z.number().optional().describe('Drop target at viewport x (with y) when there is no ref/selector'),
+ y: z.number().optional().describe('Drop target at viewport y'),
}),
timeoutMs: 15_000,
handler: forwardHandler('browser_upload_file'),
diff --git a/mcp-server/src/tools/wait.ts b/mcp-server/src/tools/wait.ts
index db9688d..323f4ca 100644
--- a/mcp-server/src/tools/wait.ts
+++ b/mcp-server/src/tools/wait.ts
@@ -5,7 +5,7 @@ import { requireTabId, forwardHandler } from './types.js';
export const waitTool: ToolDefinition = {
name: 'browser_wait',
summary: 'Wait for a duration or condition', description:
- 'Wait for a condition: element to appear, element to disappear, or a fixed delay. Useful for SPAs and dynamic content.',
+ 'Wait for a condition: element to appear (any visible match, including inside shadow DOM / same-origin iframes), element to disappear, text to appear/disappear, the URL to change, or a fixed delay. Useful for SPAs and dynamic content.',
inputSchema: z.object({
tabId: requireTabId(),
selector: z.string().optional().describe('CSS selector to wait for'),
@@ -15,7 +15,9 @@ export const waitTool: ToolDefinition = {
.default('visible')
.describe('Wait until element is visible, hidden, or attached to DOM'),
timeout: z.number().optional().default(10000).describe('Max wait time in ms'),
- delay: z.number().optional().describe('Fixed delay in ms (ignores selector)'),
+ delay: z.number().optional().describe('Fixed delay in ms (ignores selector/text/urlIncludes)'),
+ text: z.string().optional().describe('Wait until this text is on the page (state "hidden": until it is gone). Case-insensitive.'),
+ urlIncludes: z.string().optional().describe('Wait until the tab URL contains this string (e.g. after a client-side navigation).'),
}),
timeoutMs: 60_000,
handler: forwardHandler('browser_wait'),
diff --git a/package-lock.json b/package-lock.json
index 4906e7a..5d34447 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "browser-controller",
- "version": "2.3.0",
+ "version": "2.4.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "browser-controller",
- "version": "2.3.0",
+ "version": "2.4.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.30.0",
@@ -18,7 +18,7 @@
},
"devDependencies": {
"@babel/core": "^8.0.6",
- "@babel/eslint-parser": "^7.29.9",
+ "@babel/eslint-parser": "^8.0.6",
"@babel/preset-typescript": "^8.0.1",
"@eslint/js": "^10.0.1",
"@types/node": "^26.6.2",
@@ -151,22 +151,22 @@
}
},
"node_modules/@babel/eslint-parser": {
- "version": "7.29.9",
- "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.29.9.tgz",
- "integrity": "sha512-GmrJTAtiRbNip+eKFqeuSvoHMJhKlH36j8U0yE3Gqn45B+SIO7NvsEiHtsmzkBqV1hLSMqYr41dI1aZLTkJ37g==",
+ "version": "8.0.6",
+ "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-8.0.6.tgz",
+ "integrity": "sha512-mXx93HLovamLnDDLQ4i48gm4HHtaoqJrbMAuJqrMfpCGJkJ5M4sXhRgKGTrxykg1P8tAN5myrJxsRZuMuTq+OQ==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1",
- "eslint-visitor-keys": "^2.1.0",
- "semver": "^6.3.1"
+ "eslint-scope": "^9.1.0",
+ "eslint-visitor-keys": "^5.0.0",
+ "verkit": "^0.3.2"
},
"engines": {
- "node": "^10.13.0 || ^12.13.0 || >=14.0.0"
+ "node": "^22.18.0 || >=24.11.0"
},
"peerDependencies": {
- "@babel/core": "^7.11.0",
- "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0"
+ "@babel/core": "^8.0.0",
+ "eslint": "^9.0.0 || ^10.0.0"
}
},
"node_modules/@babel/generator": {
@@ -1240,16 +1240,6 @@
}
}
},
- "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": {
- "version": "5.1.1-v1",
- "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz",
- "integrity": "sha512-54/JRvkLIzzDWshCWfuhadfrfZVPiElY8Fcgmg1HroEly/EDSszzhBAsarCux+D/kOslTRquNzuyGSmUSTTHGg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "eslint-scope": "5.1.1"
- }
- },
"node_modules/@oxc-project/types": {
"version": "0.149.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.149.0.tgz",
@@ -2627,47 +2617,6 @@
}
},
"node_modules/eslint-scope": {
- "version": "5.1.1",
- "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz",
- "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==",
- "dev": true,
- "license": "BSD-2-Clause",
- "dependencies": {
- "esrecurse": "^4.3.0",
- "estraverse": "^4.1.1"
- },
- "engines": {
- "node": ">=8.0.0"
- }
- },
- "node_modules/eslint-visitor-keys": {
- "version": "2.1.0",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz",
- "integrity": "sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": ">=10"
- }
- },
- "node_modules/eslint/node_modules/ajv": {
- "version": "6.15.0",
- "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
- "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "fast-deep-equal": "^3.1.1",
- "fast-json-stable-stringify": "^2.0.0",
- "json-schema-traverse": "^0.4.1",
- "uri-js": "^4.2.2"
- },
- "funding": {
- "type": "github",
- "url": "https://github.com/sponsors/epoberezkin"
- }
- },
- "node_modules/eslint/node_modules/eslint-scope": {
"version": "9.1.2",
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
"integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
@@ -2686,7 +2635,7 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/eslint/node_modules/eslint-visitor-keys": {
+ "node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
"integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
@@ -2699,14 +2648,21 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/eslint/node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "node_modules/eslint/node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/eslint/node_modules/json-schema-traverse": {
@@ -2734,19 +2690,6 @@
"url": "https://opencollective.com/eslint"
}
},
- "node_modules/espree/node_modules/eslint-visitor-keys": {
- "version": "5.0.1",
- "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
- "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
- "dev": true,
- "license": "Apache-2.0",
- "engines": {
- "node": "^20.19.0 || ^22.13.0 || >=24"
- },
- "funding": {
- "url": "https://opencollective.com/eslint"
- }
- },
"node_modules/esquery": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
@@ -2760,16 +2703,6 @@
"node": ">=0.10"
}
},
- "node_modules/esquery/node_modules/estraverse": {
- "version": "5.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
- "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
"node_modules/esrecurse": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
@@ -2783,7 +2716,7 @@
"node": ">=4.0"
}
},
- "node_modules/esrecurse/node_modules/estraverse": {
+ "node_modules/estraverse": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
"integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
@@ -2793,16 +2726,6 @@
"node": ">=4.0"
}
},
- "node_modules/estraverse": {
- "version": "4.3.0",
- "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz",
- "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==",
- "dev": true,
- "license": "BSD-2-Clause",
- "engines": {
- "node": ">=4.0"
- }
- },
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
@@ -4244,16 +4167,6 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
- "node_modules/semver": {
- "version": "6.3.1",
- "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
- "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
- "dev": true,
- "license": "ISC",
- "bin": {
- "semver": "bin/semver.js"
- }
- },
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz",
diff --git a/package.json b/package.json
index 4c6064f..e81bad9 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "browser-controller",
"mcpName": "io.github.noiemany/browser-controller",
- "version": "2.3.0",
+ "version": "2.4.0",
"description": "MCP server + Chrome extension that gives AI agents control of your real browser with existing sessions and logins",
"type": "module",
"bin": {
@@ -71,7 +71,7 @@
},
"devDependencies": {
"@babel/core": "^8.0.6",
- "@babel/eslint-parser": "^7.29.9",
+ "@babel/eslint-parser": "^8.0.6",
"@babel/preset-typescript": "^8.0.1",
"@eslint/js": "^10.0.1",
"@types/node": "^26.6.2",
diff --git a/tests/bridge-multibrowser.test.ts b/tests/bridge-multibrowser.test.ts
new file mode 100644
index 0000000..49d198e
--- /dev/null
+++ b/tests/bridge-multibrowser.test.ts
@@ -0,0 +1,107 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { WebSocket } from 'ws';
+import { ExtensionBridge } from '../mcp-server/src/bridge.js';
+import { buildExtensionHelloAck } from '../mcp-server/src/protocol.js';
+import { buildExtensionHelloAck as extensionHelloAck } from '../extension/lib/protocol.js';
+
+let port = 26_000 + (process.pid % 3_000);
+const sockets: WebSocket[] = [];
+const bridges: ExtensionBridge[] = [];
+
+afterEach(async () => {
+ sockets.forEach((s) => { try { s.close(); } catch { /* closed */ } });
+ sockets.length = 0;
+ bridges.forEach((b) => b.stop());
+ bridges.length = 0;
+ await new Promise((r) => setTimeout(r, 30));
+});
+
+async function startBridge() {
+ const bridge = new ExtensionBridge({ port: ++port, maxRetries: 0, pingIntervalMs: 60_000, handshakeGraceMs: 500 });
+ bridges.push(bridge);
+ await bridge.start();
+ return { bridge, port };
+}
+
+/** A fake extension: answers the hello with its browser identity and echoes tool calls with its name. */
+async function fakeBrowser(p: number, browserId: string, opts: { silent?: boolean } = {}) {
+ const ws = new WebSocket(`ws://localhost:${p}`);
+ sockets.push(ws);
+ const calls: string[] = [];
+ ws.on('message', (data) => {
+ const msg = JSON.parse(data.toString());
+ if (msg.type === 'hello') ws.send(JSON.stringify({ ...buildExtensionHelloAck('test'), browserId, browserLabel: `Chrome ${browserId}` }));
+ if (msg.tool) {
+ calls.push(msg.tool);
+ if (!opts.silent) ws.send(JSON.stringify({ id: msg.id, success: true, result: { from: browserId } }));
+ }
+ });
+ await new Promise((resolve, reject) => { ws.on('open', () => resolve()); ws.on('error', reject); });
+ await new Promise((r) => setTimeout(r, 60));
+ return { ws, calls };
+}
+
+describe('multi-browser bridge', () => {
+ it('keeps several browsers connected; the newest is the default', async () => {
+ const { bridge, port: p } = await startBridge();
+ await fakeBrowser(p, 'work');
+ await fakeBrowser(p, 'home');
+ const list = bridge.callTool('browser_list_browsers', {}, 's1') as Promise;
+ const { browsers } = await list;
+ expect(browsers.map((b: any) => b.browserId).sort()).toEqual(['home', 'work']);
+ expect(browsers.find((b: any) => b.default).browserId).toBe('home');
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's1')).toEqual({ from: 'home' });
+ });
+
+ it('routes a session to the browser it selected, other sessions keep the default', async () => {
+ const { bridge, port: p } = await startBridge();
+ await fakeBrowser(p, 'work');
+ await fakeBrowser(p, 'home');
+ expect(await bridge.callTool('browser_select_browser', { browserId: 'Chrome work' }, 's1')).toMatchObject({ selected: 'work' });
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's1')).toEqual({ from: 'work' });
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's2')).toEqual({ from: 'home' });
+ await bridge.callTool('browser_select_browser', { browserId: 'auto' }, 's1');
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's1')).toEqual({ from: 'home' });
+ await expect(bridge.callTool('browser_select_browser', { browserId: 'nope' }, 's1')).rejects.toThrow(/No connected browser/);
+ });
+
+ it('a reconnect of the same browser replaces its old socket; a different browser is untouched', async () => {
+ const { bridge, port: p } = await startBridge();
+ const first = await fakeBrowser(p, 'work');
+ await fakeBrowser(p, 'home');
+ const firstClosed = new Promise((r) => first.ws.once('close', () => r()));
+ await fakeBrowser(p, 'work');
+ await firstClosed;
+ const { browsers } = await (bridge.callTool('browser_list_browsers', {}, 's1') as Promise);
+ expect(browsers.map((b: any) => b.browserId).sort()).toEqual(['home', 'work']);
+ });
+
+ it('one browser disconnecting fails only its own calls', async () => {
+ const { bridge, port: p } = await startBridge();
+ const work = await fakeBrowser(p, 'work', { silent: true });
+ await fakeBrowser(p, 'home');
+ await bridge.callTool('browser_select_browser', { browserId: 'work' }, 's1');
+ const hanging = bridge.callTool('browser_wait', { delay: 10 }, 's1');
+ await new Promise((r) => setTimeout(r, 50));
+ work.ws.close();
+ await expect(hanging).rejects.toThrow(/disconnected/);
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's2')).toEqual({ from: 'home' });
+ // The session that picked the gone browser gets a clear error, not the wrong browser.
+ await expect(bridge.callTool('browser_tabs', { action: 'list' }, 's1')).rejects.toThrow(/not connected/);
+ });
+
+ it('releasing a session forgets its browser choice', async () => {
+ const { bridge, port: p } = await startBridge();
+ await fakeBrowser(p, 'work');
+ await fakeBrowser(p, 'home');
+ await bridge.callTool('browser_select_browser', { browserId: 'work' }, 's1');
+ bridge.sendControl('releaseSession', { sessionId: 's1' });
+ expect(await bridge.callTool('browser_tabs', { action: 'list' }, 's1')).toEqual({ from: 'home' });
+ });
+
+ it('the extension announces its browser identity in helloAck', () => {
+ expect(extensionHelloAck('2.4.0', { browserId: 'abc123', browserLabel: 'Chrome on Windows (abc1)' }))
+ .toMatchObject({ type: 'helloAck', browserId: 'abc123', browserLabel: 'Chrome on Windows (abc1)' });
+ expect(extensionHelloAck('2.4.0')).not.toHaveProperty('browserId');
+ });
+});
diff --git a/tests/extension-agent-api.test.ts b/tests/extension-agent-api.test.ts
index dec7bd2..3e2156f 100644
--- a/tests/extension-agent-api.test.ts
+++ b/tests/extension-agent-api.test.ts
@@ -32,6 +32,8 @@ let queryNodeId = 2;
debuggerCommands.push(method);
if (method === 'DOM.getDocument') return { root: { nodeId: 1 } };
if (method === 'DOM.querySelector') return { nodeId: queryNodeId };
+ // upload_file finds the marked input in the page and hands CDP its objectId.
+ if (method === 'Runtime.evaluate') return { result: queryNodeId ? { objectId: 'obj-1' } : { type: 'object', subtype: 'null' } };
return {};
},
},
@@ -196,7 +198,7 @@ describe('observe/act extension handlers', () => {
}, 'session-a');
expect(result).toMatchObject({ success: true, ok: true, action: 'upload', files: ['/tmp/resume.pdf'] });
- expect(debuggerCommands.filter((m) => m.startsWith('DOM.'))).toEqual(['DOM.enable', 'DOM.getDocument', 'DOM.querySelector', 'DOM.setFileInputFiles']);
+ expect(debuggerCommands.filter((m) => m.startsWith('DOM.'))).toEqual(['DOM.setFileInputFiles']);
expect(result.metrics).toMatchObject({ protocolCalls: 8 });
});
diff --git a/tests/extension-router.test.ts b/tests/extension-router.test.ts
index 1a973ce..f7f7495 100644
--- a/tests/extension-router.test.ts
+++ b/tests/extension-router.test.ts
@@ -41,7 +41,7 @@ const tabStore = new Map {
@@ -79,8 +79,39 @@ describe('extension router (handleMessage)', () => {
const frame = lastFrame();
expect(frame.id).toBe('w1');
expect(frame.success).toBe(false);
- expect(frame.error).toBe('Need selector or delay');
- expect(frame.result).toEqual({ success: false, error: 'Need selector or delay' });
+ expect(frame.error).toBe('Need selector, text, urlIncludes or delay');
+ expect(frame.result).toEqual({ success: false, error: 'Need selector, text, urlIncludes or delay' });
+ });
+
+ it('frozen-tab navigate cannot replace a tab locked by another session', async () => {
+ const created: unknown[] = [];
+ const removed: number[] = [];
+ (globalThis as any).chrome.tabs.create = async (o: unknown) => { created.push(o); return { id: 99 }; };
+ (globalThis as any).chrome.tabs.remove = async (id: number) => { removed.push(id); };
+ tabLocks.lock(3, 'session-a');
+ wedgedTabs.set(3, Date.now());
+ await handleMessage({ id: 'fz1', tool: 'browser_navigate', params: { tabId: 3, url: 'https://example.com/x', snapshot: false }, sessionId: 'session-b' });
+ await flush();
+ expect(lastFrame()).toMatchObject({ id: 'fz1', success: false });
+ expect(String(lastFrame().error)).toMatch(/locked by session-a/);
+ expect(created).toEqual([]);
+ expect(removed).toEqual([]);
+ expect(tabLocks.owner(3)).toBe('session-a');
+ wedgedTabs.clear();
+ });
+
+ it('the lock owner recovering its own frozen tab keeps the lock on the replacement', async () => {
+ const { replaceFrozenTab } = await import('../extension/lib/page-exec.js');
+ (globalThis as any).chrome.tabs.create = async () => ({ id: 99 });
+ (globalThis as any).chrome.tabs.remove = async () => {};
+ tabLocks.lock(3, 'session-a');
+ wedgedTabs.set(3, Date.now());
+ await expect(replaceFrozenTab({ id: 3, windowId: 1, index: 0, active: true }, null, 'session-b')).rejects.toThrow(/locked by session-a/);
+ const fresh = await replaceFrozenTab({ id: 3, windowId: 1, index: 0, active: true }, null, 'session-a');
+ expect(fresh.id).toBe(99);
+ expect(tabLocks.owner(99)).toBe('session-a');
+ expect(tabLocks.owner(3)).toBeUndefined();
+ wedgedTabs.clear();
});
it('converts a THROWN handler error into a wire-level error', async () => {
@@ -239,7 +270,8 @@ describe('observe/act concurrency integration', () => {
describe('dispatch registry ↔ MCP tool registry (drift guard)', () => {
// Server-local tools never reach the extension: browser_batch runs other
// tools' handlers in the MCP process (the meta tool isn't in allTools).
- const wireTools = allTools.filter((t) => t.name !== 'browser_batch');
+ // browser_batch runs in the MCP process; browser selection is answered by the bridge.
+ const wireTools = allTools.filter((t) => !['browser_batch', 'browser_shortcuts', 'browser_list_browsers', 'browser_select_browser'].includes(t.name));
it('every registered MCP tool has an extension handler', () => {
for (const tool of wireTools) {
diff --git a/tests/gif-encoder.test.ts b/tests/gif-encoder.test.ts
new file mode 100644
index 0000000..ec980f0
--- /dev/null
+++ b/tests/gif-encoder.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest';
+import { encodeGif, lzwEncode, indexPixels, buildPalette, drawMarker } from '../extension/lib/gif-encoder.js';
+
+/** Reference GIF LZW decoder (spec algorithm) for round-trip checks. */
+function lzwDecode(data: number[]): number[] {
+ const min = data[0];
+ const bytes: number[] = [];
+ let i = 1;
+ while (data[i] !== 0) { const n = data[i]; bytes.push(...data.slice(i + 1, i + 1 + n)); i += n + 1; }
+ const clear = 1 << min;
+ const eoi = clear + 1;
+ let size = min + 1;
+ let dict: number[][] = [];
+ const reset = () => { dict = []; for (let k = 0; k < clear; k++) dict[k] = [k]; dict[clear] = []; dict[eoi] = []; size = min + 1; };
+ reset();
+ const out: number[] = [];
+ let bitPos = 0;
+ const read = () => {
+ let code = 0;
+ for (let b = 0; b < size; b++) {
+ const byte = bytes[(bitPos + b) >> 3];
+ if (((byte >> ((bitPos + b) & 7)) & 1) === 1) code |= 1 << b;
+ }
+ bitPos += size;
+ return code;
+ };
+ let prev: number[] | null = null;
+ for (;;) {
+ const code = read();
+ if (code === clear) { reset(); prev = null; continue; }
+ if (code === eoi) break;
+ let entry: number[];
+ if (dict[code]) entry = dict[code];
+ else if (prev) entry = [...prev, prev[0]];
+ else throw new Error('bad code');
+ out.push(...entry);
+ if (prev) {
+ dict.push([...prev, entry[0]]);
+ if (dict.length === (1 << size) && size < 12) size++;
+ }
+ prev = entry;
+ }
+ return out;
+}
+
+describe('GIF encoder', () => {
+ it('LZW round-trips short, repetitive and dictionary-overflowing data', () => {
+ const cases = [
+ [5],
+ [1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
+ Array.from({ length: 5000 }, (_, i) => (i * 7) % 256),
+ Array.from({ length: 60000 }, (_, i) => ((i * 2654435761) >>> 24) & 0xff), // forces dictionary resets
+ ];
+ for (const c of cases) expect(lzwDecode(lzwEncode(Uint8Array.from(c)))).toEqual(c);
+ });
+
+ it('maps greys to the grey ramp and colours to the cube', () => {
+ const pal = buildPalette();
+ const idx = indexPixels(Uint8Array.from([255, 255, 255, 255, 0, 0, 0, 255, 255, 0, 0, 255, 128, 128, 128, 255]), 4);
+ expect([pal[idx[0] * 3], pal[idx[0] * 3 + 1]]).toEqual([255, 255]);
+ expect(pal[idx[1] * 3]).toBe(0);
+ expect([pal[idx[2] * 3], pal[idx[2] * 3 + 1], pal[idx[2] * 3 + 2]]).toEqual([255, 0, 0]);
+ expect(idx[3]).toBeGreaterThanOrEqual(216);
+ });
+
+ it('writes a well-formed looping GIF89a with one image per frame', () => {
+ const w = 4; const h = 3;
+ const frame = (v: number) => ({ rgba: new Uint8Array(w * h * 4).fill(v), delayMs: 400 });
+ const f2 = frame(200);
+ drawMarker(f2.rgba, w, h, 1, 1, 1);
+ const gif = encodeGif(w, h, [frame(0), f2]);
+ const text = String.fromCharCode(...gif.slice(0, 6));
+ expect(text).toBe('GIF89a');
+ expect(gif[6] | (gif[7] << 8)).toBe(w);
+ expect(gif[8] | (gif[9] << 8)).toBe(h);
+ expect(String.fromCharCode(...gif.slice(13 + 768 + 3, 13 + 768 + 14))).toBe('NETSCAPE2.0');
+ // one graphic-control extension (21 F9 04) per frame
+ expect(gif.filter((b, i) => b === 0x21 && gif[i + 1] === 0xf9 && gif[i + 2] === 0x04).length).toBe(2);
+ expect(gif[gif.length - 1]).toBe(0x3b);
+ });
+});
diff --git a/tests/helpers/fake-dom.ts b/tests/helpers/fake-dom.ts
new file mode 100644
index 0000000..9842048
--- /dev/null
+++ b/tests/helpers/fake-dom.ts
@@ -0,0 +1,173 @@
+/**
+ * Tiny DOM for exercising the injected page runtime (extension/lib/page-dom.js)
+ * in node: elements, text nodes, open shadow roots, a handful of selector
+ * forms, visibility via a `hidden` flag / inline display, fixed-size layout.
+ * Deliberately small — enough for resolver / find / click_text semantics.
+ */
+export class FakeText {
+ nodeType = 3;
+ parentNode: FakeNode | null = null;
+ constructor(public nodeValue: string) {}
+ get textContent() { return this.nodeValue; }
+}
+
+type FakeNode = FakeElement | FakeShadowRoot;
+
+export class FakeShadowRoot {
+ nodeType = 11;
+ childNodes: Array = [];
+ constructor(public host: FakeElement) {}
+ get children() { return this.childNodes.filter((c): c is FakeElement => c instanceof FakeElement); }
+ append(...nodes: Array) { for (const n of nodes) { n.parentNode = this; if (n instanceof FakeElement) n.parentElement = null; this.childNodes.push(n); } return this; }
+ querySelectorAll(sel: string) { return this.children.flatMap((c) => c.selfAndDescendants()).filter((e) => e.matches(sel)); }
+ querySelector(sel: string) { return this.querySelectorAll(sel)[0] ?? null; }
+ getElementById(id: string) { return this.querySelectorAll(`#${id}`)[0] ?? null; }
+ get activeElement() { return null; }
+ elementFromPoint() { return null; }
+}
+
+export class FakeElement {
+ nodeType = 1;
+ tagName: string;
+ attrs = new Map();
+ childNodes: Array = [];
+ parentElement: FakeElement | null = null;
+ parentNode: FakeNode | null = null;
+ shadowRoot: FakeShadowRoot | null = null;
+ hidden = false;
+ value: string | undefined;
+ type: string | undefined;
+ disabled = false;
+ isConnected = true;
+ onclick = null;
+ isContentEditable = false;
+ events: string[] = [];
+ ownerDocument: FakeDocument;
+
+ constructor(doc: FakeDocument, tag: string, attrs: Record = {}) {
+ this.ownerDocument = doc;
+ this.tagName = tag.toUpperCase();
+ for (const [k, v] of Object.entries(attrs)) this.setAttribute(k, v);
+ }
+
+ get children() { return this.childNodes.filter((c): c is FakeElement => c instanceof FakeElement); }
+ get childElementCount() { return this.children.length; }
+ get id() { return this.attrs.get('id') ?? ''; }
+ get className() { return this.attrs.get('class') ?? ''; }
+ get textContent(): string { return this.childNodes.map((c) => c.textContent).join(''); }
+ get innerText(): string { return this.textContent; }
+ get labels() { return []; }
+ get multiple() { return false; }
+
+ append(...nodes: Array) {
+ for (const raw of nodes) {
+ const n = typeof raw === 'string' ? new FakeText(raw) : raw;
+ n.parentNode = this;
+ if (n instanceof FakeElement) n.parentElement = this;
+ this.childNodes.push(n);
+ }
+ return this;
+ }
+ attachShadow() { this.shadowRoot = new FakeShadowRoot(this); return this.shadowRoot; }
+ getAttribute(n: string) { if (n === 'type' && this.type) return this.type; return this.attrs.get(n) ?? null; }
+ setAttribute(n: string, v: string) { this.attrs.set(n, v); if (n === 'type') this.type = v; if (n === 'value') this.value = v; }
+ removeAttribute(n: string) { this.attrs.delete(n); }
+ hasAttribute(n: string) { return this.attrs.has(n); }
+ getRootNode(): unknown {
+ let cur: FakeElement = this;
+ while (cur.parentElement) cur = cur.parentElement;
+ if (cur.parentNode instanceof FakeShadowRoot) return cur.parentNode;
+ return this.ownerDocument;
+ }
+ closest() { return null; }
+ get isHiddenInTree(): boolean {
+ let cur: FakeElement | null = this;
+ while (cur) {
+ if (cur.hidden || cur.attrs.get('style')?.includes('display:none')) return true;
+ const p: FakeNode | null = cur.parentNode;
+ cur = cur.parentElement ?? (p instanceof FakeShadowRoot ? p.host : null);
+ }
+ return false;
+ }
+ getBoundingClientRect() {
+ const w = this.isHiddenInTree ? 0 : 100;
+ return { x: 10, y: 10, left: 10, top: 10, width: w, height: w ? 20 : 0, right: 10 + w, bottom: 30 };
+ }
+ checkVisibility() { return !this.isHiddenInTree; }
+ scrollIntoView() {}
+ focus() { this.ownerDocument.activeElement = this; }
+ dispatchEvent(e: { type: string }) { this.events.push(e.type); return true; }
+ selfAndDescendants(): FakeElement[] {
+ return [this, ...this.children.flatMap((c) => c.selfAndDescendants())];
+ }
+ querySelectorAll(sel: string) { return this.children.flatMap((c) => c.selfAndDescendants()).filter((e) => e.matches(sel)); }
+ querySelector(sel: string) { return this.querySelectorAll(sel)[0] ?? null; }
+ /** Supports: *, tag, #id, .cls, tag.cls, [attr], [attr="v"], tag[attr="v"], comma lists. */
+ matches(sel: string): boolean {
+ return sel.split(',').some((part) => {
+ const s = part.trim();
+ if (s === '*') return true;
+ const m = s.match(/^([a-zA-Z0-9-]*)((?:[#.][\w-]+)*)((?:\[[^\]]+\])*)$/);
+ if (!m) throw new Error(`fake-dom: unsupported selector ${s}`);
+ const [, tag, idcls, attrPart] = m;
+ if (tag && tag.toUpperCase() !== this.tagName) return false;
+ for (const t of idcls.match(/[#.][\w-]+/g) ?? []) {
+ if (t[0] === '#' && this.id !== t.slice(1)) return false;
+ if (t[0] === '.' && !this.className.split(/\s+/).includes(t.slice(1))) return false;
+ }
+ for (const a of attrPart.match(/\[[^\]]+\]/g) ?? []) {
+ const am = a.match(/^\[([\w-]+)(?:=["']?([^"'\]]*)["']?)?\]$/);
+ if (!am) return false;
+ const have = this.getAttribute(am[1]);
+ if (have == null) return false;
+ if (am[2] !== undefined && have !== am[2]) return false;
+ }
+ return true;
+ });
+ }
+}
+
+export class FakeDocument {
+ nodeType = 9;
+ title = 'Fixture';
+ activeElement: FakeElement | null = null;
+ body: FakeElement;
+ defaultView: { getComputedStyle: (el: FakeElement) => Record; frameElement: FakeElement | null } = { getComputedStyle: (el: FakeElement) => ({ display: el.attrs.get('style')?.includes('display:contents') ? 'contents' : el.isHiddenInTree ? 'none' : 'block', visibility: 'visible', opacity: '1' }), frameElement: null };
+ constructor() { this.body = new FakeElement(this, 'BODY'); }
+ el(tag: string, attrs: Record = {}, ...kids: Array) {
+ return new FakeElement(this, tag, attrs).append(...kids);
+ }
+ querySelectorAll(sel: string) { return this.body.selfAndDescendants().filter((e) => e.matches(sel)); }
+ querySelector(sel: string) { return this.querySelectorAll(sel)[0] ?? null; }
+ getElementById(id: string) { return this.querySelector(`#${id}`); }
+ elementFromPoint() { return null; }
+ createTreeWalker() { throw new Error('fake-dom: tree walkers are not supported'); }
+}
+
+/** Give an