pi-chrome 0.15.49 → 0.15.53

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.
@@ -173,8 +173,67 @@ function formatChromeSnapshot(snapshot: any): string {
173
173
  }
174
174
 
175
175
  function formatIncludedSnapshotText(raw: unknown, text: string): string {
176
- const snapshot = raw && typeof raw === "object" ? (raw as { snapshot?: unknown }).snapshot : undefined;
177
- return snapshot ? `${text}\n\n${formatChromeSnapshot(snapshot)}` : text;
176
+ const payload = raw && typeof raw === "object"
177
+ ? (raw as { snapshot?: unknown; navigation?: { settled?: boolean; from?: string; to?: string; waitedMs?: number } })
178
+ : undefined;
179
+ const snapshot = payload?.snapshot;
180
+ const navigation = payload?.navigation;
181
+ let body = text;
182
+ if (navigation) {
183
+ body += navigation.settled === false
184
+ ? `\n⚠ The action started a navigation that had not finished after ${navigation.waitedMs ?? "?"}ms (${navigation.from || "?"} → ${navigation.to || "?"}). The snapshot below may describe the page being replaced; re-check with chrome_snapshot.`
185
+ : `\nNavigated ${navigation.from || "?"} → ${navigation.to || "?"} (waited ${navigation.waitedMs ?? "?"}ms for load).`;
186
+ }
187
+ return snapshot ? `${body}\n\n${formatChromeSnapshot(snapshot)}` : body;
188
+ }
189
+
190
+ // Pi-side text for chrome_type's before/after evidence. The worker returns valueBefore/valueAfter
191
+ // (or valueRedacted), existingTextLengthBefore, and insertedAt.
192
+ function describeTypeEvidence(result: unknown, typedLength: number, pressEnter: boolean): string[] {
193
+ if (!result || typeof result !== "object") return [];
194
+ const r = result as Record<string, unknown>;
195
+ const lines: string[] = [];
196
+ if (r.valueRedacted === true) {
197
+ lines.push(`Field value [redacted] (${r.existingTextLengthBefore ?? "?"} → ${r.valueLengthAfter ?? "?"} chars; insertedAt=${r.insertedAt ?? "unknown"}).`);
198
+ } else if (typeof r.valueBefore === "string" || typeof r.valueAfter === "string") {
199
+ const before = typeof r.valueBefore === "string" ? JSON.stringify(r.valueBefore) : "?";
200
+ const after = typeof r.valueAfter === "string" ? JSON.stringify(r.valueAfter) : "?";
201
+ lines.push(`Field went from ${before} to ${after}${r.insertedAt ? ` (insertedAt=${r.insertedAt})` : ""}.`);
202
+ }
203
+ const unchanged = typedLength > 0 && r.replaced !== true
204
+ && typeof r.existingTextLengthBefore === "number" && r.existingTextLengthBefore === r.valueLengthAfter
205
+ && (r.valueRedacted === true || r.valueBefore === r.valueAfter);
206
+ if (unchanged) {
207
+ lines.push("\u26a0 The field value did not change: the keystrokes did not reach this field. It may not have keyboard focus (background/hidden tabs often cannot take focus); focus it with a uid/selector click, or ask the user to run /chrome background off, then verify.");
208
+ } else if (r.insertedAt === "caret-middle" && typedLength > 0) {
209
+ lines.push("⚠ Text was spliced into the middle of existing content; chrome_type does NOT replace. Use chrome_fill (or chrome_type replace=true) to replace a field's value.");
210
+ if (pressEnter) lines.push("⚠ If Enter submitted the form, it submitted the spliced value above, not just your text.");
211
+ } else if (r.insertedAt === "caret-end" && typedLength > 0) {
212
+ lines.push("Note: text was appended to existing content (chrome_type does not replace).");
213
+ }
214
+ return lines;
215
+ }
216
+
217
+ // Keep raw CDP payloads (screenshots, huge DOM dumps) out of the model context and transcript.
218
+ const CDP_OVERSIZE_JSON_CHARS = 262_144;
219
+ function formatCdpResult(method: string, value: unknown): ToolTextResult {
220
+ const data = value && typeof value === "object" ? (value as { data?: unknown }).data : undefined;
221
+ if (typeof data === "string" && (/captureScreenshot|printToPDF|screencast/i.test(method) || data.length >= CDP_OVERSIZE_JSON_CHARS)) {
222
+ const padding = data.endsWith("==") ? 2 : data.endsWith("=") ? 1 : 0;
223
+ const bytes = Math.floor((data.length / 4) * 3) - padding;
224
+ const fields = Object.keys(value as object).filter((key) => key !== "data");
225
+ const text = `CDP ${method} returned ~${bytes} bytes in its "data" field; the payload was omitted to protect the context window. Use chrome_screenshot to save images to disk.${fields.length ? ` Other fields: ${fields.join(", ")}.` : ""}`;
226
+ return { content: [{ type: "text", text }], details: { value: { omitted: "data-field", bytes, fields } } };
227
+ }
228
+ const text = value === undefined ? "undefined" : typeof value === "string" ? value : (safeJson(value) ?? "undefined");
229
+ if (text.length > CDP_OVERSIZE_JSON_CHARS) {
230
+ const fields = value && typeof value === "object" ? Object.keys(value as object) : [];
231
+ return {
232
+ content: [{ type: "text", text: `${truncateText(text)}\n\n[details omitted: ${text.length} chars of JSON]` }],
233
+ details: { value: { omitted: "oversized-result", chars: text.length, fields } },
234
+ };
235
+ }
236
+ return { content: [{ type: "text", text: truncateText(text) }], details: { value } };
178
237
  }
