all repos — sz-chrome-remote-extension @ 0192da77e06b6ee3d0ce04e9d43ea387372281ca

pi-chrome, patched to drive a signed-in browser on another host through a broker, with an MCP server

Report hidden Pi tab in /chrome doctor (#26)

Chrome ignores clicks and typing in hidden pages. Doctor now asks the worker
whether this session's own tab is visible (automation.status includeVisibility,
which never creates or activates a tab) and reports the reason and fix when it
is hidden.
Tianren Dong tianrendong.dtr@gmail.com
Mon, 28 Sep 2026 20:18:03 -0400
commit

0192da77e06b6ee3d0ce04e9d43ea387372281ca

parent

104d7f442e7e3596781f32d8f42df39fc1c41aa0

M CHANGELOG.md → CHANGELOG.md

@@ -4,6 +4,7 @@ All notable user-facing changes to `pi-chrome`.

## Unreleased +- **`/chrome doctor` flags a hidden Pi tab.** Chrome ignores clicks and typing in hidden pages. Doctor now checks this session's own tab and says whether it is visible. If it's hidden, doctor gives the reason (inactive tab, minimized window, covered by another window) and the fix, instead of the first click or type failing. It never creates or activates a tab to check. - **Pi keeps track of its tabs across extension reloads.** Ownership of Pi's automation window and session tabs lived only in `chrome.storage.session`, which Chrome wipes when the extension reloads. The extension reloads itself on every pi-chrome update, so afterwards Pi could not close or reuse the windows and tabs it had opened, and they were left behind. Ownership is now also kept in `chrome.storage.local` and restored after a reload. A restored tab is trusted only if it still exists in the window it was recorded in (adopted tabs must also still be in Pi's group). The copy is dropped on browser startup, since tab ids do not carry over between browser runs. ## 0.15.56 — 2026-09-28
M extensions/chrome-profile-bridge/browser-extension/service_worker.js → extensions/chrome-profile-bridge/browser-extension/service_worker.js

@@ -1985,7 +1985,24 @@ case "automation.status": {

// Report this session's owned automation target (ids only). Used for diagnostics/tests. await hydrateAutomationTargets(); const t = automationTargets.get(sessionKeyOf(params)); - return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null }; + const status = { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null }; + if (!params.includeVisibility || !t) return status; + // For /chrome doctor: is this session's own tab visible? Never creates or activates anything. + const tab = await chrome.tabs.get(t.tabId).catch(() => null); + if (!tab) return { ...status, tabId: null }; + const results = await executeScriptWithFallback({ + target: { tabId: tab.id, frameIds: [0] }, + world: "MAIN", + func: () => document.visibilityState, + }, `read visibility of tab ${tab.id}`).catch(() => null); + const visibility = typeof results?.[0]?.result === "string" ? results[0].result : "unknown"; + const win = typeof chrome.windows?.get === "function" ? await chrome.windows.get(tab.windowId).catch(() => null) : null; + return { + ...status, + visibility, + url: tab.url, + hiddenReason: visibility === "hidden" ? hiddenTabReason(tab, win) : undefined, + }; } case "automation.cleanup": // Close recorded creations, and only ungroup user tabs still in their adopted group.
M extensions/chrome-profile-bridge/index.ts → extensions/chrome-profile-bridge/index.ts

@@ -1219,6 +1219,25 @@ if (probe && probe.webdriver) lines.push(`⚠ Your Chrome is reporting itself as automated to websites. Some sites use this signal to block sign-ins or bot checks.`);

} catch (error) { lines.push(`⚠ Couldn't inspect the active tab: ${(error as Error).message}`); } + + // Pi's own tab for this session: hidden pages ignore clicks and typing, so say so up front. + try { + const own = (await bridge.send("automation.status", { sessionKey: sessionKeyFor(ctx), includeVisibility: true }, 10_000)) as + | { tabId?: number | null; visibility?: string; hiddenReason?: string } + | undefined; + if (own?.tabId === null || own?.tabId === undefined) { + lines.push(`• Pi hasn't opened its own tab in this session yet.`); + } else if (own.visibility === "hidden") { + lines.push( + `⚠ Pi's tab is hidden because ${own.hiddenReason ?? "its window is not visible"}. Chrome ignores clicks and typing in hidden tabs, so those tools will fail until it's visible.`, + ` Fix: bring Pi's window forward or uncover it, or run /chrome background off to let Pi bring it forward.`, + ); + } else if (own.visibility === "visible") { + lines.push(`✓ Pi's tab is visible, so clicks and typing will reach it.`); + } + } catch { + // Older extensions or a closed tab: nothing useful to add. + } } else if (versionMismatch) { lines.push(`… Skipped the remaining checks until you reload the Chrome extension.`); }
M test-suite/unit/chrome-command.test.mjs → test-suite/unit/chrome-command.test.mjs

