pi-chrome 0.15.54 → 0.15.56

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,22 @@
2
2
 
3
3
  All notable user-facing changes to `pi-chrome`.
4
4
 
5
+ ## Unreleased
6
+
7
+ ## 0.15.56 — 2026-09-28
8
+
9
+ - **Pi's automation tab stays in its own window.** Joining the session tab group called `chrome.tabs.group` without a window. Chrome then creates the group in the current window (yours) and moves the tab there, where it sits as an inactive, hidden tab and trusted input is refused. This happened on a session's first page action and is the main source of the hidden-tab errors. New groups are now created in the tab's own window.
10
+ - **`chrome_fill` replaces all of a multi-line textarea.** Fill selected the old value with a triple-click, which in a textarea selects only the clicked line. The rest stayed, and the tool still reported success. Inputs and textareas now select the whole value with Chrome's `selectAll` editing command before deleting. Contenteditables are unchanged.
11
+ - **Early capture covers a new tab's first page.** `chrome_tab new` could commit its first page before the tab was recorded as Pi's, so early console/network capture skipped that load. Early capture now waits for in-flight Pi tab creations before deciding.
12
+
13
+ ## 0.15.55 — 2026-09-26
14
+
15
+ - **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.
16
+ - **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.
17
+ - **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.
18
+ - **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.
19
+ - **Validation.** New `prompt-section` unit suite.
20
+
5
21
  ## 0.15.54 — 2026-09-26
6
22
 
7
23
  - **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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.15.54",
4
+ "version": "0.15.56",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": [
7
7
  "tabs",
@@ -1232,11 +1232,16 @@ async function chromeInputFill(params) {
1232
1232
  await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
1233
1233
  await sleep(rng(20, 60));
1234
1234
  }
1235
- await contentEditableInTab(tab.id, params);
1236
- // Delete selection.
1237
- await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
1238
- await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
1239
- await sleep(rng(20, 60));
1235
+ if (await contentEditableInTab(tab.id, params)) {
1236
+ // The requested editor's whole contents are already selected; delete the selection.
1237
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
1238
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
1239
+ await sleep(rng(20, 60));
1240
+ } else {
1241
+ // A triple-click selects only one line of a multi-line textarea. Select the whole value with
1242
+ // Chrome's selectAll editing command before deleting.
1243
+ await selectAllAndDelete(tab.id);
1244
+ }
1240
1245
  const text = String(params.text || "");
1241
1246
  const typing = await typeTextInTab(tab.id, text, params.perCharacter);
1242
1247
  if (params.submit) await chromeInputKey({ ...params, targetId: tab.id, key: "Enter" });
