pi-chrome 0.10.2 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -69,7 +69,7 @@ pi-chrome v<version>
69
69
 
70
70
  By default, `chrome_*` clicks and keystrokes are **synthetic** DOM events (`event.isTrusted === false`). They drive React/Vue/Angular state correctly but **do not** satisfy Chrome's user-activation gates: clipboard write, fullscreen, file picker, and autoplay all need a real user gesture.
71
71
 
72
- pi-chrome can optionally route input through `chrome.debugger` (CDP `Input.dispatchMouseEvent` / `Input.dispatchKeyEvent`) so each event arrives as `isTrusted=true`, satisfies user-activation, and bypasses site bot-detection that filters synthetic events. The tradeoff: Chrome pins a yellow *"Pi Existing Chrome Profile Bridge started debugging this browser"* banner to the top of any debugged tab.
72
+ pi-chrome can optionally route input through `chrome.debugger` (CDP `Input.dispatchMouseEvent` / `Input.dispatchKeyEvent`) so each event arrives as `isTrusted=true`, satisfies user-activation, and bypasses site bot-detection that filters synthetic events. The tradeoff: Chrome pins a yellow *"Pi Chrome Connector started debugging this browser"* banner to the top of any debugged tab.
73
73
 
74
74
  Usage:
75
75
 
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "manifest_version": 3,
3
- "name": "Pi Existing Chrome Profile Bridge",
4
- "version": "0.10.2",
5
- "description": "Lets Pi control tabs in this existing Chrome profile via a local bridge at 127.0.0.1.",
3
+ "name": "Pi Chrome Connector",
4
+ "version": "0.11.1",
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/*"],
8
8
  "background": {
9
9
  "service_worker": "service_worker.js"
10
10
  },
11
11
  "action": {
12
- "default_title": "Pi Chrome Bridge"
12
+ "default_title": "Pi Chrome Connector"
13
13
  }
14
14
  }
@@ -1,12 +1,12 @@
1
1
  const BRIDGE_URL = "http://127.0.0.1:17318";
2
- const CLIENT_NAME = `Pi Chrome Bridge ${chrome.runtime.id}`;
2
+ const CLIENT_NAME = `Pi Chrome Connector ${chrome.runtime.id}`;
3
3
  const POLL_ERROR_BACKOFF_MS = 2000;
4
4
  let polling = false;
5
5
 
6
6
  // =================== Trusted-input (CDP) layer ===================
7
7
  // Tracks which tabs we have attached chrome.debugger to, plus session-level mode.
8
8
  const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} }
9
- let TRUSTED_MODE = "off"; // "off" | "on" | "auto"
9
+ let TRUSTED_MODE = "auto"; // "off" | "on" | "auto" (default: smart retry only)
10
10
  const TRUSTED_IDLE_DETACH_MS = 15_000;
11
11
  const CDP_VERSION = "1.3";
12
12
 
@@ -35,6 +35,31 @@ function trustedStatus() {
35
35
  };
36
36
  }
37
37
 
38
+ // Auto-upgrade: if synthetic result carries suggestTrusted=true, the bridge mode is "auto"
39
+ // (default) or "on", and the caller didn't explicitly opt out, retry once with trusted CDP
40
+ // path. Surfaces both results so callers can see what happened.
41
+ async function maybeUpgradeToTrusted(kind, params, syntheticResult, trustedFn) {
42
+ if (!syntheticResult || !syntheticResult.suggestTrusted) return syntheticResult;
43
+ if (params && params.trusted === false) return syntheticResult;
44
+ if (TRUSTED_MODE === "off") return syntheticResult;
45
+ if (!chrome.debugger) return syntheticResult;
46
+ try {
47
+ const trustedResult = await trustedFn();
48
+ return {
49
+ ...trustedResult,
50
+ autoRetried: true,
51
+ autoRetryReason: syntheticResult.suggestReason || `${kind} produced no mutation`,
52
+ syntheticAttempt: { pageMutated: syntheticResult.pageMutated, suggestReason: syntheticResult.suggestReason },
53
+ };
54
+ } catch (error) {
55
+ return {
56
+ ...syntheticResult,
57
+ autoRetryAttempted: true,
58
+ autoRetryError: error?.message || String(error),
59
+ };
60
+ }
61
+ }
62
+
38
63
  async function attachDebugger(tabId) {
39
64
  if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission");
40
65
  if (attachedTabs.has(tabId)) {
@@ -78,7 +103,7 @@ setInterval(() => {
78
103
  }
79
104
  }, 5000);
80
105
 
81
- function cdp(tabId, method, params) {
106
+ function cdpRaw(tabId, method, params) {
82
107
  return new Promise((resolve, reject) => {
83
108
  chrome.debugger.sendCommand({ tabId }, method, params || {}, (result) => {
84
109
  if (chrome.runtime.lastError) reject(new Error(`${method}: ${chrome.runtime.lastError.message}`));
@@ -87,6 +112,24 @@ function cdp(tabId, method, params) {
87
112
  });
88
113
  }
89
114
 
115
+ // Wraps cdpRaw with one auto-recover on detached/closed sessions:
116
+ // chrome.debugger.attach can stay cached in attachedTabs even after Chrome killed
117
+ // the session (tab nav, devtools opened/closed, etc). Recover by detaching the
118
+ // stale entry and re-attaching, then retry the command once.
119
+ async function cdp(tabId, method, params) {
120
+ try {
121
+ return await cdpRaw(tabId, method, params);
122
+ } catch (error) {
123
+ const msg = String(error?.message || error);
124
+ const isStale = /Debugger is not attached|Detached while|Target closed|No tab with id/i.test(msg);
125
+ if (!isStale) throw error;
126
+ attachedTabs.delete(tabId);
127
+ await chrome.debugger.attach({ tabId }, CDP_VERSION).catch(() => undefined);
128
+ attachedTabs.set(tabId, { detachAt: Date.now() + TRUSTED_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } });
129
+ return cdpRaw(tabId, method, params);
130
+ }
131
+ }
132
+
90
133
  // Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
91
134
  async function resolveTargetInTab(tabId, params) {
92
135
  const results = await chrome.scripting.executeScript({
@@ -324,20 +367,48 @@ async function trustedScroll(params) {
324
367
  const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x;
325
368
  const y = resolved.rect ? resolved.rect.top + Math.min(resolved.rect.height, 600) / 2 : resolved.y;
326
369
  const totalY = params.deltaY || 0, totalX = params.deltaX || 0;
327
- const n = Math.max(3, Math.min(20, params.steps || Math.ceil(Math.abs(totalY) / 120)));
328
- // momentum-shaped front-loaded weights
329
- const w = []; for (let i = 1; i <= n; i++) w.push(1 / i);
370
+ // Cap per-step delta so IntersectionObserver / scroll-driven animations get gradient samples.
371
+ // Real wheel notches ~50-120px; we aim ~40-90.
372
+ const MAX_STEP = 80;
373
+ const minStepsByDelta = Math.ceil(Math.max(Math.abs(totalY), Math.abs(totalX)) / MAX_STEP);
374
+ const n = Math.max(6, Math.min(60, params.steps || Math.max(minStepsByDelta, 12)));
375
+ // Momentum shape: ramp-up, plateau, decay. Each weight peaked mid-sequence then tapers.
376
+ const w = [];
377
+ for (let i = 0; i < n; i++) {
378
+ const t = i / Math.max(1, n - 1);
379
+ // bell-ish curve biased earlier so motion starts strong then decays (like a trackpad flick).
380
+ const base = Math.sin(Math.min(1, t * 1.4) * Math.PI);
381
+ const decay = Math.pow(1 - t * 0.6, 2);
382
+ w.push(0.2 + base * 0.8 * decay);
383
+ }
330
384
  const sumW = w.reduce((a, b) => a + b, 0);
331
385
  for (let i = 0; i < n; i++) {
332
386
  const dy = totalY * (w[i] / sumW), dx = totalX * (w[i] / sumW);
333
387
  await cdp(tab.id, "Input.dispatchMouseEvent", {
334
388
  type: "mouseWheel", x, y, deltaX: dx, deltaY: dy, pointerType: "mouse",
335
389
  });
336
- await sleep(rng(14, 32));
390
+ // Sleep ~one frame+ so IntersectionObserver / rAF samples can run between events.
391
+ await sleep(rng(22, 52));
337
392
  }
338
393
  return { trusted: true, deltaX: totalX, deltaY: totalY, steps: n };
339
394
  }
340
395
 
396
+ async function trustedTap(params) {
397
+ const tab = await getTabByParams(params);
398
+ if (params.foreground) await bringToFront(tab);
399
+ await attachDebugger(tab.id);
400
+ const resolved = (params.selector || params.uid || (typeof params.x === "number" && typeof params.y === "number"))
401
+ ? await resolveTargetInTab(tab.id, params)
402
+ : null;
403
+ if (!resolved || !resolved.found) throw new Error("trusted.tap: target not found");
404
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
405
+ const tp = { x: point.x, y: point.y, radiusX: 8, radiusY: 8, rotationAngle: 0, force: 0.5, id: 1 };
406
+ await cdp(tab.id, "Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [tp] });
407
+ await sleep(rng(40, 110));
408
+ await cdp(tab.id, "Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
409
+ return { trusted: true, x: point.x, y: point.y, tag: resolved.tag };
410
+ }
411
+
341
412
  async function trustedDrag(params) {
342
413
  const tab = await getTabByParams(params);
343
414
  if (params.foreground) await bringToFront(tab);
@@ -485,9 +556,11 @@ async function dispatch(action, params) {
485
556
  ]);
486
557
  case "page.evaluate":
487
558
  return evaluateInTab(params);
488
- case "page.click":
559
+ case "page.click": {
489
560
  if (await wantsTrusted(params)) return trustedClick(params);
490
- return executeActionInTab(params, clickPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
561
+ const synth = await executeActionInTab(params, clickPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
562
+ return await maybeUpgradeToTrusted("click", params, synth, () => trustedClick(params));
563
+ }
491
564
  case "page.hover":
492
565
  if (await wantsTrusted(params)) return trustedHover(params);
493
566
  return executeActionInTab(params, hoverPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
@@ -496,9 +569,11 @@ async function dispatch(action, params) {
496
569
  return executeActionInTab(params, dragPage, [params.fromUid ?? null, params.fromSelector ?? null, params.fromX ?? null, params.fromY ?? null, params.toUid ?? null, params.toSelector ?? null, params.toX ?? null, params.toY ?? null, params.steps ?? 12]);
497
570
  case "page.upload":
498
571
  return executeActionInTab(params, uploadFiles, [params.selector ?? null, params.uid ?? null, params.files || []]);
499
- case "page.type":
572
+ case "page.type": {
500
573
  if (await wantsTrusted(params)) return trustedType(params);
501
- return executeActionInTab(params, typeIntoPage, [params.selector ?? null, params.uid ?? null, params.text || "", Boolean(params.pressEnter)]);
574
+ const synth = await executeActionInTab(params, typeIntoPage, [params.selector ?? null, params.uid ?? null, params.text || "", Boolean(params.pressEnter)]);
575
+ return await maybeUpgradeToTrusted("type", params, synth, () => trustedType(params));
576
+ }
502
577
  case "page.fill":
503
578
  if (await wantsTrusted(params)) return trustedFill(params);
504
579
  return executeActionInTab(params, fillPage, [params.selector ?? null, params.uid ?? null, params.text || "", params.submit === true]);
@@ -508,6 +583,8 @@ async function dispatch(action, params) {
508
583
  case "page.scroll":
509
584
  if (await wantsTrusted(params)) return trustedScroll(params);
510
585
  return executeActionInTab(params, scrollPage, [params.selector ?? null, params.uid ?? null, params.deltaY ?? 0, params.deltaX ?? 0, params.steps ?? null]);
586
+ case "page.tap":
587
+ return trustedTap(params);
511
588
  case "trusted.mode":
512
589
  return setTrustedMode(params.mode);
513
590
  case "trusted.status":
@@ -1262,23 +1339,46 @@ async function clickPage(selector, uid, x, y) {
1262
1339
  // Heuristic: if the clicked thing looks like a media play affordance and the page has paused
1263
1340
  // audio/video, the synthetic click may not unlock autoplay. Surface a warning.
1264
1341
  let autoplayHint;
1265
- const label = (point.element.getAttribute("aria-label") || point.element.textContent || "").toLowerCase();
1266
- if (/^(play|start|begin|next|continue|unmute)/.test(label.trim())) {
1342
+ const labelRaw = (point.element.getAttribute("aria-label") || point.element.textContent || "").trim();
1343
+ const label = labelRaw.toLowerCase();
1344
+ if (/^(play|start|begin|next|continue|unmute)/.test(label)) {
1267
1345
  const idleMedia = Array.from(document.querySelectorAll("audio,video")).some((m) => m.paused);
1268
1346
  if (idleMedia) autoplayHint = "This element looks like a media affordance and the page has paused media. Synthetic clicks do not satisfy user-activation gates; audio/video may not start.";
1269
1347
  }
1348
+ const pageMutated = pageHash() !== before;
1349
+ // Smart-auto retry hint: only set when synthetic produced no observable change AND the
1350
+ // element looks gated, OR the page just emitted a user-activation rejection. The dispatcher
1351
+ // uses this to decide whether to retry with trusted mode.
1352
+ let suggestTrusted = false;
1353
+ let suggestReason;
1354
+ if (!pageMutated) {
1355
+ if (autoplayHint) { suggestTrusted = true; suggestReason = "play/media affordance + idle media"; }
1356
+ else if (/copy(\s|$)|paste|share|download|fullscreen|sign in with|continue with|allow|enable/i.test(label)) {
1357
+ suggestTrusted = true; suggestReason = `label '${labelRaw.slice(0, 40)}' looks gated`;
1358
+ } else {
1359
+ // Inspect recent console errors for activation-gate rejections.
1360
+ const recent = (state.console || []).slice(-8);
1361
+ const hit = recent.find((e) => /NotAllowedError|Document is not focused|requires transient activation|gesture is required/.test(
1362
+ (e.args || []).map((a) => typeof a === "string" ? a : (a && a.message) || JSON.stringify(a)).join(" ")
1363
+ ));
1364
+ if (hit) { suggestTrusted = true; suggestReason = "recent console error indicates user-activation gate"; }
1365
+ }
1366
+ }
1270
1367
  return {
1271
1368
  x: point.x,
1272
1369
  y: point.y,
1273
1370
  selector,
1274
1371
  uid,
1275
1372
  tag: point.element.tagName,
1373
+ label: labelRaw.slice(0, 80) || undefined,
1276
1374
  isTrusted: false,
1277
1375
  defaultPrevented,
1278
1376
  elementVisible: visible,
1279
1377
  occludedBy: occluded || undefined,
1280
- pageMutated: pageHash() !== before,
1378
+ pageMutated,
1281
1379
  autoplayHint,
1380
+ suggestTrusted: suggestTrusted || undefined,
1381
+ suggestReason,
1282
1382
  };
1283
1383
  }
1284
1384
 
@@ -1526,15 +1626,27 @@ async function typeIntoPage(selector, uid, text, pressEnter) {
1526
1626
  const before = pageHash();
1527
1627
  let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
1528
1628
  if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
1629
+ const initialValue = "value" in element ? element.value : (element.isContentEditable ? element.textContent : null);
1529
1630
  element.focus();
1530
1631
  if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable");
1531
1632
  for (const ch of Array.from(text)) await typeCharacter(element, ch);
1532
1633
  if (pressEnter) pressKeyInPage("Enter");
1634
+ const finalValue = "value" in element ? element.value : element.textContent;
1635
+ const valueMatches = "value" in element ? element.value.includes(text) : (element.textContent || "").includes(text);
1636
+ const pageMutated = pageHash() !== before;
1637
+ // Smart-auto retry hint when typing didn't land at all (e.g., editor blocks synthetic input).
1638
+ let suggestTrusted = false, suggestReason;
1639
+ if (text.length > 0 && initialValue === finalValue) {
1640
+ suggestTrusted = true;
1641
+ suggestReason = "value did not change — editor likely rejects synthetic input";
1642
+ }
1533
1643
  return {
1534
1644
  selector, uid, length: text.length, pressEnter,
1535
1645
  isTrusted: false,
1536
- valueMatches: "value" in element ? element.value.includes(text) : undefined,
1537
- pageMutated: pageHash() !== before,
1646
+ valueMatches,
1647
+ pageMutated,
1648
+ suggestTrusted: suggestTrusted || undefined,
1649
+ suggestReason,
1538
1650
  };
1539
1651
  }
1540
1652
 
@@ -471,7 +471,7 @@ Usage rules:
471
471
  lines.push(
472
472
  `✗ EXTENSION VERSION MISMATCH: companion extension is v${version.extensionVersion}, but pi-chrome is v${PI_CHROME_VERSION}.`,
473
473
  ` All chrome_* tools will run with the OLD extension code until this is fixed.`,
474
- ` Fix: open chrome://extensions and click reload on "Pi Existing Chrome Profile Bridge".`,
474
+ ` Fix: open chrome://extensions and click reload on "Pi Chrome Connector".`,
475
475
  ` (Future version drifts will self-heal: the extension now polls pi-chrome's expected version and reloads itself.)`,
476
476
  );
477
477
  } else {
@@ -518,7 +518,14 @@ Usage rules:
518
518
  permissionGranted?: boolean;
519
519
  };
520
520
  if (status.permissionGranted) {
521
- lines.push(`✓ Trusted-input mode available via chrome.debugger (current: ${status.mode ?? "off"}${status.attachedTabs && status.attachedTabs.length ? `; attached to tab ${status.attachedTabs.join(",")}` : ""}). Pass trusted=true on chrome_click/type/etc, or run /chrome-trusted on, to satisfy isTrusted + user-activation gates.`);
521
+ const attached = status.attachedTabs && status.attachedTabs.length ? `; attached to tab ${status.attachedTabs.join(",")}` : "";
522
+ const note =
523
+ status.mode === "auto"
524
+ ? " — smart-retry enabled: synthetic input runs first; if a click/type produced no page change AND the target looks gated, the call is automatically re-run with trusted CDP (yellow debugger banner appears only for that retry)."
525
+ : status.mode === "on"
526
+ ? " — every chrome_* call goes through CDP; the yellow debugger banner is visible while attached."
527
+ : " — synthetic events only; pass trusted=true on chrome_click/type/etc, or switch to auto/on with /chrome-trusted, when isTrusted or user-activation gates matter.";
528
+ lines.push(`✓ Trusted-input mode available via chrome.debugger (current: ${status.mode ?? "off"}${attached}).${note}`);
522
529
  } else {
523
530
  lines.push(`⚠ chrome.debugger API unavailable. The extension is missing the "debugger" permission — reload the extension in chrome://extensions and accept the new permission prompt.`);
524
531
  }
@@ -560,7 +567,7 @@ Usage rules:
560
567
 
561
568
  if (!status.permissionGranted) {
562
569
  ctx.ui.notify(
563
- "chrome.debugger API unavailable — the extension is missing the 'debugger' permission. Open chrome://extensions, reload 'Pi Existing Chrome Profile Bridge', and accept the new permission prompt.",
570
+ "chrome.debugger API unavailable — the extension is missing the 'debugger' permission. Open chrome://extensions, reload 'Pi Chrome Connector', and accept the new permission prompt.",
564
571
  "warning",
565
572
  );
566
573
  return;
@@ -577,9 +584,9 @@ Usage rules:
577
584
  if (!target) {
578
585
  // Interactive picker. Show current mode + tradeoffs in each label.
579
586
  const options = [
580
- `on${current === "on" ? " (current)" : ""} — all chrome_* tools via CDP; yellow debugger banner appears`,
581
- `off${current === "off" ? " (current)" : ""} — synthetic DOM events only (default)`,
582
- `auto${current === "auto" ? " (current)" : ""} — CDP only when a tool passes trusted=true`,
587
+ `auto${current === "auto" ? " (current)" : ""} — default; synthetic first, retry with CDP only when a call looks gated`,
588
+ `off${current === "off" ? " (current)" : ""} — synthetic DOM events only; never auto-retry`,
589
+ `on${current === "on" ? " (current)" : ""} — every chrome_* call goes through CDP (yellow debugger banner permanently visible)`,
583
590
  `status — print current mode and any attached tabs\u2026`,
584
591
  ];
585
592
  const picked = await ctx.ui.select(
@@ -610,7 +617,7 @@ Usage rules:
610
617
  if (target === "on" && current === "off") {
611
618
  const ok = await ctx.ui.confirm(
612
619
  "Turn on trusted-input mode?",
613
- "All chrome_* tools will dispatch through chrome.debugger (CDP). Events will arrive as isTrusted=true and satisfy user-activation gates (clipboard, fullscreen, autoplay, file picker).\n\nChrome will pin a yellow 'Pi Existing Chrome Profile Bridge started debugging this browser' banner to the top of any debugged tab while attached. Clicking 'Cancel' on that banner detaches the debugger.",
620
+ "All chrome_* tools will dispatch through chrome.debugger (CDP). Events will arrive as isTrusted=true and satisfy user-activation gates (clipboard, fullscreen, autoplay, file picker).\n\nChrome will pin a yellow 'Pi Chrome Connector started debugging this browser' banner to the top of any debugged tab while attached. Clicking 'Cancel' on that banner detaches the debugger.",
614
621
  );
615
622
  if (!ok) {
616
623
  ctx.ui.notify("Trusted-input mode unchanged.", "info");
@@ -835,7 +842,7 @@ Usage rules:
835
842
  name: "chrome_click",
836
843
  label: "Chrome Click",
837
844
  description:
838
- "Click a snapshot uid, CSS selector, or viewport coordinate in an existing Chrome tab through the companion extension. Defaults to synthetic DOM events (isTrusted=false). Pass trusted=true (or run /chrome-trusted on) to route through chrome.debugger so events arrive as browser-trusted and satisfy user-activation gates Chrome shows a yellow 'started debugging' banner while attached. Pass includeSnapshot=true to return a fresh snapshot after the click.",
845
+ "Click a snapshot uid, CSS selector, or viewport coordinate. Default 'auto' mode runs synthetic DOM events first and silently retries with trusted CDP only when the click looks gated (no page change + affordance label matches play/copy/share/sign-in/etc, or a recent NotAllowedError). The yellow 'started debugging' banner appears only when the retry actually happens. Pass trusted=true to force CDP for this call (banner appears immediately). Pass trusted=false to skip retry. Pass includeSnapshot=true to return a fresh snapshot after the click.",
839
846
  promptSnippet: "Click page elements in Chrome by snapshot uid, selector, or viewport coordinate.",
840
847
  parameters: Type.Object({
841
848
  uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot. Prefer uid over selector after taking a snapshot." })),
@@ -868,7 +875,7 @@ Usage rules:
868
875
  name: "chrome_type",
869
876
  label: "Chrome Type",
870
877
  description:
871
- "Focus an optional snapshot uid or CSS selector, then type text into an existing Chrome tab. Defaults to synthetic per-character keydown/beforeinput/input/keyup sequence. Pass trusted=true (or run /chrome-trusted on) to route through chrome.debugger so each keystroke is browser-trusted (isTrusted=true). Pass includeSnapshot=true to return a fresh snapshot after typing.",
878
+ "Focus an optional snapshot uid or CSS selector, then type text. Default 'auto' mode runs synthetic per-character keydown/beforeinput/input/keyup first; if the input value doesn't change at all (editor rejected synthetic input) the call is silently retried through chrome.debugger so each keystroke is browser-trusted (isTrusted=true). Pass trusted=true to force CDP for this call. Pass trusted=false to skip retry. Pass includeSnapshot=true to return a fresh snapshot after typing.",
872
879
  promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
873
880
  parameters: Type.Object({
874
881
  text: Type.String(),
@@ -1158,6 +1165,29 @@ Usage rules:
1158
1165
  },
1159
1166
  });
1160
1167
 
1168
+ pi.registerTool({
1169
+ name: "chrome_tap",
1170
+ label: "Chrome Tap (Touch)",
1171
+ description:
1172
+ "Dispatch a real browser-trusted touchstart/touchend tap via chrome.debugger (CDP Input.dispatchTouchEvent). Use for sites that gate on TouchEvent rather than MouseEvent (mobile-first PWAs, swipe carousels). Always uses the trusted CDP path — the yellow debugger banner appears.",
1173
+ promptSnippet: "Tap (real touch) a Chrome element by snapshot uid, selector, or coordinate.",
1174
+ parameters: Type.Object({
1175
+ uid: Type.Optional(Type.String()),
1176
+ selector: Type.Optional(Type.String()),
1177
+ x: Type.Optional(Type.Number()),
1178
+ y: Type.Optional(Type.Number()),
1179
+ targetId: Type.Optional(Type.String()),
1180
+ urlIncludes: Type.Optional(Type.String()),
1181
+ titleIncludes: Type.Optional(Type.String()),
1182
+ background: Type.Optional(Type.Boolean()),
1183
+ }),
1184
+ async execute(_id, params): Promise<ToolTextResult> {
1185
+ const result = await bridge.send("page.tap", withBackground(params), DEFAULT_TIMEOUT_MS);
1186
+ const target = params.uid ?? params.selector ?? `${params.x},${params.y}`;
1187
+ return { content: [{ type: "text", text: `Tapped ${target} (touch)` }], details: { result: result as Json } };
1188
+ },
1189
+ });
1190
+
1161
1191
  pi.registerTool({
1162
1192
  name: "chrome_scroll",
1163
1193
  label: "Chrome Scroll",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.10.2",
3
+ "version": "0.11.1",
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",