@@ -24,6 +24,7 @@ switch (action) {

case "tab.version": return { extensionVersion: version }; case "page.evaluate": return 2; case "page.probe": return { arithmetic: 2, location: "https://fixture.test/", webdriver: false }; + case "automation.status": return { windowId: 5, tabId: 6, visibility: "visible" }; default: throw new Error(`Unexpected bridge action: ${action}`); } }

@@ -44,6 +45,7 @@ const sandbox = {

Date: { now: () => now }, PI_CHROME_VERSION: version, chromeAuthorizedUntil: until, backgroundEnabled: background, hostnameOf: (url) => new URL(url).hostname, + sessionKeyFor: () => "session:alpha", bridge: { status: () => ({ mode }), async send(action, params, timeout) {

@@ -128,8 +130,10 @@ assert.match(report, /Chrome is connected/);

assert.match(report, /can run code/); assert.match(report, /fixture\.test/); assert.deepEqual(h.calls.map(({ action, timeout }) => [action, timeout]), [ - ["tab.version", 35_000], ["page.evaluate", 10_000], ["page.probe", 10_000], + ["tab.version", 35_000], ["page.evaluate", 10_000], ["page.probe", 10_000], ["automation.status", 10_000], ]); + assert.deepEqual(h.calls.at(-1).params, { sessionKey: "session:alpha", includeVisibility: true }); + assert.match(report, /Pi's tab is visible/); assert.ok(h.calls.filter((call) => call.action.startsWith("page.")).every((call) => call.params.foreground === false)); assert.equal(h.sandbox.chromeAuthorizedUntil, until, "diagnostics do not grant or change authorization"); assert.equal(h.sandbox.backgroundEnabled, background);

@@ -157,7 +161,29 @@

test("choosing Doctor explicitly from the dashboard runs full diagnostics", async () => { const h = harness({ choices: ["Doctor / troubleshoot"] }); await h.run(); - assert.deepEqual(h.calls.map((call) => call.action), ["tab.version", "tab.version", "page.evaluate", "page.probe"]); + assert.deepEqual(h.calls.map((call) => call.action), ["tab.version", "tab.version", "page.evaluate", "page.probe", "automation.status"]); assert.match(h.notices.at(-1)[0], /Authorization: locked/); assert.match(h.notices.at(-1)[0], /Background: on \(hard\)/); }); + +test("Doctor warns when this session's Pi tab is hidden, and stays quiet when it cannot tell", async () => { + const withStatus = (status) => (action, ...rest) => action === "automation.status" + ? (typeof status === "function" ? status() : status) + : healthyResponse(action, ...rest); + const hidden = harness({ send: withStatus({ tabId: 6, visibility: "hidden", hiddenReason: "its window is covered by another window" }) }); + await hidden.run("doctor"); + const report = hidden.notices.at(-1)[0]; + assert.match(report, /⚠ Pi's tab is hidden because its window is covered by another window/); + assert.match(report, /Chrome ignores clicks and typing in hidden tabs/); + assert.match(report, /\/chrome background off/); + + const none = harness({ send: withStatus({ windowId: null, tabId: null }) }); + await none.run("doctor"); + assert.match(none.notices.at(-1)[0], /hasn't opened its own tab in this session yet/); + + for (const status of [{ windowId: 5, tabId: 6 }, () => { throw new Error("old extension"); }]) { + const quiet = harness({ send: withStatus(status) }); + await quiet.run("doctor"); + assert.doesNotMatch(quiet.notices.at(-1)[0], /Pi's tab|own tab/); + } +});
M test-suite/unit/hidden-input.test.mjs → test-suite/unit/hidden-input.test.mjs

@@ -277,3 +277,23 @@ assert.ok(fs.existsSync(path.join(dir, "mine.png")));

assert.equal(await prune.pruneScreenshotDir(path.join(dir, "missing")), 0); fs.rmSync(dir, { recursive: true, force: true }); }); + +// ---- /chrome doctor visibility report ---- +test("automation.status reports whether this session's own tab is visible, without creating one", async () => { + const h = worker({ tab: { id: 2, windowId: 1, active: false, url: "https://fixture.test/" }, win: { id: 1, state: "normal" } }); + h.chrome.scripting.executeScript = async () => [{ result: "hidden" }]; + const none = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true }); + assert.deepEqual(clone(none), { windowId: null, tabId: null }); + assert.equal(h.calls.some((c) => c.method === "tabs.create" || c.method === "windows.create"), false); + + vm.runInContext("automationTargets.set('s', { tabId: 2, windowId: undefined })", h.w); + const hidden = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true }); + assert.equal(hidden.visibility, "hidden"); + assert.match(hidden.hiddenReason, /inactive tab/); + + h.chrome.scripting.executeScript = async () => [{ result: "visible" }]; + const visible = await h.w.dispatch("automation.status", { sessionKey: "s", includeVisibility: true }); + assert.equal(visible.visibility, "visible"); + assert.equal(visible.hiddenReason, undefined); + assert.deepEqual(clone(await h.w.dispatch("automation.status", { sessionKey: "s" })), { windowId: null, tabId: 2 }, "plain status is unchanged"); +});