pi-chrome 0.15.27 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.27",
4
+ "version": "0.15.28",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -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
- if (r.pageMutated === false) parts.push("pageMutated=false");
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.server = createServer((request, response) => {
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
- this.server!.once("error", rejectStart);
206
- this.server!.listen(this.port, this.host, () => {
207
- this.server!.off("error", rejectStart);
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
- this.server.close();
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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.27",
3
+ "version": "0.15.28",
4
4
  "scripts": {
5
5
  "version": "node scripts/sync-manifest-version.js",
6
6
  "prepublishOnly": "node scripts/sync-manifest-version.js"