pi-chrome 0.14.0 → 0.14.2

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Chrome Connector",
4
- "version": "0.14.0",
4
+ "version": "0.14.2",
5
5
  "description": "Lets Pi control tabs in Chrome via a local connector at 127.0.0.1.",
6
6
  "permissions": ["tabs", "scripting", "storage", "activeTab", "alarms", "webNavigation", "debugger"],
7
7
  "host_permissions": ["<all_urls>", "http://127.0.0.1:17318/*"],
@@ -60,6 +60,13 @@ async function maybeUpgradeToTrusted(kind, params, syntheticResult, trustedFn) {
60
60
  }
61
61
  }
62
62
 
63
+ // Last few attach failures, kept for /chrome doctor + trusted.debug diagnostics.
64
+ const attachDebugLog = [];
65
+ function recordAttachEvent(entry) {
66
+ attachDebugLog.push({ ...entry, t: Date.now() });
67
+ if (attachDebugLog.length > 20) attachDebugLog.shift();
68
+ }
69
+
63
70
  async function attachDebugger(tabId) {
64
71
  if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission");
65
72
  if (attachedTabs.has(tabId)) {
@@ -67,28 +74,75 @@ async function attachDebugger(tabId) {
67
74
  entry.detachAt = Date.now() + TRUSTED_IDLE_DETACH_MS;
68
75
  return entry;
69
76
  }
77
+ // Before each attach, force-detach any stale CDP target this extension owns on the tab.
78
+ // Chrome sometimes keeps a half-dead session around (extension reload mid-attach, etc.) and
79
+ // surfaces it as "Cannot access a chrome-extension://" on the next attach attempt.
70
80
  try {
71
- await chrome.debugger.attach({ tabId }, CDP_VERSION);
72
- } catch (error) {
73
- // Chrome occasionally rejects attach with "Cannot access a chrome-extension:// URL of
74
- // different extension" right after a navigation, even when the target tab's URL is a
75
- // normal page. Wait a tick, verify the tab is on a non-privileged URL, and retry once.
76
- const msg = String(error?.message || error);
81
+ const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || [])));
82
+ for (const tgt of targets) {
83
+ if (tgt.tabId === tabId && tgt.attached) {
84
+ recordAttachEvent({ kind: "stale-target-found", tabId, target: { id: tgt.id, type: tgt.type, url: tgt.url, extensionId: tgt.extensionId } });
85
+ try { await chrome.debugger.detach({ tabId }); } catch {}
86
+ await sleep(80);
87
+ break;
88
+ }
89
+ }
90
+ } catch {}
91
+ const attemptAttach = async () => {
92
+ try {
93
+ await chrome.debugger.attach({ tabId }, CDP_VERSION);
94
+ return null;
95
+ } catch (error) {
96
+ return error;
97
+ }
98
+ };
99
+ let err = await attemptAttach();
100
+ if (err) {
101
+ const msg = String(err?.message || err);
77
102
  const transient = /Cannot access a chrome-extension|Cannot access contents of|No tab with id|Debugger is not attached|Another debugger|Target closed/i.test(msg);
78
- if (!transient) throw error;
103
+ const tabSnapshot = await chrome.tabs.get(tabId).catch(() => null);
104
+ recordAttachEvent({ kind: "attach-failed", tabId, message: msg, tabUrl: tabSnapshot?.url, transient });
105
+ if (!transient) throw err;
106
+ if (!tabSnapshot || (tabSnapshot.url || "").startsWith("chrome://") || (tabSnapshot.url || "").startsWith("chrome-extension://")) {
107
+ throw new Error(`Chrome can't attach the debugger to this tab (${tabSnapshot?.url ?? "unknown"}). Open a normal http(s) tab and try again.`);
108
+ }
79
109
  await sleep(180);
80
- const tab = await chrome.tabs.get(tabId).catch(() => null);
81
- if (!tab || (tab.url || "").startsWith("chrome://") || (tab.url || "").startsWith("chrome-extension://")) {
82
- throw new Error(`Chrome can't attach the debugger to this tab (${tab?.url ?? "unknown"}). Open a normal http(s) tab and try again.`);
110
+ err = await attemptAttach();
111
+ if (err) {
112
+ recordAttachEvent({ kind: "attach-retry-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
113
+ // One more try after a longer settle. Some Chrome builds need ~500ms after a navigation
114
+ // for content-script registration on the tab to drain before chrome.debugger.attach
115
+ // will accept the target.
116
+ await sleep(500);
117
+ err = await attemptAttach();
118
+ if (err) {
119
+ recordAttachEvent({ kind: "attach-retry2-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
120
+ throw err;
121
+ }
83
122
  }
84
- await chrome.debugger.attach({ tabId }, CDP_VERSION);
85
123
  }
124
+ recordAttachEvent({ kind: "attached", tabId });
86
125
  // Seed pointer in a plausible "just left the address bar" location.
87
126
  const entry = { detachAt: Date.now() + TRUSTED_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } };
88
127
  attachedTabs.set(tabId, entry);
89
128
  return entry;
90
129
  }
91
130
 
131
+ async function trustedDebug(params) {
132
+ const tab = params?.targetId ? await chrome.tabs.get(Number(params.targetId)).catch(() => null) : null;
133
+ let targets = [];
134
+ try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {}
135
+ return {
136
+ extensionVersion: chrome.runtime.getManifest().version,
137
+ extensionId: chrome.runtime.id,
138
+ trustedMode: TRUSTED_MODE,
139
+ attachedTabs: Array.from(attachedTabs.keys()),
140
+ requestedTab: tab ? { id: tab.id, url: tab.url, status: tab.status, title: tab.title } : null,
141
+ cdpTargets: targets,
142
+ recentAttachEvents: attachDebugLog.slice(),
143
+ };
144
+ }
145
+
92
146
  async function detachDebugger(tabId) {
93
147
  if (!attachedTabs.has(tabId)) return;
94
148
  attachedTabs.delete(tabId);
@@ -131,12 +185,72 @@ function cdpRaw(tabId, method, params) {
131
185
  // chrome.debugger.attach can stay cached in attachedTabs even after Chrome killed
132
186
  // the session (tab nav, devtools opened/closed, etc). Recover by detaching the
133
187
  // stale entry and re-attaching, then retry the command once.
188
+ // Find foreign chrome-extension targets currently anchored to the tab. Password managers,
189
+ // autofill helpers, and other input-attached extensions create type:"other" CDP targets
190
+ // whose URL is chrome-extension://<otherId>/... When that target is in focus, CDP refuses
191
+ // our Input.dispatchMouseEvent calls with "Cannot access a chrome-extension:// URL of
192
+ // different extension" — surfacing a cryptic error to the user.
193
+ async function findForeignExtensionTargets() {
194
+ try {
195
+ const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || [])));
196
+ return targets.filter((t) => {
197
+ const url = String(t.url || "");
198
+ if (!url.startsWith("chrome-extension://")) return false;
199
+ if (t.extensionId === chrome.runtime.id) return false;
200
+ return true;
201
+ });
202
+ } catch {
203
+ return [];
204
+ }
205
+ }
206
+
207
+ function extractForeignExtId(targets) {
208
+ for (const t of targets) {
209
+ if (t.extensionId && t.extensionId !== chrome.runtime.id) return t.extensionId;
210
+ const m = String(t.url || "").match(/chrome-extension:\/\/([a-p]+)\//);
211
+ if (m && m[1] !== chrome.runtime.id) return m[1];
212
+ }
213
+ return null;
214
+ }
215
+
216
+ async function dismissOverlayViaEscape(tabId) {
217
+ // Esc routes through key dispatcher (target-by-focus), not by mouse coordinates, so it
218
+ // works even when a foreign chrome-extension popup is intercepting pointer events.
219
+ try {
220
+ await cdpRaw(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
221
+ await cdpRaw(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Escape", code: "Escape", windowsVirtualKeyCode: 27 });
222
+ await sleep(120);
223
+ } catch {}
224
+ }
225
+
134
226
  async function cdp(tabId, method, params) {
135
227
  try {
136
228
  return await cdpRaw(tabId, method, params);
137
229
  } catch (error) {
138
230
  const msg = String(error?.message || error);
139
231
  const isStale = /Debugger is not attached|Detached while|Target closed|No tab with id/i.test(msg);
232
+ const isForeignExtBlock = /Cannot access a chrome-extension:\/\/ URL of different extension/i.test(msg);
233
+ if (isForeignExtBlock && /Input\./.test(method)) {
234
+ // Foreign chrome-extension popup (autofill, password manager) is hijacking input.
235
+ // Try once: dismiss via Esc, then retry.
236
+ const before = await findForeignExtensionTargets();
237
+ recordAttachEvent({ kind: "foreign-ext-detected", tabId, method, foreignExtId: extractForeignExtId(before), targetCount: before.length });
238
+ await dismissOverlayViaEscape(tabId);
239
+ try {
240
+ return await cdpRaw(tabId, method, params);
241
+ } catch (retryErr) {
242
+ const retryMsg = String(retryErr?.message || retryErr);
243
+ if (/Cannot access a chrome-extension:\/\/ URL of different extension/i.test(retryMsg)) {
244
+ const after = await findForeignExtensionTargets();
245
+ const id = extractForeignExtId(after) || extractForeignExtId(before) || "unknown";
246
+ throw new Error(
247
+ `Another Chrome extension (${id}) has an input overlay on this page (e.g. a password manager / autofill popup). \n` +
248
+ `pi-chrome tried to dismiss it with Escape but it reappeared. Disable that extension on this page, focus the field via Tab instead of clicking, or run /chrome quiet off so the agent uses synthetic input here.`,
249
+ );
250
+ }
251
+ throw retryErr;
252
+ }
253
+ }
140
254
  if (!isStale) throw error;
141
255
  attachedTabs.delete(tabId);
142
256
  await chrome.debugger.attach({ tabId }, CDP_VERSION).catch(() => undefined);
@@ -639,6 +753,8 @@ async function dispatch(action, params) {
639
753
  return setTrustedMode(params.mode);
640
754
  case "trusted.status":
641
755
  return trustedStatus();
756
+ case "trusted.debug":
757
+ return trustedDebug(params);
642
758
  case "page.console.list":
643
759
  return executeInTab(params, listConsoleMessages, [params.clear === true]);
644
760
  case "page.network.list":
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
- import { StringEnum } from "@earendil-works/pi-ai";
4
3
  import { Container, type SettingItem, SettingsList, Text } from "@earendil-works/pi-tui";
5
4
  import { Type } from "typebox";
6
5
  import { existsSync, readFileSync, statSync } from "node:fs";
@@ -381,6 +380,10 @@ const tabActionValues = ["list", "new", "activate", "close", "version"] as const
381
380
  const imageFormatValues = ["png", "jpeg"] as const;
382
381
  const waitForValues = ["selector", "expression"] as const;
383
382
 
383
+ function StringEnum<T extends readonly [string, ...string[]]>(values: T) {
384
+ return Type.Union(values.map((value) => Type.Literal(value)) as [ReturnType<typeof Type.Literal>, ...ReturnType<typeof Type.Literal>[]]);
385
+ }
386
+
384
387
  export default function (pi: ExtensionAPI): void {
385
388
  const globalState = globalThis as typeof globalThis & {
386
389
  [PI_CHROME_GLOBAL_KEY]?: { version: string; root: string };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
4
4
  "description": "Drive your existing logged-in Chrome from Pi — no re-login, no throwaway profile, watch the agent work in real time (or toggle quiet background mode).",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -24,8 +24,15 @@
24
24
  ]
25
25
  },
26
26
  "peerDependencies": {
27
- "@earendil-works/pi-ai": "*",
28
27
  "@earendil-works/pi-coding-agent": "*",
29
28
  "typebox": "*"
29
+ },
30
+ "peerDependenciesMeta": {
31
+ "@earendil-works/pi-coding-agent": {
32
+ "optional": true
33
+ },
34
+ "typebox": {
35
+ "optional": true
36
+ }
30
37
  }
31
38
  }