pi-chrome 0.15.37 → 0.15.39

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.
@@ -4,8 +4,170 @@ const POLL_ERROR_BACKOFF_MS = 2000;
4
4
  const DEFAULT_GROUP_COLOR = "blue";
5
5
  const PI_GROUP_RE = /^Pi(\b|\s*-)/i;
6
6
  const VALID_GROUP_COLORS = new Set(["grey", "blue", "red", "yellow", "green", "pink", "purple", "cyan", "orange"]);
7
+ const COMMAND_TIMEOUT_MS = 25_000;
8
+ const CDP_COMMAND_TIMEOUT_MS = 5_000;
9
+ const SCRIPTING_TIMEOUT_MS = 8_000;
10
+ const ATTACH_TIMEOUT_MS = 3_000;
7
11
  let polling = false;
8
12
 
13
+ // =================== pi-chrome automation target ownership ===================
14
+ // pi-chrome must never hijack the user's active tab. When a page/navigation action runs without
15
+ // an explicit target (targetId/urlIncludes/titleIncludes), we route it to a dedicated automation
16
+ // target that pi-chrome created and owns. We prefer a separate Chrome window so the user's
17
+ // windows are left untouched; if the windows API is unavailable we fall back to a dedicated tab.
18
+ //
19
+ // Ownership is SESSION-SCOPED, keyed by the calling Pi session's `sessionKey` (forwarded on the
20
+ // wire). One Chrome extension / service worker brokers commands for *all* Pi sessions (see the
21
+ // client/server bridge in index.ts), so a single global target would make concurrent sessions
22
+ // fight over one window. A per-session map gives each session its own isolated window and lets
23
+ // cleanup close exactly that session's target — never another session's, never a user's.
24
+ //
25
+ // State is mirrored to chrome.storage.session so a service-worker restart (MV3 can suspend the
26
+ // worker at any time) re-hydrates ownership instead of orphaning the window it already created.
27
+ // storage.session is cleared on browser restart; any window restored by Chrome's session-restore
28
+ // is then untracked and simply left alone (we only ever close ids we still recognize as ours).
29
+ const automationTargets = new Map(); // sessionKey -> { windowId?: number, tabId: number }
30
+ const DEFAULT_SESSION_KEY = "__default__";
31
+ const AUTOMATION_STORAGE_KEY = "piChromeAutomationTargets";
32
+ let automationHydrated = false;
33
+
34
+ function sessionKeyOf(params) {
35
+ return params && typeof params.sessionKey === "string" && params.sessionKey
36
+ ? params.sessionKey
37
+ : DEFAULT_SESSION_KEY;
38
+ }
39
+
40
+ // Re-hydrate the in-memory ownership map from storage.session once per worker lifetime. Best
41
+ // effort: storage may be unavailable on old Chrome, and a failure just means we may create a
42
+ // fresh window (a harmless orphan) rather than reusing one.
43
+ async function hydrateAutomationTargets() {
44
+ if (automationHydrated) return;
45
+ automationHydrated = true;
46
+ try {
47
+ const stored = await chrome.storage?.session?.get?.(AUTOMATION_STORAGE_KEY);
48
+ const saved = stored && stored[AUTOMATION_STORAGE_KEY];
49
+ if (saved && typeof saved === "object") {
50
+ for (const [key, value] of Object.entries(saved)) {
51
+ if (value && typeof value.tabId === "number") {
52
+ automationTargets.set(key, {
53
+ windowId: typeof value.windowId === "number" ? value.windowId : undefined,
54
+ tabId: value.tabId,
55
+ });
56
+ }
57
+ }
58
+ }
59
+ } catch {
60
+ // Ignore: treat as "no persisted state".
61
+ }
62
+ }
63
+
64
+ async function persistAutomationTargets() {
65
+ try {
66
+ const obj = {};
67
+ for (const [key, value] of automationTargets) {
68
+ obj[key] = { windowId: typeof value.windowId === "number" ? value.windowId : null, tabId: value.tabId };
69
+ }
70
+ await chrome.storage?.session?.set?.({ [AUTOMATION_STORAGE_KEY]: obj });
71
+ } catch {
72
+ // Ignore: persistence is an optimization, not a correctness requirement.
73
+ }
74
+ }
75
+
76
+ // True if `tabId` is a pi-chrome-owned automation tab. Pass `sessionKey` to check a specific
77
+ // session; omit it to check ownership across *any* session (used as a safety predicate so we
78
+ // never operate on a user-created tab). Never infers ownership from "active".
79
+ function isPiChromeOwnedTarget(tabId, sessionKey) {
80
+ if (typeof tabId !== "number") return false;
81
+ if (sessionKey !== undefined) {
82
+ const t = automationTargets.get(sessionKey);
83
+ return !!t && t.tabId === tabId;
84
+ }
85
+ for (const t of automationTargets.values()) if (t.tabId === tabId) return true;
86
+ return false;
87
+ }
88
+
89
+ // Create a fresh dedicated automation target for `sessionKey`. Prefer an isolated window; fall
90
+ // back to a tab. Structured so a window can be the default while tab-fallback stays a one-liner.
91
+ async function createAutomationTarget(sessionKey) {
92
+ if (chrome.windows && typeof chrome.windows.create === "function") {
93
+ try {
94
+ const win = await chrome.windows.create({ url: "about:blank", focused: false });
95
+ const created = win && Array.isArray(win.tabs) ? win.tabs[0] : undefined;
96
+ if (created && typeof created.id === "number") {
97
+ automationTargets.set(sessionKey, { windowId: typeof win.id === "number" ? win.id : undefined, tabId: created.id });
98
+ await persistAutomationTargets();
99
+ return created;
100
+ }
101
+ } catch {
102
+ // Window creation can fail (policy, headless, etc.); fall back to a dedicated tab below.
103
+ }
104
+ }
105
+ // Tab fallback: the tab lives in a pre-existing (user/shared) window we did NOT create, so we
106
+ // must leave windowId unset — cleanup then closes only our tab, never the user's window.
107
+ const tab = await chrome.tabs.create({ url: "about:blank", active: false });
108
+ automationTargets.set(sessionKey, { windowId: undefined, tabId: typeof tab.id === "number" ? tab.id : undefined });
109
+ await persistAutomationTargets();
110
+ return tab;
111
+ }
112
+
113
+ // Return the session's owned automation target if it still exists, else null. Robust to the user
114
+ // (or Chrome) having closed it: a stale entry is forgotten so callers can recreate cleanly.
115
+ async function resolveOwnedAutomationTarget(sessionKey) {
116
+ await hydrateAutomationTargets();
117
+ const t = automationTargets.get(sessionKey);
118
+ if (!t || typeof t.tabId !== "number") return null;
119
+ const existing = await chrome.tabs.get(t.tabId).catch(() => null);
120
+ if (existing && typeof existing.id === "number") return existing;
121
+ automationTargets.delete(sessionKey);
122
+ await persistAutomationTargets();
123
+ return null;
124
+ }
125
+
126
+ // Return the session's dedicated automation target, creating it on first use (or after the user
127
+ // closed it). Used by page/navigation actions that need a live surface to drive.
128
+ async function getOrCreateAutomationTarget(sessionKey) {
129
+ return (await resolveOwnedAutomationTarget(sessionKey)) || createAutomationTarget(sessionKey);
130
+ }
131
+
132
+ // Close only the session's pi-chrome-owned window/tab, and only if it still exists. Never touches
133
+ // user tabs/windows or other sessions' targets. Safe to call repeatedly and when nothing exists.
134
+ async function cleanupAutomationTarget(sessionKey) {
135
+ await hydrateAutomationTargets();
136
+ const t = automationTargets.get(sessionKey);
137
+ automationTargets.delete(sessionKey);
138
+ await persistAutomationTargets();
139
+ if (!t) return { closedWindowId: null, closedTabId: null };
140
+ const { windowId, tabId } = t;
141
+ if (typeof windowId === "number" && chrome.windows && typeof chrome.windows.remove === "function") {
142
+ const win = await chrome.windows.get(windowId).catch(() => null);
143
+ if (win) {
144
+ await chrome.windows.remove(windowId).catch(() => {});
145
+ return { closedWindowId: windowId, closedTabId: typeof tabId === "number" ? tabId : null };
146
+ }
147
+ }
148
+ if (typeof tabId === "number") {
149
+ const tab = await chrome.tabs.get(tabId).catch(() => null);
150
+ if (tab) {
151
+ await chrome.tabs.remove(tabId).catch(() => {});
152
+ return { closedWindowId: null, closedTabId: tabId };
153
+ }
154
+ }
155
+ return { closedWindowId: null, closedTabId: null };
156
+ }
157
+
158
+ function withTimeout(promise, ms, label, onTimeout) {
159
+ let timer;
160
+ return Promise.race([
161
+ Promise.resolve(promise).finally(() => clearTimeout(timer)),
162
+ new Promise((_, reject) => {
163
+ timer = setTimeout(async () => {
164
+ try { await onTimeout?.(); } catch {}
165
+ reject(new Error(`${label} timed out after ${ms}ms`));
166
+ }, ms);
167
+ }),
168
+ ]);
169
+ }
170
+
9
171
  // =================== Chrome input (CDP) layer ===================
