aboutsummaryrefslogtreecommitdiff
path: root/extensions
diff options
context:
space:
mode:
authorTianren Dong <tianrendong.dtr@gmail.com>2026-05-31 13:59:42 -0400
committerTianren Dong <tianrendong.dtr@gmail.com>2026-05-31 13:59:42 -0400
commit0fc5b8950601a36dfffdc8f7163bb12fb94f353e (patch)
tree8bbd4992530c2fb8de6db032c79964c73c9c1fa0 /extensions
parentea6c25d1ace29ba5c4d01b02ba33f130d2f6f4ff (diff)
0.15.28: bridge self-heal, timeout diagnostics, pressKeyInPage helper, DOMRect serialization, stale-target errors, pageMutated wording
Diffstat (limited to 'extensions')
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/manifest.json2
-rw-r--r--extensions/chrome-profile-bridge/browser-extension/service_worker.js29
-rw-r--r--extensions/chrome-profile-bridge/index.ts86
3 files changed, 101 insertions, 16 deletions
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(