pi-chrome 0.15.26 → 0.15.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable user-facing changes to `pi-chrome`.
|
|
4
4
|
|
|
5
|
+
## 0.15.28 — 2026-05-31
|
|
6
|
+
|
|
7
|
+
Low-risk reliability fixes from a long-session bug report.
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
- **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.
|
|
11
|
+
- **`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.
|
|
12
|
+
- **`getBoundingClientRect()` / DOMRect now serialize in `chrome_evaluate`.** DOMRect-like values return `{x,y,width,height,top,right,bottom,left}` instead of `{}`.
|
|
13
|
+
- **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".
|
|
14
|
+
- **`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`.
|
|
15
|
+
|
|
5
16
|
## 0.15.26 — 2026-05-16
|
|
6
17
|
|
|
7
18
|
- **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.
|
|
@@ -787,6 +787,20 @@ async function getTabByParams(params) {
|
|
|
787
787
|
if (params.targetId !== undefined) {
|
|
788
788
|
const id = Number(params.targetId);
|
|
789
789
|
tab = tabs.find((candidate) => candidate.id === id);
|
|
790
|
+
if (!tab?.id) {
|
|
791
|
+
// Chrome tab ids are not stable across reloads/navigations; a long session can hold a
|
|
792
|
+
// stale id. Surface the current tabs so the caller can re-target instead of guessing.
|
|
793
|
+
const listed = tabs
|
|
794
|
+
.filter((candidate) => candidate.id !== undefined)
|
|
795
|
+
.slice(0, 20)
|
|
796
|
+
.map((candidate) => ` ${candidate.id}${candidate.active ? " *" : ""}\t${(candidate.title || "(untitled)").slice(0, 60)}\t${candidate.url || ""}`)
|
|
797
|
+
.join("\n");
|
|
798
|
+
throw new Error(
|
|
799
|
+
`No Chrome tab with id ${id} (it was likely closed or replaced). ` +
|
|
800
|
+
`Re-target with chrome_tab list, or pass urlIncludes/titleIncludes instead of targetId.\n` +
|
|
801
|
+
`Current tabs:\n${listed || " (none)"}`,
|
|
802
|
+
);
|
|
803
|
+
}
|
|
790
804
|
} else if (params.urlIncludes) {
|
|
791
805
|
tab = tabs.find((candidate) => (candidate.url || "").includes(params.urlIncludes));
|
|
792
806
|
} else if (params.titleIncludes) {
|
|
@@ -826,6 +840,7 @@ const HELPER_FUNCS = [
|
|
|
826
840
|
printableKeyCode,
|
|
827
841
|
dispatchKeyEvent,
|
|
828
842
|
typeCharacter,
|
|
843
|
+
pressKeyInPage,
|
|
829
844
|
scrollPage,
|
|
830
845
|
];
|
|
831
846
|
|
|
@@ -882,6 +897,14 @@ async function evaluateInTab(params) {
|
|
|
882
897
|
if (typeof v === "symbol") return { kind: "symbol", description: v.description };
|
|
883
898
|
if (typeof v === "bigint") return { kind: "bigint", value: v.toString() };
|
|
884
899
|
if (v instanceof Error) return { kind: "error", name: v.name, message: v.message, stack: v.stack };
|
|
900
|
+
// DOMRect/DOMRectReadOnly (and getBoundingClientRect results) have non-enumerable
|
|
901
|
+
// properties, so JSON.stringify yields `{}`. Expand the fields explicitly.
|
|
902
|
+
if ((typeof DOMRectReadOnly !== "undefined" && v instanceof DOMRectReadOnly) ||
|
|
903
|
+
(typeof DOMRect !== "undefined" && v instanceof DOMRect) ||
|
|
904
|
+
(v && typeof v === "object" && typeof v.toJSON === "function" &&
|
|
905
|
+
typeof v.width === "number" && typeof v.height === "number" && typeof v.top === "number")) {
|
|
906
|
+
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 };
|
|
907
|
+
}
|
|
885
908
|
return v;
|
|
886
909
|
};
|
|
887
910
|
// 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) {
|
|
|
1927
1950
|
element.focus();
|
|
1928
1951
|
if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable");
|
|
1929
1952
|
for (const ch of Array.from(text)) await typeCharacter(element, ch);
|
|
1930
|
-
if (pressEnter) pressKeyInPage("Enter");
|
|
1953
|
+
if (pressEnter) await pressKeyInPage("Enter");
|
|
1931
1954
|
const finalValue = "value" in element ? element.value : element.textContent;
|
|
1932
1955
|
const valueMatches = "value" in element ? element.value.includes(text) : (element.textContent || "").includes(text);
|
|
1933
1956
|
const pageMutated = pageHash() !== before;
|
|
@@ -1947,7 +1970,7 @@ async function typeIntoPage(selector, uid, text, pressEnter) {
|
|
|
1947
1970
|
};
|
|
1948
1971
|
}
|
|
1949
1972
|
|
|
1950
|
-
function fillPage(selector, uid, text, submit) {
|
|
1973
|
+
async function fillPage(selector, uid, text, submit) {
|
|
1951
1974
|
installPiChromeInstrumentation();
|
|
1952
1975
|
const before = pageHash();
|
|
1953
1976
|
let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
|
|
@@ -1964,7 +1987,7 @@ function fillPage(selector, uid, text, submit) {
|
|
|
1964
1987
|
} else {
|
|
1965
1988
|
throw new Error("Focused element is not text-editable");
|
|
1966
1989
|
}
|
|
1967
|
-
if (submit) pressKeyInPage("Enter");
|
|
1990
|
+
if (submit) await pressKeyInPage("Enter");
|
|
1968
1991
|
return {
|
|
1969
1992
|
selector, uid, length: String(text).length, submit,
|
|
1970
1993
|
input: "dom",
|
|
@@ -35,6 +35,7 @@ type PendingCommand = {
|
|
|
35
35
|
resolve: (value: unknown) => void;
|
|
36
36
|
reject: (error: Error) => void;
|
|
37
37
|
timer: NodeJS.Timeout;
|
|
38
|
+
deliveredAt?: number;
|
|
38
39
|
};
|
|
39
40
|
|
|
40
41
|
type BridgeResult = {
|
|
@@ -103,7 +104,11 @@ function summarizeActionResult(result: unknown): string | undefined {
|
|
|
103
104
|
if (!result || typeof result !== "object") return undefined;
|
|
104
105
|
const r = result as Record<string, unknown>;
|
|
105
106
|
const parts: string[] = [];
|
|
106
|
-
|
|
107
|
+
// NOTE: pageMutated is a coarse heuristic (a hash over body text + input values + node count).
|
|
108
|
+
// Many real effects — class/aria/data-state toggles, JS-held state, canvas, async updates —
|
|
109
|
+
// don't move it, so a false value is NOT proof the action did nothing. Surface it only as a
|
|
110
|
+
// soft hint, and never present it as a failure on its own.
|
|
111
|
+
if (r.pageMutated === false) parts.push("no coarse DOM change detected (may still have taken effect — verify with includeSnapshot)");
|
|
107
112
|
if (r.defaultPrevented === true) parts.push("defaultPrevented=true");
|
|
108
113
|
if (r.elementVisible === false) parts.push("element NOT visible");
|
|
109
114
|
if (r.occludedBy) {
|
|
@@ -195,23 +200,29 @@ class ChromeProfileBridge {
|
|
|
195
200
|
|
|
196
201
|
async start(): Promise<void> {
|
|
197
202
|
if (this.server || this.mode === "client") return;
|
|
198
|
-
this.
|
|
203
|
+
await this.bindServerOrClient();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Try to own the bridge port. On success we are the server; on EADDRINUSE another Pi
|
|
207
|
+
// session owns it and we run as a client that forwards commands to that owner.
|
|
208
|
+
private async bindServerOrClient(): Promise<void> {
|
|
209
|
+
const server = createServer((request, response) => {
|
|
199
210
|
void this.handle(request, response).catch((error) => {
|
|
200
211
|
sendJson(response, 500, { error: (error as Error).message });
|
|
201
212
|
});
|
|
202
213
|
});
|
|
203
214
|
try {
|
|
204
215
|
await new Promise<void>((resolveStart, rejectStart) => {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
216
|
+
server.once("error", rejectStart);
|
|
217
|
+
server.listen(this.port, this.host, () => {
|
|
218
|
+
server.off("error", rejectStart);
|
|
208
219
|
resolveStart();
|
|
209
220
|
});
|
|
210
221
|
});
|
|
222
|
+
this.server = server;
|
|
211
223
|
this.mode = "server";
|
|
212
224
|
} catch (error) {
|
|
213
|
-
|
|
214
|
-
this.server = undefined;
|
|
225
|
+
server.close();
|
|
215
226
|
if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE") throw error;
|
|
216
227
|
// Another Pi session already owns the bridge port. Use it as the shared
|
|
217
228
|
// machine-local broker so multiple Pi sessions can control Chrome at once.
|
|
@@ -219,6 +230,16 @@ class ChromeProfileBridge {
|
|
|
219
230
|
}
|
|
220
231
|
}
|
|
221
232
|
|
|
233
|
+
// Client-mode self-heal: when the owning Pi session disappears, fetches to its port fail
|
|
234
|
+
// with `fetch failed` / ECONNREFUSED forever. Try to grab the now-free port and become the
|
|
235
|
+
// server ourselves so chrome_* tools recover without a manual restart.
|
|
236
|
+
private async tryPromoteToServer(): Promise<boolean> {
|
|
237
|
+
if (this.mode !== "client") return this.mode === "server";
|
|
238
|
+
this.mode = undefined;
|
|
239
|
+
await this.bindServerOrClient();
|
|
240
|
+
return this.mode === "server";
|
|
241
|
+
}
|
|
242
|
+
|
|
222
243
|
stop(): void {
|
|
223
244
|
if (this.mode === "client") {
|
|
224
245
|
this.mode = undefined;
|
|
@@ -261,14 +282,11 @@ class ChromeProfileBridge {
|
|
|
261
282
|
rejectCommand(new Error("Chrome command aborted"));
|
|
262
283
|
};
|
|
263
284
|
const timer = setTimeout(() => {
|
|
285
|
+
const entry = this.pending.get(id);
|
|
264
286
|
this.pending.delete(id);
|
|
265
287
|
this.queue = this.queue.filter((queued) => queued.id !== id);
|
|
266
288
|
cleanupAbort();
|
|
267
|
-
rejectCommand(
|
|
268
|
-
new Error(
|
|
269
|
-
`Timed out waiting for Chrome extension after ${timeoutMs}ms. Run /chrome onboard, then load the bundled browser-extension folder in your normal Chrome profile.`,
|
|
270
|
-
),
|
|
271
|
-
);
|
|
289
|
+
rejectCommand(new Error(this.timeoutMessage(entry, timeoutMs)));
|
|
272
290
|
}, timeoutMs);
|
|
273
291
|
this.pending.set(id, {
|
|
274
292
|
command,
|
|
@@ -281,6 +299,21 @@ class ChromeProfileBridge {
|
|
|
281
299
|
});
|
|
282
300
|
}
|
|
283
301
|
|
|
302
|
+
// Classify why a local command timed out so the agent isn't left guessing. The three
|
|
303
|
+
// distinct failure modes are: extension never polled (not installed / not running),
|
|
304
|
+
// extension polled but never picked up this command, and extension picked up the command
|
|
305
|
+
// but never posted a result back (long-running action or a failed /result post).
|
|
306
|
+
private timeoutMessage(entry: PendingCommand | undefined, timeoutMs: number): string {
|
|
307
|
+
const pollAgeMs = this.lastSeenAt === undefined ? undefined : Date.now() - this.lastSeenAt;
|
|
308
|
+
if (entry?.deliveredAt) {
|
|
309
|
+
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.`;
|
|
310
|
+
}
|
|
311
|
+
if (pollAgeMs === undefined || pollAgeMs > 60_000) {
|
|
312
|
+
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.`;
|
|
313
|
+
}
|
|
314
|
+
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.`;
|
|
315
|
+
}
|
|
316
|
+
|
|
284
317
|
private async sendViaOwner(action: string, params: Record<string, unknown>, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
|
|
285
318
|
const controller = new AbortController();
|
|
286
319
|
const timer = setTimeout(() => controller.abort(), timeoutMs + 2_000);
|
|
@@ -309,6 +342,16 @@ class ChromeProfileBridge {
|
|
|
309
342
|
if (signal?.aborted) throw new Error("Chrome command aborted");
|
|
310
343
|
throw new Error(`Timed out waiting for shared Chrome bridge owner after ${timeoutMs}ms`);
|
|
311
344
|
}
|
|
345
|
+
// `fetch failed` / ECONNREFUSED means the Pi session that owned the bridge port is gone.
|
|
346
|
+
// Try to take over the port ourselves and re-run the command locally instead of staying
|
|
347
|
+
// stuck as a client pointed at a dead owner.
|
|
348
|
+
if (this.isOwnerUnreachable(error)) {
|
|
349
|
+
const promoted = await this.tryPromoteToServer().catch(() => false);
|
|
350
|
+
if (promoted) return this.sendLocal(action, params, timeoutMs, signal);
|
|
351
|
+
throw new Error(
|
|
352
|
+
"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.",
|
|
353
|
+
);
|
|
354
|
+
}
|
|
312
355
|
throw error;
|
|
313
356
|
} finally {
|
|
314
357
|
clearTimeout(timer);
|
|
@@ -316,6 +359,19 @@ class ChromeProfileBridge {
|
|
|
316
359
|
}
|
|
317
360
|
}
|
|
318
361
|
|
|
362
|
+
private isOwnerUnreachable(error: unknown): boolean {
|
|
363
|
+
const message = (error as Error)?.message ?? "";
|
|
364
|
+
const code = (error as NodeJS.ErrnoException)?.code ?? "";
|
|
365
|
+
const cause = (error as { cause?: NodeJS.ErrnoException })?.cause;
|
|
366
|
+
const causeCode = cause?.code ?? "";
|
|
367
|
+
return (
|
|
368
|
+
/fetch failed|ECONNREFUSED|ECONNRESET|other side closed|socket hang up/i.test(message) ||
|
|
369
|
+
code === "ECONNREFUSED" ||
|
|
370
|
+
causeCode === "ECONNREFUSED" ||
|
|
371
|
+
causeCode === "ECONNRESET"
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
319
375
|
private enqueue(command: BridgeCommand): void {
|
|
320
376
|
const waiter = this.waiters.shift();
|
|
321
377
|
if (waiter) waiter(command);
|
|
@@ -384,6 +440,12 @@ class ChromeProfileBridge {
|
|
|
384
440
|
if (command) this.queue.unshift(command);
|
|
385
441
|
return;
|
|
386
442
|
}
|
|
443
|
+
// Mark the command as delivered so a later timeout can distinguish "extension never
|
|
444
|
+
// picked it up" from "extension is running it / failed to post a result".
|
|
445
|
+
if (command) {
|
|
446
|
+
const entry = this.pending.get(command.id);
|
|
447
|
+
if (entry) entry.deliveredAt = Date.now();
|
|
448
|
+
}
|
|
387
449
|
// Re-read version on every /next so bumping package.json takes effect without pi restart.
|
|
388
450
|
const currentVersion = readPiChromeVersion();
|
|
389
451
|
sendJson(
|