10
172
  // Tracks which tabs we have attached chrome.debugger to.
11
173
  const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} }
@@ -29,6 +191,31 @@ function recordAttachEvent(entry) {
29
191
  if (attachDebugLog.length > 20) attachDebugLog.shift();
30
192
  }
31
193
 
194
+ function normalPageTarget(target, tabId) {
195
+ const url = String(target?.url || "");
196
+ return target?.tabId === tabId && target?.type === "page" && !url.startsWith("chrome://") && !url.startsWith("chrome-extension://") && !url.startsWith("devtools://");
197
+ }
198
+
199
+ async function pageDebuggeeForTab(tabId) {
200
+ const targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))).catch(() => []);
201
+ const target = targets.find((t) => normalPageTarget(t, tabId));
202
+ return target?.id ? { targetId: target.id } : { tabId };
203
+ }
204
+
205
+ async function debuggerAttachRaw(tabId, preferredDebuggee) {
206
+ const debuggee = preferredDebuggee || { tabId };
207
+ await withTimeout(
208
+ chrome.debugger.attach(debuggee, CDP_VERSION),
209
+ ATTACH_TIMEOUT_MS,
210
+ `Chrome debugger attach to tab ${tabId}`,
211
+ async () => {
212
+ attachedTabs.delete(tabId);
213
+ try { await chrome.debugger.detach(debuggee); } catch {}
214
+ },
215
+ );
216
+ return debuggee;
217
+ }
218
+
32
219
  async function attachDebugger(tabId) {
33
220
  if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission");
34
221
  if (attachedTabs.has(tabId)) {
@@ -50,15 +237,23 @@ async function attachDebugger(tabId) {
50
237
  }
51
238
  }
52
239
  } catch {}
53
- const attemptAttach = async () => {
240
+ let attachedDebuggee = null;
241
+ const attemptAttach = async (debuggee) => {
54
242
  try {
55
- await chrome.debugger.attach({ tabId }, CDP_VERSION);
243
+ attachedDebuggee = await debuggerAttachRaw(tabId, debuggee);
56
244
  return null;
57
245
  } catch (error) {
58
246
  return error;
59
247
  }
60
248
  };
249
+ const retryPageTargetIfExtensionBlocked = async (err, kind) => {
250
+ if (!/Cannot access a chrome-extension:\/\/ URL of different extension/i.test(String(err?.message || err))) return err;
251
+ const pageDebuggee = await pageDebuggeeForTab(tabId);
252
+ recordAttachEvent({ kind, tabId, debuggee: pageDebuggee });
253
+ return attemptAttach(pageDebuggee);
254
+ };
61
255
  let err = await attemptAttach();
256
+ if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry");
62
257
  if (err) {
63
258
  const msg = String(err?.message || err);
64
259
  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);
@@ -70,6 +265,7 @@ async function attachDebugger(tabId) {
70
265
  }
71
266
  await sleep(180);
72
267
  err = await attemptAttach();
268
+ if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry2");
73
269
  if (err) {
74
270
  recordAttachEvent({ kind: "attach-retry-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
75
271
  // One more try after a longer settle. Some Chrome builds need ~500ms after a navigation
@@ -77,37 +273,53 @@ async function attachDebugger(tabId) {
77
273
  // will accept the target.
78
274
  await sleep(500);
79
275
  err = await attemptAttach();
276
+ if (err) err = await retryPageTargetIfExtensionBlocked(err, "attach-page-target-retry3");
80
277
  if (err) {
81
278
  recordAttachEvent({ kind: "attach-retry2-failed", tabId, message: String(err.message || err), tabUrl: tabSnapshot?.url });
82
- throw err;
279
+ const meta = await describeInputTarget(tabId);
280
+ throw new Error(`Chrome debugger attach failed for tab ${tabId}: ${String(err.message || err)}${targetMetaSuffix(meta)}`);
83
281
  }
84
282
  }
85
283
  }
86
- recordAttachEvent({ kind: "attached", tabId });
284
+ recordAttachEvent({ kind: "attached", tabId, debuggee: attachedDebuggee });
87
285
  // Seed pointer in a plausible "just left the address bar" location.
88
- const entry = { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } };
286
+ const entry = { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 }, debuggee: attachedDebuggee || { tabId } };
89
287
  attachedTabs.set(tabId, entry);
90
288
  return entry;
91
289
  }
92
290
 
93
- async function inputDebug(params) {
94
- const tab = params?.targetId ? await chrome.tabs.get(Number(params.targetId)).catch(() => null) : null;
291
+ async function describeInputTarget(tabId) {
292
+ const tab = await chrome.tabs.get(Number(tabId)).catch(() => null);
293
+ const active = (await chrome.tabs.query({ active: true, lastFocusedWindow: true }).catch(() => []))[0] || null;
95
294
  let targets = [];
96
295
  try { targets = await new Promise((resolve) => chrome.debugger.getTargets((t) => resolve(t || []))); } catch {}
296
+ return {
297
+ resolvedTab: tab ? { id: tab.id, windowId: tab.windowId, url: tab.url, status: tab.status, title: tab.title, active: tab.active } : null,
298
+ activeTab: active ? { id: active.id, windowId: active.windowId, url: active.url, status: active.status, title: active.title, active: active.active } : null,
299
+ attachedTabs: Array.from(attachedTabs.keys()),
300
+ cdpTargets: targets.map((t) => ({ id: t.id, tabId: t.tabId, type: t.type, url: t.url, attached: t.attached, extensionId: t.extensionId })),
301
+ };
302
+ }
303
+
304
+ function targetMetaSuffix(meta) {
305
+ return `\nTarget metadata: ${JSON.stringify(meta).slice(0, 4000)}`;
306
+ }
307
+
308
+ async function inputDebug(params) {
309
+ const requested = params?.targetId ? await describeInputTarget(Number(params.targetId)) : await describeInputTarget(-1);
97
310
  return {
98
311
  extensionVersion: chrome.runtime.getManifest().version,
99
312
  extensionId: chrome.runtime.id,
100
- attachedTabs: Array.from(attachedTabs.keys()),
101
- requestedTab: tab ? { id: tab.id, url: tab.url, status: tab.status, title: tab.title } : null,
102
- cdpTargets: targets,
313
+ ...requested,
103
314
  recentAttachEvents: attachDebugLog.slice(),
104
315
  };
105
316
  }
106
317
 
107
318
  async function detachDebugger(tabId) {
108
- if (!attachedTabs.has(tabId)) return;
319
+ const entry = attachedTabs.get(tabId);
320
+ if (!entry) return;
109
321
  attachedTabs.delete(tabId);
110
- try { await chrome.debugger.detach({ tabId }); } catch {}
322
+ try { await chrome.debugger.detach(entry.debuggee || { tabId }); } catch {}
111
323
  }
112
324
 
113
325
  async function detachAll() {
@@ -134,14 +346,22 @@ setInterval(() => {
134
346
  }, 5000);
135
347
 
136
348
  function cdpRaw(tabId, method, params) {
137
- return new Promise((resolve, reject) => {
138
- chrome.debugger.sendCommand({ tabId }, method, params || {}, (result) => {
349
+ const debuggee = attachedTabs.get(tabId)?.debuggee || { tabId };
350
+ return withTimeout(new Promise((resolve, reject) => {
351
+ chrome.debugger.sendCommand(debuggee, method, params || {}, (result) => {
139
352
  if (chrome.runtime.lastError) reject(new Error(`${method}: ${chrome.runtime.lastError.message}`));
140
353
  else resolve(result);
141
354
  });
355
+ }), CDP_COMMAND_TIMEOUT_MS, `CDP ${method}`, async () => {
356
+ attachedTabs.delete(tabId);
357
+ try { await chrome.debugger.detach(debuggee); } catch {}
142
358
  });
143
359
  }
144
360
 
361
+ function executeScriptTimed(options, label) {
362
+ return withTimeout(chrome.scripting.executeScript(options), SCRIPTING_TIMEOUT_MS, label || "chrome.scripting.executeScript");
363
+ }
364
+
145
365
  // Wraps cdpRaw with one auto-recover on detached/closed sessions:
146
366
  // chrome.debugger.attach can stay cached in attachedTabs even after Chrome killed
147
367
  // the session (tab nav, devtools opened/closed, etc). Recover by detaching the
@@ -214,8 +434,7 @@ async function cdp(tabId, method, params) {
214
434
  }
215
435
  if (!isStale) throw error;
216
436
  attachedTabs.delete(tabId);
217
- await chrome.debugger.attach({ tabId }, CDP_VERSION).catch(() => undefined);
218
- attachedTabs.set(tabId, { detachAt: Date.now() + INPUT_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } });
437
+ await attachDebugger(tabId).catch(() => undefined);
219
438
  return cdpRaw(tabId, method, params);
220
439
  }
221
440
  }
@@ -254,14 +473,18 @@ function cdpIsSyntaxError(details) {
254
473
 
255
474
  // Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
256
475
  async function resolveTargetInTab(tabId, params) {
257
- const results = await chrome.scripting.executeScript({
476
+ const results = await executeScriptTimed({
258
477
  target: { tabId, frameIds: [0] },
259
478
  world: "MAIN",
260
479
  func: (selector, uid, x, y) => {
261
480
  const state = window.__PI_CHROME_STATE__;
262
481
  let el = null;
263
- if (uid && state && state.elements && state.elements[uid]) el = state.elements[uid];
264
- else if (selector) el = document.querySelector(selector);
482
+ if (uid) {
483
+ el = state && state.elements ? state.elements[uid] : null;
484
+ if (!el || !el.isConnected) return { found: false, staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href };
485
+ } else if (selector) {
486
+ el = document.querySelector(selector);
487
+ }
265
488
  if (el) {
266
489
  el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
267
490
  const r = el.getBoundingClientRect();
@@ -271,8 +494,9 @@ async function resolveTargetInTab(tabId, params) {
271
494
  return { found: false };
272
495
  },
273
496
  args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null],
274
- });
497
+ }, `resolve input target in tab ${tabId}`);
275
498
  const v = results?.[0]?.result;
499
+ if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot");
276
500
  if (!v || !v.found) throw new Error("Could not resolve target element for Chrome input");
277
501
  return v;
278
502
  }
@@ -400,36 +624,70 @@ async function cdpTypeChar(tabId, ch) {
400
624
  await sleep(rng(35, 130));
401
625
  }
402
626
 
627
+ async function domClickFallback(tabId, params, cause) {
628
+ const results = await executeScriptTimed({
629
+ target: { tabId, frameIds: [0] },
630
+ world: "MAIN",
631
+ func: (selector, uid, x, y) => {
632
+ const state = window.__PI_CHROME_STATE__;
633
+ let el = uid && state && state.elements ? state.elements[uid] : null;
634
+ if (uid && (!el || !el.isConnected)) return { staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href };
635
+ if (!el && selector) el = document.querySelector(selector);
636
+ if (!el && typeof x === "number" && typeof y === "number") el = document.elementFromPoint(x, y);
637
+ if (!el) throw new Error(`DOM fallback target not found: ${uid || selector || `${x},${y}`}`);
638
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
639
+ const rect = el.getBoundingClientRect();
640
+ const eventInit = { bubbles: true, cancelable: true, view: window, clientX: rect.left + rect.width / 2, clientY: rect.top + rect.height / 2, button: 0, buttons: 1 };
641
+ el.dispatchEvent(new PointerEvent("pointerdown", { ...eventInit, pointerId: 1, pointerType: "mouse", isPrimary: true }));
642
+ el.dispatchEvent(new MouseEvent("mousedown", eventInit));
643
+ if (typeof el.focus === "function") el.focus({ preventScroll: true });
644
+ el.dispatchEvent(new PointerEvent("pointerup", { ...eventInit, pointerId: 1, pointerType: "mouse", isPrimary: true, buttons: 0 }));
645
+ el.dispatchEvent(new MouseEvent("mouseup", { ...eventInit, buttons: 0 }));
646
+ el.click();
647
+ return { tag: el.tagName, url: location.href };
648
+ },
649
+ args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null],
650
+ }, `DOM click fallback in tab ${tabId}`);
651
+ const v = results?.[0]?.result;
652
+ if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot");
653
+ return { input: "dom-fallback", reason: String(cause?.message || cause).slice(0, 500), tag: v?.tag };
654
+ }
655
+
403
656
  async function chromeInputClick(params) {
404
657
  const tab = await getTabByParams(params);
405
658
  if (params.foreground) await bringToFront(tab);
406
- await attachDebugger(tab.id);
407
- const resolved = await resolveTargetInTab(tab.id, params);
408
- const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
409
- await cdpMoveTo(tab.id, point.x, point.y);
410
- await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse", force: 0.5 });
411
- await sleep(rng(45, 140));
412
- await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
413
- // Reset :focus-visible if the click landed on a focusable element. CDP-driven pointer
414
- // focus can leave :focus-visible=true in Chromium, which trips heuristics that expect
415
- // Reset focus styling after pointer click when possible.
416
- if (params.selector || params.uid) {
417
- await chrome.scripting.executeScript({
418
- target: { tabId: tab.id, frameIds: [0] },
419
- world: "MAIN",
420
- func: (sel, uid) => {
421
- const state = window.__PI_CHROME_STATE__;
422
- let el = null;
423
- if (uid && state && state.elements && state.elements[uid]) el = state.elements[uid];
424
- else if (sel) el = document.querySelector(sel);
425
- if (el && typeof el.focus === "function" && el === document.activeElement) {
426
- try { el.blur(); el.focus({ preventScroll: true, focusVisible: false }); } catch {}
427
- }
428
- },
429
- args: [params.selector ?? null, params.uid ?? null],
430
- }).catch(() => undefined);
659
+ try {
660
+ await attachDebugger(tab.id);
661
+ const resolved = await resolveTargetInTab(tab.id, params);
662
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
663
+ await cdpMoveTo(tab.id, point.x, point.y);
664
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse", force: 0.5 });
665
+ await sleep(rng(45, 140));
666
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
667
+ // Reset :focus-visible if the click landed on a focusable element. CDP-driven pointer
668
+ // focus can leave :focus-visible=true in Chromium, which trips heuristics that expect
669
+ // Reset focus styling after pointer click when possible.
670
+ if (params.selector || params.uid) {
671
+ await executeScriptTimed({
672
+ target: { tabId: tab.id, frameIds: [0] },
673
+ world: "MAIN",
674
+ func: (sel, uid) => {
675
+ const state = window.__PI_CHROME_STATE__;
676
+ let el = null;
677
+ if (uid && state && state.elements && state.elements[uid]) el = state.elements[uid];
678
+ else if (sel) el = document.querySelector(sel);
679
+ if (el && typeof el.focus === "function" && el === document.activeElement) {
680
+ try { el.blur(); el.focus({ preventScroll: true, focusVisible: false }); } catch {}
681
+ }
682
+ },
683
+ args: [params.selector ?? null, params.uid ?? null],
684
+ }, `reset focus style in tab ${tab.id}`).catch(() => undefined);
685
+ }
686
+ return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag };
687
+ } catch (error) {
688
+ if (params.domFallback === false) throw error;
689
+ return domClickFallback(tab.id, params, error);
431
690
  }
432
- return { input: "chrome", x: point.x, y: point.y, tag: resolved.tag };
433
691
  }
434
692
 
435
693
  async function chromeInputHover(params) {
@@ -504,29 +762,74 @@ async function chromeInputType(params) {
504
762
  return { input: "chrome", length: text.length };
505
763
  }
506
764
 
765
+ async function domFillFallback(tabId, params, cause) {
766
+ if (!(params.selector || params.uid)) throw cause;
767
+ const results = await executeScriptTimed({
768
+ target: { tabId, frameIds: [0] },
769
+ world: "MAIN",
770
+ func: async (selector, uid, text, submit) => {
771
+ const state = window.__PI_CHROME_STATE__;
772
+ let el = uid && state && state.elements ? state.elements[uid] : null;
773
+ if (uid && (!el || !el.isConnected)) return { staleUid: true, reason: `snapshot uid ${uid} is stale; refresh chrome_snapshot`, url: location.href };
774
+ if (!el && selector) el = document.querySelector(selector);
775
+ if (!el) throw new Error(`DOM fallback target not found: ${uid || selector}`);
776
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
777
+ if (typeof el.focus === "function") el.focus({ preventScroll: true });
778
+ const value = String(text ?? "");
779
+ if ("value" in el) {
780
+ const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
781
+ const setter = Object.getOwnPropertyDescriptor(proto, "value")?.set;
782
+ if (setter) setter.call(el, value);
783
+ else el.value = value;
784
+ } else if (el.isContentEditable) {
785
+ el.textContent = value;
786
+ } else {
787
+ throw new Error(`DOM fallback target is not fillable: <${el.tagName.toLowerCase()}>`);
788
+ }
789
+ el.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value }));
790
+ el.dispatchEvent(new Event("change", { bubbles: true }));
791
+ if (submit) {
792
+ const form = el.closest("form");
793
+ if (form) form.requestSubmit ? form.requestSubmit() : form.submit();
794
+ else document.querySelector("button,[type=submit]")?.click();
795
+ }
796
+ return { valueMatches: "value" in el ? el.value === value : el.textContent === value, tag: el.tagName, url: location.href };
797
+ },
798
+ args: [params.selector ?? null, params.uid ?? null, params.text ?? "", params.submit === true],
799
+ }, `DOM fill fallback in tab ${tabId}`);
800
+ const v = results?.[0]?.result;
801
+ if (v?.staleUid) throw new Error(v.reason || "snapshot uid is stale; refresh chrome_snapshot");
802
+ return { input: "dom-fallback", length: String(params.text || "").length, valueMatches: v?.valueMatches, reason: String(cause?.message || cause).slice(0, 500), tag: v?.tag };
803
+ }
804
+
507
805
  async function chromeInputFill(params) {
508
806
  const tab = await getTabByParams(params);
509
807
  if (params.foreground) await bringToFront(tab);
510
- await attachDebugger(tab.id);
511
- if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required");
512
- const resolved = await resolveTargetInTab(tab.id, params);
513
- const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
514
- await cdpMoveTo(tab.id, point.x, point.y);
515
- // Triple-click selects all in input fields.
516
- for (let i = 1; i <= 3; i++) {
517
- await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: i, pointerType: "mouse", force: 0.5 });
518
- await sleep(rng(20, 60));
519
- await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
808
+ try {
809
+ await attachDebugger(tab.id);
810
+ if (!(params.selector || params.uid)) throw new Error("chrome.fill: selector or uid required");
811
+ const resolved = await resolveTargetInTab(tab.id, params);
812
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
813
+ await cdpMoveTo(tab.id, point.x, point.y);
814
+ // Triple-click selects all in input fields.
815
+ for (let i = 1; i <= 3; i++) {
816
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: i, pointerType: "mouse", force: 0.5 });
817
+ await sleep(rng(20, 60));
818
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
819
+ await sleep(rng(20, 60));
820
+ }
821
+ // Delete selection.
822
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
823
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
520
824
  await sleep(rng(20, 60));
825
+ const text = String(params.text || "");
826
+ for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
827
+ if (params.submit) await chromeInputKey({ ...params, key: "Enter" });
828
+ return { input: "chrome", length: text.length };
829
+ } catch (error) {
830
+ if (params.domFallback === false) throw error;
831
+ return domFillFallback(tab.id, params, error);
521
832
  }
