aboutsummaryrefslogtreecommitdiff
path: root/extensions
diff options
context:
space:
mode:
Diffstat (limited to 'extensions')
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js210
-rw-r--r--extensions/chrome-profile-bridge/index.ts43
2 files changed, 211 insertions, 42 deletions
diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
index 1f178e4..d47b385 100644
--- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js
+++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js
@@ -228,9 +228,10 @@ function isPiChromeOwnedTarget(tabId, sessionKey) {
// Create a fresh automation target for `sessionKey`. If this session already has a tab group,
// create the tab inside that group's window so one Pi session keeps one Chrome tab group (Chrome
-// groups cannot span windows). If no group exists yet, prefer an isolated window; fall back to a
-// tab. When the tab is created in a pre-existing group window, leave windowId unset so cleanup only
-// closes our tab, never that whole window.
+// groups cannot span windows). If it has none, give the session its own window: a window shows
+// only one tab, so sessions sharing a window would hide each other's tabs, and a hidden tab gets no
+// real input and blank screenshots (measured 2026-11-08). Fall back to a tab. When the tab is
+// created in a pre-existing window, leave windowId unset so cleanup only closes our tab.
async function createAutomationTarget(sessionKey, groupTitle) {
const existingGroup = groupTitle ? await findGroupRecordByTitle(groupTitle) : null;
if (existingGroup && typeof existingGroup.windowId === "number") {
@@ -430,6 +431,7 @@ async function attachDebugger(tabId) {
if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry3");
if (err) {
recordAttachEvent({ kind: "attach-retry2-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
+ if (FOREIGN_EXTENSION_BLOCK.test(String(err.message || err))) throw await overlayBlockedError(tabId);
const meta = await describeInputTarget(tabId);
throw new Error(`Chrome debugger attach failed for tab ${tabId}: ${String(err.message || err)}${targetMetaSuffix(meta)}`);
}
@@ -439,6 +441,8 @@ async function attachDebugger(tabId) {
// Seed pointer in a plausible "just left the address bar" location.
const entry = { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 }, debuggee: attachedDebuggee || { tabId } };
attachedTabs.set(tabId, entry);
+ // Page events are what tell us a page opened a dialog; without them nobody answers it.
+ await cdpRaw(tabId, "Page.enable", {}).catch(() => undefined);
return entry;
}
@@ -656,6 +660,12 @@ async function findForeignExtensionTargets() {
}
}
+// Other extensions' targets that belong to this tab: by the target's tabId, or by the tabId that
+// password managers put in the frame's own URL (the frame target can come without a tabId).
+function foreignTargetsForTab(all, tabId) {
+ return all.filter((t) => t.tabId === tabId || new RegExp(`[?&]tabId=${tabId}(?:[&#]|$)`).test(String(t.url || "")));
+}
+
function extractForeignExtId(targets) {
for (const t of targets) {
if (t.extensionId && t.extensionId !== chrome.runtime.id) return t.extensionId;
@@ -665,14 +675,90 @@ function extractForeignExtId(targets) {
return null;
}
-async function dismissOverlayViaEscape(tabId) {
- // Esc routes through key dispatcher (target-by-focus), not by mouse coordinates, so it
- // works even when a foreign chrome-extension popup is intercepting pointer events.
+const FOREIGN_EXTENSION_BLOCK = /Cannot access a chrome-extension:\/\/ URL of different extension/i;
+
+// Another extension's frame on the page (a password manager's inline prompt, an autofill menu)
+// makes Chrome refuse the debugger on that tab. Nothing is closed automatically: the user may be
+// using the prompt (signing in, approving something for the agent). The error gives the agent the
+// facts and lets it decide, with overlay.clear (chrome_clear_overlay) to close it when it is only
+// in the way.
+async function overlayBlockedError(tabId) {
+ const targets = foreignTargetsForTab(await findForeignExtensionTargets(), tabId);
+ const url = String(targets[0]?.url || "").replace(/[?#].*$/, "");
+ const id = extractForeignExtId(targets) || "unknown";
+ return new Error(
+ `Another extension (${id}${url ? `, ${url}` : ""}) has a prompt open on this tab, probably a password manager such as 1Password, and Chrome will not let the debugger work on the tab while it is there. ` +
+ `If it is only in your way, call chrome_clear_overlay for this tab. If you are waiting for the user to sign in or approve something with it, leave it alone and tell the user. Navigating the tab away also frees it.`,
+ );
+}
+
+// Injected into the page by overlay.clear, in the extension's isolated world: only there can
+// chrome.dom.openOrClosedShadowRoot read a closed shadow root, and password managers put their
+// frame in one (1Password: a <com-1password-uso> element with the frame inside; there is no iframe
+// in the page's own DOM). Removes every element whose shadow root holds another extension's
+// iframe, plus loose foreign iframes, then blurs the focused field and sends Escape.
+function clearForeignOverlays(ownId) {
+ const removed = [];
+ const isForeignFrame = (el) => el.tagName === "IFRAME" && /^chrome-extension:\/\//.test(el.src) && !el.src.startsWith(`chrome-extension://${ownId}/`);
+ const rootOf = (el) => {
+ try {
+ return (typeof chrome !== "undefined" && chrome.dom && chrome.dom.openOrClosedShadowRoot ? chrome.dom.openOrClosedShadowRoot(el) : null) || el.shadowRoot;
+ } catch {
+ return el.shadowRoot;
+ }
+ };
+ // Returns the foreign frame URLs found inside `root`, looking through nested shadow roots.
+ const framesIn = (root) => {
+ const found = [];
+ for (const el of Array.from(root.querySelectorAll("*"))) {
+ if (isForeignFrame(el)) found.push(el.src.replace(/[?#].*$/, ""));
+ const inner = rootOf(el);
+ if (inner) found.push(...framesIn(inner));
+ }
+ return found;
+ };
+ for (const el of Array.from(document.querySelectorAll("*"))) {
+ if (!el.isConnected) continue;
+ if (isForeignFrame(el)) {
+ removed.push(el.src.replace(/[?#].*$/, ""));
+ el.remove();
+ continue;
+ }
+ const root = rootOf(el);
+ const frames = root ? framesIn(root) : [];
+ if (frames.length > 0) {
+ removed.push(`<${el.tagName.toLowerCase()}> holding ${frames.join(", ")}`);
+ el.remove();
+ }
+ }
+ const active = document.activeElement;
+ if (active && active !== document.body && typeof active.blur === "function") active.blur();
+ document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true }));
+ const custom = Array.from(document.querySelectorAll("*")).filter((el) => el.tagName.includes("-")).map((el) => el.tagName.toLowerCase());
+ return { removed, seen: { iframes: document.querySelectorAll("iframe").length, customElements: [...new Set(custom)].slice(0, 10), active: document.activeElement?.tagName ?? null } };
+}
+
+async function clearOverlay(params) {
+ const tab = await getTabByParams(params, { createOwnedTarget: false });
+ let injected = { removed: [] };
try {
- await cdpRaw(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
- await cdpRaw(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
- await sleep(120);
- } catch {}
+ const [first] = await executeScriptTimed({ target: { tabId: tab.id, frameIds: [0] }, func: clearForeignOverlays, args: [chrome.runtime.id] }, "chrome_clear_overlay");
+ injected = first?.result ?? injected;
+ } catch (error) {
+ throw new Error(`Could not run the clean-up on tab ${tab.id}: ${error?.message || error}`);
+ }
+ // The prompt takes a moment to go: try the debugger for up to about a second.
+ let debuggerAttachable = false;
+ for (let attempt = 0; attempt < 6 && !debuggerAttachable; attempt++) {
+ await sleep(attempt === 0 ? 150 : 200);
+ try {
+ await attachDebugger(tab.id);
+ debuggerAttachable = true;
+ } catch {}
+ }
+ const allForeign = await findForeignExtensionTargets();
+ const remaining = foreignTargetsForTab(allForeign, tab.id).map((t) => String(t.url || "").replace(/[?#].*$/, ""));
+ return { tab: { id: tab.id, url: tab.url, title: tab.title }, removedFrames: injected.removed, remainingForeignTargets: remaining, foreignTargetsAnywhere: allForeign.map((t) => ({ type: t.type, tabId: t.tabId, url: String(t.url || "").replace(/[?#].*$/, "") })).slice(0, 20), injectedReport: injected, debuggerAttachable, cleared: debuggerAttachable && remaining.length === 0 };
}
async function cdp(tabId, method, params, opts) {
@@ -681,28 +767,8 @@ async function cdp(tabId, method, params, opts) {
} catch (error) {
const msg = String(error?.message || error);
const isStale = /Debugger is not attached|Detached while|Target closed|No tab with id/i.test(msg);
- const isForeignExtBlock = /Cannot access a chrome-extension:\/\/ URL of different extension/i.test(msg);
- if (isForeignExtBlock && /Input\./.test(method)) {
- // Foreign chrome-extension popup (autofill, password manager) is hijacking input.
- // Try once: dismiss via Esc, then retry.
- const before = await findForeignExtensionTargets();
- recordAttachEvent({ kind: "foreign-ext-detected", tabId, method, foreignExtId: extractForeignExtId(before), targetCount: before.length });
- await dismissOverlayViaEscape(tabId);
- try {
- return await cdpRaw(tabId, method, params, opts);
- } catch (retryErr) {
- const retryMsg = String(retryErr?.message || retryErr);
- if (/Cannot access a chrome-extension:\/\/ URL of different extension/i.test(retryMsg)) {
- const after = await findForeignExtensionTargets();
- const id = extractForeignExtId(after) || extractForeignExtId(before) || "unknown";
- throw new Error(
- `Another Chrome extension (${id}) has an input overlay on this page (e.g. a password manager / autofill popup). \n` +
- `pi-chrome tried to dismiss it with Escape but it reappeared. Disable that extension on this page, close its popup, or focus the field via Tab instead of clicking.`,
- );
- }
- throw retryErr;
- }
- }
+ const isForeignExtBlock = FOREIGN_EXTENSION_BLOCK.test(msg);
+ if (isForeignExtBlock) throw await overlayBlockedError(tabId);
if (!isStale) throw error;
attachedTabs.delete(tabId);
await attachDebugger(tabId).catch(() => undefined);
@@ -1402,7 +1468,35 @@ function fileChooserTabIdForSource(source) {
return undefined;
}
+// Dialogs a page opens (alert, confirm, prompt, and the "Leave site?" prompt of a beforeunload
+// handler) block the tab until someone answers, and the agent cannot see them. We answer them:
+// "stay"/cancel, unless page.navigate was told leaveIfAsked and it is a beforeunload prompt.
+// page.navigate waits on dialogWaiters to report a dismissed one instead of timing out.
+const dialogPolicy = new Map(); // tabId -> { leave: boolean }, only while page.navigate runs
+const dialogWaiters = new Map(); // tabId -> resolve(dialog), only while page.navigate runs
+
+function describeDialog(dialog) {
+ if (dialog.type === "beforeunload") return 'a "Leave site? Changes you made may not be saved" prompt (the page has a beforeunload handler)';
+ return `a JavaScript ${dialog.type} dialog saying "${dialog.message}"`;
+}
+
+async function handleDialogOpening(tabId, params) {
+ const leave = params?.type === "beforeunload" && dialogPolicy.get(tabId)?.leave === true;
+ const dialog = { type: params?.type, message: params?.message ?? "", url: params?.url, accepted: leave };
+ try {
+ await cdp(tabId, "Page.handleJavaScriptDialog", { accept: leave });
+ } catch (error) {
+ console.warn(`[pi-chrome] could not answer a ${dialog.type} dialog on tab ${tabId}: ${error?.message || error}`);
+ }
+ if (!leave) dialogWaiters.get(tabId)?.(dialog);
+}
+
function handleDebuggerEvent(source, method, params) {
+ if (method === "Page.javascriptDialogOpening") {
+ const dialogTabId = fileChooserTabIdForSource(source);
+ if (dialogTabId !== undefined) void handleDialogOpening(dialogTabId, params);
+ return;
+ }
if (method !== "Page.fileChooserOpened") return;
const tabId = fileChooserTabIdForSource(source);
const entry = tabId === undefined ? undefined : pendingFileChoosers.get(tabId);
@@ -1795,10 +1889,20 @@ async function dispatch(action, params) {
const existingGroup = await findGroupRecordByTitle(groupTitle);
const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) };
if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
+ // No group of this session yet: use the window of its automation tab if it has one, else open
+ // the tab in a window of its own. Never the user's focused window, where Chrome would put it.
+ let ownWindow = false;
+ if (createParams.windowId === undefined) {
+ const home = await resolveOwnedAutomationTarget(sessionKeyOf(params)).catch(() => null);
+ if (home && typeof home.windowId === "number") createParams.windowId = home.windowId;
+ else ownWindow = typeof chrome.windows?.create === "function";
+ }
// Register the creation as in flight until the tab is tracked, so early capture (which only
// runs in tracked tabs) waits for it instead of skipping the new tab's first page load.
const creation = (async () => {
- const created = await chrome.tabs.create(createParams);
+ const created = ownWindow
+ ? (await chrome.windows.create({ url: createParams.url, focused: false })).tabs[0]
+ : await chrome.tabs.create(createParams);
await trackSessionTab(sessionKeyOf(params), created.id, true);
return created;
})();
@@ -1922,17 +2026,49 @@ async function dispatch(action, params) {
// Register a one-shot document_start content script. We register, navigate, wait, then unregister.
await registerInitScript(tab.id, params.initScript);
}
- const wait = params.waitUntilLoad !== false ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined);
- const updated = await chrome.tabs.update(tab.id, { url: params.url });
+ // Attach first: the debugger is how we hear about, and answer, a "Leave site?" prompt.
+ // Best effort: another extension's frame on the page (a password manager's inline prompt)
+ // makes Chrome refuse the attach, and navigating away is often how a stuck tab is freed.
+ // Without the debugger we cannot answer a Leave-site prompt, but the navigation still runs.
try {
- await wait;
+ await attachDebugger(tab.id);
+ await cdp(tab.id, "Page.enable", {}).catch(() => undefined);
+ } catch (error) {
+ console.warn(`[pi-chrome] navigating tab ${tab.id} without the debugger: ${error?.message || error}`);
+ }
+ dialogPolicy.set(tab.id, { leave: params.leaveIfAsked === true });
+ const dialogDismissed = new Promise((resolve) => dialogWaiters.set(tab.id, resolve));
+ const waitsForLoad = params.waitUntilLoad !== false;
+ const wait = waitsForLoad ? waitForTabComplete(tab.id, params.timeoutMs || 15000) : Promise.resolve(undefined);
+ wait.catch(() => undefined); // if the dialog wins the race, a later load timeout is not an error
+ try {
+ const updated = await chrome.tabs.update(tab.id, { url: params.url });
+ // Without waiting for the load, still give a prompt a moment to show up.
+ const outcome = await Promise.race([
+ wait.then(() => ({ loaded: true })),
+ dialogDismissed.then((dialog) => ({ dialog })),
+ ...(waitsForLoad ? [] : [new Promise((resolve) => setTimeout(() => resolve({ loaded: true }), 700))]),
+ ]);
+ if (outcome.dialog) {
+ throw new Error(
+ `Navigation to ${params.url} did not happen: the page at ${outcome.dialog.url || tab.url} opened ${describeDialog(outcome.dialog)}, ` +
+ `and it was dismissed, so the tab stayed where it was. ` +
+ (outcome.dialog.type === "beforeunload"
+ ? "Nothing was lost, but the page may hold unsaved changes. If you know nothing unsaved matters here (you just saved or submitted, or this is a throwaway tab), call chrome_navigate again with leaveIfAsked: true. Otherwise ask the user."
+ : "Look at the page to see what it was asking."),
+ );
+ }
+ return await formatTab(await chrome.tabs.get(updated.id));
} finally {
+ dialogPolicy.delete(tab.id);
+ dialogWaiters.delete(tab.id);
if (params.initScript) await unregisterInitScript(tab.id).catch(() => undefined);
}
- return await formatTab(await chrome.tabs.get(updated.id));
}
case "page.screenshot":
return takeScreenshot(params);
+ case "overlay.clear":
+ return clearOverlay(params);
case "cdp.targets": {
// Diagnostics: CDP targets anchored to the resolved tab, including foreign overlay targets
// (password managers, autofill, DevTools front-ends) that explain "Detached while handling
diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts
index 6156e81..525de6e 100644
--- a/extensions/chrome-profile-bridge/index.ts
+++ b/extensions/chrome-profile-bridge/index.ts
@@ -878,6 +878,7 @@ const CHROME_TOOL_NAMES = [
"chrome_upload_file",
"chrome_cdp",
"chrome_cdp_targets",
+ "chrome_clear_overlay",
] as const;
const CHROME_TOOL_NAME_SET = new Set<string>(CHROME_TOOL_NAMES);
const CHROME_TOOL_CHANGE_ENTRY = "pi-chrome-tool-change";
@@ -1204,6 +1205,12 @@ export default function (pi: ExtensionAPI): void {
// /reload continues the same session. On exit, give Chrome a bounded opportunity to
// close this session's created tabs and ungroup adopted tabs before stopping the broker.
if (event?.reason !== "reload") await cleanupAutomationTargetBestEffort();
+ // A /chrome authorize grant belongs to this session: /reload keeps it, while /new, /resume
+ // and /fork start a different session in this process, which must not inherit it. "quit"
+ // leaves it alone: a subagent quitting in this process must not drop its parent's grant.
+ if (event?.reason === "new" || event?.reason === "resume" || event?.reason === "fork") {
+ delete globalState[PI_CHROME_AUTH_KEY];
+ }
bridge.stop();
if (globalState[PI_CHROME_GLOBAL_KEY]?.token === instanceToken) {
delete globalState[PI_CHROME_GLOBAL_KEY];
@@ -1259,7 +1266,7 @@ Capability model (important):
- Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly. If the action started a navigation, the snapshot waits (up to 5s) for the new page.
- \`chrome_type\` never replaces: with a uid/selector it appends at the end of the field; without one it types at the current caret. Check its before/after report. Use \`chrome_fill\` to replace a field.
- Chrome ignores real input to hidden pages (inactive tabs, minimized windows, windows behind a macOS full-screen window). Input tools report this instead of silently doing nothing; retry with background:false only when bringing the tab forward is acceptable.
-- \`chrome_cdp\` runs any raw CDP method on a tab (emulation, cookies, PDF, accessibility tree, etc.) when no dedicated chrome_* tool fits; \`chrome_cdp_targets\` diagnoses debugger/overlay conflicts.
+- \`chrome_cdp\` runs any raw CDP method on a tab (emulation, cookies, PDF, accessibility tree, etc.) when no dedicated chrome_* tool fits; \`chrome_cdp_targets\` diagnoses debugger/overlay conflicts, and \`chrome_clear_overlay\` closes a password manager prompt that blocks a tab (unless you are waiting for the user to use it).
Usage rules:
1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying.
@@ -1269,7 +1276,10 @@ Usage rules:
5. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
6. /chrome background on (default) is a hard policy: per-call \`background=false\` cannot override it, new tabs stay inactive, and \`chrome_tab activate\` is blocked. Ask the user to run /chrome background off when they want foreground/watch mode. With background off, per-call \`background=true\` still avoids explicit focus/tab activation. Screenshots use CDP without activating background tabs; debugger failures never fall back to tab activation. Page scripts, trusted input, native prompts, and Chrome/OS behavior can still affect focus.
7. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input.
-8. Run /chrome doctor when in doubt about connectivity or capabilities.`;
+8. Run /chrome doctor when in doubt about connectivity or capabilities.
+9. A page can open dialogs you cannot see: alert, confirm, prompt, and the \"Leave site? Changes you made may not be saved\" prompt of a page with unsaved changes. They are dismissed for you (stay, cancel). \`chrome_navigate\` reports one by failing, and \`leaveIfAsked: true\` leaves anyway; use that only when you know nothing unsaved matters, and ask the user when unsure. If a click or key press that should have navigated did nothing, a Leave-site prompt may have stopped it: check the page, then ask the user before discarding anything.
+10. Your session has its own browser window, with your tabs in your own colored tab group. Only the active tab of a window is visible, and a hidden tab gets no real input and blank screenshots, so reuse your tab (navigate it) rather than opening new ones; open a second tab only when you need two pages at once, and expect only the active one to take input. Do not open new windows unless the user asks.
+11. If a tool fails saying another extension (a password manager such as 1Password) has a prompt open on the tab, decide whether it is in your way or part of the task. If it is just in your way, call \`chrome_clear_overlay\` for the tab. If you need the user to sign in, authorize or approve something with it, leave it alone and tell the user what you are waiting for. Navigating the tab away also frees it.`;
// Shared handlers, dispatched by the unified /chrome command below.
const doctorHandler = async (ctx: ExtensionContext) => {
@@ -1644,7 +1654,7 @@ Usage rules:
pi.registerTool({
name: "chrome_tab",
label: "Chrome Tab",
- description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension. New/grouped tabs always use this session's Pi tab group. Background mode keeps new tabs inactive and blocks activate; ask the user to run /chrome background off for foreground/watch mode. activate/close/group/ungroup require a target (targetId/urlIncludes/titleIncludes); with no target they act on this session's pi-chrome automation tab if one exists, and otherwise error rather than touching the user's active tab.",
+ description: "List, create, activate, close, group, ungroup, or inspect tabs in the user's existing Chrome profile via the companion extension. New/grouped tabs always use this session's Pi tab group, in the session's own window; prefer navigating a tab you already have to opening another, since only a window's active tab is visible. Background mode keeps new tabs inactive and blocks activate; ask the user to run /chrome background off for foreground/watch mode. activate/close/group/ungroup require a target (targetId/urlIncludes/titleIncludes); with no target they act on this session's pi-chrome automation tab if one exists, and otherwise error rather than touching the user's active tab.",
promptSnippet: "List/open/activate/close/group existing Chrome tabs through the companion extension.",
parameters: Type.Object({
action: StringEnum(tabActionValues),
@@ -1785,8 +1795,8 @@ Usage rules:
name: "chrome_navigate",
label: "Chrome Navigate",
description:
- "Navigate a Chrome tab to a URL via the companion extension. With no target, navigation goes to pi-chrome's own dedicated automation window/tab — it never replaces the user's active tab. Pass targetId/urlIncludes/titleIncludes only to act on a specific existing tab. Background mode (default) blocks explicit focus/tab activation even with background=false; /chrome background off allows foreground/watch mode. Optionally waits for load completion.",
- promptSnippet: "Navigate a Chrome tab in the user's existing profile.",
+ "Navigate a Chrome tab to a URL via the companion extension. With no target, navigation goes to pi-chrome's own dedicated automation window/tab — it never replaces the user's active tab. Pass targetId/urlIncludes/titleIncludes only to act on a specific existing tab. Background mode (default) blocks explicit focus/tab activation even with background=false; /chrome background off allows foreground/watch mode. Optionally waits for load completion. Some pages ask \"Leave site? Changes you made may not be saved\" before navigating away; you cannot see that prompt, so it is answered for you: the default is to stay, and the call then fails at once saying so, with the tab unchanged and nothing lost. Retry with leaveIfAsked: true only if you know nothing unsaved matters (you just saved or submitted, or the tab is a throwaway you opened); if you are not sure, ask the user, because leaving discards unsaved work for good. leaveIfAsked applies to this one call only. Any other JavaScript dialog (alert, confirm, prompt) that appears during the navigation is dismissed and reported the same way.",
+ promptSnippet: "Navigate a Chrome tab in the user's existing profile; a page's \"Leave site?\" prompt makes the call fail unless leaveIfAsked is true.",
parameters: Type.Object({
url: Type.String(),
targetId: Type.Optional(Type.String()),
@@ -1794,6 +1804,7 @@ Usage rules:
titleIncludes: Type.Optional(Type.String()),
waitUntilLoad: Type.Optional(Type.Boolean({ default: true })),
timeoutMs: Type.Optional(Type.Number({ default: 15_000 })),
+ leaveIfAsked: Type.Optional(Type.Boolean({ description: "Leave the page even if it asks \"Leave site? Changes you made may not be saved\". Default false: the navigation fails and the tab stays. Set it only when you know nothing unsaved matters; otherwise ask the user. This call only." })),
initScript: Type.Optional(Type.String({ description: "Optional JavaScript source to run in MAIN world at document_start of the next navigation. Useful for seeding localStorage, stubbing Date.now(), or defining navigator.webdriver=undefined. Requires the companion extension's webNavigation permission." })),
background: Type.Optional(Type.Boolean({ description: BACKGROUND_PARAM_DESCRIPTION })),
host: Type.Optional(Type.String()),
@@ -2272,6 +2283,28 @@ Usage rules:
});
pi.registerTool({
+ name: "chrome_clear_overlay",
+ label: "Chrome Clear Overlay",
+ description:
+ "Close another extension's prompt that is blocking a tab, usually a password manager's (1Password) inline sign-in or autofill prompt. While such a prompt is open Chrome refuses the debugger on that tab, so screenshots, clicks, typing and evaluate fail with \"Another extension ... has a prompt open on this tab\". Decide for yourself whether to use it: if the prompt is just in your way (it popped up on a field you are filling, or you do not need anything from it), clear it. If you need the user to sign in, authorize or approve something with it, or the user may be in the middle of using it, do not clear it: tell the user what you are waiting for. It removes the other extension's frame, blurs the focused field and sends Escape, then reports whether the tab is usable again. Side effect: the password manager may not show its inline prompt again on that page until the page is reloaded, so if the user then needs to sign in there, reload the page (or tell them to). It does not touch the page's own content, and it cannot close 1Password's browser-toolbar popup or the 1Password desktop app (if the result says the tab is still blocked, tell the user). Navigating the tab away also frees it.",
+ promptSnippet: "Close a password manager prompt that blocks a tab, unless you are waiting for the user to use it.",
+ parameters: Type.Object({
+ targetId: Type.Optional(Type.String()),
+ urlIncludes: Type.Optional(Type.String()),
+ titleIncludes: Type.Optional(Type.String()),
+ }),
+ async execute(_id, params, signal): Promise<ToolTextResult> {
+ const value = await authorizedBridgeSend("overlay.clear", params, DEFAULT_TIMEOUT_MS, signal);
+ const result = value as { tab?: { id?: number; url?: string }; removedFrames?: string[]; remainingForeignTargets?: string[]; debuggerAttachable?: boolean; cleared?: boolean } | undefined;
+ const removed = result?.removedFrames ?? [];
+ const text = result?.cleared
+ ? `Cleared${removed.length ? `: removed ${removed.join(", ")}` : " (nothing to remove, the prompt had already gone)"}. Tab ${result.tab?.id} is usable again. The password manager may not show its inline prompt again on this page until it is reloaded.`
+ : `The tab is still blocked${result?.remainingForeignTargets?.length ? ` by ${result.remainingForeignTargets.join(", ")}` : ""}${removed.length ? ` (removed ${removed.join(", ")} but it is still there or came back)` : ""}. The prompt may be the password manager's toolbar popup or desktop app, which this tool cannot close: tell the user, or navigate the tab away. Diagnostics: ${JSON.stringify({ foreignTargetsAnywhere: (result as any)?.foreignTargetsAnywhere, injectedReport: (result as any)?.injectedReport })}`;
+ return { content: [{ type: "text", text }], details: { value: (value ?? null) as Json } };
+ },
+ });
+
+ pi.registerTool({
name: "chrome_cdp_targets",
label: "Chrome CDP Targets",
description: