pi-chrome 0.8.0 → 0.10.0

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
@@ -65,6 +65,25 @@ pi-chrome v<version>
65
65
  ✓ Companion Chrome extension responding (ID: <chrome-extension-id>, ext v<version>)
66
66
  ```
67
67
 
68
+ ## Trusted-input mode (CDP)
69
+
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
+
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.
73
+
74
+ Usage:
75
+
76
+ ```text
77
+ /chrome-trusted on # all chrome_* tools dispatch via CDP
78
+ /chrome-trusted off # synthetic events only (default)
79
+ /chrome-trusted auto # CDP only when a tool passes trusted=true
80
+ /chrome-trusted status # show current mode and attached tabs
81
+ ```
82
+
83
+ For a single call, pass `trusted: true` on `chrome_click`, `chrome_type`, `chrome_fill`, `chrome_key`, `chrome_hover`, `chrome_drag`, or `chrome_scroll`. The per-call value always wins over the session toggle.
84
+
85
+ First time you upgrade to a pi-chrome that uses trusted input, Chrome will prompt to re-approve the extension's new `debugger` permission — do that once in `chrome://extensions`.
86
+
68
87
  ## Background mode
69
88
 
70
89
  By default, `chrome_*` tools focus Chrome and activate the target tab so you can watch the agent work — great for demos, pair-driving, debugging, and first-time confidence that things are happening.