522
- // Delete selection.
523
- await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
524
- await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
525
- await sleep(rng(20, 60));
526
- const text = String(params.text || "");
527
- for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
528
- if (params.submit) await chromeInputKey({ ...params, key: "Enter" });
529
- return { input: "chrome", length: text.length };
530
833
  }
531
834
 
532
835
  async function chromeInputScroll(params) {
@@ -714,7 +1017,12 @@ async function pollLoop() {
714
1017
 
715
1018
  async function handleCommand(command) {
716
1019
  try {
717
- const result = await dispatch(command.action, command.params ?? {});
1020
+ const result = await withTimeout(
1021
+ dispatch(command.action, command.params ?? {}),
1022
+ COMMAND_TIMEOUT_MS,
1023
+ command.action || "Chrome command",
1024
+ () => detachAll(),
1025
+ );
718
1026
  await postResult({ id: command.id, ok: true, result });
719
1027
  } catch (error) {
720
1028
  await postResult({ id: command.id, ok: false, error: error?.message ?? String(error) });
@@ -765,8 +1073,10 @@ async function groupRecord(groupId) {
765
1073
  };
766
1074
  }
767
1075
 
768
- // Find an existing tab group in `windowId` whose title matches `title` (case-insensitive).
769
- // Used so all Pi-opened tabs collect into one group per window instead of spawning new ones.
1076
+ // Find existing tab groups whose title matches `title` (case-insensitive).
1077
+ // Same-window lookup is used when grouping an already-created tab. Any-window lookup is used before
1078
+ // creating a new Pi tab so one Pi session keeps one tab group and new tabs are created in that
1079
+ // group's window (Chrome tab groups cannot span windows).
770
1080
  async function findGroupByTitle(windowId, title) {
771
1081
  if (!chrome.tabGroups) return null;
772
1082
  const wanted = cleanGroupTitle(title).toLowerCase();
@@ -775,6 +1085,13 @@ async function findGroupByTitle(windowId, title) {
775
1085
  return match ? match.id : null;
776
1086
  }
777
1087
 
1088
+ async function findGroupRecordByTitle(title) {
1089
+ if (!chrome.tabGroups) return null;
1090
+ const wanted = cleanGroupTitle(title).toLowerCase();
1091
+ const groups = await chrome.tabGroups.query({}).catch(() => []);
1092
+ return groups.find((g) => (g.title || "").trim().toLowerCase() === wanted) || null;
1093
+ }
1094
+
778
1095
  // Add `tab` to a tab group, then set title/color. If the tab is ungrouped, reuse an
779
1096
  // existing same-title group in its window when present, otherwise create a new group.
780
1097
  async function groupTab(tab, title, color) {
@@ -807,28 +1124,40 @@ async function dispatch(action, params) {
807
1124
  return Promise.all(tabs.map(formatTab));
808
1125
  }
809
1126
  case "tab.new": {
810
- const tab = await chrome.tabs.create({ url: params.url || "about:blank", active: true });
811
- // Every Pi-opened tab joins a group by default. Pass groupTitle:"" (or group:false) to opt out.
812
- const optOut = params.groupTitle === "" || params.group === false;
813
- if (optOut && !params.groupColor) return formatTab(tab);
814
- return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
1127
+ // Every Pi-opened tab must join a tab group. There is intentionally no opt-out: an ungrouped
1128
+ // Pi-created tab is easy to lose among user tabs. If grouping fails after creation, close the
1129
+ // tab best-effort before surfacing the error so tab.new never leaves an ungrouped Pi tab.
1130
+ const groupTitle = params.groupTitle || "Pi";
1131
+ const existingGroup = await findGroupRecordByTitle(groupTitle);
1132
+ const createParams = { url: params.url || "about:blank", active: true };
1133
+ if (existingGroup && typeof existingGroup.windowId === "number") createParams.windowId = existingGroup.windowId;
1134
+ const tab = await chrome.tabs.create(createParams);
1135
+ try {
1136
+ return await groupTab(tab, groupTitle, params.groupColor);
1137
+ } catch (error) {
1138
+ if (typeof tab.id === "number") await chrome.tabs.remove(tab.id).catch(() => {});
1139
+ throw error;
1140
+ }
815
1141
  }
816
1142
  case "tab.activate": {
817
- const tab = await getTabByParams(params);
1143
+ // Management actions never auto-create an automation target (createOwnedTarget:false): with
1144
+ // no explicit target they act on an owned target if one exists, else error — they must never
1145
+ // fall back to (or spawn a tab just to touch) the user's active tab.
1146
+ const tab = await getTabByParams(params, { createOwnedTarget: false });
818
1147
  await chrome.windows.update(tab.windowId, { focused: true });
819
1148
  return formatTab(await chrome.tabs.update(tab.id, { active: true }));
820
1149
  }
821
1150
  case "tab.group": {
822
- const tab = await getTabByParams(params);
1151
+ const tab = await getTabByParams(params, { createOwnedTarget: false });
823
1152
  return groupTab(tab, params.groupTitle || "Pi", params.groupColor);
824
1153
  }
825
1154
  case "tab.ungroup": {
826
- const tab = await getTabByParams(params);
1155
+ const tab = await getTabByParams(params, { createOwnedTarget: false });
827
1156
  if (typeof tab.groupId === "number" && tab.groupId >= 0) await chrome.tabs.ungroup(tab.id);
828
1157
  return formatTab(await chrome.tabs.get(tab.id));
829
1158
  }
830
1159
  case "tab.close": {
831
- const tab = await getTabByParams(params);
1160
+ const tab = await getTabByParams(params, { createOwnedTarget: false });
832
1161
  await chrome.tabs.remove(tab.id);
833
1162
  return { closed: tab.id };
834
1163
  }
@@ -910,6 +1239,16 @@ async function dispatch(action, params) {
910
1239
  }
911
1240
  case "page.screenshot":
912
1241
  return takeScreenshot(params);
1242
+ case "automation.status": {
1243
+ // Report this session's owned automation target (ids only). Used for diagnostics/tests.
1244
+ await hydrateAutomationTargets();
1245
+ const t = automationTargets.get(sessionKeyOf(params));
1246
+ return { windowId: t?.windowId ?? null, tabId: t?.tabId ?? null };
1247
+ }
1248
+ case "automation.cleanup":
1249
+ // Close only THIS session's pi-chrome-owned window/tab. Never touches user tabs/windows or
1250
+ // another Pi session's target.
1251
+ return cleanupAutomationTarget(sessionKeyOf(params));
913
1252
  default:
914
1253
  throw new Error(`Unknown action: ${action}`);
915
1254
  }
@@ -931,12 +1270,28 @@ async function formatTab(tab) {
931
1270
  };
932
1271
  }
933
1272
 
934
- async function getTabByParams(params) {
1273
+ // Resolve which Chrome tab an action targets.
1274
+ //
1275
+ // Explicit targeting (targetId / urlIncludes / titleIncludes) is unchanged: callers can still act
1276
+ // on any existing tab, including a user tab, when they ask for it by name. Only the implicit
1277
+ // "no target given" case changed — it used to grab the user's *active* tab (and page.navigate
1278
+ // would then overwrite it); it now resolves to this Pi session's dedicated automation target.
1279
+ //
1280
+ // `createOwnedTarget` controls the implicit case:
1281
+ // - true (default): create the automation target on first use. Used by every page/content
1282
+ // action — page.navigate, click/type/fill/key/hover/drag/scroll/tap/upload, snapshot,
1283
+ // inspect, evaluate, screenshot, waitFor, console/network list, probe. These need a live
1284
+ // surface to drive, so auto-creating is correct and they no longer touch the user's tab.
1285
+ // - false: do NOT create. Used by tab.activate/close/group/ungroup (tab *management*): with no
1286
+ // explicit target they operate on an already-owned automation target if one exists, else
1287
+ // throw asking for an explicit target — so e.g. `chrome_tab close` can never silently close
1288
+ // the user's active tab the way it used to, and never spawns a throwaway tab just to close it.
1289
+ async function getTabByParams(params, { createOwnedTarget = true } = {}) {
935
1290
  const tabs = await chrome.tabs.query({});
936
1291
  let tab;
937
1292
  if (params.targetId !== undefined) {
938
1293
  const id = Number(params.targetId);
939
- tab = tabs.find((candidate) => candidate.id === id);
1294
+ tab = await chrome.tabs.get(id).catch(() => null);
940
1295
  if (!tab?.id) {
941
1296
  // Chrome tab ids are not stable across reloads/navigations; a long session can hold a
942
1297
  // stale id. Surface the current tabs so the caller can re-target instead of guessing.
@@ -956,12 +1311,25 @@ async function getTabByParams(params) {
956
1311
  } else if (params.titleIncludes) {
957
1312
  tab = tabs.find((candidate) => (candidate.title || "").includes(params.titleIncludes));
958
1313
  } else {
959
- const active = await chrome.tabs.query({ active: true, lastFocusedWindow: true });
960
- tab = active[0] || tabs.find((candidate) => candidate.active) || tabs[0];
1314
+ // No explicit target: use this session's dedicated automation target instead of hijacking the
1315
+ // user's active tab. This keeps human browsing and Pi automation separated — navigating here
1316
+ // never replaces whatever the user currently has open. Callers that *want* a specific
1317
+ // existing tab pass targetId/urlIncludes/titleIncludes above.
1318
+ const sessionKey = sessionKeyOf(params);
1319
+ tab = createOwnedTarget
1320
+ ? await getOrCreateAutomationTarget(sessionKey)
1321
+ : await resolveOwnedAutomationTarget(sessionKey);
1322
+ if (!tab) {
1323
+ throw new Error(
1324
+ "No target tab specified and this Pi session has no automation tab yet. " +
1325
+ "Pass targetId/urlIncludes/titleIncludes, or run chrome_navigate first.",
1326
+ );
1327
+ }
961
1328
  }
962
1329
  if (!tab?.id) throw new Error("No matching Chrome tab found");
963
- if ((tab.url || "").startsWith("chrome://") || (tab.url || "").startsWith("chrome-extension://")) {
964
- throw new Error(`Chrome blocks extension automation on protected URL: ${tab.url}`);
1330
+ const url = tab.url || "";
1331
+ if (url.startsWith("chrome://") || url.startsWith("chrome-extension://") || url.startsWith("devtools://")) {
1332
+ throw new Error(`Chrome blocks extension automation on protected URL: tab=${tab.id} url=${url}`);
965
1333
  }
966
1334
  // Tabs Pi interacts with (page.* actions) join this session's group so the user can see exactly
967
1335
  // which tabs Pi is driving. We only adopt *ungrouped* tabs — never hijack a tab the user (or
@@ -1032,7 +1400,7 @@ async function executeInTab(params, func, args) {
1032
1400
  // Phase 2: run the action via chrome.scripting.executeScript. The `func:` form is
1033
1401
  // injected by Chrome itself (not `new Function`), so it is CSP-safe, and it lets Chrome
1034
1402
  // serialize the invocation args. The wrapper references window.__piAction defined above.
1035
- const results = await chrome.scripting.executeScript({
1403
+ const results = await executeScriptTimed({
1036
1404
  target: { tabId: tab.id },
1037
1405
  world: "MAIN",
1038
1406
  func: async (invocationArgs) => {
@@ -1043,7 +1411,7 @@ async function executeInTab(params, func, args) {
1043
1411
  }
1044
1412
  },
1045
1413
  args: [args || []],
1046
- });
1414
+ }, `execute page action in tab ${tab.id}`);
1047
1415
  const first = results?.[0];
1048
1416
  if (first?.error) {
1049
1417
  const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
@@ -1139,12 +1507,12 @@ async function snapshotInTab(params) {
1139
1507
  params.query ?? null,
1140
1508
  params.maxTextChars ?? null,
1141
1509
  ];
1142
- await chrome.scripting.executeScript({
1510
+ await executeScriptTimed({
1143
1511
  target: { tabId: tab.id, frameIds: [0] },
1144
1512
  world: "MAIN",
1145
1513
  files: ["snapshot_injected.js"],
1146
- });
1147
- const results = await chrome.scripting.executeScript({
1514
+ }, `inject snapshot script in tab ${tab.id}`);
1515
+ const results = await executeScriptTimed({
1148
1516
  target: { tabId: tab.id, frameIds: [0] },
1149
1517
  world: "MAIN",
1150
1518
  func: async (invocationArgs) => {
@@ -1157,7 +1525,7 @@ async function snapshotInTab(params) {
1157
1525
  }
1158
1526
  },
1159
1527
  args: [args],
1160
- });
1528
+ }, `run snapshot script in tab ${tab.id}`);
1161
1529
  const first = results?.[0];
1162
1530
  if (first?.error) {
1163
1531
  const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));
@@ -1175,12 +1543,12 @@ async function inspectInTab(params) {
1175
1543
  const tab = await getTabByParams(params);
1176
1544
  if (params.foreground) await bringToFront(tab);
1177
1545
  const args = [params.uid ?? null, params.selector ?? null, params.scrollIntoView === true];
1178
- await chrome.scripting.executeScript({
1546
+ await executeScriptTimed({
1179
1547
  target: { tabId: tab.id, frameIds: [0] },
1180
1548
  world: "MAIN",
1181
1549
  files: ["snapshot_injected.js"],
1182
- });
1183
- const results = await chrome.scripting.executeScript({
1550
+ }, `inject inspect script in tab ${tab.id}`);
1551
+ const results = await executeScriptTimed({
1184
1552
  target: { tabId: tab.id, frameIds: [0] },
1185
1553
  world: "MAIN",
1186
1554
  func: async (invocationArgs) => {
@@ -1193,7 +1561,7 @@ async function inspectInTab(params) {
1193
1561
  }
1194
1562
  },
1195
1563
  args: [args],
1196
- });
1564
+ }, `run inspect script in tab ${tab.id}`);
1197
1565
  const first = results?.[0];
1198
1566
  if (first?.error) {
1199
1567
  const message = typeof first.error === "string" ? first.error : (first.error.message || JSON.stringify(first.error));