@@ -1706,10 +1711,14 @@ async function groupTab(tab, title, color) {
1706
1711
  const groupTitle = cleanGroupTitle(title);
1707
1712
  let groupId = tab.groupId;
1708
1713
  if (typeof groupId !== "number" || groupId < 0) {
1709
- const existing = await findGroupByTitle(tab.windowId, groupTitle);
1714
+ // Some browsers omit windowId on freshly created tabs; read it back instead of guessing.
1715
+ const windowId = typeof tab.windowId === "number" ? tab.windowId : (await chrome.tabs.get(tab.id).catch(() => null))?.windowId;
1716
+ const existing = await findGroupByTitle(windowId, groupTitle);
1717
+ // Without createProperties.windowId Chrome creates the group in the *current* (user's) window
1718
+ // and moves the tab there, pulling Pi's automation tab out of its own window as a hidden tab.
1710
1719
  groupId = existing !== null
1711
1720
  ? await chrome.tabs.group({ groupId: existing, tabIds: [tab.id] })
1712
- : await chrome.tabs.group({ tabIds: [tab.id] });
1721
+ : await chrome.tabs.group(typeof windowId === "number" ? { tabIds: [tab.id], createProperties: { windowId } } : { tabIds: [tab.id] });
1713
1722
  }
1714
1723
  await chrome.tabGroups.update(groupId, { title: groupTitle, color: cleanGroupColor(color), collapsed: false });
1715
1724
  const grouped = await chrome.tabs.get(tab.id);
@@ -1743,8 +1752,20 @@ async function dispatch(action, params) {
1743
1752
  const existingGroup = await findGroupRecordByTitle(groupTitle);
1744
1753
  const createParams = { url: params.url || "about:blank", active: foregroundRequested(params) };
1745
1754
  if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
1746
- const tab = await chrome.tabs.create(createParams);
1747
- await trackSessionTab(sessionKeyOf(params), tab.id, true);
1755
+ // Register the creation as in flight until the tab is tracked, so early capture (which only
1756
+ // runs in tracked tabs) waits for it instead of skipping the new tab's first page load.
1757
+ const creation = (async () => {
1758
+ const created = await chrome.tabs.create(createParams);
1759
+ await trackSessionTab(sessionKeyOf(params), created.id, true);
1760
+ return created;
1761
+ })();
1762
+ pendingTabCreations.add(creation);
1763
+ let tab;
1764
+ try {
1765
+ tab = await creation;
1766
+ } finally {
1767
+ pendingTabCreations.delete(creation);
1768
+ }
1748
1769
  try {
1749
1770
  await bringToFront(tab, params);
1750
1771
  const grouped = await groupTab(tab, groupTitle, params.groupColor);
@@ -2293,14 +2314,26 @@ async function unregisterInitScript(tabId) {
2293
2314
  await cdp(tabId, "Page.removeScriptToEvaluateOnNewDocument", { identifier }).catch(() => undefined);
2294
2315
  }
2295
2316
 
2296
- // Always inject early console/network capture at document_start on every navigation.
2297
- // Catches console messages, errors, and network requests that fire during page load,
2298
- // before chrome_snapshot or chrome_evaluate install the instrumentation normally.
2299
- // The function installEarlyCapture sets __piChromeWrapped flags so the post-hoc
2300
- // installPiChromeInstrumentation() call is idempotent.
2317
+ // Inject early console/network capture at document_start, but only into tabs Pi owns or has used
2318
+ // (its automation tab and tabs tracked for a session). Your other tabs are never patched. Catches
2319
+ // console messages, errors, and network requests that fire during page load, before chrome_snapshot
2320
+ // or chrome_evaluate install the instrumentation normally. installEarlyCapture sets __piChromeWrapped
2321
+ // flags so the post-hoc installPiChromeInstrumentation() call is idempotent.
2322
+ const pendingTabCreations = new Set(); // in-flight Pi tab creations, resolved once tracked
2323
+
2324
+ async function isPiTrackedTab(tabId) {
2325
+ await Promise.all([hydrateAutomationTargets(), hydrateSessionTabs()]);
2326
+ // A tab Pi is creating right now can commit its first page before tab.new has tracked it.
2327
+ if (pendingTabCreations.size) await Promise.allSettled([...pendingTabCreations]);
2328
+ if (isPiChromeOwnedTarget(tabId)) return true;
2329
+ for (const tabs of sessionTabs.values()) if (tabs.has(tabId)) return true;
2330
+ return false;
2331
+ }
2332
+
2301
2333
  if (chrome.webNavigation && chrome.webNavigation.onCommitted) {
2302
- chrome.webNavigation.onCommitted.addListener((details) => {
2334
+ chrome.webNavigation.onCommitted.addListener(async (details) => {
2303
2335
  if (details.frameId !== 0) return;
2336
+ if (!(await isPiTrackedTab(details.tabId).catch(() => false))) return;
2304
2337
  chrome.scripting.executeScript({
2305
2338
  target: { tabId: details.tabId, frameIds: [0] },
2306
2339
  world: "MAIN",
@@ -2592,15 +2625,9 @@ function installPiChromeInstrumentation() {
2592
2625
  level,
2593
2626
  timestamp: Date.now(),
2594
2627
  url: location.href,
2595
- args: Array.from(args).map((arg) => {
2596
- try {
2597
- if (typeof arg === "string") return arg;
2598
- if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
2599
- return JSON.parse(JSON.stringify(arg));
2600
- } catch {
2601
- return String(arg);
2602
- }
2603
- }),
2628
+ // Keep the raw values and serialize only when Pi lists messages. Serializing here reads every
2629
+ // logged object (getters, toString), which is exactly what anti-DevTools checks watch for.
2630
+ args: Array.from(args),
2604
2631
  });
2605
2632
  if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
2606
2633
  };
@@ -2715,15 +2742,7 @@ function installEarlyCapture() {
2715
2742
  level: level,
2716
2743
  timestamp: Date.now(),
2717
2744
  url: location.href,
2718
- args: Array.from(args).map(function(arg) {
2719
- try {
2720
- if (typeof arg === "string") return arg;
2721
- if (arg instanceof Error) return { name: arg.name, message: arg.message, stack: arg.stack };
2722
- return JSON.parse(JSON.stringify(arg));
2723
- } catch (e) {
2724
- return String(arg);
2725
- }
2726
- }),
2745
+ args: Array.from(args), // raw; serialized on read (see listConsoleMessages)
2727
2746
  });
2728
2747
  if (state.console.length > 500) state.console.splice(0, state.console.length - 500);
2729
2748
  }
@@ -2917,7 +2936,7 @@ async function clickPage(selector, uid, x, y) {
2917
2936
  // Inspect recent console errors for activation-gate rejections.
2918
2937
  const recent = (state.console || []).slice(-8);
2919
2938
  const hit = recent.find((e) => /NotAllowedError|Document is not focused|requires transient activation|gesture is required/.test(
2920
- (e.args || []).map((a) => typeof a === "string" ? a : (a && a.message) || JSON.stringify(a)).join(" ")
2939
+ (e.args || []).map((a) => typeof a === "string" ? a : a instanceof Error ? a.message : "").join(" ")
2921
2940
  ));
2922
2941
  if (hit) { suggestChromeInput = true; suggestReason = "recent console error indicates user-activation gate"; }
2923
2942
  }
@@ -3285,7 +3304,85 @@ async function pressKeyInPage(key) {
3285
3304
  function listConsoleMessages(clear) {
3286
3305
  installPiChromeInstrumentation();
3287
3306
  const state = getPiChromeState();
3288
- const messages = state.console.slice();
3307
+ // Serialize without running page code. Anti-DevTools checks log objects with getters or a custom
3308
+ // toString and treat any call as "DevTools is open" (issue #9). Copy data properties only, report
3309
+ // accessors as "[getter]" without calling them, and describe built-ins through native getters.
3310
+ const nativeSource = Function.prototype.toString;
3311
+ const isNative = (fn) => { try { return typeof fn === "function" && nativeSource.call(fn).includes("[native code]"); } catch { return false; } };
3312
+ const protoGetter = (proto, key) => { try { return Object.getOwnPropertyDescriptor(proto, key)?.get; } catch { return undefined; } };
3313
+ const callGetter = (proto, key, value) => { const get = protoGetter(proto, key); return get ? get.call(value) : undefined; };
3314
+ const dataProp = (value, key) => {
3315
+ for (let obj = value, hops = 0; obj && hops < 20; obj = Object.getPrototypeOf(obj), hops++) {
3316
+ const desc = Object.getOwnPropertyDescriptor(obj, key);
3317
+ if (!desc) continue;
3318
+ if ("value" in desc) return { ok: true, value: desc.value };
3319
+ return isNative(desc.get) ? { ok: true, value: desc.get.call(value) } : { ok: false };
3320
+ }
3321
+ return { ok: true, value: undefined };
3322
+ };
3323
+ const serialize = (value, depth = 0, seen = new Set()) => {
3324
+ try {
3325
+ const type = typeof value;
3326
+ if (value === null || type === "string" || type === "boolean") return value;
3327
+ if (type === "number") return Number.isFinite(value) ? value : String(value);
3328
+ if (type === "undefined") return "undefined";
3329
+ if (type === "bigint") return `${BigInt.prototype.toString.call(value)}n`;
3330
+ if (type === "symbol") return Symbol.prototype.toString.call(value);
3331
+ if (type === "function") {
3332
+ const name = dataProp(value, "name");
3333
+ return `[function ${name.ok && typeof name.value === "string" && name.value ? name.value : "anonymous"}]`;
3334
+ }
3335
+ if (seen.has(value)) return "[circular]";
3336
+ if (value instanceof Error) {
3337
+ const out = {};
3338
+ for (const key of ["name", "message"]) {
3339
+ const prop = dataProp(value, key);
3340
+ out[key] = prop.ok ? (typeof prop.value === "string" ? prop.value : serialize(prop.value, depth + 1, seen)) : "[getter]";
3341
+ }
3342
+ // V8 formats `stack` lazily from name/message, so only read it when both are plain data.
3343
+ const stack = out.name !== "[getter]" && out.message !== "[getter]" ? dataProp(value, "stack") : { ok: false };
3344
+ out.stack = stack.ok ? (typeof stack.value === "string" ? stack.value : undefined) : "[getter]";
3345
+ return out;
3346
+ }
3347
+ if (value instanceof Date) return Date.prototype.toISOString.call(value);
3348
+ if (value instanceof RegExp) return `/${callGetter(RegExp.prototype, "source", value)}/${callGetter(RegExp.prototype, "flags", value)}`;
3349
+ if (typeof Node !== "undefined" && value instanceof Node) {
3350
+ const name = String(callGetter(Node.prototype, "nodeName", value) || "node").toLowerCase();
3351
+ if (typeof Element === "undefined" || !(value instanceof Element)) return `<${name}>`;
3352
+ const id = callGetter(Element.prototype, "id", value);
3353
+ const cls = String(callGetter(Element.prototype, "className", value) || "").trim().split(/\s+/).filter(Boolean).slice(0, 3);
3354
+ return `<${name}${id ? `#${id}` : ""}${cls.map((c) => `.${c}`).join("")}>`;
3355
+ }
3356
+ if (value instanceof Map) return `[Map(${callGetter(Map.prototype, "size", value)})]`;
3357
+ if (value instanceof Set) return `[Set(${callGetter(Set.prototype, "size", value)})]`;
3358
+ if (depth >= 4) return Array.isArray(value) ? "[array]" : "[object]";
3359
+ seen.add(value);
3360
+ if (Array.isArray(value)) {
3361
+ const length = dataProp(value, "length");
3362
+ const n = length.ok ? Math.min(Number(length.value) || 0, 50) : 0;
3363
+ const out = [];
3364
+ for (let i = 0; i < n; i++) {
3365
+ const item = dataProp(value, String(i));
3366
+ out.push(item.ok ? serialize(item.value, depth + 1, seen) : "[getter]");
3367
+ }
3368
+ if (length.ok && length.value > 50) out.push(`[${length.value - 50} more]`);
3369
+ seen.delete(value);
3370
+ return out;
3371
+ }
3372
+ const out = {};
3373
+ const keys = Object.keys(value);
3374
+ for (const key of keys.slice(0, 50)) {
3375
+ const desc = Object.getOwnPropertyDescriptor(value, key);
3376
+ out[key] = desc && "value" in desc ? serialize(desc.value, depth + 1, seen) : "[getter]";
3377
+ }
3378
+ if (keys.length > 50) out["…"] = `${keys.length - 50} more keys`;
3379
+ seen.delete(value);
3380
+ return out;
3381
+ } catch {
3382
+ return "[unserializable]";
3383
+ }
3384
+ };
3385
+ const messages = state.console.map((entry) => ({ ...entry, args: Array.from(entry.args || [], (arg) => serialize(arg)) }));
3289
3386
  if (clear) state.console = [];
3290
3387
  return { messages, count: messages.length };
3291
3388
  }
@@ -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
  };
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
2
3
  import { Type } from "typebox";
3
4
  import { existsSync, readFileSync, statSync } from "node:fs";
4
5
  import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
@@ -801,6 +802,14 @@ const CHROME_TOOL_NAMES = [
801
802
  "chrome_cdp_targets",
802
803
  ] as const;
803
804
  const CHROME_TOOL_NAME_SET = new Set<string>(CHROME_TOOL_NAMES);
805
+ const CHROME_TOOL_CHANGE_ENTRY = "pi-chrome-tool-change";
806
+ type ChromeToolChangeEntry = {
807
+ content: string;
808
+ action: "authorized" | "reauthorized" | "revoked" | "expired";
809
+ tools: string[];
810
+ authorizedUntil?: number | "indefinite";
811
+ at: number;
812
+ };
804
813
 
805
814
  function StringEnum<T extends readonly [string, ...string[]]>(values: T) {
806
815
  return Type.Union(values.map((value) => Type.Literal(value)) as [ReturnType<typeof Type.Literal>, ...ReturnType<typeof Type.Literal>[]]);
@@ -890,17 +899,15 @@ export default function (pi: ExtensionAPI): void {
890
899
  : action === "expired"
891
900
  ? "Chrome tools disabled because /chrome authorize grant expired."
892
901
  : "Chrome tools disabled by /chrome revoke.";
893
- pi.sendMessage({
894
- customType: "pi-chrome-tool-change",
902
+ // Session-history log only (custom entry, not sent to the model). The model already learns about
903
+ // the change from pi's tool/prompt-section delta, so a model-facing message would be redundant.
904
+ pi.appendEntry<ChromeToolChangeEntry>(CHROME_TOOL_CHANGE_ENTRY, {
895
905
  content,
896
- display: true,
897
- details: {
898
- action,
899
- tools: [...CHROME_TOOL_NAMES],
900
- authorizedUntil: options.authorizedUntil,
901
- at: Date.now(),
902
- },
903
- }, { triggerTurn: false });
906
+ action,
907
+ tools: [...CHROME_TOOL_NAMES],
908
+ authorizedUntil: options.authorizedUntil,
909
+ at: Date.now(),
910
+ });
904
911
  };
905
912
 
906
913
  // Bound the entire request, including shared-owner forwarding/takeover. Revoke can launch
@@ -1088,6 +1095,12 @@ export default function (pi: ExtensionAPI): void {
1088
1095
  updateChromeStatus(ctx);
1089
1096
  });
1090
1097
 
1098
+ // Render authorization log entries in the transcript. Custom entries never reach the model.
1099
+ pi.registerEntryRenderer?.<ChromeToolChangeEntry>(CHROME_TOOL_CHANGE_ENTRY, (entry, _options, theme) => {
1100
+ const content = entry.data?.content ?? "Chrome authorization changed.";
1101
+ return new Text(theme.fg("dim", `[pi-chrome] ${content}`), 1, 0);
1102
+ });
1103
+
1091
1104
  pi.on("session_shutdown", async (event) => {
1092
1105
  clearAuthExpiryTimer();
1093
1106
  clearCountdownInterval();
@@ -1100,13 +1113,26 @@ export default function (pi: ExtensionAPI): void {
1100
1113
  }
1101
1114
  });
1102
1115
 
1116
+ // Contribute the primer as a named prompt section instead of returning `systemPrompt`.
1117
+ // Returning `systemPrompt` forces a whole-prompt override, which makes pi collapse every system
1118
+ // message into a new leading prompt each run (busting the provider prompt cache and disabling
1119
+ // pi's append-only section/tool deltas for all extensions). Mutating `systemPromptOptions.sections`
1120
+ // lets pi diff the section and append a small system-message delta when it appears/disappears.
1103
1121
  pi.on("before_agent_start", (event) => {
1104
- if (!chromeToolsRegistered || !chromeControlAuthorized()) {
1105
- return { systemPrompt: event.systemPrompt };
1122
+ const active = chromeToolsRegistered && chromeControlAuthorized();
1123
+ const sections = (event as { systemPromptOptions?: { sections?: Record<string, string> } }).systemPromptOptions?.sections;
1124
+ if (sections) {
1125
+ if (active) sections[CHROME_PRIMER_SECTION] = CHROME_PRIMER;
1126
+ else delete sections[CHROME_PRIMER_SECTION];
1127
+ return undefined;
1106
1128
  }
1107
- const primer = `
1108
- <chrome-profile-bridge>
1109
- Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
1129
+ // Legacy pi without structured prompt sections: append the primer to the rendered prompt.
1130
+ if (!active) return undefined;
1131
+ return { systemPrompt: `${event.systemPrompt}\n<${CHROME_PRIMER_SECTION}>\n${CHROME_PRIMER}\n</${CHROME_PRIMER_SECTION}>` };
1132
+ });
1133
+
1134
+ const CHROME_PRIMER_SECTION = "chrome-profile-bridge";
1135
+ const CHROME_PRIMER = `Chrome control is available through the chrome_* tools via a companion Chrome extension installed in the user's normal Chrome profile. Tools target the existing signed-in profile: no remote-debug port, no throwaway profile.
1110
1136
 
1111
1137
  Tab/window isolation (important):
1112
1138
  - pi-chrome owns a dedicated automation window/tab. When a chrome_* tool runs with no explicit target, it acts on that pi-chrome-owned target — it never reuses or overwrites the user's currently active tab. The dedicated target is created on first use and reused afterward.
@@ -1130,10 +1156,7 @@ Usage rules:
1130
1156
  5. \`chrome_navigate\` supports an optional \`initScript\` that runs at document_start in MAIN world for the next navigation (good for seeding localStorage or stubbing Date.now).
1131
1157
  6. /chrome background on (default) is a hard policy: per-call \`background=false\` cannot override it, new tabs stay inactive, and \`chrome_tab activate\` is blocked. Ask the user to run /chrome background off when they want foreground/watch mode. With background off, per-call \`background=true\` still avoids explicit focus/tab activation. Screenshots use CDP without activating background tabs; debugger failures never fall back to tab activation. Page scripts, trusted input, native prompts, and Chrome/OS behavior can still affect focus.
1132
1158
  7. If you hit a native file-picker or privileged browser prompt gate, tell the user; generic clicks/typing/CSP gates are handled by Chrome input.
1133
- 8. Run /chrome doctor when in doubt about connectivity or capabilities.
1134
- </chrome-profile-bridge>`;
1135
- return { systemPrompt: event.systemPrompt + primer };
1136
- });
1159
+ 8. Run /chrome doctor when in doubt about connectivity or capabilities.`;
1137
1160
 
1138
1161
  // Shared handlers, dispatched by the unified /chrome command below.
1139
1162
  const doctorHandler = async (ctx: ExtensionContext) => {
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.54",
3
+ "version": "0.15.56",
4
4
  "scripts": {
5
- "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs",
5
+ "test": "node test-suite/unit/csp-eval.test.mjs && node test-suite/unit/automation-target.test.mjs && node test-suite/unit/session-cleanup.test.mjs && node test-suite/unit/background-policy.test.mjs && node test-suite/unit/input-reliability.test.mjs && node test-suite/unit/chrome-command.test.mjs && node test-suite/unit/bridge-resilience.test.mjs && node test-suite/unit/cdp-passthrough.test.mjs && node test-suite/unit/type-evidence.test.mjs && node test-suite/unit/hidden-input.test.mjs && node test-suite/unit/console-capture.test.mjs && node test-suite/unit/prompt-section.test.mjs",
6
6
  "version": "node scripts/sync-manifest-version.js",
7
7
  "prepublishOnly": "node scripts/sync-manifest-version.js"
8
8
  },
@@ -67,12 +67,16 @@
67
67
  },
68
68
  "peerDependencies": {
69
69
  "@earendil-works/pi-coding-agent": "*",
70
+ "@earendil-works/pi-tui": "*",
70
71
  "typebox": "*"
71
72
  },
72
73
  "peerDependenciesMeta": {
73
74
  "@earendil-works/pi-coding-agent": {
74
75
  "optional": true
75
76
  },
77
+ "@earendil-works/pi-tui": {
78
+ "optional": true
79
+ },
76
80
  "typebox": {
77
81
  "optional": true
78
82
  }
@@ -87,14 +87,17 @@ function makeChrome(state, { withWindows = true, withStorage = true, withTabGrou
87
87
  // Chrome closes a window automatically when its final tab is removed.
88
88
  if (tab && ![...tabs.values()].some((other) => other.windowId === tab.windowId)) windows.delete(tab.windowId);
89
89
  },
90
- group: async ({ groupId, tabIds = [] } = {}) => {
90
+ // Like Chrome: a new group goes into createProperties.windowId, else the *current* window
91
+ // (the user's), and grouped tabs move into the group's window.
92
+ group: async ({ groupId, tabIds = [], createProperties } = {}) => {
91
93
  let gid = groupId;
92
94
  if (typeof gid !== "number") {
93
95
  gid = alloc.group();
94
- const firstTab = tabs.get(tabIds[0]);
95
- groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId: firstTab ? firstTab.windowId : userWindowId });
96
+ const windowId = typeof createProperties?.windowId === "number" ? createProperties.windowId : userWindowId;
97
+ groups.set(gid, { id: gid, title: "", color: "grey", collapsed: false, windowId });
96
98
  }
97
- for (const tid of tabIds) { const t = tabs.get(tid); if (t) t.groupId = gid; }
99
+ const groupWindow = groups.get(gid).windowId;
100
+ for (const tid of tabIds) { const t = tabs.get(tid); if (t) { t.groupId = gid; t.windowId = groupWindow; } }
98
101
  return gid;
99
102
  },
100
103
  ungroup: async (id) => { const ids = Array.isArray(id) ? id : [id]; for (const tid of ids) { const t = tabs.get(tid); if (t) t.groupId = -1; } },
@@ -0,0 +1,141 @@
1
+ // Console capture must not trip anti-DevTools checks (issue #9) and must only touch Pi's tabs.
2
+ // Sites detect DevTools by logging an object whose getter/toString fires when something inspects
3
+ // it. Closed DevTools never does; our capture used to serialize every argument immediately.
4
+ import assert from "node:assert/strict";
5
+ import fs from "node:fs";
6
+ import vm from "node:vm";
7
+ import { test } from "node:test";
8
+
9
+ const workerSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/service_worker.js", import.meta.url), "utf8");
10
+ const snapshotSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/browser-extension/snapshot_injected.js", import.meta.url), "utf8");
11
+
12
+ function loadWorker() {
13
+ const navListeners = [];
14
+ const injected = [];
15
+ const noop = { addListener() {}, removeListener() {} };
16
+ const chrome = {
17
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.0" }), onInstalled: noop, onStartup: noop },
18
+ alarms: { create() {}, onAlarm: noop }, action: { onClicked: noop },
19
+ webNavigation: { onCommitted: { addListener: (fn) => navListeners.push(fn) } },
20
+ debugger: { onDetach: noop, onEvent: noop },
21
+ storage: { session: { get: async () => ({}), set: async () => {} } },
22
+ scripting: { executeScript: async (options) => { injected.push(options); return []; } },
23
+ tabs: { get: async () => null, create: async () => { throw new Error("not mocked"); } },
24
+ tabGroups: { query: async () => [] },
25
+ };
26
+ const w = { chrome, console, setTimeout, clearTimeout, setInterval: () => 0, navigator: { userAgent: "unit-test" }, fetch: async () => { throw new Error("offline"); } };
27
+ w.self = w;
28
+ vm.runInNewContext(workerSource, w);
29
+ const commit = async (tabId, frameId = 0) => {
30
+ for (const fn of navListeners) await fn({ tabId, frameId, url: "https://site.test/" });
31
+ };
32
+ return { w, injected, commit };
33
+ }
34
+
35
+ // A page world whose console records native calls and a DevTools trap object.
36
+ function pageWorld() {
37
+ const nativeCalls = [];
38
+ const trap = { tripped: 0 };
39
+ const page = vm.createContext({
40
+ console: Object.fromEntries(["debug", "log", "info", "warn", "error"].map((l) => [l, (...a) => nativeCalls.push([l, ...a])])),
41
+ location: { href: "https://site.test/" },
42
+ addEventListener() {},
43
+ fetch: undefined, XMLHttpRequest: undefined,
44
+ });
45
+ page.window = page;
46
+ page.trip = () => { trap.tripped++; };
47
+ vm.runInContext(`
48
+ var bait = { name: "bait" };
49
+ Object.defineProperty(bait, "id", { enumerable: true, get() { trip(); return "x"; } });
50
+ bait.toString = function toString() { trip(); return "bait"; };
51
+ var trapError = new Error("trap");
52
+ Object.defineProperty(trapError, "message", { get() { trip(); return "m"; } });
53
+ var trapRegex = /a+/g;
54
+ trapRegex.toString = function () { trip(); return "re"; };
55
+ Object.defineProperty(trapRegex, "source", { get() { trip(); return "x"; } });
56
+ var trapDate = new Date(0);
57
+ trapDate.toString = function () { trip(); return "d"; };
58
+ `, page);
59
+ return { page, trap, nativeCalls };
60
+ }
61
+
62
+ const fnSource = (name) => {
63
+ const start = workerSource.indexOf(`function ${name}(`);
64
+ let depth = 0;
65
+ for (let i = workerSource.indexOf("{", start); i < workerSource.length; i++) {
66
+ if (workerSource[i] === "{") depth++;
67
+ else if (workerSource[i] === "}" && --depth === 0) return workerSource.slice(start, i + 1);
68
+ }
69
+ throw new Error(`missing ${name}`);
70
+ };
71
+
72
+ for (const [label, install] of [
73
+ ["early capture", (page) => vm.runInContext(`(${fnSource("installEarlyCapture")})()`, page)],
74
+ ["post-hoc instrumentation", (page) => vm.runInContext(`${fnSource("getPiChromeState")}\n(${fnSource("installPiChromeInstrumentation")})()`, page)],
75
+ ["snapshot_injected instrumentation", (page) => { vm.runInContext(snapshotSource, page); }],
76
+ ]) {
77
+ test(`${label}: logging an object does not read it until Pi lists messages`, () => {
78
+ const { page, trap, nativeCalls } = pageWorld();
79
+ install(page);
80
+ if (label.startsWith("snapshot")) {
81
+ // snapshot_injected installs lazily through its snapshot entry point; force the install.
82
+ assert.equal(typeof page.__piChromeSnapshotPage, "function");
83
+ try { page.__piChromeSnapshotPage({}); } catch {}
84
+ }
85
+ vm.runInContext("console.log('hello', bait); console.error(new Error('boom')); console.log(trapError, trapRegex, trapDate)", page);
86
+ assert.equal(trap.tripped, 0, "no getter/toString fired while the page runs");
87
+ assert.equal(nativeCalls.length, 3, "native console still receives the calls");
88
+ const listed = vm.runInContext(`${fnSource("getPiChromeState")}\n${fnSource("installPiChromeInstrumentation")}\n(${fnSource("listConsoleMessages")})(false)`, page);
89
+ assert.equal(trap.tripped, 0, "listing does not fire page getters or toString either");
90
+ assert.equal(listed.count, 3);
91
+ assert.equal(listed.messages[0].args[0], "hello");
92
+ assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[0].args[1])), { name: "bait", id: "[getter]", toString: "[function toString]" });
93
+ assert.equal(listed.messages[1].args[0].message, "boom");
94
+ assert.match(listed.messages[1].args[0].stack, /boom/);
95
+ const [err, re, date] = listed.messages[2].args;
96
+ assert.equal(err.message, "[getter]");
97
+ assert.equal(re, "/a+/g", "regex described through native getters");
98
+ assert.equal(date, "1970-01-01T00:00:00.000Z");
99
+ });
100
+ }
101
+
102
+ test("listing survives values that cannot be serialized", () => {
103
+ const { page } = pageWorld();
104
+ vm.runInContext(`(${fnSource("installEarlyCapture")})()`, page);
105
+ vm.runInContext("const loop = { a: [1, 2n, undefined, Symbol('s')] }; loop.self = loop; console.log(loop); console.log(new Proxy({}, { ownKeys() { throw new Error('no'); } }), new Map([[1, 2]]), function named() {})", page);
106
+ const listed = vm.runInContext(`${fnSource("getPiChromeState")}\n${fnSource("installPiChromeInstrumentation")}\n(${fnSource("listConsoleMessages")})(true)`, page);
107
+ assert.equal(listed.count, 2);
108
+ assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[0].args[0])), { a: [1, "2n", "undefined", "Symbol(s)"], self: "[circular]" });
109
+ assert.deepEqual(JSON.parse(JSON.stringify(listed.messages[1].args)), ["[unserializable]", "[Map(1)]", "[function named]"]);
110
+ assert.equal(vm.runInContext("window.__PI_CHROME_STATE__.console.length", page), 0, "clear still empties the buffer");
111
+ });
112
+
113
+ test("early capture is injected only into tabs Pi owns or tracks", async () => {
114
+ const h = loadWorker();
115
+ await h.commit(10);
116
+ assert.equal(h.injected.length, 0, "a user tab is never patched");
117
+ vm.runInContext("automationTargets.set('s', { tabId: 11 }); sessionTabs.set('s', new Map([[12, { created: true }]]));", h.w);
118
+ await h.commit(11);
119
+ await h.commit(12);
120
+ await h.commit(11, 3); // subframe
121
+ assert.deepEqual(h.injected.map((o) => o.target.tabId), [11, 12]);
122
+ assert.ok(h.injected.every((o) => o.func.name === "installEarlyCapture" && o.injectImmediately === true));
123
+ });
124
+
125
+ test("a Pi tab whose first page commits before tab.new tracks it still gets early capture", async () => {
126
+ const h = loadWorker();
127
+ let commit;
128
+ h.w.chrome.tabs.create = async (props) => {
129
+ // Chrome can commit the first document while tab.new is still recording ownership.
130
+ commit = h.commit(21);
131
+ return { id: 21, windowId: 1, url: props.url, status: "loading" };
132
+ };
133
+ h.w.groupTab = async (tab) => ({ tab: { id: tab.id }, group: { title: "Pi" } });
134
+ h.w.waitForTabSettled = async () => true;
135
+ h.w.formatTab = async (tab) => tab;
136
+ await h.w.dispatch("tab.new", { url: "https://site.test/", background: true, sessionKey: "s" });
137
+ await commit;
138
+ assert.deepEqual(h.injected.map((o) => o.target.tabId), [21]);
139
+ await h.commit(22);
140
+ assert.deepEqual(h.injected.map((o) => o.target.tabId), [21], "unrelated tabs stay untouched");
141
+ });
@@ -215,6 +215,18 @@ for (const perCharacter of [false, true]) {
215
215
  });
216
216
  }
217
217
 
218
+ test("fill selects a textarea's whole value with the selectAll command, not just the clicked line", async () => {
219
+ const h = harness({ tag: "TEXTAREA", editable: false });
220
+ const result = await h.call("fill", { selector: "#target", text: "NEW", domFallback: false });
221
+ assert.equal(result.input, "chrome");
222
+ const keys = h.commands("Input.dispatchKeyEvent");
223
+ const selectAll = keys.findIndex((c) => (c.params.commands || []).includes("selectAll"));
224
+ const del = keys.findIndex((c) => c.params.key === "Delete" && c.params.type === "keyDown");
225
+ assert.ok(selectAll >= 0, "selectAll editing command sent");
226
+ assert.ok(del > selectAll, "delete follows select-all");
227
+ assert.equal(h.selected.length, 0, "no DOM range selection for value fields");
228
+ });
229
+
218
230
  test("fill refuses to select an unrelated focused editor", async () => {
219
231
  const h = harness({ initial: "keep this" });
220
232
  h.page.document.activeElement = { isContentEditable: true, contains: () => false };
@@ -0,0 +1,52 @@
1
+ // The Chrome primer must be a structured prompt section, never a forced `systemPrompt` override.
2
+ // A forced override makes pi collapse all system messages into a new leading prompt every run,
3
+ // which busts the provider prompt cache.
4
+ import assert from "node:assert/strict";
5
+ import fs from "node:fs";
6
+ import vm from "node:vm";
7
+ import { stripTypeScriptTypes } from "node:module";
8
+ import { test } from "node:test";
9
+
10
+ const source = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8");
11
+ const from = source.indexOf('pi.on("before_agent_start",');
12
+ const to = source.indexOf("// Shared handlers,", from);
13
+ assert.ok(from >= 0 && to > from, "Missing before_agent_start section");
14
+ const handlerSource = stripTypeScriptTypes(source.slice(from, to));
15
+
16
+ function load({ registered = true, authorized = true } = {}) {
17
+ let handler;
18
+ const pi = { on: (name, fn) => { if (name === "before_agent_start") handler = fn; } };
19
+ vm.runInNewContext(`${handlerSource}`, {
20
+ pi,
21
+ chromeToolsRegistered: registered,
22
+ chromeControlAuthorized: () => authorized,
23
+ });
24
+ return handler;
25
+ }
26
+
27
+ test("authorized: adds primer section and does not force systemPrompt", () => {
28
+ const event = { systemPrompt: "base", systemPromptOptions: { sections: {} } };
29
+ const result = load()(event);
30
+ assert.equal(result, undefined);
31
+ const primer = event.systemPromptOptions.sections["chrome-profile-bridge"];
32
+ assert.match(primer, /^Chrome control is available/);
33
+ assert.doesNotMatch(primer, /<\/?chrome-profile-bridge>/, "pi wraps sections in their tag");
34
+ });
35
+
36
+ test("locked: removes primer section and does not force systemPrompt", () => {
37
+ const event = { systemPrompt: "base", systemPromptOptions: { sections: { "chrome-profile-bridge": "stale", other: "keep" } } };
38
+ assert.equal(load({ authorized: false })(event), undefined);
39
+ assert.deepEqual(event.systemPromptOptions.sections, { other: "keep" });
40
+ });
41
+
42
+ test("tools not registered: no primer", () => {
43
+ const event = { systemPrompt: "base", systemPromptOptions: { sections: {} } };
44
+ assert.equal(load({ registered: false })(event), undefined);
45
+ assert.deepEqual(event.systemPromptOptions.sections, {});
46
+ });
47
+
48
+ test("legacy pi without sections: appends only when authorized", () => {
49
+ assert.equal(load({ authorized: false })({ systemPrompt: "base" }), undefined);
50
+ const result = load()({ systemPrompt: "base" });
51
+ assert.match(result.systemPrompt, /^base\n<chrome-profile-bridge>\nChrome control is available[\s\S]*<\/chrome-profile-bridge>$/);
52
+ });