@@ -124,7 +143,7 @@ If the Chrome extension you have loaded is older than `pi-chrome` on disk, `/chr
124
143
  ## Compose with
125
144
 
126
145
  - **pi-qq** — ask side questions about what the agent saw in Chrome without polluting the main transcript: `/qq summarize what the active GitHub tab shows`.
127
- - **trifecta-footer** — watch context pressure as the agent scrapes large pages; the footer's red threshold is a clean signal to `/qq` for a recap before context overflows.
146
+ - **pi-bar** — watch context pressure as the agent scrapes large pages; the footer's red threshold is a clean signal to `/qq` for a recap before context overflows.
128
147
  - **PR demo skills** (such as `ios-pr-agent` / `ios-demo-record` workflows) — `chrome_screenshot` writes to `.pi/chrome-screenshots/` so you can attach images to PR descriptions or demo bundles.
129
148
 
130
149
  ## Tools
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Existing Chrome Profile Bridge",
4
- "version": "0.8.0",
4
+ "version": "0.10.0",
5
5
  "description": "Lets Pi control tabs in this existing Chrome profile via a local bridge at 127.0.0.1.",
6
- "permissions": ["tabs", "scripting", "storage", "activeTab", "alarms", "webNavigation"],
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"
@@ -3,6 +3,350 @@ const CLIENT_NAME = `Pi Chrome Bridge ${chrome.runtime.id}`;
3
3
  const POLL_ERROR_BACKOFF_MS = 2000;
4
4
  let polling = false;
5
5
 
6
+ // =================== Trusted-input (CDP) layer ===================
7
+ // Tracks which tabs we have attached chrome.debugger to, plus session-level mode.
8
+ const attachedTabs = new Map(); // tabId -> { detachAt: number, pointer: {x,y} }
9
+ let TRUSTED_MODE = "off"; // "off" | "on" | "auto"
10
+ const TRUSTED_IDLE_DETACH_MS = 15_000;
11
+ const CDP_VERSION = "1.3";
12
+
13
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
14
+ function rng(min, max) { return min + Math.random() * (max - min); }
15
+
16
+ async function wantsTrusted(params) {
17
+ if (params && params.trusted === false) return false;
18
+ if (params && params.trusted === true) return true;
19
+ return TRUSTED_MODE === "on";
20
+ }
21
+
22
+ function setTrustedMode(mode) {
23
+ const next = String(mode || "").toLowerCase();
24
+ if (!["off", "on", "auto"].includes(next)) throw new Error(`bad trusted mode: ${next}`);
25
+ TRUSTED_MODE = next;
26
+ if (next === "off") void detachAll();
27
+ return { mode: TRUSTED_MODE };
28
+ }
29
+
30
+ function trustedStatus() {
31
+ return {
32
+ mode: TRUSTED_MODE,
33
+ attachedTabs: Array.from(attachedTabs.keys()),
34
+ permissionGranted: typeof chrome !== "undefined" && !!chrome.debugger,
35
+ };
36
+ }
37
+
38
+ async function attachDebugger(tabId) {
39
+ if (!chrome.debugger) throw new Error("chrome.debugger API unavailable; reload the extension to grant the new permission");
40
+ if (attachedTabs.has(tabId)) {
41
+ const entry = attachedTabs.get(tabId);
42
+ entry.detachAt = Date.now() + TRUSTED_IDLE_DETACH_MS;
43
+ return entry;
44
+ }
45
+ await chrome.debugger.attach({ tabId }, CDP_VERSION);
46
+ // Seed pointer in a plausible "just left the address bar" location.
47
+ const entry = { detachAt: Date.now() + TRUSTED_IDLE_DETACH_MS, pointer: { x: 120 + Math.random() * 200, y: 80 + Math.random() * 120 } };
48
+ attachedTabs.set(tabId, entry);
49
+ return entry;
50
+ }
51
+
52
+ async function detachDebugger(tabId) {
53
+ if (!attachedTabs.has(tabId)) return;
54
+ attachedTabs.delete(tabId);
55
+ try { await chrome.debugger.detach({ tabId }); } catch {}
56
+ }
57
+
58
+ async function detachAll() {
59
+ const ids = Array.from(attachedTabs.keys());
60
+ await Promise.all(ids.map(detachDebugger));
61
+ }
62
+
63
+ if (chrome.debugger && chrome.debugger.onDetach) {
64
+ chrome.debugger.onDetach.addListener(({ tabId }, reason) => {
65
+ if (tabId !== undefined) attachedTabs.delete(tabId);
66
+ if (reason === "canceled_by_user") {
67
+ console.warn(`[pi-chrome] debugger canceled by user on tab ${tabId}; trusted mode will reattach on next call`);
68
+ }
69
+ });
70
+ }
71
+
72
+ setInterval(() => {
73
+ const now = Date.now();
74
+ for (const [tabId, entry] of attachedTabs) {
75
+ if (entry.detachAt && entry.detachAt < now && TRUSTED_MODE !== "on") {
76
+ void detachDebugger(tabId);
77
+ }
78
+ }
79
+ }, 5000);
80
+
81
+ function cdp(tabId, method, params) {
82
+ return new Promise((resolve, reject) => {
83
+ chrome.debugger.sendCommand({ tabId }, method, params || {}, (result) => {
84
+ if (chrome.runtime.lastError) reject(new Error(`${method}: ${chrome.runtime.lastError.message}`));
85
+ else resolve(result);
86
+ });
87
+ });
88
+ }
89
+
90
+ // Resolve target -> {x, y, rect} in viewport coords by running tiny script in tab.
91
+ async function resolveTargetInTab(tabId, params) {
92
+ const results = await chrome.scripting.executeScript({
93
+ target: { tabId, frameIds: [0] },
94
+ world: "MAIN",
95
+ func: (selector, uid, x, y) => {
96
+ const state = window.__PI_CHROME_STATE__;
97
+ let el = null;
98
+ if (uid && state && state.elements && state.elements[uid]) el = state.elements[uid];
99
+ else if (selector) el = document.querySelector(selector);
100
+ if (el) {
101
+ el.scrollIntoView({ block: "center", inline: "center", behavior: "instant" });
102
+ const r = el.getBoundingClientRect();
103
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2, rect: { left: r.left, top: r.top, width: r.width, height: r.height }, tag: el.tagName, found: true };
104
+ }
105
+ if (typeof x === "number" && typeof y === "number") return { x, y, rect: null, tag: null, found: true };
106
+ return { found: false };
107
+ },
108
+ args: [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null],
109
+ });
110
+ const v = results?.[0]?.result;
111
+ if (!v || !v.found) throw new Error("Could not resolve target element for trusted action");
112
+ return v;
113
+ }
114
+
115
+ function pickInsideRect(rect) {
116
+ if (!rect) return null;
117
+ const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1));
118
+ const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1));
119
+ return {
120
+ x: rect.left + rect.width / 2 + rng(-insetX, insetX),
121
+ y: rect.top + rect.height / 2 + rng(-insetY, insetY),
122
+ };
123
+ }
124
+
125
+ async function cdpMoveTo(tabId, x, y) {
126
+ const entry = attachedTabs.get(tabId);
127
+ const startX = entry?.pointer?.x ?? Math.max(20, Math.min(400, x - 200));
128
+ const startY = entry?.pointer?.y ?? Math.max(20, Math.min(400, y - 200));
129
+ const n = Math.max(10, Math.min(36, Math.round(Math.hypot(x - startX, y - startY) / 20)));
130
+ for (let i = 1; i <= n; i++) {
131
+ const t = i / n;
132
+ const ease = t * t * (3 - 2 * t);
133
+ const wobble = Math.sin(t * Math.PI) * 8;
134
+ const px = startX + (x - startX) * ease + rng(-wobble, wobble);
135
+ const py = startY + (y - startY) * ease + rng(-wobble, wobble);
136
+ await cdp(tabId, "Input.dispatchMouseEvent", {
137
+ type: "mouseMoved", x: px, y: py, button: "none", buttons: 0, pointerType: "mouse",
138
+ });
139
+ await sleep(rng(5, 16));
140
+ }
141
+ if (entry) entry.pointer = { x, y };
142
+ }
143
+
144
+ function cdpModifiersFor(mods) {
145
+ let m = 0;
146
+ if (mods?.altKey) m |= 1;
147
+ if (mods?.ctrlKey) m |= 2;
148
+ if (mods?.metaKey) m |= 4;
149
+ if (mods?.shiftKey) m |= 8;
150
+ return m;
151
+ }
152
+
153
+ function cdpKeyInfo(key, shifted) {
154
+ // Map common keys to CDP key event init fields. Returns { code, key, windowsVirtualKeyCode, text }.
155
+ const SPECIAL = {
156
+ Enter: { code: "Enter", windowsVirtualKeyCode: 13, text: "\r" },
157
+ Tab: { code: "Tab", windowsVirtualKeyCode: 9, text: "\t" },
158
+ Backspace: { code: "Backspace", windowsVirtualKeyCode: 8, text: "" },
159
+ Delete: { code: "Delete", windowsVirtualKeyCode: 46, text: "" },
160
+ Escape: { code: "Escape", windowsVirtualKeyCode: 27, text: "" },
161
+ ArrowLeft: { code: "ArrowLeft", windowsVirtualKeyCode: 37, text: "" },
162
+ ArrowUp: { code: "ArrowUp", windowsVirtualKeyCode: 38, text: "" },
163
+ ArrowRight: { code: "ArrowRight", windowsVirtualKeyCode: 39, text: "" },
164
+ ArrowDown: { code: "ArrowDown", windowsVirtualKeyCode: 40, text: "" },
165
+ Shift: { code: "ShiftLeft", windowsVirtualKeyCode: 16, text: "" },
166
+ Control: { code: "ControlLeft", windowsVirtualKeyCode: 17, text: "" },
167
+ Alt: { code: "AltLeft", windowsVirtualKeyCode: 18, text: "" },
168
+ Meta: { code: "MetaLeft", windowsVirtualKeyCode: 91, text: "" },
169
+ " ": { code: "Space", windowsVirtualKeyCode: 32, text: " " },
170
+ };
171
+ if (SPECIAL[key]) return { key, ...SPECIAL[key] };
172
+ if (key.length === 1) {
173
+ const ch = key;
174
+ let code, vk;
175
+ if (/^[a-zA-Z]$/.test(ch)) { code = `Key${ch.toUpperCase()}`; vk = ch.toUpperCase().charCodeAt(0); }
176
+ else if (/^[0-9]$/.test(ch)) { code = `Digit${ch}`; vk = ch.charCodeAt(0); }
177
+ else { code = ch; vk = ch.charCodeAt(0); }
178
+ return { key: ch, code, windowsVirtualKeyCode: vk, text: ch };
179
+ }
180
+ return { key, code: key, windowsVirtualKeyCode: 0, text: "" };
181
+ }
182
+
183
+ async function cdpTypeChar(tabId, ch) {
184
+ const needShift = /^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch);
185
+ let modifiers = 0;
186
+ if (needShift) {
187
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyDown", key: "Shift", code: "ShiftLeft", windowsVirtualKeyCode: 16, modifiers: 8 });
188
+ modifiers = 8;
189
+ await sleep(rng(8, 22));
190
+ }
191
+ const info = cdpKeyInfo(ch);
192
+ await cdp(tabId, "Input.dispatchKeyEvent", {
193
+ type: "keyDown", key: info.key, code: info.code,
194
+ windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
195
+ text: info.text, unmodifiedText: info.text, modifiers,
196
+ });
197
+ await sleep(rng(25, 90));
198
+ await cdp(tabId, "Input.dispatchKeyEvent", {
199
+ type: "keyUp", key: info.key, code: info.code,
200
+ windowsVirtualKeyCode: info.windowsVirtualKeyCode, modifiers,
201
+ });
202
+ if (needShift) {
203
+ await sleep(rng(5, 18));
204
+ await cdp(tabId, "Input.dispatchKeyEvent", { type: "keyUp", key: "Shift", code: "ShiftLeft", windowsVirtualKeyCode: 16, modifiers: 0 });
205
+ }
206
+ await sleep(rng(35, 130));
207
+ }
208
+
209
+ async function trustedClick(params) {
210
+ const tab = await getTabByParams(params);
211
+ if (params.foreground) await bringToFront(tab);
212
+ await attachDebugger(tab.id);
213
+ const resolved = await resolveTargetInTab(tab.id, params);
214
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
215
+ await cdpMoveTo(tab.id, point.x, point.y);
216
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
217
+ await sleep(rng(45, 140));
218
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
219
+ return { trusted: true, x: point.x, y: point.y, tag: resolved.tag };
220
+ }
221
+
222
+ async function trustedHover(params) {
223
+ const tab = await getTabByParams(params);
224
+ if (params.foreground) await bringToFront(tab);
225
+ await attachDebugger(tab.id);
226
+ const resolved = await resolveTargetInTab(tab.id, params);
227
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
228
+ await cdpMoveTo(tab.id, point.x, point.y);
229
+ await sleep(rng(80, 220));
230
+ return { trusted: true, x: point.x, y: point.y, tag: resolved.tag };
231
+ }
232
+
233
+ async function trustedKey(params) {
234
+ const tab = await getTabByParams(params);
235
+ if (params.foreground) await bringToFront(tab);
236
+ await attachDebugger(tab.id);
237
+ const key = String(params.key || "");
238
+ if (!key) throw new Error("trusted.key: missing key");
239
+ const info = cdpKeyInfo(key);
240
+ await cdp(tab.id, "Input.dispatchKeyEvent", {
241
+ type: "keyDown", key: info.key, code: info.code,
242
+ windowsVirtualKeyCode: info.windowsVirtualKeyCode, nativeVirtualKeyCode: info.windowsVirtualKeyCode,
243
+ text: info.text, unmodifiedText: info.text,
244
+ });
245
+ await sleep(rng(25, 90));
246
+ await cdp(tab.id, "Input.dispatchKeyEvent", {
247
+ type: "keyUp", key: info.key, code: info.code,
248
+ windowsVirtualKeyCode: info.windowsVirtualKeyCode,
249
+ });
250
+ return { trusted: true, key: info.key };
251
+ }
252
+
253
+ async function trustedType(params) {
254
+ const tab = await getTabByParams(params);
255
+ if (params.foreground) await bringToFront(tab);
256
+ await attachDebugger(tab.id);
257
+ if (params.selector || params.uid) {
258
+ // Focus target by clicking it first.
259
+ const resolved = await resolveTargetInTab(tab.id, params);
260
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
261
+ await cdpMoveTo(tab.id, point.x, point.y);
262
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
263
+ await sleep(rng(45, 110));
264
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
265
+ await sleep(rng(50, 120));
266
+ }
267
+ const text = String(params.text || "");
268
+ for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
269
+ if (params.pressEnter) {
270
+ await cdpTypeChar(tab.id, "\r").catch(() => undefined);
271
+ await trustedKey({ ...params, key: "Enter" });
272
+ }
273
+ return { trusted: true, length: text.length };
274
+ }
275
+
276
+ async function trustedFill(params) {
277
+ const tab = await getTabByParams(params);
278
+ if (params.foreground) await bringToFront(tab);
279
+ await attachDebugger(tab.id);
280
+ if (!(params.selector || params.uid)) throw new Error("trusted.fill: selector or uid required");
281
+ const resolved = await resolveTargetInTab(tab.id, params);
282
+ const point = resolved.rect ? pickInsideRect(resolved.rect) : { x: resolved.x, y: resolved.y };
283
+ await cdpMoveTo(tab.id, point.x, point.y);
284
+ // Triple-click selects all in input fields.
285
+ for (let i = 1; i <= 3; i++) {
286
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: "left", buttons: 1, clickCount: i, pointerType: "mouse" });
287
+ await sleep(rng(20, 60));
288
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: "left", buttons: 0, clickCount: i, pointerType: "mouse" });
289
+ await sleep(rng(20, 60));
290
+ }
291
+ // Delete selection.
292
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyDown", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
293
+ await cdp(tab.id, "Input.dispatchKeyEvent", { type: "keyUp", key: "Delete", code: "Delete", windowsVirtualKeyCode: 46 });
294
+ await sleep(rng(20, 60));
295
+ const text = String(params.text || "");
296
+ for (const ch of Array.from(text)) await cdpTypeChar(tab.id, ch);
297
+ if (params.submit) await trustedKey({ ...params, key: "Enter" });
298
+ return { trusted: true, length: text.length };
299
+ }
300
+
301
+ async function trustedScroll(params) {
302
+ const tab = await getTabByParams(params);
303
+ if (params.foreground) await bringToFront(tab);
304
+ await attachDebugger(tab.id);
305
+ const resolved = (params.selector || params.uid) ? await resolveTargetInTab(tab.id, params) : { x: 100, y: 100, rect: null };
306
+ const x = resolved.rect ? resolved.rect.left + Math.min(resolved.rect.width, 800) / 2 : resolved.x;
307
+ const y = resolved.rect ? resolved.rect.top + Math.min(resolved.rect.height, 600) / 2 : resolved.y;
308
+ const totalY = params.deltaY || 0, totalX = params.deltaX || 0;
309
+ const n = Math.max(3, Math.min(20, params.steps || Math.ceil(Math.abs(totalY) / 120)));
310
+ // momentum-shaped front-loaded weights
311
+ const w = []; for (let i = 1; i <= n; i++) w.push(1 / i);
312
+ const sumW = w.reduce((a, b) => a + b, 0);
313
+ for (let i = 0; i < n; i++) {
314
+ const dy = totalY * (w[i] / sumW), dx = totalX * (w[i] / sumW);
315
+ await cdp(tab.id, "Input.dispatchMouseEvent", {
316
+ type: "mouseWheel", x, y, deltaX: dx, deltaY: dy, pointerType: "mouse",
317
+ });
318
+ await sleep(rng(14, 32));
319
+ }
320
+ return { trusted: true, deltaX: totalX, deltaY: totalY, steps: n };
321
+ }
322
+
323
+ async function trustedDrag(params) {
324
+ const tab = await getTabByParams(params);
325
+ if (params.foreground) await bringToFront(tab);
326
+ await attachDebugger(tab.id);
327
+ const from = await resolveTargetInTab(tab.id, { selector: params.fromSelector ?? null, uid: params.fromUid ?? null, x: params.fromX ?? null, y: params.fromY ?? null });
328
+ const to = await resolveTargetInTab(tab.id, { selector: params.toSelector ?? null, uid: params.toUid ?? null, x: params.toX ?? null, y: params.toY ?? null });
329
+ const fp = from.rect ? pickInsideRect(from.rect) : { x: from.x, y: from.y };
330
+ const tp = to.rect ? pickInsideRect(to.rect) : { x: to.x, y: to.y };
331
+ await cdpMoveTo(tab.id, fp.x, fp.y);
332
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mousePressed", x: fp.x, y: fp.y, button: "left", buttons: 1, clickCount: 1, pointerType: "mouse" });
333
+ await sleep(rng(60, 140));
334
+ const steps = params.steps || 20;
335
+ for (let i = 1; i <= steps; i++) {
336
+ const t = i / steps;
337
+ const ease = t * t * (3 - 2 * t);
338
+ const wobble = Math.sin(t * Math.PI) * 6;
339
+ const x = fp.x + (tp.x - fp.x) * ease + rng(-wobble, wobble);
340
+ const y = fp.y + (tp.y - fp.y) * ease + rng(-wobble, wobble);
341
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseMoved", x, y, button: "left", buttons: 1, pointerType: "mouse" });
342
+ await sleep(rng(10, 26));
343
+ }
344
+ await cdp(tab.id, "Input.dispatchMouseEvent", { type: "mouseReleased", x: tp.x, y: tp.y, button: "left", buttons: 0, clickCount: 1, pointerType: "mouse" });
345
+ return { trusted: true, from: fp, to: tp, steps };
346
+ }
347
+ // ===============================================================
348
+
349
+
6
350
  function armKeepaliveAlarm() {
7
351
  chrome.alarms.create("pi-bridge-keepalive", { periodInMinutes: 0.5 });
8
352
  }
@@ -43,6 +387,13 @@ async function pollLoop() {
43
387
  cache: "no-store",
44
388
  });
45
389
  if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`);