179
238
 
180
239
  function formatChromeInspect(inspect: any): string {
@@ -339,6 +398,31 @@ class ChromeProfileBridge {
339
398
  await this.bindServerOrClient();
340
399
  }
341
400
 
401
+ // A client-mode session (another Pi session or a subagent owns the port) never sees extension
402
+ // polls itself, so its local status always reads "not connected". Ask the owner instead.
403
+ async connectionStatus(): Promise<Record<string, unknown>> {
404
+ const local = this.status();
405
+ if (this.mode !== "client") return local;
406
+ const controller = new AbortController();
407
+ const timer = setTimeout(() => controller.abort(), 1_000);
408
+ try {
409
+ const response = await fetch(`${this.url}/status`, { signal: controller.signal });
410
+ if (!response.ok) return local;
411
+ const owner = (await response.json()) as Record<string, unknown>;
412
+ return {
413
+ ...local,
414
+ connected: owner.connected === true,
415
+ lastSeenAt: typeof owner.lastSeenAt === "number" ? owner.lastSeenAt : local.lastSeenAt,
416
+ clientName: typeof owner.clientName === "string" ? owner.clientName : local.clientName,
417
+ ownerMode: owner.mode,
418
+ };
419
+ } catch {
420
+ return local;
421
+ } finally {
422
+ clearTimeout(timer);
423
+ }
424
+ }
425
+
342
426
  // Try to own the bridge port. On success we are the server; on EADDRINUSE another Pi
343
427
  // session owns it and we run as a client that forwards commands to that owner.
344
428
  private async bindServerOrClient(): Promise<void> {
@@ -643,6 +727,8 @@ const CHROME_TOOL_NAMES = [
643
727
  "chrome_launch",
644
728
  "chrome_tab",
645
729
  "chrome_snapshot",
730
+ "chrome_find",
731
+ "chrome_inspect",
646
732
  "chrome_navigate",
647
733
  "chrome_evaluate",
648
734
  "chrome_click",
@@ -659,6 +745,8 @@ const CHROME_TOOL_NAMES = [
659
745
  "chrome_tap",
660
746
  "chrome_scroll",
661
747
  "chrome_upload_file",
748
+ "chrome_cdp",
749
+ "chrome_cdp_targets",
662
750
  ] as const;
663
751
  const CHROME_TOOL_NAME_SET = new Set<string>(CHROME_TOOL_NAMES);
664
752
 
@@ -674,16 +762,19 @@ export default function (pi: ExtensionAPI): void {
674
762
  [PI_CHROME_AUTH_KEY]?: { until: number | "indefinite" };
675
763
  };
676
764
  const alreadyLoaded = globalState[PI_CHROME_GLOBAL_KEY];
677
- if (alreadyLoaded?.token || (alreadyLoaded && alreadyLoaded.root !== currentRoot)) {
765
+ // Only a *different* install root (two copies of pi-chrome) is a duplicate. A same-root re-entry is
766
+ // legitimate: subagent sessions (e.g. pi-subagents) load extensions into their own runner in this
767
+ // process, so this factory runs once per session. Skipping it left subagents with no chrome_* tools.
768
+ // Each instance gets its own bridge; the second binds as a client of the port owner (EADDRINUSE),
769
+ // so all sessions share the one Chrome connector. Stale flags from older releases (<=0.15.19) that
770
+ // point at this same root are harmless for the same reason.
771
+ if (alreadyLoaded && alreadyLoaded.root !== currentRoot) {
678
772
  console.warn(
679
773
  `pi-chrome already loaded from ${alreadyLoaded.root} (v${alreadyLoaded.version}); skipping duplicate from ${currentRoot}.`,
680
774
  );
681
775
  return;
682
776
  }
683
- // pi-chrome <=0.15.19 set the singleton flag but did not clear it on reload.
684
- // If the stale flag points at this same extension root, replace it instead of
685
- // skipping the freshly reloaded extension.
686
- globalState[PI_CHROME_GLOBAL_KEY] = { version: PI_CHROME_VERSION, root: currentRoot, token: instanceToken };
777
+ if (!alreadyLoaded) globalState[PI_CHROME_GLOBAL_KEY] = { version: PI_CHROME_VERSION, root: currentRoot, token: instanceToken };
687
778
 
688
779
  const bridge = new ChromeProfileBridge(DEFAULT_HOST, DEFAULT_PORT);
689
780
  let backgroundEnabled = true;
@@ -904,11 +995,11 @@ export default function (pi: ExtensionAPI): void {
904
995
  if ((action === "tab.new" || action === "tab.group") && sessionTitle !== undefined) {
905
996
  wireParams = { ...wireParams, groupTitle: sessionTitle };
906
997
  }
907
- // Any tab Pi *uses* (page.* interactions) should join this session's group, mirroring the
998
+ // Any tab Pi *uses* (page.* interactions and raw cdp.call) should join this session's group, mirroring the
908
999
  // auto-grouping that tab.new already does. Tagging the wire params lets getTabByParams pull
909
- // the resolved tab into the session group on the service-worker side. We skip tab.* actions:
1000
+ // the resolved tab into the session group on the service-worker side. We skip tab.* and cdp.targets actions:
910
1001
  // tab.new/group are forced above, and activate/close/ungroup/list must not group tabs.
911
- const shouldJoinGroup = action.startsWith("page.") && sessionTitle !== undefined && params.sessionGroupTitle === undefined;
1002
+ const shouldJoinGroup = (action.startsWith("page.") || action === "cdp.call") && sessionTitle !== undefined && params.sessionGroupTitle === undefined;
912
1003
  if (shouldJoinGroup) {
913
1004
  wireParams = { ...wireParams, sessionGroupTitle: sessionTitle, joinSessionGroup: true };
914
1005
  }
@@ -974,7 +1065,9 @@ Capability model (important):
974
1065
  - Interactive controls (click/type/fill/key/hover/drag/scroll/tap) use Chrome's real input layer via chrome.debugger / CDP. Events satisfy normal user-activation gates.
975
1066
  - Input bypasses page CSP because it is injected at browser input layer, not page JavaScript. Chrome may show the “Pi Chrome Connector started debugging this browser” banner while attached.
976
1067
  - \`chrome_evaluate\` and \`chrome_snapshot\` run in MAIN world via **CDP \`Runtime.evaluate\`**, which is not subject to the page's Content-Security-Policy. They work even on strict-CSP pages (e.g. github.com, many bank/SaaS apps) that block \`'unsafe-eval'\`. \`chrome_navigate initScript\` likewise injects at document_start via CDP and bypasses CSP. \`chrome_screenshot\`, \`chrome_tab\`, and Chrome input also work under any CSP.
977
- - Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly.
1068
+ - Input tools return structured details and support \`includeSnapshot=true\` on click/type/fill/key. Use the fresh snapshot to verify state instead of repeating blindly. If the action started a navigation, the snapshot waits (up to 5s) for the new page.
1069
+ - \`chrome_type\` inserts at the caret and never replaces; check its before/after report. Use \`chrome_fill\` to replace a field.
1070
+ - \`chrome_cdp\` runs any raw CDP method on a tab (emulation, cookies, PDF, accessibility tree, etc.) when no dedicated chrome_* tool fits; \`chrome_cdp_targets\` diagnoses debugger/overlay conflicts.
978
1071
 
979
1072
  Usage rules:
980
1073
  1. If a chrome_* tool says Chrome control is locked, ask the user to run \`/chrome authorize\` before retrying.
@@ -992,7 +1085,11 @@ Usage rules:
992
1085
  // Shared handlers, dispatched by the unified /chrome command below.
993
1086
  const doctorHandler = async (ctx: ExtensionContext) => {
994
1087
  ctx.ui.notify("Checking pi-chrome…", "info");
995
- const lines: string[] = [`pi-chrome v${PI_CHROME_VERSION}`];
1088
+ const lines: string[] = [
1089
+ `pi-chrome v${PI_CHROME_VERSION}`,
1090
+ `• Authorization: ${authSummary()}.`,
1091
+ `• Background: ${backgroundEnabled ? "on (hard)" : "off (foreground/watch mode)"}.`,
1092
+ ];
996
1093
  const status = bridge.status();
997
1094
  const roleLabel = status.mode === "client" ? "sharing another pi session's connection" : "running the Chrome connection for this machine";
998
1095
  lines.push(`• This pi session is ${roleLabel}.`);
@@ -1144,8 +1241,7 @@ Usage rules:
1144
1241
  );
1145
1242
  };
1146
1243
 
1147
- // One-line snapshot of pi-chrome's current state. Used as a header in the bare-/chrome
1148
- // picker and as the body of /chrome status.
1244
+ // Lightweight connection/auth/background header for the bare-/chrome picker. No page probes.
1149
1245
  const statusSummary = async (): Promise<string> => {
1150
1246
  const parts: string[] = [];
1151
1247
  try {
@@ -1163,11 +1259,6 @@ Usage rules:
1163
1259
  return parts.join(" · ");
1164
1260
  };
1165
1261
 
1166
- const statusHandler = async (ctx: ExtensionContext) => {
1167
- ctx.ui.notify("Checking Chrome connection…", "info");
1168
- ctx.ui.notify(await statusSummary(), "info");
1169
- };
1170
-
1171
1262
  const openAuthorizeMenu = async (ctx: ExtensionContext): Promise<void> => {
1172
1263
  while (true) {
1173
1264
  const choice = await ctx.ui.select("Authorize Chrome control", [
@@ -1225,7 +1316,7 @@ Usage rules:
1225
1316
 
1226
1317
  pi.registerCommand("chrome", {
1227
1318
  description:
1228
- "All pi-chrome controls in one place.\n /chrome authorize [15m|30m|<minutes>|indefinite] — allow this Pi session to use chrome_* tools.\n /chrome revoke — lock Chrome control.\n /chrome status — one-line snapshot of connection, auth, and background setting.\n /chrome doctor — full health check.\n /chrome onboard — install the Chrome companion extension.\n /chrome background [on|off|status|toggle] — enforce no explicit focus/tab activation, or allow foreground/watch mode.\nRun with no arguments for an interactive picker that shows current state.",
1319
+ "All pi-chrome controls in one place.\n /chrome authorize [15m|30m|<minutes>|indefinite] — allow this Pi session to use chrome_* tools.\n /chrome revoke — lock Chrome control.\n /chrome doctor — full health check plus authorization and background state.\n /chrome onboard — install the Chrome companion extension.\n /chrome background [on|off|status|toggle] — enforce no explicit focus/tab activation, or allow foreground/watch mode.\nRun with no arguments for an interactive picker that shows current state.",
1229
1320
  getArgumentCompletions: (prefix) => {
1230
1321
  const raw = prefix;
1231
1322
  const trimmedRight = raw.replace(/\s+$/, "");
@@ -1244,8 +1335,7 @@ Usage rules:
1244
1335
  candidates = [
1245
1336
  { fullValue: "authorize", label: "authorize", description: "Allow this Pi session to use chrome_* tools." },
1246
1337
  { fullValue: "revoke", label: "revoke", description: "Lock Chrome control for this Pi session." },
1247
- { fullValue: "status", label: "status", description: "One-line summary: connection, auth, and background setting." },
1248
- { fullValue: "doctor", label: "doctor", description: "Full health check. Tells you if Chrome is connected and what's wrong if it isn't." },
1338
+ { fullValue: "doctor", label: "doctor", description: "Full diagnostics: connection, version, page checks, authorization, and background state." },
1249
1339
  { fullValue: "onboard", label: "onboard", description: "Install the Chrome companion extension (first-time setup)." },
1250
1340
  { fullValue: "background", label: "background", description: "Enforce hard background or allow foreground/watch mode." },
1251
1341
  ];
@@ -1279,7 +1369,6 @@ Usage rules:
1279
1369
  switch (head) {
1280
1370
  case "authorize": return authorizeHandler(ctx, subArgs);
1281
1371
  case "revoke": return revokeHandler(ctx);
1282
- case "status": return statusHandler(ctx);
1283
1372
  case "doctor": return doctorHandler(ctx);
1284
1373
  case "onboard": return onboardHandler(ctx);
1285
1374
  case "background":
@@ -1292,7 +1381,7 @@ Usage rules:
1292
1381
  return;
1293
1382
  }
1294
1383
  default:
1295
- ctx.ui.notify(`Unknown subcommand '${head}'. Try: /chrome authorize | revoke | status | doctor | onboard | background.`, "warning");
1384
+ ctx.ui.notify(`Unknown subcommand '${head}'. Run /chrome for current state and controls, or try: /chrome authorize | revoke | doctor | onboard | background.`, "warning");
1296
1385
  }
1297
1386
  },
1298
1387
  });
@@ -1315,9 +1404,10 @@ Usage rules:
1315
1404
  headless: Type.Optional(Type.Boolean({ description: "Ignored." })),
1316
1405
  }),
1317
1406
  async execute(_id, params, signal, _onUpdate, ctx): Promise<ToolTextResult> {
1318
- if (params.url && bridge.connected) {
1407
+ const status = await bridge.connectionStatus();
1408
+ if (params.url && status.connected === true) {
1319
1409
  const result = await authorizedBridgeSend("tab.new", { url: params.url }, DEFAULT_TIMEOUT_MS, signal);
1320
- return { content: [{ type: "text", text: `Chrome bridge connected; opened ${params.url}` }], details: { status: bridge.status(), result } };
1410
+ return { content: [{ type: "text", text: `Chrome bridge connected; opened ${params.url}` }], details: { status, result } };
1321
1411
  }
1322
1412
  return {
1323
1413
  content: [
@@ -1330,10 +1420,10 @@ Usage rules:
1330
1420
  `2. Enable Developer mode.\n` +
1331
1421
  `3. Click “Load unpacked”.\n` +
1332
1422
  `4. Select: ${browserExtensionPath()}\n\n` +
1333
- `Status: ${bridge.connected ? "connected" : "waiting for extension"}.`,
1423
+ `Status: ${status.connected === true ? "connected" : "waiting for extension"}.`,
1334
1424
  },
1335
1425
  ],
1336
- details: { status: bridge.status(), extensionPath: browserExtensionPath() },
1426
+ details: { status, extensionPath: browserExtensionPath() },
1337
1427
  };
1338
1428
  },
1339
1429
  });
@@ -1564,12 +1654,13 @@ Usage rules:
1564
1654
  name: "chrome_type",
1565
1655
  label: "Chrome Type",
1566
1656
  description:
1567
- "Focus an optional snapshot uid or CSS selector, then type using Chrome's real input. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. Pass includeSnapshot=true to verify after typing.",
1568
- promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
1657
+ "Focus an optional snapshot uid or CSS selector, then type at the caret using Chrome's real input. It does NOT replace existing text: in a non-empty field the text is inserted wherever the caret lands after the focus click. Use chrome_fill (or replace=true) to replace a field's value. Contenteditables use one native text insertion; other fields use key events. Set perCharacter=true for editors needing individual keydown events. The result reports the field value before/after and insertedAt (empty|caret-end|caret-middle|replaced-selection|replaced-all), with a warning when text was spliced into existing content. Pass includeSnapshot=true to verify after typing; if the action starts a navigation, the snapshot waits (up to 5s) for it to load.",
1658
+ promptSnippet: "Type text at the caret in Chrome (does not replace existing text; use chrome_fill or replace=true).",
1569
1659
  parameters: Type.Object({
1570
1660
  text: Type.String(),
1571
1661
  uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot." })),
1572
1662
  selector: Type.Optional(Type.String({ description: "CSS selector to focus before typing." })),
1663
+ replace: Type.Optional(Type.Boolean({ default: false, description: "If true, select all of the focused field's contents and delete them before typing (real key events). Reports replaced:true." })),
1573
1664
  perCharacter: Type.Optional(Type.Boolean({ default: false, description: "Send individual key events even in contenteditables. Default: one native text insertion for contenteditables; key events for other fields." })),
1574
1665
  includeSnapshot: Type.Optional(Type.Boolean({ description: "If true, include a fresh chrome_snapshot result after typing." })),
1575
1666
  maxElements: Type.Optional(Type.Number({ default: MAX_ELEMENTS, description: "Max elements in the included snapshot." })),
@@ -1586,9 +1677,9 @@ Usage rules:
1586
1677
  const result = (params.includeSnapshot ? (raw as { result: unknown }).result : raw) as Json;
1587
1678
  const summary = summarizeActionResult(result);
1588
1679
  const into = params.uid || params.selector ? ` into ${params.uid ?? params.selector}` : "";
1589
- const base = `Typed ${params.text.length} character(s)${into}.`;
1590
- const text = summary ? `${base} (${summary})` : base;
1591
- return { content: [{ type: "text", text: formatIncludedSnapshotText(raw, text) }], details: { result: raw as Json } };
1680
+ const base = `Typed ${params.text.length} character(s)${into}${params.replace ? " (replacing existing contents)" : ""}.`;
1681
+ const lines = [summary ? `${base} (${summary})` : base, ...describeTypeEvidence(result, params.text.length, params.pressEnter === true)];
1682
+ return { content: [{ type: "text", text: formatIncludedSnapshotText(raw, lines.join("\n")) }], details: { result: raw as Json } };
1592
1683
  },
1593
1684
  });
1594
1685
 
@@ -1596,7 +1687,7 @@ Usage rules:
1596
1687
  name: "chrome_fill",
1597
1688
  label: "Chrome Fill",
1598
1689
  description:
1599
- "Set the full value of a text input, textarea, or contenteditable using Chrome click/select/delete/type input. Contenteditables use one native text insertion; perCharacter=true retains individual keydown events. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.",
1690
+ "Replace the whole value of a text input, textarea, or contenteditable using Chrome click/select/delete/type input. Unlike chrome_type, existing contents are cleared first. Contenteditables use one native text insertion; perCharacter=true retains individual keydown events. Accepts a snapshot uid or CSS selector. Pass includeSnapshot=true to verify after filling.",
1600
1691
  promptSnippet: "Fill a Chrome form field by snapshot uid or selector, optionally returning a fresh snapshot.",
1601
1692
  parameters: Type.Object({
1602
1693
  text: Type.String(),
@@ -1914,6 +2005,64 @@ Usage rules:
1914
2005
  return { content: [{ type: "text", text: `Uploaded ${paths.length} file(s) to ${params.uid ?? params.selector}` }], details: { result: result as Json } };
1915
2006
  },
1916
2007
  });
2008
+
2009
+ pi.registerTool({
2010
+ name: "chrome_cdp",
2011
+ label: "Chrome CDP Call",
2012
+ description:
2013
+ "Low-level escape hatch: run one Chrome DevTools Protocol (CDP) method against a tab (no target = this session's automation tab), e.g. Emulation.setDeviceMetricsOverride, Network.getCookies, DOM.getDocument, Page.printToPDF, Accessibility.getFullAXTree. Put CDP fields inside params. Nothing is filtered against a safe list: destructive methods run as given. Prefer dedicated chrome_* tools when they cover the task. Background mode blocks Page.bringToFront and Target.activateTarget, but other methods can still change what the user sees. Screenshot/binary payloads and results over 256 KiB are summarised instead of returned in full. Domain events are not streamed back; only the method's direct result is returned.",
2014
+ promptSnippet: "Run a raw Chrome DevTools Protocol method against a Chrome tab (low-level escape hatch).",
2015
+ parameters: Type.Object({
2016
+ method: Type.String({ description: "CDP method name, e.g. \"Runtime.evaluate\" or \"Emulation.setDeviceMetricsOverride\"." }),
2017
+ params: Type.Optional(Type.Object({}, { additionalProperties: true, description: "CDP parameter object for the method." })),
2018
+ timeoutMs: Type.Optional(Type.Number({ description: "Deadline for the CDP command in milliseconds. Default 5000, max 120000. On timeout the debugger session is detached and the next call re-attaches." })),
2019
+ targetId: Type.Optional(Type.String()),
2020
+ urlIncludes: Type.Optional(Type.String()),
2021
+ titleIncludes: Type.Optional(Type.String()),
2022
+ background: Type.Optional(Type.Boolean({ description: BACKGROUND_PARAM_DESCRIPTION })),
2023
+ }),
2024
+ async execute(_id, params, signal): Promise<ToolTextResult> {
2025
+ // Raw CDP fields passed at the top level would reach Chrome as params:{} and fail with an
2026
+ // opaque "Invalid parameters". Point the caller at params instead.
2027
+ const knownKeys = new Set(["method", "params", "timeoutMs", "targetId", "urlIncludes", "titleIncludes", "background", "foreground", "host", "port"]);
2028
+ const unknownKeys = Object.keys(params).filter((key) => !knownKeys.has(key));
2029
+ if (unknownKeys.length > 0) {
2030
+ throw new Error(`chrome_cdp received unknown top-level parameter(s): ${unknownKeys.join(", ")}. Put CDP fields inside "params", e.g. { method: "Runtime.evaluate", params: { expression: "1+1" } }.`);
2031
+ }
2032
+ // Keep the bridge deadline above the worker's (CDP deadline + 5s grace) so the precise
2033
+ // "CDP <method> timed out" error wins.
2034
+ const requested = Number(params.timeoutMs);
2035
+ const bridgeTimeoutMs = Number.isFinite(requested) && requested > 0
2036
+ ? Math.max(DEFAULT_TIMEOUT_MS, Math.min(Math.floor(requested), 120_000) + 10_000)
2037
+ : DEFAULT_TIMEOUT_MS;
2038
+ const value = await authorizedBridgeSend("cdp.call", params, bridgeTimeoutMs, signal);
2039
+ return formatCdpResult(params.method, value);
2040
+ },
2041
+ });
2042
+
2043
+ pi.registerTool({
2044
+ name: "chrome_cdp_targets",
2045
+ label: "Chrome CDP Targets",
2046
+ description:
2047
+ "List Chrome DevTools Protocol targets attached to a tab (type, url, attached, extensionId). Use it to diagnose chrome_* / chrome_cdp failures such as \"Detached while handling command\": password-manager/autofill overlays and DevTools front-ends show up here. Targets on other tabs are only counted. Does not attach the debugger or create an automation tab; with no target it reports this session's automation tab if one exists.",
2048
+ promptSnippet: "List CDP targets (including foreign extension overlays) attached to a Chrome tab.",
2049
+ parameters: Type.Object({
2050
+ targetId: Type.Optional(Type.String()),
2051
+ urlIncludes: Type.Optional(Type.String()),
2052
+ titleIncludes: Type.Optional(Type.String()),
2053
+ }),
2054
+ async execute(_id, params, signal): Promise<ToolTextResult> {
2055
+ const value = await authorizedBridgeSend("cdp.targets", params, DEFAULT_TIMEOUT_MS, signal);
2056
+ const result = value as { tab?: { id?: number; title?: string; url?: string } | null; targets?: Array<{ type?: string; attached?: boolean; url?: string; extensionId?: string }>; otherTabTargetCount?: number } | undefined;
2057
+ const targets = result?.targets ?? [];
2058
+ const text = [
2059
+ result?.tab ? `Tab ${result.tab.id}: ${result.tab.title || "(untitled)"} \u2014 ${result.tab.url ?? ""}` : "No resolved tab (pass targetId/urlIncludes/titleIncludes, or run chrome_navigate first).",
2060
+ `${targets.length} CDP target(s) on this tab${result?.otherTabTargetCount ? ` (${result.otherTabTargetCount} on other tabs, not listed)` : ""}:`,
2061
+ ...targets.map((t) => `- ${t.type ?? "?"}\t${t.attached ? "attached" : "detached"}\t${t.extensionId ? `ext=${t.extensionId}\t` : ""}${t.url ?? ""}`),
2062
+ ].join("\n");
2063
+ return { content: [{ type: "text", text: truncateText(text) }], details: { value: (value ?? null) as Json } };
2064
+ },
2065
+ });
1917
2066
  }
1918
2067
 
1919
2068
  }
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.15.49",
3
+ "version": "0.15.53",
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",
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",
6
6
  "version": "node scripts/sync-manifest-version.js",
7
7
  "prepublishOnly": "node scripts/sync-manifest-version.js"
8
8
  },
@@ -24,7 +24,7 @@ function piHarness({ session = "alpha", send } = {}) {
24
24
  let authorized = true;
25
25
  const ctx = { key: `session:${session}`, title: `Pi Session: ${session}`, cwd: "/fixture", ui: { notify: (...args) => notices.push(args) } };
26
26
  const bridge = {
27
- connected: true, status: () => ({}),
27
+ connected: true, status: () => ({}), connectionStatus: async () => ({ connected: true }),
28
28
  async send(action, params, timeout, signal) {
29
29
  calls.push({ action, params: clone(params), timeout, signal });
30
30
  if (signal?.aborted) throw new Error("Chrome command aborted");
@@ -48,6 +48,7 @@ function piHarness({ session = "alpha", send } = {}) {
48
48
  tabActionValues: [], snapshotModeValues: [], waitForValues: [], imageFormatValues: [],
49
49
  safeJson: JSON.stringify, truncateText: (s) => s, formatChromeSnapshot: JSON.stringify,
50
50
  formatChromeInspect: JSON.stringify, summarizeActionResult: () => "", formatIncludedSnapshotText: (_r, text) => text,
51
+ describeTypeEvidence: () => [], formatCdpResult: (_method, value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], details: { value } }),
51
52
  workspaceCwd: () => ctx.cwd, ...path,
52
53
  mkdir: async () => {}, writeFile: async (...args) => writes.push(args),
53
54
  };
@@ -107,6 +108,7 @@ test("every registered page tool, tab.new, and chrome_launch(url) use the centra
107
108
  ["chrome_get_network_request", { requestId: "1" }, "page.network.get"], ["chrome_screenshot", {}, "page.screenshot"],
108
109
  ["chrome_hover", {}, "page.hover"], ["chrome_drag", {}, "page.drag"], ["chrome_tap", {}, "page.tap"],
109
110
  ["chrome_scroll", {}, "page.scroll"], ["chrome_upload_file", { paths: ["fixture.txt"] }, "page.upload"],
111
+ ["chrome_cdp", { method: "Runtime.evaluate", params: { expression: "1" } }, "cdp.call"],
110
112
  ];
111
113
  for (const [name, params, action] of cases) {
112
114
  await h.tool(name, { ...params, background: false, foreground: true });
@@ -0,0 +1,138 @@
1
+ // Bridge transport resilience in the shipped worker, plus same-process (subagent) loading in index.ts.
2
+ // Worker timers are scaled 1000x down so the real 45s /next deadline elapses in ~45ms.
3
+ import assert from "node:assert/strict";
4
+ import fs from "node:fs";
5
+ import vm from "node:vm";
6
+ import { stripTypeScriptTypes } from "node:module";
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 indexSource = fs.readFileSync(new URL("../../extensions/chrome-profile-bridge/index.ts", import.meta.url), "utf8");
11
+
12
+ function loadWorker(fetchImpl) {
13
+ const listener = { addListener() {}, removeListener() {} };
14
+ const chrome = {
15
+ runtime: { id: "test", getManifest: () => ({ version: "0.0.1" }), getURL: (f) => `chrome-extension://test/${f}`, onInstalled: listener, onStartup: listener, reload() {} },
16
+ alarms: { create() {}, onAlarm: listener }, action: { onClicked: listener }, webNavigation: { onCommitted: listener },
17
+ debugger: { onDetach: listener }, scripting: {}, tabs: { onUpdated: listener },
18
+ };
19
+ const sandbox = {
20
+ chrome, console, AbortController, URL,
21
+ navigator: { userAgent: "unit-test" },
22
+ setTimeout: (fn, ms, ...args) => setTimeout(fn, Math.ceil((ms || 0) / 1000), ...args),
23
+ clearTimeout, setInterval: () => 0, clearInterval() {},
24
+ fetch: fetchImpl,
25
+ };
26
+ sandbox.self = sandbox;
27
+ vm.runInNewContext(workerSource, sandbox);
28
+ return sandbox;
29
+ }
30
+
31
+ function response(status, body, headers = {}) {
32
+ return { ok: status >= 200 && status < 300, status, headers: { get: (name) => headers[name.toLowerCase()] ?? null }, json: async () => body };
33
+ }
34
+
35
+ test("a half-open /next long poll is aborted at its deadline and polling resumes", async () => {
36
+ const requests = [];
37
+ let aborted = 0;
38
+ const worker = loadWorker((url, options = {}) => {
39
+ requests.push({ url, options });
40
+ if (requests.length === 1) {
41
+ // Zombie socket: never settles unless aborted.
42
+ return new Promise((_resolve, reject) => {
43
+ options.signal.addEventListener("abort", () => {
44
+ aborted++;
45
+ const error = new Error("The operation was aborted");
46
+ error.name = "AbortError";
47
+ reject(error);
48
+ });
49
+ });
50
+ }
51
+ return Promise.reject(new Error("bridge down"));
52
+ });
53
+ const first = worker.pollLoop();
54
+ assert.ok(requests[0]?.options.signal, "the /next fetch carries an abort signal");
55
+ // Resolves only after the scaled 45s deadline fires and the 2s backoff elapses.
56
+ await first;
57
+ assert.equal(aborted, 1, "the stalled /next fetch was aborted");
58
+ await worker.pollLoop();
59
+ assert.equal(requests.length, 2, "a later poll opens a fresh /next request instead of staying parked");
60
+ });
61
+
62
+ test("a command result is posted exactly once, retried on transport failure, and not retried on 4xx", async () => {
63
+ const posts = [];
64
+ let postStatus = [500, 200];
65
+ const worker = loadWorker(async (url, options = {}) => {
66
+ if (url.includes("/result")) {
67
+ posts.push(JSON.parse(options.body));
68
+ const status = postStatus.shift() ?? 200;
69
+ if (status === 0) throw new Error("fetch failed");
70
+ return response(status, {});
71
+ }
72
+ throw new Error("not polling in this test");
73
+ });
74
+ await worker.handleCommand({ id: "cmd-1", action: "tab.version", params: {} });
75
+ assert.equal(posts.length, 2, "one retry after HTTP 500");
76
+ assert.ok(posts.every((p) => p.id === "cmd-1" && p.ok === true), "the retry re-sends the success payload, not an error payload");
77
+
78
+ posts.length = 0;
79
+ postStatus = [0, 0, 0];
80
+ await worker.handleCommand({ id: "cmd-2", action: "tab.version", params: {} });
81
+ assert.equal(posts.length, 3, "network failures retry up to the attempt limit");
82
+ assert.ok(posts.every((p) => p.ok === true), "a failed success-post never turns into a second, error result");
83
+
84
+ posts.length = 0;
85
+ postStatus = [404];
86
+ await worker.handleCommand({ id: "cmd-3", action: "no.such.action", params: {} });
87
+ assert.equal(posts.length, 1, "4xx (unknown command id) is final");
88
+ assert.equal(posts[0].ok, false);
89
+ assert.match(posts[0].error, /Unknown action/);
90
+ });
91
+
92
+ test("cdp.call widens only its own command deadline", () => {
93
+ const worker = loadWorker(async () => { throw new Error("offline"); });
94
+ assert.equal(worker.commandTimeoutMs("page.click", { timeoutMs: 90_000 }), 25_000);
95
+ assert.equal(worker.commandTimeoutMs("cdp.call", {}), 25_000);
96
+ assert.equal(worker.commandTimeoutMs("cdp.call", { timeoutMs: 60_000 }), 65_000);
97
+ assert.equal(worker.commandTimeoutMs("cdp.call", { timeoutMs: 10_000_000 }), 125_000, "capped below the MV3 worker lifetime");
98
+ });
99
+
100
+ // ---- index.ts: a subagent session loads pi-chrome again in the same process. ----
101
+ function loadFactoryPrelude(globalState, root) {
102
+ const from = indexSource.indexOf("\tconst alreadyLoaded = globalState[PI_CHROME_GLOBAL_KEY];");
103
+ const to = indexSource.indexOf("\tconst bridge = new ChromeProfileBridge(", from);
104
+ assert.ok(from > 0 && to > from, "factory prelude section");
105
+ const body = stripTypeScriptTypes(`(() => {\n${indexSource.slice(from, to)}\nreturn true;\n})()`);
106
+ const warnings = [];
107
+ const token = Symbol(root);
108
+ const sandbox = {
109
+ globalState, currentRoot: root, instanceToken: token, PI_CHROME_GLOBAL_KEY: "loaded", PI_CHROME_VERSION: "9.9.9",
110
+ console: { warn: (msg) => warnings.push(msg) },
111
+ };
112
+ const loaded = vm.runInNewContext(body, sandbox);
113
+ return { loaded: loaded === true, token, warnings };
114
+ }
115
+
116
+ test("a same-root second load (subagent session) is not skipped, and does not steal the singleton flag", () => {
117
+ const globalState = {};
118
+ const parent = loadFactoryPrelude(globalState, "/pkg/pi-chrome");
119
+ assert.equal(parent.loaded, true);
120
+ assert.equal(globalState.loaded.token, parent.token);
121
+ const subagent = loadFactoryPrelude(globalState, "/pkg/pi-chrome");
122
+ assert.equal(subagent.loaded, true, "subagent gets its own pi-chrome instance and chrome_* tools");
123
+ assert.equal(subagent.warnings.length, 0);
124
+ assert.equal(globalState.loaded.token, parent.token, "only the first instance owns (and later clears) the flag");
125
+ });
126
+
127
+ test("a second install root is still treated as a duplicate", () => {
128
+ const globalState = {};
129
+ loadFactoryPrelude(globalState, "/npm/pi-chrome");
130
+ const other = loadFactoryPrelude(globalState, "/checkout/pi-chrome");
131
+ assert.equal(other.loaded, false);
132
+ assert.match(other.warnings[0], /already loaded from \/npm\/pi-chrome/);
133
+ });
134
+
135
+ test("a stale same-root flag from an older release does not block loading", () => {
136
+ const globalState = { loaded: { version: "0.15.19", root: "/pkg/pi-chrome" } };
137
+ assert.equal(loadFactoryPrelude(globalState, "/pkg/pi-chrome").loaded, true);
138
+ });