pi-chrome 0.15.53 → 0.15.55

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,27 @@
2
2
 
3
3
  All notable user-facing changes to `pi-chrome`.
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 0.15.55 — 2026-09-26
8
+
9
+ - **Console capture no longer breaks sites with DevTools detection (#9).** Some sites (BOSS Zhipin) log an object with a getter or custom `toString` and close the tab if anything reads it, since only open DevTools would. The console capture serialized every logged argument immediately, so it tripped the check. Captured arguments are now stored as-is, and `chrome_list_console_messages` serializes them without running page code: data properties only, accessors shown as `[getter]`, and DOM nodes, regexes, dates and errors described through Chrome's native getters.
10
+ - **Early capture only in Pi's tabs.** The document-start console/network capture used to be injected into every page you opened. It now runs only in Pi's automation tab and in tabs tracked for a Pi session. Other tabs are not touched until a Pi tool acts on them.
11
+ - **Prompt-cache-friendly primer.** The Chrome primer is now contributed as a structured `chrome-profile-bridge` prompt section instead of a forced `systemPrompt` override. Previously the `before_agent_start` handler returned `systemPrompt` on every run (even while locked), which made pi collapse all system messages into a freshly rendered leading prompt each run. That disabled pi's append-only prompt/tool deltas for the whole session and busted the provider prompt cache when `/chrome authorize`, revoke, or expiry toggled the primer. Pi now appends a small section delta instead. Older pi versions without structured sections keep the previous append behavior.
12
+ - **Authorization log stays out of model context.** `pi-chrome-tool-change` records (authorize, reauthorize, revoke, expiry) are now session custom entries rendered in the transcript instead of model-facing custom messages. The model already sees the change through pi's tool and prompt-section delta, so the message was redundant context.
13
+ - **Validation.** New `prompt-section` unit suite.
14
+
15
+ ## 0.15.54 — 2026-09-26
16
+
17
+ - **Hidden tabs report instead of silently dropping input.** Chrome ignores trusted input to hidden pages. A page is hidden when it is an inactive tab, its window is minimized, its window is fully covered by another window, or (on macOS) its window became a hidden window tab behind a full-screen window. In a hidden page, mouse presses and keys were dropped while the tool reported success, and mouse moves could hang until a timeout (the source of `Detached while handling command` in background runs). Input tools now check page visibility first. `chrome_type`, `chrome_key`, `chrome_hover`, `chrome_scroll`, `chrome_tap` and `chrome_drag` fail fast with the reason and the options (`background:false`, or `/chrome background off`). `chrome_click` and `chrome_fill` go straight to their existing DOM fallback and report the reason, or reject with `domFallback:false`. Hard background mode is unchanged: nothing is activated or focused.
18
+ - **No hidden automation windows in full screen.** While your Chrome window is full screen, a new automation window would become a hidden macOS window tab or land on another Space. The automation target is now an inactive tab in your window instead.
19
+ - **Targeted `chrome_type` appends.** With a uid/selector, `chrome_type` clicked a random point inside the field, which left the caret mid-text and spliced input into existing content. After the focus click it now moves the caret to the end with Chrome's `moveToEndOfDocument` editing command. Untargeted typing still uses the current caret; `replace:true` is unchanged.
20
+ - **Upload buttons, labels, wrappers, and iframes.** `chrome_upload_file` accepts a label for a file input, or a wrapper/dropzone containing exactly one, and searches same-origin iframes for selectors. For upload buttons that open the native picker (no persistent `<input type=file>`), it intercepts the file chooser (`Page.setInterceptFileChooserDialog`) so no OS dialog appears. The trigger click uses trusted Chrome input on visible pages. On hidden pages it uses `element.click()` with a CDP user gesture, unless `domFallback:false`. It rejects multiple paths for single-file inputs. Adapted from kkunkunya's fork.
21
+ - **No duplicate `change` on upload.** Chrome's `DOM.setFileInputFiles` already fires `input`/`change`, and pi-chrome dispatched a second pair, which could make apps upload twice. It now dispatches only when Chrome did not, and reports `events: native|dispatched`.
22
+ - **Bounded screenshot folder.** Default-path `chrome_screenshot` captures prune `.pi/chrome-screenshots` at capture time. Captures older than 7 days are removed, but the newest 20 are always kept. Only files matching the tool's own naming (including full-page tiles and manifests) are eligible, and explicit `path:` captures are never pruned. `retentionDays` overrides the window; `0` disables pruning. Adapted from ardhiqii's fork.
23
+ - **`input.debug` without a target.** It no longer fails with `Value must be at least 0`; it describes this session's automation tab if one exists.
24
+ - **Validation.** New `hidden-input` unit suite. Live-checked on Chrome/macOS: hidden-tab errors and DOM fallbacks, caret-end typing in inputs and contenteditables, all upload shapes in visible and hidden tabs, single `change` events, and pruning through a real Pi session.
25
+
5
26
  ## 0.15.53 — 2026-09-26
6
27
 
7
28
  Changes adapted from community forks (ardhiqii, kkunkunya, nihar-oracle, steimerbyte).
package/README.md CHANGED
@@ -85,7 +85,7 @@ Run `/chrome revoke` when finished. Use `/chrome authorize` again whenever you w
85
85
 
86
86
  This is browser automation, not full OS control. Native Chrome/OS dialogs, password-manager prompts, passkeys/security keys/biometrics, CAPTCHA challenges, cross-origin iframe DOM access, rich multitouch/stylus gestures, and arbitrary desktop apps are outside its reliable tool surface. Some workflows need human assistance.
87
87
 
88
- If page inspection or evaluation is blocked, use screenshots and coordinate input where possible. Background pages can throttle rendering or reject focus-gated actions. See the [FAQ](./docs/FAQ.md) for details.
88
+ If page inspection or evaluation is blocked, use screenshots and coordinate input where possible. Background pages can throttle rendering or reject focus-gated actions. Chrome ignores real input to hidden pages (inactive tabs, covered or minimized windows, windows behind a macOS full-screen window); input tools report this instead of pretending to succeed. See the [FAQ](./docs/FAQ.md) for details.
89
89
 
90
90
  ## Commands
91
91
 
package/docs/FAQ.md CHANGED
@@ -75,7 +75,7 @@ If the page did not change, take a fresh snapshot or screenshot and check for ov
75
75
 
76
76
  ## How do I attach a file to a React file input?
77
77
 
78
- `chrome_upload_file` — uses Chrome DevTools file-input control and fires `input` + `change` events. It does **not** open the native file picker. Works with React/Vue/Angular controlled inputs.
78
+ `chrome_upload_file` — uses Chrome DevTools file-input control; the page gets one `input` + `change`. Target the file input, a label or wrapper containing one, or an upload button: for buttons, the native file chooser is intercepted, so no OS dialog appears. Selectors also search same-origin iframes. Works with React/Vue/Angular controlled inputs.
79
79
 
80
80
  ## Can it record videos?
81
81
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.53",
4
+ "version": "0.15.55",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -174,6 +174,17 @@ function isPiChromeOwnedTarget(tabId, sessionKey) {
174
174
  return false;
175
175
  }
176
176
 
177
+ // A new window created while the user's Chrome window is full screen does not become a visible
178
+ // standalone window: macOS either merges it into the full-screen window as a hidden native window
179
+ // tab ("Prefer tabs: in full screen") or places it on another Space. Its page is hidden, so trusted
180
+ // input cannot reach it. Use a grouped background tab in the user's window instead, which is at least
181
+ // visible in the tab strip and does not add a macOS tab bar to the user's full-screen window.
182
+ async function userWindowIsFullscreen() {
183
+ if (typeof chrome.windows?.getLastFocused !== "function") return false;
184
+ const win = await chrome.windows.getLastFocused({ windowTypes: ["normal"] }).catch(() => null);
185
+ return win?.state === "fullscreen";
186
+ }
187
+
177
188
  // Create a fresh automation target for `sessionKey`. If this session already has a tab group,
178
189
  // create the tab inside that group's window so one Pi session keeps one Chrome tab group (Chrome
179
190
  // groups cannot span windows). If no group exists yet, prefer an isolated window; fall back to a
@@ -187,7 +198,7 @@ async function createAutomationTarget(sessionKey, groupTitle) {
187
198
  await persistAutomationTargets();
188
199
  return tab;
189
200
  }
190
- if (chrome.windows && typeof chrome.windows.create === "function") {
201
+ if (chrome.windows && typeof chrome.windows.create === "function" && !(await userWindowIsFullscreen())) {
191
202
  try {
192
203
  const win = await chrome.windows.create({ url: "about:blank", focused: false });
193
204
  const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined;
@@ -391,7 +402,8 @@ async function attachDebugger(tabId) {
391
402
  }
392
403
 
393
404
  async function describeInputTarget(tabId) {
394
- const tab = await chrome.tabs.get(Number(tabId)).catch(() => null);
405
+ const id = Number(tabId);
406
+ const tab = Number.isInteger(id) && id >= 0 ? await chrome.tabs.get(id).catch(() => null) : null;
395
407
  const active = (await chrome.tabs.query({ active: true, lastFocusedWindow: true }).catch(() => []))[0] || null;
396
408
  let targets = [];
397
409
  try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {}
@@ -408,7 +420,13 @@ function targetMetaSuffix(meta) {
408
420
  }
409
421
 
410
422
  async function inputDebug(params) {
411
- const requested = params?.targetId ? await describeInputTarget(Number(params.targetId)) : await describeInputTarget(-1);
423
+ // No explicit target: describe this session's automation tab if it exists, without creating one.
424
+ let tabId = params?.targetId !== undefined ? Number(params.targetId) : null;
425
+ if (tabId === null) {
426
+ const owned = await resolveOwnedAutomationTarget(sessionKeyOf(params || {})).catch(() => null);
427
+ tabId = owned?.id ?? null;
428
+ }
429
+ const requested = await describeInputTarget(tabId);
412
430
  return {
413
431
  extensionVersion: chrome.runtime.getManifest().version,
414
432
  extensionId: chrome.runtime.id,
@@ -417,6 +435,57 @@ async function inputDebug(params) {
417
435
  };
418
436
  }
419
437
 
438
+ // Chrome drops trusted CDP input to hidden pages: mouse presses and keys are silently ignored and
439
+ // mouse moves can hang waiting for a frame. A page is hidden when its tab is inactive, its window is
440
+ // minimized, or (macOS) its window became a native window-tab behind a full-screen window. Check
441
+ // before sending input so the caller gets an actionable error instead of a false success.
442
+ async function pageVisibilityState(tabId) {
443
+ try {
444
+ const evaluated = await cdp(tabId, "Runtime.evaluate", { expression: "document.visibilityState", returnByValue: true });
445
+ const value = evaluated?.result?.value;
446
+ return typeof value === "string" ? value : null;
447
+ } catch {
448
+ return null; // Unknown: do not block input on a failed probe.
449
+ }
450
+ }
451
+
452
+ function hiddenTabReason(tab, win) {
453
+ if (win?.state === "minimized") return "its window is minimized";
454
+ if (tab && tab.active === false) return "it is an inactive tab in its window";
455
+ if (win?.state === "fullscreen") return "its window is behind a full-screen window (on macOS, new windows can become hidden window tabs)";
456
+ return "its window is covered by another window or is on another Space (Chrome treats fully covered windows as hidden)";
457
+ }
458
+
459
+ async function assertTabVisibleForInput(tab, params, label) {
460
+ let state = await pageVisibilityState(tab.id);
461
+ if (state !== "hidden") return;
462
+ if (foregroundRequested(params)) {
463
+ // bringToFront just ran; give Chrome a moment to report the page visible.
464
+ for (let i = 0; i < 6 && state === "hidden"; i++) {
465
+ await sleep(100);
466
+ state = await pageVisibilityState(tab.id);
467
+ }
468
+ if (state !== "hidden") return;
469
+ }
470
+ const fresh = await chrome.tabs.get(tab.id).catch(() => tab);
471
+ const win = typeof chrome.windows?.get === "function" ? await chrome.windows.get(fresh?.windowId ?? tab.windowId).catch(() => null) : null;
472
+ const error = new Error(
473
+ `${label}: tab ${tab.id} is hidden because ${hiddenTabReason(fresh, win)}. Chrome ignores trusted input to hidden pages, so the events would be dropped. ` +
474
+ `Pass background:false for this call (or run /chrome background off) to bring the tab forward. chrome_click and chrome_fill can instead use their DOM fallback (untrusted page events).`,
475
+ );
476
+ error.hiddenTab = true;
477
+ error.windowState = win?.state;
478
+ throw error;
479
+ }
480
+
481
+ // Put the caret at the end of the focused field with Chrome's editing command (platform-neutral,
482
+ // works for inputs, textareas, and contenteditables). Used after chrome_type clicks a field to focus
483
+ // it, so the random click point never splices text into the middle of existing content.
484
+ async function moveCaretToEnd(tabId) {
485
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "rawKeyDown", key: "End", code: "End", windowsVirtualKeyCode: 35, commands: ["moveToEndOfDocument"] });
486
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "End", code: "End", windowsVirtualKeyCode: 35 });
487
+ }
488
+
420
489
  async function detachDebugger(tabId) {
421
490
  const entry = attachedTabs.get(tabId);
422
491
  if (!entry) return;
@@ -825,6 +894,7 @@ async function chromeInputClick(params) {
825
894
  await bringToFront(tab, params);
826
895
  try {
827
896
  await attachDebugger(tab.id);
897
+ await assertTabVisibleForInput(tab, params, "chrome.click");
828
898
  const resolved = await resolveTargetInTab(tab.id, params);
829
899
  const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
830
900
  await cdpMoveTo(tab.id, point.x, point.y);
@@ -861,6 +931,7 @@ async function chromeInputHover(params) {
861
931
  const tab = await getTabByParams(params);
862
932
  await bringToFront(tab, params);
863
933
  await attachDebugger(tab.id);
934
+ await assertTabVisibleForInput(tab, params, "chrome.hover");
864
935
  const resolved = await resolveTargetInTab(tab.id, params);
865
936
  const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
866
937
  await cdpMoveTo(tab.id, point.x, point.y);
@@ -872,6 +943,7 @@ async function chromeInputKey(params) {
872
943
  const tab = await getTabByParams(params);
873
944
  await bringToFront(tab, params);
874
945
  await attachDebugger(tab.id);
946
+ await assertTabVisibleForInput(tab, params, "chrome.key");
875
947
  const key = String(params.key || "");
876
948
  if (!key) throw new Error("chrome.key: missing key");
877
949
  const mods = params.modifiers || {};
@@ -954,6 +1026,7 @@ async function chromeInputType(params) {
954
1026
  const tab = await getTabByParams(params);
955
1027
  await bringToFront(tab, params);
956
1028
  await attachDebugger(tab.id);
1029
+ await assertTabVisibleForInput(tab, params, "chrome.type");
957
1030
  if (params.selector || params.uid) {
958
1031
  // Focus target by clicking it first.
959
1032
  const resolved = await resolveTargetInTab(tab.id, params);
@@ -963,6 +1036,9 @@ async function chromeInputType(params) {
963
1036
  await sleep(rng(45, 110));
964
1037
  await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
965
1038
  await sleep(rng(50, 120));
1039
+ // The click lands at a random point inside the field, which leaves the caret mid-text. Move it to
1040
+ // the end so a targeted chrome_type appends; replace=true selects everything below anyway.
1041
+ if (params.replace !== true) await moveCaretToEnd(tab.id);
966
1042
  }
967
1043
  const text = String(params.text || "");
968
1044
  // chrome_type inserts at the caret. Read the field before and after so a splice into existing
@@ -1144,6 +1220,7 @@ async function chromeInputFill(params) {
1144
1220
  await bringToFront(tab, params);
1145
1221
  try {
1146
1222
  await attachDebugger(tab.id);
1223
+ await assertTabVisibleForInput(tab, params, "chrome.fill");
1147
1224
  if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required");
1148
1225
  const resolved = await resolveTargetInTab(tab.id, params);
1149
1226
  const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
@@ -1174,6 +1251,7 @@ async function chromeInputScroll(params) {
1174
1251
  const tab = await getTabByParams(params);
1175
1252
  await bringToFront(tab, params);
1176
1253
  await attachDebugger(tab.id);
1254
+ await assertTabVisibleForInput(tab, params, "chrome.scroll");
1177
1255
  const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null };
1178
1256
  const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x;
1179
1257
  const y = resolved.rect ? resolved.rect.top + Math.min(resolved.rect.height, 600) / 2 : resolved.y;
@@ -1224,6 +1302,7 @@ async function chromeInputTap(params) {
1224
1302
  const tab = await getTabByParams(params);
1225
1303
  await bringToFront(tab, params);
1226
1304
  await attachDebugger(tab.id);
1305
+ await assertTabVisibleForInput(tab, params, "chrome.tap");
1227
1306
  const resolved = (params.selector || params.uid || (typeof params.x === "number" && typeof params.y === "number"))
1228
1307
  ? await resolveTargetInTab(tab.id, params)
1229
1308
  : null;
@@ -1240,6 +1319,7 @@ async function chromeInputDrag(params) {
1240
1319
  const tab = await getTabByParams(params);
1241
1320
  await bringToFront(tab, params);
1242
1321
  await attachDebugger(tab.id);
1322
+ await assertTabVisibleForInput(tab, params, "chrome.drag");
1243
1323
  const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null });
1244
1324
  const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null });
1245
1325
  const fp = from.rect ? pickInsideRect(from.rect) : { x: from.x, y: from.y };
@@ -1261,6 +1341,100 @@ async function chromeInputDrag(params) {
1261
1341
  return { input: "chrome", from: fp, to: tp, steps };
1262
1342
  }
1263
1343
 
1344
+ // Pending native file-chooser intercepts, keyed by tabId. chrome_upload_file uses these when the
1345
+ // target is an upload trigger (button/dropzone) rather than a persistent <input type=file>:
1346
+ // Page.setInterceptFileChooserDialog -> click the trigger -> Page.fileChooserOpened ->
1347
+ // DOM.setFileInputFiles(backendNodeId). No OS dialog is shown.
1348
+ const pendingFileChoosers = new Map();
1349
+ const FILE_CHOOSER_TIMEOUT_MS = 8_000;
1350
+
1351
+ function fileChooserTabIdForSource(source) {
1352
+ if (typeof source?.tabId === "number") return source.tabId;
1353
+ if (source?.targetId) {
1354
+ for (const [tabId, entry] of attachedTabs) if (entry?.debuggee?.targetId === source.targetId) return tabId;
1355
+ }
1356
+ return undefined;
1357
+ }
1358
+
1359
+ function handleDebuggerEvent(source, method, params) {
1360
+ if (method !== "Page.fileChooserOpened") return;
1361
+ const tabId = fileChooserTabIdForSource(source);
1362
+ const entry = tabId === undefined ? undefined : pendingFileChoosers.get(tabId);
1363
+ if (!entry) return;
1364
+ pendingFileChoosers.delete(tabId);
1365
+ entry.resolve({ backendNodeId: params?.backendNodeId, mode: params?.mode });
1366
+ }
1367
+
1368
+ if (chrome.debugger && chrome.debugger.onEvent) {
1369
+ chrome.debugger.onEvent.addListener(handleDebuggerEvent);
1370
+ }
1371
+
1372
+ function waitForFileChooser(tabId, timeoutMs) {
1373
+ let entry;
1374
+ const promise = new Promise((resolve, reject) => {
1375
+ entry = { resolve, reject };
1376
+ entry.timer = setTimeout(() => {
1377
+ if (pendingFileChoosers.get(tabId) === entry) pendingFileChoosers.delete(tabId);
1378
+ reject(new Error("chrome.upload: no file chooser opened after clicking the target. Pass the <input type=file> itself, or an element that opens the file picker when clicked."));
1379
+ }, timeoutMs);
1380
+ });
1381
+ pendingFileChoosers.set(tabId, entry);
1382
+ return { promise: promise.finally(() => clearTimeout(entry.timer)), cancel: () => { if (pendingFileChoosers.get(tabId) === entry) pendingFileChoosers.delete(tabId); clearTimeout(entry.timer); } };
1383
+ }
1384
+
1385
+ // Resolve the upload target in the page. Selectors also search same-origin iframes (editors often
1386
+ // host their file input in one). A label for a file input, or a wrapper/dropzone containing exactly
1387
+ // one file input, resolves to that input so no click is needed.
1388
+ function uploadTargetExpression(selector, uid) {
1389
+ return `(() => {
1390
+ const selector = ${JSON.stringify(selector ?? null)};
1391
+ const uid = ${JSON.stringify(uid ?? null)};
1392
+ const state = window.__PI_CHROME_STATE__;
1393
+ const findIn = (doc, depth) => {
1394
+ let found = null;
1395
+ try { found = doc.querySelector(selector); } catch (error) { throw new Error("Invalid selector: " + selector); }
1396
+ if (found || depth >= 3) return found;
1397
+ for (const frame of doc.querySelectorAll("iframe,frame")) {
1398
+ let inner = null;
1399
+ try { inner = frame.contentDocument; } catch {}
1400
+ if (!inner) continue;
1401
+ found = findIn(inner, depth + 1);
1402
+ if (found) return found;
1403
+ }
1404
+ return null;
1405
+ };
1406
+ let el = uid ? state?.elements?.[uid] : (selector ? findIn(document, 0) : null);
1407
+ if (uid && (!el || !el.isConnected)) throw new Error("snapshot uid " + uid + " is stale; refresh chrome_snapshot");
1408
+ if (!el) throw new Error("chrome.upload: target not found: " + (uid || selector));
1409
+ const isFile = (node) => node && node.tagName === "INPUT" && String(node.type).toLowerCase() === "file";
1410
+ if (!isFile(el)) {
1411
+ const control = el.tagName === "LABEL" && isFile(el.control) ? el.control : null;
1412
+ const nested = control ? null : Array.from(el.querySelectorAll ? el.querySelectorAll("input[type=file]") : []);
1413
+ if (control) el = control;
1414
+ else if (nested && nested.length === 1) el = nested[0];
1415
+ }
1416
+ return el;
1417
+ })()`;
1418
+ }
1419
+
1420
+ const UPLOAD_TARGET_INFO_FN = `function() {
1421
+ const isFile = this.tagName === "INPUT" && String(this.type).toLowerCase() === "file";
1422
+ if (!isFile) this.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1423
+ const r = this.getBoundingClientRect();
1424
+ let x = r.left + r.width / 2, y = r.top + r.height / 2;
1425
+ let win = this.ownerDocument.defaultView;
1426
+ // Convert iframe-relative coordinates to top-level viewport coordinates (same-origin frames only).
1427
+ while (win && win.frameElement) {
1428
+ const frame = win.frameElement;
1429
+ const fr = frame.getBoundingClientRect();
1430
+ const cs = frame.ownerDocument.defaultView.getComputedStyle(frame);
1431
+ x += fr.left + (parseFloat(cs.borderLeftWidth) || 0) + (parseFloat(cs.paddingLeft) || 0);
1432
+ y += fr.top + (parseFloat(cs.borderTopWidth) || 0) + (parseFloat(cs.paddingTop) || 0);
1433
+ win = win.parent === win ? null : win.parent;
1434
+ }
1435
+ return { tag: this.tagName, isFile, multiple: isFile ? this.multiple === true : undefined, inFrame: this.ownerDocument !== document, x, y, width: r.width, height: r.height };
1436
+ }`;
1437
+
1264
1438
  async function chromeInputUpload(params) {
1265
1439
  const tab = await getTabByParams(params);
1266
1440
  await bringToFront(tab, params);
@@ -1268,36 +1442,80 @@ async function chromeInputUpload(params) {
1268
1442
  if (!(params.selector || params.uid)) throw new Error("chrome.upload: selector or uid required");
1269
1443
  const paths = Array.isArray(params.paths) ? params.paths.map(String) : [];
1270
1444
  if (!paths.length) throw new Error("chrome.upload: no file paths provided");
1271
- const expression = `(() => {
1272
- const selector = ${JSON.stringify(params.selector ?? null)};
1273
- const uid = ${JSON.stringify(params.uid ?? null)};
1274
- const state = window.__PI_CHROME_STATE__;
1275
- const el = uid ? state?.elements?.[uid] : (selector ? document.querySelector(selector) : null);
1276
- if (uid && (!el || !el.isConnected)) throw new Error("snapshot uid " + uid + " is stale; refresh chrome_snapshot");
1277
- if (!el || el.tagName !== "INPUT" || el.type !== "file") throw new Error("Target must be <input type=file>");
1278
- el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
1279
- return el;
1280
- })()`;
1281
- const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression, objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false });
1282
- if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve file input");
1445
+ const evaluated = await cdp(tab.id, "Runtime.evaluate", { expression: uploadTargetExpression(params.selector, params.uid), objectGroup: "pi-chrome-upload", includeCommandLineAPI: false, returnByValue: false });
1446
+ if (evaluated.exceptionDetails) throw new Error(cdpExceptionText(evaluated.exceptionDetails) || "Could not resolve upload target");
1283
1447
  const objectId = evaluated.result?.objectId;
1284
- if (!objectId) throw new Error("Could not resolve file input object");
1448
+ if (!objectId) throw new Error("Could not resolve upload target object");
1285
1449
  try {
1286
- await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
1287
- // Some DOM agents return nodeId:0 (or reject conversion) for a valid remote
1288
- // element. CDP accepts that same objectId directly, before any file mutation.
1289
- const requested = await cdp(tab.id, "DOM.requestNode", { objectId }).catch(() => null);
1290
- const target = requested?.nodeId ? { nodeId: requested.nodeId } : { objectId };
1291
- await cdp(tab.id, "DOM.setFileInputFiles", { ...target, files: paths });
1292
- await cdp(tab.id, "Runtime.callFunctionOn", {
1293
- objectId,
1294
- functionDeclaration: `function() { this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return this.files ? this.files.length : 0; }`,
1295
- returnByValue: true,
1296
- }).catch(() => undefined);
1450
+ const infoResult = await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: UPLOAD_TARGET_INFO_FN, returnByValue: true });
1451
+ const info = infoResult?.result?.value || {};
1452
+ if (info.isFile) {
1453
+ if (paths.length > 1 && info.multiple === false) throw new Error(`chrome.upload: the file input accepts one file but ${paths.length} paths were given`);
1454
+ await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
1455
+ // Some DOM agents return nodeId:0 (or reject conversion) for a valid remote
1456
+ // element. CDP accepts that same objectId directly, before any file mutation.
1457
+ const requested = await cdp(tab.id, "DOM.requestNode", { objectId }).catch(() => null);
1458
+ const target = requested?.nodeId ? { nodeId: requested.nodeId } : { objectId };
1459
+ // Chrome's setFileInputFiles normally fires input/change itself. Watch for that and only
1460
+ // dispatch our own events when it did not, so apps never see a duplicate change (double upload).
1461
+ await cdp(tab.id, "Runtime.callFunctionOn", {
1462
+ objectId,
1463
+ functionDeclaration: `function() { const el = this; el.__piChromeChangeSeen = false; el.addEventListener("change", () => { el.__piChromeChangeSeen = true; }, { once: true, capture: true }); }`,
1464
+ }).catch(() => undefined);
1465
+ await cdp(tab.id, "DOM.setFileInputFiles", { ...target, files: paths });
1466
+ const notified = await cdp(tab.id, "Runtime.callFunctionOn", {
1467
+ objectId,
1468
+ functionDeclaration: `function() { const seen = this.__piChromeChangeSeen === true; delete this.__piChromeChangeSeen; if (seen) return "native"; this.dispatchEvent(new Event("input", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true })); return "dispatched"; }`,
1469
+ returnByValue: true,
1470
+ }).catch(() => null);
1471
+ const events = notified?.result?.value;
1472
+ return { input: "chrome", mode: "file-input", events: typeof events === "string" ? events : undefined, inFrame: info.inFrame || undefined, uploaded: paths.map((path) => ({ path })) };
1473
+ }
1474
+ return await uploadViaFileChooser(tab, params, paths, objectId, info);
1297
1475
  } finally {
1298
1476
  await cdp(tab.id, "Runtime.releaseObject", { objectId }).catch(() => undefined);
1299
1477
  }
1300
- return { input: "chrome", uploaded: paths.map((path) => ({ path })) };
1478
+ }
1479
+
1480
+ // The target opens the native picker when clicked. Intercept the chooser so no OS dialog appears,
1481
+ // then attach the files to the input Chrome reports. The click is trusted Chrome input when the page
1482
+ // is visible; a hidden page drops trusted input, so there we call element.click() with a CDP user
1483
+ // gesture instead (an untrusted event, like chrome_click's DOM fallback) unless domFallback:false.
1484
+ async function uploadViaFileChooser(tab, params, paths, objectId, info) {
1485
+ const hidden = (await pageVisibilityState(tab.id)) === "hidden";
1486
+ if (hidden && params.domFallback === false) await assertTabVisibleForInput(tab, params, "chrome.upload");
1487
+ await cdp(tab.id, "Page.enable", {});
1488
+ await cdp(tab.id, "Page.setInterceptFileChooserDialog", { enabled: true });
1489
+ const chooser = waitForFileChooser(tab.id, FILE_CHOOSER_TIMEOUT_MS);
1490
+ let trigger;
1491
+ try {
1492
+ if (!hidden && typeof info.x === "number" && typeof info.y === "number" && info.width > 0 && info.height > 0) {
1493
+ trigger = "chrome";
1494
+ await cdpMoveTo(tab.id, info.x, info.y);
1495
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: info.x, y: info.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
1496
+ await sleep(rng(40, 110));
1497
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: info.x, y: info.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
1498
+ } else {
1499
+ trigger = "activation-click";
1500
+ await cdp(tab.id, "Runtime.callFunctionOn", { objectId, functionDeclaration: "function() { this.click(); }", userGesture: true, awaitPromise: false });
1501
+ }
1502
+ const opened = await chooser.promise;
1503
+ if (typeof opened.backendNodeId !== "number") throw new Error("chrome.upload: file chooser opened without an input node");
1504
+ if (paths.length > 1 && opened.mode === "selectSingle") throw new Error(`chrome.upload: the file chooser accepts one file but ${paths.length} paths were given`);
1505
+ await cdp(tab.id, "DOM.enable", {}).catch(() => undefined);
1506
+ await cdp(tab.id, "DOM.setFileInputFiles", { backendNodeId: opened.backendNodeId, files: paths });
1507
+ return {
1508
+ input: "chrome",
1509
+ mode: "file-chooser",
1510
+ trigger,
1511
+ reason: trigger === "activation-click" ? "target tab is hidden; Chrome drops trusted input to hidden pages" : undefined,
1512
+ inFrame: info.inFrame || undefined,
1513
+ uploaded: paths.map((path) => ({ path })),
1514
+ };
1515
+ } finally {
1516
+ chooser.cancel();
1517
+ await cdp(tab.id, "Page.setInterceptFileChooserDialog", { enabled: false }).catch(() => undefined);
1518
+ }
1301
1519
  }
1302
1520
  // ===============================================================
1303
1521
 
@@ -2075,14 +2293,22 @@ async function unregisterInitScript(tabId) {
2075
2293
  await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined);
2076
2294
  }
2077
2295
 
2078
- // Always inject early console/network capture at document_start on every navigation.
2079
- // Catches console messages, errors, and network requests that fire during page load,
2080
- // before chrome_snapshot or chrome_evaluate install the instrumentation normally.
2081
- // The function installEarlyCapture sets __piChromeWrapped flags so the post-hoc
2082
- // installPiChromeInstrumentation() call is idempotent.
2296
+ // Inject early console/network capture at document_start, but only into tabs Pi owns or has used
2297
+ // (its automation tab and tabs tracked for a session). Your other tabs are never patched. Catches
2298
+ // console messages, errors, and network requests that fire during page load, before chrome_snapshot
2299
+ // or chrome_evaluate install the instrumentation normally. installEarlyCapture sets __piChromeWrapped
2300
+ // flags so the post-hoc installPiChromeInstrumentation() call is idempotent.
2301
+ async function isPiTrackedTab(tabId) {
2302
+ await Promise.all([hydrateAutomationTargets(), hydrateSessionTabs()]);
2303
+ if (isPiChromeOwnedTarget(tabId)) return true;
2304
+ for (const tabs of sessionTabs.values()) if (tabs.has(tabId)) return true;
2305
+ return false;
2306
+ }
2307
+
2083
2308
  if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
2084
- chrome.webNavigation.onCommitted.addListener((details) => {
2309
+ chrome.webNavigation.onCommitted.addListener(async (details) => {
2085
2310
  if (details.frameId !== 0) return;
2311
+ if (!(await isPiTrackedTab(details.tabId).catch(() => false))) return;
2086
2312
  chrome.scripting.executeScript({
2087
2313
  target: { tabId: details.tabId, frameIds: [0] },
2088
2314
  world: "MAIN",
@@ -2374,15 +2600,9 @@ function installPiChromeInstrumentation() {
2374
2600
  level,
2375
2601
  timestamp: Date.now(),
2376
2602
  url: location.href,
2377
- args: Array.from(args).map((arg) => {
2378
- try {
2379
- if (typeof arg === "string") return arg;
2380
- if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
2381
- return JSON.parse(JSON.stringify(arg));
2382
- } catch {
2383
- return String(arg);
2384
- }
2385
- }),
2603
+ // Keep the raw values and serialize only when Pi lists messages. Serializing here reads every
2604
+ // logged object (getters, toString), which is exactly what anti-DevTools checks watch for.
2605
+ args: Array.from(args),
2386
2606
  });
2387
2607
  if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
2388
2608
  };
@@ -2497,15 +2717,7 @@ function installEarlyCapture() {
2497
2717
  level: level,
2498
2718
  timestamp: Date.now(),
2499
2719
  url: location.href,
2500
- args: Array.from(args).map(function(arg) {
2501
- try {
2502
- if (typeof arg === "string") return arg;
2503
- if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
2504
- return JSON.parse(JSON.stringify(arg));
2505
- } catch (e) {
2506
- return String(arg);
2507
- }
2508
- }),
2720
+ args: Array.from(args), // raw; serialized on read (see listConsoleMessages)
2509
2721
  });
2510
2722
  if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
2511
2723
  }
@@ -2699,7 +2911,7 @@ async function clickPage(selector, uid, x, y) {
2699
2911
  // Inspect recent console errors for activation-gate rejections.
2700
2912
  const recent = (state.console || []).slice(-8);
2701
2913
  const hit = recent.find((e) => /NotAllowedError|Document is not focused|requires transient activation|gesture is required/.test(
2702
- (e.args || []).map((a) => typeof a === "string" ? a : (a && a.message) || JSON.stringify(a)).join(" ")
2914
+ (e.args || []).map((a) => typeof a === "string" ? a : a instanceof Error ? a.message : "").join(" ")
2703
2915
  ));
2704
2916
  if (hit) { suggestChromeInput = true; suggestReason = "recent console error indicates user-activation gate"; }
2705
2917
  }
@@ -3067,7 +3279,85 @@ async function pressKeyInPage(key) {
3067
3279
  function listConsoleMessages(clear) {
3068
3280
  installPiChromeInstrumentation();
3069
3281
  const state = getPiChromeState();
3070
- const messages = state.console.slice();
3282
+ // Serialize without running page code. Anti-DevTools checks log objects with getters or a custom
3283
+ // toString and treat any call as "DevTools is open" (issue #9). Copy data properties only, report
3284
+ // accessors as "[getter]" without calling them, and describe built-ins through native getters.
3285
+ const nativeSource = Function.prototype.toString;
3286
+ const isNative = (fn) => { try { return typeof fn === "function" && nativeSource.call(fn).includes("[native code]"); } catch { return false; } };
3287
+ const protoGetter = (proto, key) => { try { return Object.getOwnPropertyDescriptor(proto, key)?.get; } catch { return undefined; } };
3288
+ const callGetter = (proto, key, value) => { const get = protoGetter(proto, key); return get ? get.call(value) : undefined; };
3289
+ const dataProp = (value, key) => {
3290
+ for (let obj = value, hops = 0; obj && hops < 20; obj = Object.getPrototypeOf(obj), hops++) {
3291
+ const desc = Object.getOwnPropertyDescriptor(obj, key);
3292
+ if (!desc) continue;
3293
+ if ("value" in desc) return { ok: true, value: desc.value };
3294
+ return isNative(desc.get) ? { ok: true, value: desc.get.call(value) } : { ok: false };
3295
+ }
3296
+ return { ok: true, value: undefined };
3297
+ };
3298
+ const serialize = (value, depth = 0, seen = new Set()) => {
3299
+ try {
3300
+ const type = typeof value;
3301
+ if (value === null || type === "string" || type === "boolean") return value;
3302
+ if (type === "number") return Number.isFinite(value) ? value : String(value);
3303
+ if (type === "undefined") return "undefined";
3304
+ if (type === "bigint") return `${BigInt.prototype.toString.call(value)}n`;
3305
+ if (type === "symbol") return Symbol.prototype.toString.call(value);
3306
+ if (type === "function") {
3307
+ const name = dataProp(value, "name");
3308
+ return `[function ${name.ok && typeof name.value === "string" && name.value ? name.value : "anonymous"}]`;
3309
+ }
3310
+ if (seen.has(value)) return "[circular]";
3311
+ if (value instanceof Error) {
3312
+ const out = {};
3313
+ for (const key of ["name", "message"]) {
3314
+ const prop = dataProp(value, key);
3315
+ out[key] = prop.ok ? (typeof prop.value === "string" ? prop.value : serialize(prop.value, depth + 1, seen)) : "[getter]";
3316
+ }
3317
+ // V8 formats `stack` lazily from name/message, so only read it when both are plain data.
3318
+ const stack = out.name !== "[getter]" && out.message !== "[getter]" ? dataProp(value, "stack") : { ok: false };
3319
+ out.stack = stack.ok ? (typeof stack.value === "string" ? stack.value : undefined) : "[getter]";
3320
+ return out;
3321
+ }
3322
+ if (value instanceof Date) return Date.prototype.toISOString.call(value);
3323
+ if (value instanceof RegExp) return `/${callGetter(RegExp.prototype, "source", value)}/${callGetter(RegExp.prototype, "flags", value)}`;
3324
+ if (typeof Node !== "undefined" && value instanceof Node) {
3325
+ const name = String(callGetter(Node.prototype, "nodeName", value) || "node").toLowerCase();
3326
+ if (typeof Element === "undefined" || !(value instanceof Element)) return `<${name}>`;
3327
+ const id = callGetter(Element.prototype, "id", value);
3328
+ const cls = String(callGetter(Element.prototype, "className", value) || "").trim().split(/\s+/).filter(Boolean).slice(0, 3);
3329
+ return `<${name}${id ? `#${id}` : ""}${cls.map((c) => `.${c}`).join("")}>`;
3330
+ }
3331
+ if (value instanceof Map) return `[Map(${callGetter(Map.prototype, "size", value)})]`;
3332
+ if (value instanceof Set) return `[Set(${callGetter(Set.prototype, "size", value)})]`;
3333
+ if (depth >= 4) return Array.isArray(value) ? "[array]" : "[object]";
3334
+ seen.add(value);
3335
+ if (Array.isArray(value)) {
3336
+ const length = dataProp(value, "length");
3337
+ const n = length.ok ? Math.min(Number(length.value) || 0, 50) : 0;
3338
+ const out = [];
3339
+ for (let i = 0; i < n; i++) {
3340
+ const item = dataProp(value, String(i));
3341
+ out.push(item.ok ? serialize(item.value, depth + 1, seen) : "[getter]");
3342
+ }
3343
+ if (length.ok && length.value > 50) out.push(`[${length.value - 50} more]`);
3344
+ seen.delete(value);
3345
+ return out;
3346
+ }
3347
+ const out = {};
3348
+ const keys = Object.keys(value);
3349
+ for (const key of keys.slice(0, 50)) {
3350
+ const desc = Object.getOwnPropertyDescriptor(value, key);
3351
+ out[key] = desc && "value" in desc ? serialize(desc.value, depth + 1, seen) : "[getter]";
3352
+ }
3353
+ if (keys.length > 50) out["…"] = `${keys.length - 50} more keys`;
3354
+ seen.delete(value);
3355
+ return out;
3356
+ } catch {
3357
+ return "[unserializable]";
3358
+ }
3359
+ };
3360
+ const messages = state.console.map((entry) => ({ ...entry, args: Array.from(entry.args || [], (arg) => serialize(arg)) }));
3071
3361
  if (clear) state.console = [];
3072
3362
  return { messages, count: messages.length };
3073
3363
  }
@@ -125,15 +125,7 @@
125
125
  level,
126
126
  timestamp: Date.now(),
127
127
  url: location.href,
128
- args: Array.from(args).map((arg) => {
129
- try {
130
- if (typeof arg === "string") return arg;
131
- if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
132
- return JSON.parse(JSON.stringify(arg));
133
- } catch {
134
- return String(arg);
135
- }
136
- }),
128
+ args: Array.from(args), // raw; serialized on read by listConsoleMessages
137
129
  });
138
130
  if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
139
131
  };