390
+ const expected = response.headers.get("x-pi-chrome-version");
391
+ const ours = chrome.runtime.getManifest().version;
392
+ if (expected && expected !== ours && isVersionOlder(ours, expected)) {
393
+ console.warn(`[pi-chrome] extension v${ours} behind pi-chrome v${expected}; reloading extension`);
394
+ try { chrome.runtime.reload(); } catch {}
395
+ return;
396
+ }
46
397
  const payload = await response.json();
47
398
  if (payload.type === "command") await handleCommand(payload.command);
48
399
  }
@@ -70,8 +421,16 @@ async function postResult(result) {
70
421
  });
71
422
  }
72
423
 
73
- function sleep(ms) {
74
- return new Promise((resolve) => setTimeout(resolve, ms));
424
+ function isVersionOlder(a, b) {
425
+ const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
426
+ const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
427
+ const n = Math.max(pa.length, pb.length);
428
+ for (let i = 0; i < n; i++) {
429
+ const x = pa[i] ?? 0, y = pb[i] ?? 0;
430
+ if (x < y) return true;
431
+ if (x > y) return false;
432
+ }
433
+ return false;
75
434
  }
76
435
 
77
436
  async function dispatch(action, params) {
@@ -109,19 +468,32 @@ async function dispatch(action, params) {
109
468
  case "page.evaluate":
110
469
  return evaluateInTab(params);
111
470
  case "page.click":
471
+ if (await wantsTrusted(params)) return trustedClick(params);
112
472
  return executeActionInTab(params, clickPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
113
473
  case "page.hover":
474
+ if (await wantsTrusted(params)) return trustedHover(params);
114
475
  return executeActionInTab(params, hoverPage, [params.selector ?? null, params.uid ?? null, params.x ?? null, params.y ?? null]);
115
476
  case "page.drag":
477
+ if (await wantsTrusted(params)) return trustedDrag(params);
116
478
  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]);
117
479
  case "page.upload":
118
480
  return executeActionInTab(params, uploadFiles, [params.selector ?? null, params.uid ?? null, params.files || []]);
119
481
  case "page.type":
482
+ if (await wantsTrusted(params)) return trustedType(params);
120
483
  return executeActionInTab(params, typeIntoPage, [params.selector ?? null, params.uid ?? null, params.text || "", Boolean(params.pressEnter)]);
121
484
  case "page.fill":
485
+ if (await wantsTrusted(params)) return trustedFill(params);
122
486
  return executeActionInTab(params, fillPage, [params.selector ?? null, params.uid ?? null, params.text || "", params.submit === true]);
123
487
  case "page.key":
488
+ if (await wantsTrusted(params)) return trustedKey(params);
124
489
  return executeActionInTab(params, pressKeyInPage, [params.key]);
490
+ case "page.scroll":
491
+ if (await wantsTrusted(params)) return trustedScroll(params);
492
+ return executeActionInTab(params, scrollPage, [params.selector ?? null, params.uid ?? null, params.deltaY ?? 0, params.deltaX ?? 0, params.steps ?? null]);
493
+ case "trusted.mode":
494
+ return setTrustedMode(params.mode);
495
+ case "trusted.status":
496
+ return trustedStatus();
125
497
  case "page.console.list":
126
498
  return executeInTab(params, listConsoleMessages, [params.clear === true]);
127
499
  case "page.network.list":
@@ -207,6 +579,15 @@ const HELPER_FUNCS = [
207
579
  occluderAt,
208
580
  pageHash,
209
581
  pointerEventSequence,
582
+ sleepPage,
583
+ rand,
584
+ dispatchPointerLikeEvent,
585
+ humanMoveTo,
586
+ humanClickPoint,
587
+ printableKeyCode,
588
+ dispatchKeyEvent,
589
+ typeCharacter,
590
+ scrollPage,
210
591
  ];
211
592
 
212
593
  async function executeInTab(params, func, args) {
@@ -497,32 +878,94 @@ function pageHash() {
497
878
  return h;
498
879
  }
499
880
 
881
+ function sleepPage(ms) {
882
+ return new Promise((resolve) => setTimeout(resolve, ms));
883
+ }
884
+
885
+ function rand(min, max) {
886
+ return min + Math.random() * (max - min);
887
+ }
888
+
889
+ function dispatchPointerLikeEvent(element, type, x, y, prevX, prevY, opts = {}) {
890
+ const isPointer = type.startsWith("pointer");
891
+ const Ctor = isPointer ? PointerEvent : MouseEvent;
892
+ const isMove = type === "pointermove" || type === "mousemove";
893
+ const isUpOrClick = type === "pointerup" || type === "mouseup" || type === "click";
894
+ const init = {
895
+ bubbles: true,
896
+ cancelable: true,
897
+ view: window,
898
+ clientX: x,
899
+ clientY: y,
900
+ screenX: x + (window.screenX || 0),
901
+ screenY: y + (window.screenY || 0),
902
+ movementX: Number.isFinite(prevX) ? x - prevX : 0,
903
+ movementY: Number.isFinite(prevY) ? y - prevY : 0,
904
+ button: 0,
905
+ buttons: isMove || isUpOrClick ? 0 : 1,
906
+ };
907
+ if (isPointer) {
908
+ init.pointerType = "mouse";
909
+ init.pointerId = 1;
910
+ init.isPrimary = true;
911
+ init.width = 1;
912
+ init.height = 1;
913
+ init.pressure = opts.pressure ?? (type === "pointerdown" ? 0.5 : 0);
914
+ init.tangentialPressure = 0;
915
+ init.tiltX = 0;
916
+ init.tiltY = 0;
917
+ }
918
+ const ev = new Ctor(type, init);
919
+ element.dispatchEvent(ev);
920
+ return ev.defaultPrevented;
921
+ }
922
+
500
923
  function pointerEventSequence(element, x, y, sequence) {
501
924
  let defaultPrevented = false;
925
+ const state = getPiChromeState();
926
+ const prevX = state.pointer?.x;
927
+ const prevY = state.pointer?.y;
502
928
  for (const type of sequence) {
503
- const isPointer = type.startsWith("pointer");
504
- const Ctor = isPointer ? PointerEvent : MouseEvent;
505
- const init = {
506
- bubbles: true,
507
- cancelable: true,
508
- view: window,
509
- clientX: x,
510
- clientY: y,
511
- button: 0,
512
- buttons: type === "pointermove" || type === "mousemove" ? 0 : 1,
513
- };
514
- if (isPointer) {
515
- init.pointerType = "mouse";
516
- init.pointerId = 1;
517
- init.isPrimary = true;
518
- }
519
- const ev = new Ctor(type, init);
520
- element.dispatchEvent(ev);
521
- if (ev.defaultPrevented) defaultPrevented = true;
929
+ defaultPrevented = dispatchPointerLikeEvent(element, type, x, y, prevX, prevY) || defaultPrevented;
522
930
  }
931
+ state.pointer = { x, y, t: performance.now() };
523
932
  return defaultPrevented;
524
933
  }
525
934
 
935
+ async function humanMoveTo(x, y, steps) {
936
+ const state = getPiChromeState();
937
+ const startX = Number.isFinite(state.pointer?.x) ? state.pointer.x : rand(12, Math.max(24, innerWidth - 12));
938
+ const startY = Number.isFinite(state.pointer?.y) ? state.pointer.y : rand(12, Math.max(24, innerHeight - 12));
939
+ const n = steps || Math.max(12, Math.min(42, Math.round(Math.hypot(x - startX, y - startY) / 18)));
940
+ let prevX = startX, prevY = startY;
941
+ let defaultPrevented = false;
942
+ for (let i = 1; i <= n; i++) {
943
+ const t = i / n;
944
+ const ease = t * t * (3 - 2 * t);
945
+ const wobble = Math.sin(t * Math.PI) * 8;
946
+ const px = startX + (x - startX) * ease + rand(-wobble, wobble);
947
+ const py = startY + (y - startY) * ease + rand(-wobble, wobble);
948
+ const el = document.elementFromPoint(px, py) || document.body || document.documentElement;
949
+ defaultPrevented = dispatchPointerLikeEvent(el, "pointermove", px, py, prevX, prevY) || defaultPrevented;
950
+ defaultPrevented = dispatchPointerLikeEvent(el, "mousemove", px, py, prevX, prevY) || defaultPrevented;
951
+ prevX = px; prevY = py;
952
+ await sleepPage(rand(4, 18));
953
+ }
954
+ state.pointer = { x, y, t: performance.now() };
955
+ return defaultPrevented;
956
+ }
957
+
958
+ function humanClickPoint(point) {
959
+ if (!point.rect) return { x: point.x, y: point.y };
960
+ const rect = point.rect;
961
+ const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1));
962
+ const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1));
963
+ return {
964
+ x: rect.left + rect.width / 2 + rand(-insetX, insetX),
965
+ y: rect.top + rect.height / 2 + rand(-insetY, insetY),
966
+ };
967
+ }
968
+
526
969
  function installPiChromeInstrumentation() {
527
970
  const state = getPiChromeState();
528
971
  if (state.instrumentationInstalled) return;
@@ -773,16 +1216,31 @@ function resolvePoint(selector, uid, x, y) {
773
1216
  return { element: document.elementFromPoint(x, y), x, y, rect: undefined };
774
1217
  }
775
1218
 
776
- function clickPage(selector, uid, x, y) {
1219
+ async function clickPage(selector, uid, x, y) {
777
1220
  installPiChromeInstrumentation();
778
1221
  const before = pageHash();
779
1222
  const point = resolvePoint(selector, uid, x, y);
780
1223
  if (!point.element) throw new Error("No element at click point");
1224
+ const clickPoint = humanClickPoint(point);
1225
+ point.x = clickPoint.x;
1226
+ point.y = clickPoint.y;
1227
+ point.element = document.elementFromPoint(point.x, point.y) || point.element;
781
1228
  const visible = isElementVisible(point.element);
782
1229
  const occluded = occluderAt(point.x, point.y, point.element);
783
- const defaultPrevented = pointerEventSequence(point.element, point.x, point.y, [
784
- "pointerdown", "mousedown", "pointerup", "mouseup", "click",
785
- ]);
1230
+ let defaultPrevented = await humanMoveTo(point.x, point.y);
1231
+ const state = getPiChromeState();
1232
+ const prevX = state.pointer?.x;
1233
+ const prevY = state.pointer?.y;
1234
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerdown", point.x, point.y, prevX, prevY, { pressure: 0.5 }) || defaultPrevented;
1235
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "mousedown", point.x, point.y, prevX, prevY) || defaultPrevented;
1236
+ if (typeof point.element.focus === "function" && /^(A|BUTTON|INPUT|TEXTAREA|SELECT|SUMMARY)$/.test(point.element.tagName)) {
1237
+ try { point.element.focus({ preventScroll: true }); } catch { try { point.element.focus(); } catch {} }
1238
+ }
1239
+ await sleepPage(rand(45, 140));
1240
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerup", point.x, point.y, prevX, prevY) || defaultPrevented;
1241
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "mouseup", point.x, point.y, prevX, prevY) || defaultPrevented;
1242
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "click", point.x, point.y, prevX, prevY) || defaultPrevented;
1243
+ state.pointer = { x: point.x, y: point.y, t: performance.now() };
786
1244
  // Heuristic: if the clicked thing looks like a media play affordance and the page has paused
