diff options
| author | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-31 13:59:42 -0400 |
|---|---|---|
| committer | Tianren Dong <tianrendong.dtr@gmail.com> | 2026-05-31 13:59:42 -0400 |
| commit | 0fc5b8950601a36dfffdc8f7163bb12fb94f353e (patch) | |
| tree | 8bbd4992530c2fb8de6db032c79964c73c9c1fa0 | |
| parent | ea6c25d1ace29ba5c4d01b02ba33f130d2f6f4ff (diff) | |
0.15.28: bridge self-heal, timeout diagnostics, pressKeyInPage helper, DOMRect serialization, stale-target errors, pageMutated wording
| -rw-r--r-- | CHANGELOG.md | 11 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/manifest.json | 2 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/browser-extension/service_worker.js | 29 | ||||
| -rw-r--r-- | extensions/chrome-profile-bridge/index.ts | 86 | ||||
| -rw-r--r-- | package.json | 2 |
5 files changed, 113 insertions, 17 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a7c2e..47240e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable user-facing changes to `pi-chrome`. +## 0.15.28 — 2026-05-31 + +Low-risk reliability fixes from a long-session bug report. + +- **Bridge self-heals when the owning session dies.** A Pi session running in shared-client mode used to fail every `chrome_*` call with a bare `fetch failed` once the session that owned `127.0.0.1:17318` exited. The client now detects the unreachable owner, takes over the bridge port, and re-runs the command locally instead of staying stuck. +- **Actionable timeout messages.** A 30s timeout now says *why*: extension not polling (not installed/closed), polling but didn't pick up the command, or picked it up but never returned a result (long-running action / failed result post) — instead of one generic message. +- **`chrome_type` / `chrome_fill` DOM path no longer throws `pressKeyInPage is not defined`.** The helper is now included in the injected MAIN-world helper set and its callers await it. +- **`getBoundingClientRect()` / DOMRect now serialize in `chrome_evaluate`.** DOMRect-like values return `{x,y,width,height,top,right,bottom,left}` instead of `{}`. +- **Clearer stale-target errors.** A missing `targetId` now lists the current tabs and suggests re-targeting via `chrome_tab list` or `urlIncludes`/`titleIncludes`, instead of a bare "No matching Chrome tab found". +- **`pageMutated=false` no longer reads as failure.** Click/type/fill summaries explain it's a coarse heuristic that can miss real effects, and suggest verifying with `includeSnapshot`. + ## 0.15.26 — 2026-05-16 - **Documentation accuracy.** README, FAQ, examples, comparison, and test-suite docs now describe the 41-challenge suite, gate buckets, strict-CSP fallback, and current human-vs-extension limitations. diff --git a/extensions/chrome-profile-bridge/browser-extension/manifest.json b/extensions/chrome-profile-bridge/browser-extension/manifest.json index 00b2b90..b6ef0fc 100644 --- a/extensions/chrome-profile-bridge/browser-extension/manifest.json +++ b/extensions/chrome-profile-bridge/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "Pi Chrome Connector", - "version": "0.15.27", + "version": "0.15.28", "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.", "permissions": [ "tabs", diff --git a/extensions/chrome-profile-bridge/browser-extension/service_worker.js b/extensions/chrome-profile-bridge/browser-extension/service_worker.js index afa962b..f269afa 100644 --- a/extensions/chrome-profile-bridge/browser-extension/service_worker.js +++ b/extensions/chrome-profile-bridge/browser-extension/service_worker.js @@ -787,6 +787,20 @@ async function getTabByParams(params) { if (params.targetId !== undefined) { const id = Number(params.targetId); tab = tabs.find((candidate) => candidate.id === id); + if (!tab?.id) { + // Chrome tab ids are not stable across reloads/navigations; a long session can hold a + // stale id. Surface the current tabs so the caller can re-target instead of guessing. + const listed = tabs + .filter((candidate) => candidate.id !== undefined) + .slice(0, 20) + .map((candidate) => ` ${candidate.id}${candidate.active ? " *" : ""}\t${(candidate.title || "(untitled)").slice(0, 60)}\t${candidate.url || ""}`) + .join("\n"); + throw new Error( + `No Chrome tab with id ${id} (it was likely closed or replaced). ` + + `Re-target with chrome_tab list, or pass urlIncludes/titleIncludes instead of targetId.\n` + + `Current tabs:\n${listed || " (none)"}`, + ); + } } else if (params.urlIncludes) { tab = tabs.find((candidate) => (candidate.url || "").includes(params.urlIncludes)); } else if (params.titleIncludes) { @@ -826,6 +840,7 @@ const HELPER_FUNCS = [ printableKeyCode, dispatchKeyEvent, typeCharacter, + pressKeyInPage, scrollPage, ]; @@ -882,6 +897,14 @@ async function evaluateInTab(params) { if (typeof v === "symbol") return { kind: "symbol", description: v.description }; if (typeof v === "bigint") return { kind: "bigint", value: v.toString() }; if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack }; + // DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable + // properties, so JSON.stringify yields `{}`. Expand the fields explicitly. + if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) || + (typeof DOMRect !== "undefined" && v instanceof DOMRect) || + (v && typeof v === "object" && typeof v.toJSON === "function" && + typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) { + return { x: v.x, y: v.y, width: v.width, height: v.height, top: v.top, right: v.right, bottom: v.bottom, left: v.left }; + } return v; }; // Compile via the Function constructor. We try expression form first so callers can pass @@ -1927,7 +1950,7 @@ async function typeIntoPage(selector, uid, text, pressEnter) { element.focus(); if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable"); for (const ch of Array.from(text)) await typeCharacter(element, ch); - if (pressEnter) pressKeyInPage("Enter"); + if (pressEnter) await pressKeyInPage("Enter"); const finalValue = "value" in element ? element.value : element.textContent; const valueMatches = "value" in element ? element.value.includes(text) : (element.textContent || "").includes(text); const pageMutated = pageHash() !== before; @@ -1947,7 +1970,7 @@ async function typeIntoPage(selector, uid, text, pressEnter) { }; } -function fillPage(selector, uid, text, submit) { +async function fillPage(selector, uid, text, submit) { installPiChromeInstrumentation(); const before = pageHash(); let element = elementBySelectorOrUid(selector, uid) || document.activeElement; @@ -1964,7 +1987,7 @@ function fillPage(selector, uid, text, submit) { } else { throw new Error("Focused element is not text-editable"); } - if (submit) pressKeyInPage("Enter"); + if (submit) await pressKeyInPage("Enter"); return { selector, uid, length: String(text).length, submit, input: "dom", diff --git a/extensions/chrome-profile-bridge/index.ts b/extensions/chrome-profile-bridge/index.ts index 77ae767..04a1810 100644 --- a/extensions/chrome-profile-bridge/index.ts +++ b/extensions/chrome-profile-bridge/index.ts @@ -35,6 +35,7 @@ type PendingCommand = { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: NodeJS.Timeout; + deliveredAt?: number; }; type BridgeResult = { @@ -103,7 +104,11 @@ function summarizeActionResult(result: unknown): string | undefined { if (!result || typeof result !== "object") return undefined; const r = result as Record<string, unknown>; const parts: string[] = []; - if (r.pageMutated === false) parts.push("pageMutated=false"); + // NOTE: pageMutated is a coarse heuristic (a hash over body text + input values + node count). + // Many real effects — class/aria/data-state toggles, JS-held state, canvas, async updates — + // don't move it, so a false value is NOT proof the action did nothing. Surface it only as a + // soft hint, and never present it as a failure on its own. + if (r.pageMutated === false) parts.push("no coarse DOM change detected (may still have taken effect — verify with includeSnapshot)"); if (r.defaultPrevented === true) parts.push("defaultPrevented=true"); if (r.elementVisible === false) parts.push("element NOT visible"); if (r.occludedBy) { @@ -195,23 +200,29 @@ class ChromeProfileBridge { async start(): Promise<void> { if (this.server || this.mode === "client") return; - this.server = createServer((request, response) => { + await this.bindServerOrClient(); + } + + // Try to own the bridge port. On success we are the server; on EADDRINUSE another Pi + // session owns it and we run as a client that forwards commands to that owner. + private async bindServerOrClient(): Promise<void> { + const server = createServer((request, response) => { void this.handle(request, response).catch((error) => { sendJson(response, 500, { error: (error as Error).message }); }); }); try { await new Promise<void>((resolveStart, rejectStart) => { - this.server!.once("error", rejectStart); - this.server!.listen(this.port, this.host, () => { - this.server!.off("error", rejectStart); + server.once("error", rejectStart); + server.listen(this.port, this.host, () => { + server.off("error", rejectStart); resolveStart(); }); }); + this.server = server; this.mode = "server"; } catch (error) { - this.server.close(); - this.server = undefined; + server.close(); if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error; // Another Pi session already owns the bridge port. Use it as the shared // machine-local broker so multiple Pi sessions can control Chrome at once. @@ -219,6 +230,16 @@ class ChromeProfileBridge { } } + // Client-mode self-heal: when the owning Pi session disappears, fetches to its port fail + // with `fetch failed` / ECONNREFUSED forever. Try to grab the now-free port and become the + // server ourselves so chrome_* tools recover without a manual restart. + private async tryPromoteToServer(): Promise<boolean> { + if (this.mode !== "client") return this.mode === "server"; + this.mode = undefined; + await this.bindServerOrClient(); + return this.mode === "server"; + } + stop(): void { if (this.mode === "client") { this.mode = undefined; @@ -261,14 +282,11 @@ class ChromeProfileBridge { rejectCommand(new Error("Chrome command aborted")); }; const timer = setTimeout(() => { + const entry = this.pending.get(id); this.pending.delete(id); this.queue = this.queue.filter((queued) => queued.id !== id); cleanupAbort(); - rejectCommand( - new Error( - `Timed out waiting for Chrome extension after ${timeoutMs}ms. Run /chrome onboard, then load the bundled browser-extension folder in your normal Chrome profile.`, - ), - ); + rejectCommand(new Error(this.timeoutMessage(entry, timeoutMs))); }, timeoutMs); this.pending.set(id, { command, @@ -281,6 +299,21 @@ class ChromeProfileBridge { }); } + // Classify why a local command timed out so the agent isn't left guessing. The three + // distinct failure modes are: extension never polled (not installed / not running), + // extension polled but never picked up this command, and extension picked up the command + // but never posted a result back (long-running action or a failed /result post). + private timeoutMessage(entry: PendingCommand | undefined, timeoutMs: number): string { + const pollAgeMs = this.lastSeenAt === undefined ? undefined : Date.now() - this.lastSeenAt; + if (entry?.deliveredAt) { + return `Timed out after ${timeoutMs}ms: the Chrome extension received the command but never returned a result. The action may be long-running, or the result post failed. Run /chrome doctor; if it persists, reload 'Pi Chrome Connector' at chrome://extensions.`; + } + if (pollAgeMs === undefined || pollAgeMs > 60_000) { + return `Timed out after ${timeoutMs}ms: the Chrome extension is not polling (last seen ${pollAgeMs === undefined ? "never" : Math.round(pollAgeMs / 1000) + "s ago"}). Run /chrome onboard, then load the bundled browser-extension folder in your normal Chrome profile and keep that Chrome window open.`; + } + return `Timed out after ${timeoutMs}ms: the Chrome extension is polling (last seen ${Math.round(pollAgeMs / 1000)}s ago) but did not pick up this command in time. Retry; if it persists, reload 'Pi Chrome Connector' at chrome://extensions.`; + } + private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<unknown> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs + 2_000); @@ -309,6 +342,16 @@ class ChromeProfileBridge { if (signal?.aborted) throw new Error("Chrome command aborted"); throw new Error(`Timed out waiting for shared Chrome bridge owner after ${timeoutMs}ms`); } + // `fetch failed` / ECONNREFUSED means the Pi session that owned the bridge port is gone. + // Try to take over the port ourselves and re-run the command locally instead of staying + // stuck as a client pointed at a dead owner. + if (this.isOwnerUnreachable(error)) { + const promoted = await this.tryPromoteToServer().catch(() => false); + if (promoted) return this.sendLocal(action, params, timeoutMs, signal); + throw new Error( + "The Pi session that owned the Chrome bridge is unreachable and this session could not take over the bridge port. Restart this Pi session, or run /chrome doctor.", + ); + } throw error; } finally { clearTimeout(timer); @@ -316,6 +359,19 @@ class ChromeProfileBridge { } } + private isOwnerUnreachable(error: unknown): boolean { + const message = (error as Error)?.message ?? ""; + const code = (error as NodeJS.ErrnoException)?.code ?? ""; + const cause = (error as { cause?: NodeJS.ErrnoException })?.cause; + const causeCode = cause?.code ?? ""; + return ( + /fetch failed|ECONNREFUSED|ECONNRESET|other side closed|socket hang up/i.test(message) || + code === "ECONNREFUSED" || + causeCode === "ECONNREFUSED" || + causeCode === "ECONNRESET" + ); + } + private enqueue(command: BridgeCommand): void { const waiter = this.waiters.shift(); if (waiter) waiter(command); @@ -384,6 +440,12 @@ class ChromeProfileBridge { if (command) this.queue.unshift(command); return; } + // Mark the command as delivered so a later timeout can distinguish "extension never + // picked it up" from "extension is running it / failed to post a result". + if (command) { + const entry = this.pending.get(command.id); + if (entry) entry.deliveredAt = Date.now(); + } // Re-read version on every /next so bumping package.json takes effect without pi restart. const currentVersion = readPiChromeVersion(); sendJson( diff --git a/package.json b/package.json index ccbc823..16f76f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pi-chrome", - "version": "0.15.27", + "version": "0.15.28", "scripts": { "version": "node scripts/sync-manifest-version.js", "prepublishOnly": "node scripts/sync-manifest-version.js" |