787
1245
  // audio/video, the synthetic click may not unlock autoplay. Surface a warning.
788
1246
  let autoplayHint;
@@ -806,38 +1264,136 @@ function clickPage(selector, uid, x, y) {
806
1264
  };
807
1265
  }
808
1266
 
809
- function hoverPage(selector, uid, x, y) {
1267
+ async function hoverPage(selector, uid, x, y) {
810
1268
  installPiChromeInstrumentation();
811
1269
  const point = resolvePoint(selector, uid, x, y);
812
1270
  if (!point.element) throw new Error("No element to hover");
813
- const defaultPrevented = pointerEventSequence(point.element, point.x, point.y, [
814
- "pointerover", "mouseover", "pointerenter", "mouseenter", "pointermove", "mousemove",
815
- ]);
1271
+ await humanMoveTo(point.x, point.y);
1272
+ const state = getPiChromeState();
1273
+ const prevX = state.pointer?.x, prevY = state.pointer?.y;
1274
+ let defaultPrevented = false;
1275
+ for (const type of ["pointerover", "mouseover", "pointerenter", "mouseenter"]) {
1276
+ defaultPrevented = dispatchPointerLikeEvent(point.element, type, point.x, point.y, prevX, prevY) || defaultPrevented;
1277
+ }
1278
+ // Small dwell so hover-intent handlers fire.
1279
+ await sleepPage(rand(80, 220));
816
1280
  return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, defaultPrevented, isTrusted: false };
817
1281
  }
818
1282
 
819
- function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) {
1283
+ async function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) {
820
1284
  installPiChromeInstrumentation();
821
1285
  const before = pageHash();
822
1286
  const from = resolvePoint(fromSelector, fromUid, fromX, fromY);
823
1287
  const to = resolvePoint(toSelector, toUid, toX, toY);
824
1288
  if (!from.element) throw new Error("Drag source element not found");
825
1289
  if (!to.element) throw new Error("Drag target element not found");
826
- pointerEventSequence(from.element, from.x, from.y, ["pointerover", "pointerdown", "mousedown"]);
827
- for (let i = 1; i <= steps; i++) {
828
- const t = i / steps;
829
- const x = from.x + (to.x - from.x) * t;
830
- const y = from.y + (to.y - from.y) * t;
1290
+ // Move to source.
1291
+ await humanMoveTo(from.x, from.y);
1292
+ const state = getPiChromeState();
1293
+ let prevX = state.pointer?.x, prevY = state.pointer?.y;
1294
+ // Build a shared DataTransfer so HTML5 drag-and-drop handlers can populate / read it.
1295
+ const dt = new DataTransfer();
1296
+ const dragInit = (type, target, x, y) => {
1297
+ const ev = new DragEvent(type, {
1298
+ bubbles: true, cancelable: true, composed: true,
1299
+ clientX: x, clientY: y,
1300
+ screenX: x + (window.screenX || 0), screenY: y + (window.screenY || 0),
1301
+ button: 0, buttons: 1, view: window,
1302
+ dataTransfer: dt,
1303
+ });
1304
+ target.dispatchEvent(ev);
1305
+ return ev;
1306
+ };
1307
+ dispatchPointerLikeEvent(from.element, "pointerover", from.x, from.y, prevX, prevY);
1308
+ dispatchPointerLikeEvent(from.element, "pointerdown", from.x, from.y, prevX, prevY, { pressure: 0.5 });
1309
+ dispatchPointerLikeEvent(from.element, "mousedown", from.x, from.y, prevX, prevY);
1310
+ await sleepPage(rand(40, 110));
1311
+ dragInit("dragstart", from.element, from.x, from.y);
1312
+ dragInit("drag", from.element, from.x, from.y);
1313
+ let lastOver = from.element;
1314
+ const n = steps || 18;
1315
+ for (let i = 1; i <= n; i++) {
1316
+ const t = i / n;
1317
+ const ease = t * t * (3 - 2 * t);
1318
+ const wobble = Math.sin(t * Math.PI) * 6;
1319
+ const x = from.x + (to.x - from.x) * ease + rand(-wobble, wobble);
1320
+ const y = from.y + (to.y - from.y) * ease + rand(-wobble, wobble);
831
1321
  const overEl = document.elementFromPoint(x, y) || to.element;
832
- pointerEventSequence(overEl, x, y, ["pointermove", "mousemove"]);
1322
+ dispatchPointerLikeEvent(overEl, "pointermove", x, y, prevX, prevY);
1323
+ dispatchPointerLikeEvent(overEl, "mousemove", x, y, prevX, prevY);
1324
+ if (overEl !== lastOver) {
1325
+ dragInit("dragleave", lastOver, x, y);
1326
+ dragInit("dragenter", overEl, x, y);
1327
+ lastOver = overEl;
1328
+ }
1329
+ dragInit("dragover", overEl, x, y);
1330
+ dragInit("drag", from.element, x, y);
1331
+ prevX = x; prevY = y;
1332
+ await sleepPage(rand(8, 26));
833
1333
  }
834
- pointerEventSequence(to.element, to.x, to.y, ["pointerover", "mouseover", "pointerup", "mouseup"]);
1334
+ dispatchPointerLikeEvent(to.element, "pointerover", to.x, to.y, prevX, prevY);
1335
+ dispatchPointerLikeEvent(to.element, "mouseover", to.x, to.y, prevX, prevY);
1336
+ dragInit("drop", to.element, to.x, to.y);
1337
+ dragInit("dragend", from.element, to.x, to.y);
1338
+ dispatchPointerLikeEvent(to.element, "pointerup", to.x, to.y, prevX, prevY);
1339
+ dispatchPointerLikeEvent(to.element, "mouseup", to.x, to.y, prevX, prevY);
1340
+ state.pointer = { x: to.x, y: to.y, t: performance.now() };
835
1341
  return {
836
1342
  from: { x: from.x, y: from.y },
837
1343
  to: { x: to.x, y: to.y },
838
- steps,
1344
+ steps: n,
1345
+ pageMutated: pageHash() !== before,
1346
+ note: "Synthetic drag with HTML5 DragEvent + shared DataTransfer. isTrusted is still false.",
1347
+ };
1348
+ }
1349
+
1350
+ async function scrollPage(selector, uid, deltaY, deltaX, steps) {
1351
+ installPiChromeInstrumentation();
1352
+ const before = pageHash();
1353
+ let target;
1354
+ if (selector || uid) {
1355
+ target = elementBySelectorOrUid(selector, uid);
1356
+ } else {
1357
+ target = document.scrollingElement || document.documentElement || document.body;
1358
+ }
1359
+ if (!target) throw new Error("No scroll target");
1360
+ const rect = target.getBoundingClientRect ? target.getBoundingClientRect() : { left: 0, top: 0, width: innerWidth, height: innerHeight };
1361
+ const cx = Math.max(0, Math.min(innerWidth - 1, rect.left + Math.min(rect.width, innerWidth) / 2));
1362
+ const cy = Math.max(0, Math.min(innerHeight - 1, rect.top + Math.min(rect.height, innerHeight) / 2));
1363
+ const n = Math.max(3, Math.min(40, steps || Math.max(3, Math.ceil(Math.abs(deltaY || 0) / 100))));
1364
+ // Front-loaded wheel deltas, momentum-style.
1365
+ const totalY = deltaY || 0;
1366
+ const totalX = deltaX || 0;
1367
+ const weights = [];
1368
+ for (let i = 1; i <= n; i++) weights.push(1 / i);
1369
+ const sumW = weights.reduce((a, b) => a + b, 0);
1370
+ let movedY = 0, movedX = 0;
1371
+ for (let i = 0; i < n; i++) {
1372
+ const dy = totalY * (weights[i] / sumW);
1373
+ const dx = totalX * (weights[i] / sumW);
1374
+ const ev = new WheelEvent("wheel", {
1375
+ bubbles: true, cancelable: true, composed: true, view: window,
1376
+ clientX: cx, clientY: cy,
1377
+ deltaX: dx, deltaY: dy, deltaMode: 0,
1378
+ });
1379
+ target.dispatchEvent(ev);
1380
+ if (!ev.defaultPrevented) {
1381
+ // Apply scroll ourselves; mirrors what the browser would do.
1382
+ if (target === document.scrollingElement || target === document.documentElement || target === document.body) {
1383
+ window.scrollBy({ left: dx, top: dy, behavior: "instant" });
1384
+ } else {
1385
+ target.scrollTop += dy;
1386
+ target.scrollLeft += dx;
1387
+ }
1388
+ }
1389
+ movedY += dy; movedX += dx;
1390
+ await sleepPage(rand(12, 28));
1391
+ }
1392
+ return {
1393
+ deltaX: movedX, deltaY: movedY, steps: n,
1394
+ scrollTop: target.scrollTop, scrollLeft: target.scrollLeft,
839
1395
  pageMutated: pageHash() !== before,
840
- note: "Synthetic pointer drag. HTML5 DataTransfer is not synthesized; native drag-and-drop targets may not respond.",
1396
+ isTrusted: false,
841
1397
  };
842
1398
  }
843
1399
 
@@ -871,23 +1427,90 @@ function setNativeValue(element, value) {
871
1427
  else element.value = value;
872
1428
  }
873
1429
 
874
- function typeIntoPage(selector, uid, text, pressEnter) {
875
- installPiChromeInstrumentation();
876
- const before = pageHash();
877
- let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
878
- if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
879
- element.focus();
1430
+ function printableKeyCode(ch) {
1431
+ if (ch === " ") return 32;
1432
+ const upper = ch.toUpperCase();
1433
+ if (/^[A-Z]$/.test(upper)) return upper.charCodeAt(0);
1434
+ if (/^[0-9]$/.test(ch)) return ch.charCodeAt(0);
1435
+ return ch.charCodeAt(0) || 0;
1436
+ }
1437
+
1438
+ function dispatchKeyEvent(element, type, key, mods = {}) {
1439
+ const code = key.length === 1 && /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` :
1440
+ key.length === 1 && /^[0-9]$/.test(key) ? `Digit${key}` :
1441
+ key === " " ? "Space" : key;
1442
+ const SPECIAL = { Enter: 13, Tab: 9, Backspace: 8, Delete: 46, Escape: 27,
1443
+ ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, " ": 32, Shift: 16, Control: 17, Alt: 18, Meta: 91 };
1444
+ const keyCode = key.length === 1 ? printableKeyCode(key) : (SPECIAL[key] ?? 0);
1445
+ const ev = new KeyboardEvent(type, {
1446
+ key,
1447
+ code,
1448
+ keyCode,
1449
+ which: keyCode,
1450
+ charCode: type === "keypress" && key.length === 1 ? key.charCodeAt(0) : 0,
1451
+ shiftKey: !!mods.shiftKey,
1452
+ ctrlKey: !!mods.ctrlKey,
1453
+ altKey: !!mods.altKey,
1454
+ metaKey: !!mods.metaKey,
1455
+ bubbles: true,
1456
+ cancelable: true,
1457
+ composed: true,
1458
+ view: window,
1459
+ });
1460
+ element.dispatchEvent(ev);
1461
+ return ev;
1462
+ }
1463
+
1464
+ async function typeCharacter(element, ch) {
1465
+ const needShift = ch.length === 1 && (/^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch));
1466
+ if (needShift) {
1467
+ dispatchKeyEvent(element, "keydown", "Shift", { shiftKey: true });
1468
+ await sleepPage(rand(8, 24));
1469
+ }
1470
+ const mods = { shiftKey: needShift };
1471
+ const down = dispatchKeyEvent(element, "keydown", ch, mods);
1472
+ if (down.defaultPrevented) {
1473
+ if (needShift) dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false });
1474
+ return { defaultPrevented: true };
1475
+ }
1476
+ if (ch.length === 1) dispatchKeyEvent(element, "keypress", ch, mods);
1477
+
880
1478
  if (element.isContentEditable) {
881
- document.execCommand("insertText", false, text);
1479
+ // execCommand("insertText") fires its own beforeinput + input. Don't double-dispatch.
1480
+ document.execCommand("insertText", false, ch);
882
1481
  } else if ("value" in element) {
883
1482
  const start = element.selectionStart ?? element.value.length;
884
1483
  const end = element.selectionEnd ?? element.value.length;
885
- setNativeValue(element, element.value.slice(0, start) + text + element.value.slice(end));
886
- element.selectionStart = element.selectionEnd = start + text.length;
887
- dispatchInputEvents(element, text, "insertText");
1484
+ const next = element.value.slice(0, start) + ch + element.value.slice(end);
1485
+ const before = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: ch });
1486
+ element.dispatchEvent(before);
1487
+ if (!before.defaultPrevented) {
1488
+ setNativeValue(element, next);
1489
+ try { element.selectionStart = element.selectionEnd = start + ch.length; } catch {}
1490
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ch }));
1491
+ }
888
1492
  } else {
889
1493
  throw new Error("Focused element is not text-editable");
890
1494
  }
1495
+
1496
+ await sleepPage(rand(25, 95));
1497
+ dispatchKeyEvent(element, "keyup", ch, mods);
1498
+ if (needShift) {
1499
+ await sleepPage(rand(5, 18));
1500
+ dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false });
1501
+ }
1502
+ await sleepPage(rand(35, 140));
1503
+ return { defaultPrevented: false };
1504
+ }
1505
+
1506
+ async function typeIntoPage(selector, uid, text, pressEnter) {
1507
+ installPiChromeInstrumentation();
1508
+ const before = pageHash();
1509
+ let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
1510
+ if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
1511
+ element.focus();
1512
+ if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable");
1513
+ for (const ch of Array.from(text)) await typeCharacter(element, ch);
891
1514
  if (pressEnter) pressKeyInPage("Enter");
892
1515
  return {
893
1516
  selector, uid, length: text.length, pressEnter,
@@ -923,14 +1546,48 @@ function fillPage(selector, uid, text, submit) {
923
1546
  };
924
1547
  }
925
1548
 
926
- function pressKeyInPage(key) {
1549
+ async function pressKeyInPage(key) {
927
1550
  const normalized = normalizeKey(key);
928
1551
  const target = document.activeElement || document.body;
929
- const before = (typeof pageHash === "function") ? pageHash() : 0;
930
- const down = new KeyboardEvent("keydown", { key: normalized, bubbles: true, cancelable: true });
931
- target.dispatchEvent(down);
932
- const up = new KeyboardEvent("keyup", { key: normalized, bubbles: true, cancelable: true });
933
- target.dispatchEvent(up);
1552
+ const before = pageHash();
1553
+ const down = dispatchKeyEvent(target, "keydown", normalized);
1554
+ if (normalized.length === 1) dispatchKeyEvent(target, "keypress", normalized);
1555
+ // Character insertion for printable keys when focus is in an editable.
1556
+ if (normalized.length === 1 && !down.defaultPrevented && (target.isContentEditable || ("value" in target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")))) {
1557
+ if (target.isContentEditable) {
1558
+ const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized });
1559
+ target.dispatchEvent(bi);
1560
+ if (!bi.defaultPrevented) {
1561
+ document.execCommand("insertText", false, normalized);
1562
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized }));
1563
+ }
1564
+ } else {
1565
+ const start = target.selectionStart ?? target.value.length;
1566
+ const end = target.selectionEnd ?? target.value.length;
1567
+ const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized });
1568
+ target.dispatchEvent(bi);
1569
+ if (!bi.defaultPrevented) {
1570
+ setNativeValue(target, target.value.slice(0, start) + normalized + target.value.slice(end));
1571
+ try { target.selectionStart = target.selectionEnd = start + 1; } catch {}
1572
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized }));
1573
+ }
1574
+ }
1575
+ } else if (normalized === "Backspace" && "value" in target) {
1576
+ const start = target.selectionStart ?? target.value.length;
1577
+ const end = target.selectionEnd ?? target.value.length;
1578
+ if (start > 0 || end > start) {
1579
+ const from = start === end ? start - 1 : start;
1580
+ const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "deleteContentBackward" });
1581
+ target.dispatchEvent(bi);
1582
+ if (!bi.defaultPrevented) {
1583
+ setNativeValue(target, target.value.slice(0, from) + target.value.slice(end));
1584
+ try { target.selectionStart = target.selectionEnd = from; } catch {}
1585
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }));
1586
+ }
1587
+ }
1588
+ }
1589
+ await sleepPage(rand(25, 95));
1590
+ const up = dispatchKeyEvent(target, "keyup", normalized);
934
1591
  if (normalized === "Enter") {
935
1592
  const form = target.closest?.("form");
936
1593
  if (form) form.requestSubmit?.();
@@ -939,7 +1596,7 @@ function pressKeyInPage(key) {
939
1596
  key: normalized,
940
1597
  isTrusted: false,
941
1598
  defaultPrevented: down.defaultPrevented || up.defaultPrevented,
942
- pageMutated: (typeof pageHash === "function") ? pageHash() !== before : undefined,
1599
+ pageMutated: pageHash() !== before,
943
1600
  };
944
1601
  }
945
1602
 
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { StringEnum } from "@earendil-works/pi-ai";
3
3
  import { Type } from "typebox";
4
- import { existsSync, statSync } from "node:fs";
4
+ import { existsSync, readFileSync, statSync } from "node:fs";
5
5
  import { mkdir, writeFile } from "node:fs/promises";
6
6
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
7
7
  import { dirname, join, resolve } from "node:path";
@@ -46,7 +46,16 @@ type BridgeResult = {
46
46
  error?: string;
47
47
  };
48
48
 
49
- const PI_CHROME_VERSION = "0.8.0";
49
+ const PI_CHROME_PKG_PATH = resolve(__dirname, "..", "..", "package.json");
50
+ function readPiChromeVersion(): string {
51
+ try {
52
+ const pkg = JSON.parse(readFileSync(PI_CHROME_PKG_PATH, "utf8")) as { version?: string };
53
+ if (pkg.version) return pkg.version;
54
+ } catch {}
55
+ return "0.0.0-dev";
56
+ }
57
+ const PI_CHROME_VERSION = readPiChromeVersion();
58
+ const PI_CHROME_GLOBAL_KEY = "__piChromeProfileBridgeLoaded__";
50
59
  const DEFAULT_HOST = process.env.PI_CHROME_BRIDGE_HOST ?? "127.0.0.1";
51
60
  const DEFAULT_PORT = Number(process.env.PI_CHROME_BRIDGE_PORT ?? "17318");
52
61
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -117,13 +126,14 @@ function readRequestBody(request: IncomingMessage): Promise<string> {
117
126
  });
118
127
  }
119
128
 
120
- function sendJson(response: ServerResponse, status: number, body: unknown): void {
129
+ function sendJson(response: ServerResponse, status: number, body: unknown, extraHeaders?: Record<string, string>): void {
121
130
  response.writeHead(status, {
122
131
  "content-type": "application/json; charset=utf-8",
123
132
  "access-control-allow-origin": "*",
124
133
  "access-control-allow-methods": "GET,POST,OPTIONS",
125
134
  "access-control-allow-headers": "content-type",
126
135
  "cache-control": "no-store",
136
+ ...(extraHeaders ?? {}),
127
137
  });
128
138
  response.end(JSON.stringify(body));
129
139
  }
@@ -315,7 +325,16 @@ class ChromeProfileBridge {
315
325
  if (command) this.queue.unshift(command);
316
326
  return;
317
327
  }
318
- sendJson(response, 200, command ? { type: "command", command } : { type: "none" });
328
+ // Re-read version on every /next so bumping package.json takes effect without pi restart.
329
+ const currentVersion = readPiChromeVersion();
330
+ sendJson(
331
+ response,
332
+ 200,
333
+ command
334
+ ? { type: "command", command, expectedExtensionVersion: currentVersion }
335
+ : { type: "none", expectedExtensionVersion: currentVersion },
336
+ { "x-pi-chrome-version": currentVersion },
337
+ );
319
338
  return;
320
339
  }
321
340
  if (request.method === "POST" && url.pathname === "/result") {
@@ -361,6 +380,18 @@ const imageFormatValues = ["png", "jpeg"] as const;
361
380
  const waitForValues = ["selector", "expression"] as const;
362
381
 
363
382
  export default function (pi: ExtensionAPI): void {
383
+ const globalState = globalThis as typeof globalThis & {
384
+ [PI_CHROME_GLOBAL_KEY]?: { version: string; root: string };
385
+ };
386
+ const alreadyLoaded = globalState[PI_CHROME_GLOBAL_KEY];
387
+ if (alreadyLoaded) {
388
+ console.warn(
389
+ `pi-chrome already loaded from ${alreadyLoaded.root} (v${alreadyLoaded.version}); skipping duplicate from ${extensionRoot()}.`,
390
+ );
391
+ return;
392
+ }
393
+ globalState[PI_CHROME_GLOBAL_KEY] = { version: PI_CHROME_VERSION, root: extensionRoot() };
394
+
364
395
  const bridge = new ChromeProfileBridge(DEFAULT_HOST, DEFAULT_PORT);
365
396
  let backgroundDefault = false;
366
397
 
@@ -425,6 +456,7 @@ Usage rules:
425
456
  const status = bridge.status();
426
457
  lines.push(`• Local bridge: mode=${status.mode}, url=${status.url}`);
427
458
  let extensionAlive = false;
459
+ let versionMismatch = false;
428
460
  try {
429
461
  const started = Date.now();
430
462
  const version = (await bridge.send("tab.version", {}, 35_000)) as {
@@ -434,11 +466,16 @@ Usage rules:
434
466
  };
435
467
  const latencyMs = Date.now() - started;
436
468
  extensionAlive = true;
437
- lines.push(`✓ Companion Chrome extension responding (ID: ${version.extensionId ?? "?"}, ext v${version.extensionVersion ?? "?"}, latency ${latencyMs}ms)`);
438
469
  if (version.extensionVersion && version.extensionVersion !== PI_CHROME_VERSION) {
470
+ versionMismatch = true;
439
471
  lines.push(
440
- `⚠ Extension version (${version.extensionVersion}) differs from pi-chrome (${PI_CHROME_VERSION}). Reload "Pi Existing Chrome Profile Bridge" in chrome://extensions to pick up the latest service worker.`,
472
+ `✗ EXTENSION VERSION MISMATCH: companion extension is v${version.extensionVersion}, but pi-chrome is v${PI_CHROME_VERSION}.`,
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".`,
475
+ ` (Future version drifts will self-heal: the extension now polls pi-chrome's expected version and reloads itself.)`,
441
476
  );
477
+ } else {
478
+ lines.push(`✓ Companion Chrome extension responding (ID: ${version.extensionId ?? "?"}, ext v${version.extensionVersion ?? "?"}, latency ${latencyMs}ms)`);
442
479
  }
443
480
  } catch (error) {
444
481
  const message = (error as Error).message;
@@ -450,7 +487,7 @@ Usage rules:
450
487
  }
451
488
  }
452
489
 
453
- if (extensionAlive) {
490
+ if (extensionAlive && !versionMismatch) {
454
491
  // MAIN-world evaluate probe.
455
492
  try {
456
493
  const value = await bridge.send("page.evaluate", { expression: "1+1", awaitPromise: true, foreground: false }, 10_000);
@@ -468,28 +505,81 @@ Usage rules:
468
505
  } catch (error) {
469
506
  lines.push(`⚠ page.probe failed: ${(error as Error).message}`);
470
507
  }
508
+ } else if (versionMismatch) {
509
+ lines.push(`… Skipped MAIN-world capability checks because the loaded extension is stale.`);
471
510
  }
472
511
 
473
- // CDP availability hint.
474
- try {
475
- const controller = new AbortController();
476
- const timer = setTimeout(() => controller.abort(), 250);
477
- const response = await fetch("http://127.0.0.1:9222/json/version", { signal: controller.signal }).catch(() => undefined);
478
- clearTimeout(timer);
479
- if (response && response.ok) {
480
- const info = (await response.json().catch(() => ({}))) as { Browser?: string };
481
- lines.push(`✓ CDP endpoint reachable at 127.0.0.1:9222 (${info.Browser ?? "unknown"}). Trusted input via CDP is not yet wired into pi-chrome — reserved for a future release.`);
482
- } else {
483
- lines.push(`• CDP not available (no listener on 127.0.0.1:9222). Synthetic input only; autoplay/clipboard/file-picker gates cannot be satisfied. Future pi-chrome versions will use CDP for trusted input when this port is enabled.`);
512
+ // Trusted-input (chrome.debugger) probe.
513
+ if (extensionAlive && !versionMismatch) {
514
+ try {
515
+ const status = (await bridge.send("trusted.status", {}, 5_000)) as {
516
+ mode?: string;
517
+ attachedTabs?: number[];
518
+ permissionGranted?: boolean;
519
+ };
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.`);
522
+ } else {
523
+ 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
+ }
525
+ } catch (error) {
526
+ lines.push(`⚠ trusted.status probe failed: ${(error as Error).message}`);
484
527
  }
485
- } catch {
486
- lines.push(`• CDP probe inconclusive.`);
487
528
  }
488
529
 
489
530
  ctx.ui.notify(lines.join("\n"), "info");
490
531
  },
491
532
  });
492
533
 
534
+ pi.registerCommand("chrome-trusted", {
535
+ description:
536
+ "Toggle trusted-input mode for chrome_* tools. ON: all clicks/types/etc go through chrome.debugger (CDP) so events are browser-trusted (isTrusted=true) and satisfy user-activation gates (clipboard, fullscreen, autoplay, file picker). Tradeoff: Chrome pins a yellow 'started debugging this browser' banner to the top of any tab in use. OFF (default): synthetic DOM events. AUTO: attach on demand only when a per-call trusted=true is passed. STATUS: print current mode and attached tabs.",
537
+ getArgumentCompletions: (prefix) => {
538
+ const items = [
539
+ { value: "on", label: "on", description: "All chrome_* tools dispatch via CDP. Yellow debugger banner appears." },
540
+ { value: "off", label: "off", description: "Synthetic events only (default)." },
541
+ { value: "auto", label: "auto", description: "Use CDP only when a tool passes trusted=true." },
542
+ { value: "status", label: "status", description: "Show current trusted mode and any attached tabs." },
543
+ ];
544
+ const lowered = prefix.toLowerCase();
545
+ const matches = items.filter((item) => item.value.startsWith(lowered));
546
+ return matches.length > 0 ? matches : null;
547
+ },
548
+ handler: async (args, ctx) => {
549
+ const arg = (args || "").trim().toLowerCase();
550
+ if (arg === "status" || arg === "") {
551
+ try {
552
+ const status = (await bridge.send("trusted.status", {}, 5_000)) as { mode: string; attachedTabs: number[]; permissionGranted: boolean };
553
+ const attached = status.attachedTabs?.length ? ` (attached to tab ${status.attachedTabs.join(",")})` : "";
554
+ const perm = status.permissionGranted ? "" : " — chrome.debugger API unavailable; reload the extension and accept the new permission.";
555
+ ctx.ui.notify(`Trusted-input mode: ${status.mode}${attached}${perm}`, "info");
556
+ } catch (error) {
557
+ ctx.ui.notify(`Failed to read trusted mode: ${(error as Error).message}`, "warning");
558
+ }
559
+ return;
560
+ }
561
+ if (!["on", "off", "auto"].includes(arg)) {
562
+ ctx.ui.notify(`Unknown argument '${arg}'. Use: on | off | auto | status`, "warning");
563
+ return;
564
+ }
565
+ try {
566
+ const result = (await bridge.send("trusted.mode", { mode: arg }, 5_000)) as { mode: string };
567
+ if (result.mode === "on") {
568
+ ctx.ui.notify(
569
+ "Trusted-input mode ON. All chrome_* tools now dispatch through chrome.debugger (CDP). Chrome will show a yellow 'started debugging this browser' banner. Events arrive as isTrusted=true and satisfy user-activation gates.",
570
+ "info",
571
+ );
572
+ } else if (result.mode === "off") {
573
+ ctx.ui.notify("Trusted-input mode OFF. Synthetic events only. Any attached debugger sessions detached.", "info");
574
+ } else {
575
+ ctx.ui.notify("Trusted-input mode AUTO. CDP attaches only when a tool passes trusted=true.", "info");
576
+ }
577
+ } catch (error) {
578
+ ctx.ui.notify(`Failed to set trusted mode: ${(error as Error).message}`, "warning");
579
+ }
580
+ },
581
+ });
582
+
493
583
  pi.registerCommand("chrome-background", {
494
584
  description:
495
585
  "Toggle silent/background mode for chrome_* tools. Background ON: chrome_* tools act silently — your editor/terminal keeps focus, Chrome does not pop up, your workflow is not interrupted. Background OFF (default): Chrome focuses and activates the target tab so you can watch the agent work, useful for demos, pair-driving, and debugging — tradeoff: Chrome pops up and steals focus. Pass `on` / `off` to set explicitly, or no argument to toggle.",
@@ -689,7 +779,7 @@ Usage rules:
689
779
  name: "chrome_click",
690
780
  label: "Chrome Click",
691
781
  description:
692
- "Click a snapshot uid, CSS selector, or viewport coordinate in an existing Chrome tab through the companion extension. The click is dispatched as a synthetic DOM event; by default Chrome is focused so the user can watch, pass background=true to click silently. Pass includeSnapshot=true to return a fresh snapshot after the click.",
782
+ "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.",
693
783
  promptSnippet: "Click page elements in Chrome by snapshot uid, selector, or viewport coordinate.",
694
784
  parameters: Type.Object({
695
785
  uid: Type.Optional(Type.String({ description: "Stable element uid from chrome_snapshot. Prefer uid over selector after taking a snapshot." })),
@@ -704,6 +794,7 @@ Usage rules:
704
794
  background: Type.Optional(
705
795
  Type.Boolean({ description: "If true, click silently without focusing Chrome. Default false." }),
706
796
  ),
797
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so the event is browser-trusted (isTrusted=true, user-activation satisfied). Triggers Chrome's 'started debugging this browser' banner." })),
707
798
  host: Type.Optional(Type.String()),
708
799
  port: Type.Optional(Type.Number()),
709
800
  }),
@@ -721,7 +812,7 @@ Usage rules:
721
812
  name: "chrome_type",
722
813
  label: "Chrome Type",
723
814
  description:
724
- "Focus an optional snapshot uid or CSS selector, then type text into an existing Chrome tab through the companion extension. By default focuses Chrome and activates the tab so the user can watch; pass background=true to type silently. Pass includeSnapshot=true to return a fresh snapshot after typing.",
815
+ "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.",
725
816
  promptSnippet: "Type text into Chrome, optionally focusing a snapshot uid or selector first.",
726
817
  parameters: Type.Object({
727
818
  text: Type.String(),
@@ -736,6 +827,7 @@ Usage rules:
736
827
  background: Type.Optional(
737
828
  Type.Boolean({ description: "If true, type silently without focusing Chrome. Default false." }),
738
829
  ),
830
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so each keystroke is browser-trusted. Triggers Chrome's debugger banner." })),
739
831
  host: Type.Optional(Type.String()),
740
832
  port: Type.Optional(Type.Number()),
741
833
  }),
@@ -768,6 +860,7 @@ Usage rules:
768
860
  background: Type.Optional(
769
861
  Type.Boolean({ description: "If true, fill silently without focusing Chrome. Default false." }),
770
862
  ),
863
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP for browser-trusted input. Triggers Chrome's debugger banner." })),
771
864
  host: Type.Optional(Type.String()),
772
865
  port: Type.Optional(Type.Number()),
773
866
  }),
@@ -797,6 +890,7 @@ Usage rules:
797
890
  background: Type.Optional(
798
891
  Type.Boolean({ description: "If true, send the key silently without focusing Chrome. Default false." }),
799
892
  ),
893
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so the keystroke is browser-trusted." })),
800
894
  host: Type.Optional(Type.String()),
801
895
  port: Type.Optional(Type.Number()),
802
896
  }),
@@ -967,6 +1061,7 @@ Usage rules:
967
1061
  urlIncludes: Type.Optional(Type.String()),
968
1062
  titleIncludes: Type.Optional(Type.String()),
969
1063
  background: Type.Optional(Type.Boolean()),
1064
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP for browser-trusted hover." })),
970
1065
  }),
971
1066
  async execute(_id, params): Promise<ToolTextResult> {
972
1067
  const result = await bridge.send("page.hover", withBackground(params), DEFAULT_TIMEOUT_MS);
@@ -977,7 +1072,7 @@ Usage rules:
977
1072
  pi.registerTool({
978
1073
  name: "chrome_drag",
979
1074
  label: "Chrome Drag",
980
- description: "Synthetic pointer drag from one uid/selector/point to another. Dispatches pointerdown → multi-step pointermove → pointerup. Note: HTML5 DataTransfer is NOT synthesized, so native HTML5 drag-and-drop targets may not respond.",
1075
+ description: "Synthetic drag from one uid/selector/point to another. Dispatches pointerdown → humanised pointermove path dragstart/drag/dragenter/dragover/dragleave/drop/dragend with a shared HTML5 DataTransfer, then pointerup. isTrusted=false.",
981
1076
  promptSnippet: "Drag a Chrome element from one point to another.",
982
1077
  parameters: Type.Object({
983
1078
  fromUid: Type.Optional(Type.String()),
@@ -993,6 +1088,7 @@ Usage rules:
993
1088
  urlIncludes: Type.Optional(Type.String()),
994
1089
  titleIncludes: Type.Optional(Type.String()),
995
1090
  background: Type.Optional(Type.Boolean()),
1091
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch through chrome.debugger / CDP so the drag is browser-trusted (real HTML5 dragstart/drop with native DataTransfer)." })),
996
1092
  }),
997
1093
  async execute(_id, params): Promise<ToolTextResult> {
998
1094
  const result = await bridge.send("page.drag", withBackground(params), DEFAULT_TIMEOUT_MS);
@@ -1000,6 +1096,29 @@ Usage rules:
1000
1096
  },
1001
1097
  });
1002
1098
 
1099
+ pi.registerTool({
1100
+ name: "chrome_scroll",
1101
+ label: "Chrome Scroll",
1102
+ description: "Scroll the page or a specific scrollable element by dispatching real wheel events with momentum-shaped deltas, then applying the scroll. Positive deltaY scrolls down. Pass uid/selector to scroll within a container, otherwise the document scrolls.",
1103
+ promptSnippet: "Scroll a Chrome page or container via wheel events (not raw scrollTop).",
1104
+ parameters: Type.Object({
1105
+ uid: Type.Optional(Type.String()),
1106
+ selector: Type.Optional(Type.String()),
1107
+ deltaY: Type.Optional(Type.Number({ description: "Pixels to scroll vertically. Positive = down." })),
1108
+ deltaX: Type.Optional(Type.Number({ description: "Pixels to scroll horizontally. Positive = right." })),
1109
+ steps: Type.Optional(Type.Number({ description: "Number of wheel events to dispatch. Defaults to ceil(|deltaY|/100)." })),
1110
+ targetId: Type.Optional(Type.String()),
1111
+ urlIncludes: Type.Optional(Type.String()),
1112
+ titleIncludes: Type.Optional(Type.String()),
1113
+ background: Type.Optional(Type.Boolean()),
1114
+ trusted: Type.Optional(Type.Boolean({ description: "If true, dispatch wheel events through chrome.debugger / CDP for browser-trusted scrolling." })),
1115
+ }),
1116
+ async execute(_id, params): Promise<ToolTextResult> {
1117
+ const result = await bridge.send("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS);
1118
+ return { content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }], details: { result: result as Json } };
1119
+ },
1120
+ });
1121
+
1003
1122
  pi.registerTool({
1004
1123
  name: "chrome_upload_file",
1005
1124
  label: "Chrome Upload File",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-chrome",
3
- "version": "0.8.0",
3
+ "version": "0.10.0",
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",