pi-chrome 0.8.0 → 0.9.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
@@ -124,7 +124,7 @@ If the Chrome extension you have loaded is older than `pi-chrome` on disk, `/chr
124
124
  ## Compose with
125
125
 
126
126
  - **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.
127
+ - **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
128
  - **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
129
 
130
130
  ## Tools
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "Pi Existing Chrome Profile Bridge",
4
- "version": "0.8.0",
4
+ "version": "0.9.1",
5
5
  "description": "Lets Pi control tabs in this existing Chrome profile via a local bridge at 127.0.0.1.",
6
6
  "permissions": ["tabs", "scripting", "storage", "activeTab", "alarms", "webNavigation"],
7
7
  "host_permissions": ["<all_urls>", "http://127.0.0.1:17318/*"],
@@ -43,6 +43,13 @@ async function pollLoop() {
43
43
  cache: "no-store",
44
44
  });
45
45
  if (!response.ok) throw new Error(`bridge /next HTTP ${response.status}`);
46
+ const expected = response.headers.get("x-pi-chrome-version");
47
+ const ours = chrome.runtime.getManifest().version;
48
+ if (expected && expected !== ours && isVersionOlder(ours, expected)) {
49
+ console.warn(`[pi-chrome] extension v${ours} behind pi-chrome v${expected}; reloading extension`);
50
+ try { chrome.runtime.reload(); } catch {}
51
+ return;
52
+ }
46
53
  const payload = await response.json();
47
54
  if (payload.type === "command") await handleCommand(payload.command);
48
55
  }
@@ -74,6 +81,18 @@ function sleep(ms) {
74
81
  return new Promise((resolve) => setTimeout(resolve, ms));
75
82
  }
76
83
 
84
+ function isVersionOlder(a, b) {
85
+ const pa = String(a).split(".").map((n) => parseInt(n, 10) || 0);
86
+ const pb = String(b).split(".").map((n) => parseInt(n, 10) || 0);
87
+ const n = Math.max(pa.length, pb.length);
88
+ for (let i = 0; i < n; i++) {
89
+ const x = pa[i] ?? 0, y = pb[i] ?? 0;
90
+ if (x < y) return true;
91
+ if (x > y) return false;
92
+ }
93
+ return false;
94
+ }
95
+
77
96
  async function dispatch(action, params) {
78
97
  switch (action) {
79
98
  case "tab.version":
@@ -122,6 +141,8 @@ async function dispatch(action, params) {
122
141
  return executeActionInTab(params, fillPage, [params.selector ?? null, params.uid ?? null, params.text || "", params.submit === true]);
123
142
  case "page.key":
124
143
  return executeActionInTab(params, pressKeyInPage, [params.key]);
144
+ case "page.scroll":
145
+ return executeActionInTab(params, scrollPage, [params.selector ?? null, params.uid ?? null, params.deltaY ?? 0, params.deltaX ?? 0, params.steps ?? null]);
125
146
  case "page.console.list":
126
147
  return executeInTab(params, listConsoleMessages, [params.clear === true]);
127
148
  case "page.network.list":
@@ -207,6 +228,15 @@ const HELPER_FUNCS = [
207
228
  occluderAt,
208
229
  pageHash,
209
230
  pointerEventSequence,
231
+ sleepPage,
232
+ rand,
233
+ dispatchPointerLikeEvent,
234
+ humanMoveTo,
235
+ humanClickPoint,
236
+ printableKeyCode,
237
+ dispatchKeyEvent,
238
+ typeCharacter,
239
+ scrollPage,
210
240
  ];
211
241
 
212
242
  async function executeInTab(params, func, args) {
@@ -497,32 +527,94 @@ function pageHash() {
497
527
  return h;
498
528
  }
499
529
 
530
+ function sleepPage(ms) {
531
+ return new Promise((resolve) => setTimeout(resolve, ms));
532
+ }
533
+
534
+ function rand(min, max) {
535
+ return min + Math.random() * (max - min);
536
+ }
537
+
538
+ function dispatchPointerLikeEvent(element, type, x, y, prevX, prevY, opts = {}) {
539
+ const isPointer = type.startsWith("pointer");
540
+ const Ctor = isPointer ? PointerEvent : MouseEvent;
541
+ const isMove = type === "pointermove" || type === "mousemove";
542
+ const isUpOrClick = type === "pointerup" || type === "mouseup" || type === "click";
543
+ const init = {
544
+ bubbles: true,
545
+ cancelable: true,
546
+ view: window,
547
+ clientX: x,
548
+ clientY: y,
549
+ screenX: x + (window.screenX || 0),
550
+ screenY: y + (window.screenY || 0),
551
+ movementX: Number.isFinite(prevX) ? x - prevX : 0,
552
+ movementY: Number.isFinite(prevY) ? y - prevY : 0,
553
+ button: 0,
554
+ buttons: isMove || isUpOrClick ? 0 : 1,
555
+ };
556
+ if (isPointer) {
557
+ init.pointerType = "mouse";
558
+ init.pointerId = 1;
559
+ init.isPrimary = true;
560
+ init.width = 1;
561
+ init.height = 1;
562
+ init.pressure = opts.pressure ?? (type === "pointerdown" ? 0.5 : 0);
563
+ init.tangentialPressure = 0;
564
+ init.tiltX = 0;
565
+ init.tiltY = 0;
566
+ }
567
+ const ev = new Ctor(type, init);
568
+ element.dispatchEvent(ev);
569
+ return ev.defaultPrevented;
570
+ }
571
+
500
572
  function pointerEventSequence(element, x, y, sequence) {
501
573
  let defaultPrevented = false;
574
+ const state = getPiChromeState();
575
+ const prevX = state.pointer?.x;
576
+ const prevY = state.pointer?.y;
502
577
  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;
578
+ defaultPrevented = dispatchPointerLikeEvent(element, type, x, y, prevX, prevY) || defaultPrevented;
522
579
  }
580
+ state.pointer = { x, y, t: performance.now() };
523
581
  return defaultPrevented;
524
582
  }
525
583
 
584
+ async function humanMoveTo(x, y, steps) {
585
+ const state = getPiChromeState();
586
+ const startX = Number.isFinite(state.pointer?.x) ? state.pointer.x : rand(12, Math.max(24, innerWidth - 12));
587
+ const startY = Number.isFinite(state.pointer?.y) ? state.pointer.y : rand(12, Math.max(24, innerHeight - 12));
588
+ const n = steps || Math.max(12, Math.min(42, Math.round(Math.hypot(x - startX, y - startY) / 18)));
589
+ let prevX = startX, prevY = startY;
590
+ let defaultPrevented = false;
591
+ for (let i = 1; i <= n; i++) {
592
+ const t = i / n;
593
+ const ease = t * t * (3 - 2 * t);
594
+ const wobble = Math.sin(t * Math.PI) * 8;
595
+ const px = startX + (x - startX) * ease + rand(-wobble, wobble);
596
+ const py = startY + (y - startY) * ease + rand(-wobble, wobble);
597
+ const el = document.elementFromPoint(px, py) || document.body || document.documentElement;
598
+ defaultPrevented = dispatchPointerLikeEvent(el, "pointermove", px, py, prevX, prevY) || defaultPrevented;
599
+ defaultPrevented = dispatchPointerLikeEvent(el, "mousemove", px, py, prevX, prevY) || defaultPrevented;
600
+ prevX = px; prevY = py;
601
+ await sleepPage(rand(4, 18));
602
+ }
603
+ state.pointer = { x, y, t: performance.now() };
604
+ return defaultPrevented;
605
+ }
606
+
607
+ function humanClickPoint(point) {
608
+ if (!point.rect) return { x: point.x, y: point.y };
609
+ const rect = point.rect;
610
+ const insetX = Math.min(rect.width * 0.35, Math.max(2, rect.width / 2 - 1));
611
+ const insetY = Math.min(rect.height * 0.35, Math.max(2, rect.height / 2 - 1));
612
+ return {
613
+ x: rect.left + rect.width / 2 + rand(-insetX, insetX),
614
+ y: rect.top + rect.height / 2 + rand(-insetY, insetY),
615
+ };
616
+ }
617
+
526
618
  function installPiChromeInstrumentation() {
527
619
  const state = getPiChromeState();
528
620
  if (state.instrumentationInstalled) return;
@@ -773,16 +865,31 @@ function resolvePoint(selector, uid, x, y) {
773
865
  return { element: document.elementFromPoint(x, y), x, y, rect: undefined };
774
866
  }
775
867
 
776
- function clickPage(selector, uid, x, y) {
868
+ async function clickPage(selector, uid, x, y) {
777
869
  installPiChromeInstrumentation();
778
870
  const before = pageHash();
779
871
  const point = resolvePoint(selector, uid, x, y);
780
872
  if (!point.element) throw new Error("No element at click point");
873
+ const clickPoint = humanClickPoint(point);
874
+ point.x = clickPoint.x;
875
+ point.y = clickPoint.y;
876
+ point.element = document.elementFromPoint(point.x, point.y) || point.element;
781
877
  const visible = isElementVisible(point.element);
782
878
  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
- ]);
879
+ let defaultPrevented = await humanMoveTo(point.x, point.y);
880
+ const state = getPiChromeState();
881
+ const prevX = state.pointer?.x;
882
+ const prevY = state.pointer?.y;
883
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerdown", point.x, point.y, prevX, prevY, { pressure: 0.5 }) || defaultPrevented;
884
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "mousedown", point.x, point.y, prevX, prevY) || defaultPrevented;
885
+ if (typeof point.element.focus === "function" && /^(A|BUTTON|INPUT|TEXTAREA|SELECT|SUMMARY)$/.test(point.element.tagName)) {
886
+ try { point.element.focus({ preventScroll: true }); } catch { try { point.element.focus(); } catch {} }
887
+ }
888
+ await sleepPage(rand(45, 140));
889
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "pointerup", point.x, point.y, prevX, prevY) || defaultPrevented;
890
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "mouseup", point.x, point.y, prevX, prevY) || defaultPrevented;
891
+ defaultPrevented = dispatchPointerLikeEvent(point.element, "click", point.x, point.y, prevX, prevY) || defaultPrevented;
892
+ state.pointer = { x: point.x, y: point.y, t: performance.now() };
786
893
  // Heuristic: if the clicked thing looks like a media play affordance and the page has paused
787
894
  // audio/video, the synthetic click may not unlock autoplay. Surface a warning.
788
895
  let autoplayHint;
@@ -806,38 +913,136 @@ function clickPage(selector, uid, x, y) {
806
913
  };
807
914
  }
808
915
 
809
- function hoverPage(selector, uid, x, y) {
916
+ async function hoverPage(selector, uid, x, y) {
810
917
  installPiChromeInstrumentation();
811
918
  const point = resolvePoint(selector, uid, x, y);
812
919
  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
- ]);
920
+ await humanMoveTo(point.x, point.y);
921
+ const state = getPiChromeState();
922
+ const prevX = state.pointer?.x, prevY = state.pointer?.y;
923
+ let defaultPrevented = false;
924
+ for (const type of ["pointerover", "mouseover", "pointerenter", "mouseenter"]) {
925
+ defaultPrevented = dispatchPointerLikeEvent(point.element, type, point.x, point.y, prevX, prevY) || defaultPrevented;
926
+ }
927
+ // Small dwell so hover-intent handlers fire.
928
+ await sleepPage(rand(80, 220));
816
929
  return { x: point.x, y: point.y, selector, uid, tag: point.element.tagName, defaultPrevented, isTrusted: false };
817
930
  }
818
931
 
819
- function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) {
932
+ async function dragPage(fromUid, fromSelector, fromX, fromY, toUid, toSelector, toX, toY, steps) {
820
933
  installPiChromeInstrumentation();
821
934
  const before = pageHash();
822
935
  const from = resolvePoint(fromSelector, fromUid, fromX, fromY);
823
936
  const to = resolvePoint(toSelector, toUid, toX, toY);
824
937
  if (!from.element) throw new Error("Drag source element not found");
825
938
  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;
939
+ // Move to source.
940
+ await humanMoveTo(from.x, from.y);
941
+ const state = getPiChromeState();
942
+ let prevX = state.pointer?.x, prevY = state.pointer?.y;
943
+ // Build a shared DataTransfer so HTML5 drag-and-drop handlers can populate / read it.
944
+ const dt = new DataTransfer();
945
+ const dragInit = (type, target, x, y) => {
946
+ const ev = new DragEvent(type, {
947
+ bubbles: true, cancelable: true, composed: true,
948
+ clientX: x, clientY: y,
949
+ screenX: x + (window.screenX || 0), screenY: y + (window.screenY || 0),
950
+ button: 0, buttons: 1, view: window,
951
+ dataTransfer: dt,
952
+ });
953
+ target.dispatchEvent(ev);
954
+ return ev;
955
+ };
956
+ dispatchPointerLikeEvent(from.element, "pointerover", from.x, from.y, prevX, prevY);
957
+ dispatchPointerLikeEvent(from.element, "pointerdown", from.x, from.y, prevX, prevY, { pressure: 0.5 });
958
+ dispatchPointerLikeEvent(from.element, "mousedown", from.x, from.y, prevX, prevY);
959
+ await sleepPage(rand(40, 110));
960
+ dragInit("dragstart", from.element, from.x, from.y);
961
+ dragInit("drag", from.element, from.x, from.y);
962
+ let lastOver = from.element;
963
+ const n = steps || 18;
964
+ for (let i = 1; i <= n; i++) {
965
+ const t = i / n;
966
+ const ease = t * t * (3 - 2 * t);
967
+ const wobble = Math.sin(t * Math.PI) * 6;
968
+ const x = from.x + (to.x - from.x) * ease + rand(-wobble, wobble);
969
+ const y = from.y + (to.y - from.y) * ease + rand(-wobble, wobble);
831
970
  const overEl = document.elementFromPoint(x, y) || to.element;
832
- pointerEventSequence(overEl, x, y, ["pointermove", "mousemove"]);
971
+ dispatchPointerLikeEvent(overEl, "pointermove", x, y, prevX, prevY);
972
+ dispatchPointerLikeEvent(overEl, "mousemove", x, y, prevX, prevY);
973
+ if (overEl !== lastOver) {
974
+ dragInit("dragleave", lastOver, x, y);
975
+ dragInit("dragenter", overEl, x, y);
976
+ lastOver = overEl;
977
+ }
978
+ dragInit("dragover", overEl, x, y);
979
+ dragInit("drag", from.element, x, y);
980
+ prevX = x; prevY = y;
981
+ await sleepPage(rand(8, 26));
833
982
  }
834
- pointerEventSequence(to.element, to.x, to.y, ["pointerover", "mouseover", "pointerup", "mouseup"]);
983
+ dispatchPointerLikeEvent(to.element, "pointerover", to.x, to.y, prevX, prevY);
984
+ dispatchPointerLikeEvent(to.element, "mouseover", to.x, to.y, prevX, prevY);
985
+ dragInit("drop", to.element, to.x, to.y);
986
+ dragInit("dragend", from.element, to.x, to.y);
987
+ dispatchPointerLikeEvent(to.element, "pointerup", to.x, to.y, prevX, prevY);
988
+ dispatchPointerLikeEvent(to.element, "mouseup", to.x, to.y, prevX, prevY);
989
+ state.pointer = { x: to.x, y: to.y, t: performance.now() };
835
990
  return {
836
991
  from: { x: from.x, y: from.y },
837
992
  to: { x: to.x, y: to.y },
838
- steps,
993
+ steps: n,
994
+ pageMutated: pageHash() !== before,
995
+ note: "Synthetic drag with HTML5 DragEvent + shared DataTransfer. isTrusted is still false.",
996
+ };
997
+ }
998
+
999
+ async function scrollPage(selector, uid, deltaY, deltaX, steps) {
1000
+ installPiChromeInstrumentation();
1001
+ const before = pageHash();
1002
+ let target;
1003
+ if (selector || uid) {
1004
+ target = elementBySelectorOrUid(selector, uid);
1005
+ } else {
1006
+ target = document.scrollingElement || document.documentElement || document.body;
1007
+ }
1008
+ if (!target) throw new Error("No scroll target");
1009
+ const rect = target.getBoundingClientRect ? target.getBoundingClientRect() : { left: 0, top: 0, width: innerWidth, height: innerHeight };
1010
+ const cx = Math.max(0, Math.min(innerWidth - 1, rect.left + Math.min(rect.width, innerWidth) / 2));
1011
+ const cy = Math.max(0, Math.min(innerHeight - 1, rect.top + Math.min(rect.height, innerHeight) / 2));
1012
+ const n = Math.max(3, Math.min(40, steps || Math.max(3, Math.ceil(Math.abs(deltaY || 0) / 100))));
1013
+ // Front-loaded wheel deltas, momentum-style.
1014
+ const totalY = deltaY || 0;
1015
+ const totalX = deltaX || 0;
1016
+ const weights = [];
1017
+ for (let i = 1; i <= n; i++) weights.push(1 / i);
1018
+ const sumW = weights.reduce((a, b) => a + b, 0);
1019
+ let movedY = 0, movedX = 0;
1020
+ for (let i = 0; i < n; i++) {
1021
+ const dy = totalY * (weights[i] / sumW);
1022
+ const dx = totalX * (weights[i] / sumW);
1023
+ const ev = new WheelEvent("wheel", {
1024
+ bubbles: true, cancelable: true, composed: true, view: window,
1025
+ clientX: cx, clientY: cy,
1026
+ deltaX: dx, deltaY: dy, deltaMode: 0,
1027
+ });
1028
+ target.dispatchEvent(ev);
1029
+ if (!ev.defaultPrevented) {
1030
+ // Apply scroll ourselves; mirrors what the browser would do.
1031
+ if (target === document.scrollingElement || target === document.documentElement || target === document.body) {
1032
+ window.scrollBy({ left: dx, top: dy, behavior: "instant" });
1033
+ } else {
1034
+ target.scrollTop += dy;
1035
+ target.scrollLeft += dx;
1036
+ }
1037
+ }
1038
+ movedY += dy; movedX += dx;
1039
+ await sleepPage(rand(12, 28));
1040
+ }
1041
+ return {
1042
+ deltaX: movedX, deltaY: movedY, steps: n,
1043
+ scrollTop: target.scrollTop, scrollLeft: target.scrollLeft,
839
1044
  pageMutated: pageHash() !== before,
840
- note: "Synthetic pointer drag. HTML5 DataTransfer is not synthesized; native drag-and-drop targets may not respond.",
1045
+ isTrusted: false,
841
1046
  };
842
1047
  }
843
1048
 
@@ -871,23 +1076,90 @@ function setNativeValue(element, value) {
871
1076
  else element.value = value;
872
1077
  }
873
1078
 
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();
1079
+ function printableKeyCode(ch) {
1080
+ if (ch === " ") return 32;
1081
+ const upper = ch.toUpperCase();
1082
+ if (/^[A-Z]$/.test(upper)) return upper.charCodeAt(0);
1083
+ if (/^[0-9]$/.test(ch)) return ch.charCodeAt(0);
1084
+ return ch.charCodeAt(0) || 0;
1085
+ }
1086
+
1087
+ function dispatchKeyEvent(element, type, key, mods = {}) {
1088
+ const code = key.length === 1 && /^[a-z]$/i.test(key) ? `Key${key.toUpperCase()}` :
1089
+ key.length === 1 && /^[0-9]$/.test(key) ? `Digit${key}` :
1090
+ key === " " ? "Space" : key;
1091
+ const SPECIAL = { Enter: 13, Tab: 9, Backspace: 8, Delete: 46, Escape: 27,
1092
+ ArrowLeft: 37, ArrowUp: 38, ArrowRight: 39, ArrowDown: 40, " ": 32, Shift: 16, Control: 17, Alt: 18, Meta: 91 };
1093
+ const keyCode = key.length === 1 ? printableKeyCode(key) : (SPECIAL[key] ?? 0);
1094
+ const ev = new KeyboardEvent(type, {
1095
+ key,
1096
+ code,
1097
+ keyCode,
1098
+ which: keyCode,
1099
+ charCode: type === "keypress" && key.length === 1 ? key.charCodeAt(0) : 0,
1100
+ shiftKey: !!mods.shiftKey,
1101
+ ctrlKey: !!mods.ctrlKey,
1102
+ altKey: !!mods.altKey,
1103
+ metaKey: !!mods.metaKey,
1104
+ bubbles: true,
1105
+ cancelable: true,
1106
+ composed: true,
1107
+ view: window,
1108
+ });
1109
+ element.dispatchEvent(ev);
1110
+ return ev;
1111
+ }
1112
+
1113
+ async function typeCharacter(element, ch) {
1114
+ const needShift = ch.length === 1 && (/^[A-Z]$/.test(ch) || "~!@#$%^&*()_+{}|:\"<>?".includes(ch));
1115
+ if (needShift) {
1116
+ dispatchKeyEvent(element, "keydown", "Shift", { shiftKey: true });
1117
+ await sleepPage(rand(8, 24));
1118
+ }
1119
+ const mods = { shiftKey: needShift };
1120
+ const down = dispatchKeyEvent(element, "keydown", ch, mods);
1121
+ if (down.defaultPrevented) {
1122
+ if (needShift) dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false });
1123
+ return { defaultPrevented: true };
1124
+ }
1125
+ if (ch.length === 1) dispatchKeyEvent(element, "keypress", ch, mods);
1126
+
880
1127
  if (element.isContentEditable) {
881
- document.execCommand("insertText", false, text);
1128
+ // execCommand("insertText") fires its own beforeinput + input. Don't double-dispatch.
1129
+ document.execCommand("insertText", false, ch);
882
1130
  } else if ("value" in element) {
883
1131
  const start = element.selectionStart ?? element.value.length;
884
1132
  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");
1133
+ const next = element.value.slice(0, start) + ch + element.value.slice(end);
1134
+ const before = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: ch });
1135
+ element.dispatchEvent(before);
1136
+ if (!before.defaultPrevented) {
1137
+ setNativeValue(element, next);
1138
+ try { element.selectionStart = element.selectionEnd = start + ch.length; } catch {}
1139
+ element.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: ch }));
1140
+ }
888
1141
  } else {
889
1142
  throw new Error("Focused element is not text-editable");
890
1143
  }
1144
+
1145
+ await sleepPage(rand(25, 95));
1146
+ dispatchKeyEvent(element, "keyup", ch, mods);
1147
+ if (needShift) {
1148
+ await sleepPage(rand(5, 18));
1149
+ dispatchKeyEvent(element, "keyup", "Shift", { shiftKey: false });
1150
+ }
1151
+ await sleepPage(rand(35, 140));
1152
+ return { defaultPrevented: false };
1153
+ }
1154
+
1155
+ async function typeIntoPage(selector, uid, text, pressEnter) {
1156
+ installPiChromeInstrumentation();
1157
+ const before = pageHash();
1158
+ let element = elementBySelectorOrUid(selector, uid) || document.activeElement;
1159
+ if (!element) throw new Error(selector || uid ? `No element for ${selector || uid}` : "No active element");
1160
+ element.focus();
1161
+ if (!(element.isContentEditable || "value" in element)) throw new Error("Focused element is not text-editable");
1162
+ for (const ch of Array.from(text)) await typeCharacter(element, ch);
891
1163
  if (pressEnter) pressKeyInPage("Enter");
892
1164
  return {
893
1165
  selector, uid, length: text.length, pressEnter,
@@ -923,14 +1195,48 @@ function fillPage(selector, uid, text, submit) {
923
1195
  };
924
1196
  }
925
1197
 
926
- function pressKeyInPage(key) {
1198
+ async function pressKeyInPage(key) {
927
1199
  const normalized = normalizeKey(key);
928
1200
  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);
1201
+ const before = pageHash();
1202
+ const down = dispatchKeyEvent(target, "keydown", normalized);
1203
+ if (normalized.length === 1) dispatchKeyEvent(target, "keypress", normalized);
1204
+ // Character insertion for printable keys when focus is in an editable.
1205
+ if (normalized.length === 1 && !down.defaultPrevented && (target.isContentEditable || ("value" in target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA")))) {
1206
+ if (target.isContentEditable) {
1207
+ const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized });
1208
+ target.dispatchEvent(bi);
1209
+ if (!bi.defaultPrevented) {
1210
+ document.execCommand("insertText", false, normalized);
1211
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized }));
1212
+ }
1213
+ } else {
1214
+ const start = target.selectionStart ?? target.value.length;
1215
+ const end = target.selectionEnd ?? target.value.length;
1216
+ const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "insertText", data: normalized });
1217
+ target.dispatchEvent(bi);
1218
+ if (!bi.defaultPrevented) {
1219
+ setNativeValue(target, target.value.slice(0, start) + normalized + target.value.slice(end));
1220
+ try { target.selectionStart = target.selectionEnd = start + 1; } catch {}
1221
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: normalized }));
1222
+ }
1223
+ }
1224
+ } else if (normalized === "Backspace" && "value" in target) {
1225
+ const start = target.selectionStart ?? target.value.length;
1226
+ const end = target.selectionEnd ?? target.value.length;
1227
+ if (start > 0 || end > start) {
1228
+ const from = start === end ? start - 1 : start;
1229
+ const bi = new InputEvent("beforeinput", { bubbles: true, cancelable: true, inputType: "deleteContentBackward" });
1230
+ target.dispatchEvent(bi);
1231
+ if (!bi.defaultPrevented) {
1232
+ setNativeValue(target, target.value.slice(0, from) + target.value.slice(end));
1233
+ try { target.selectionStart = target.selectionEnd = from; } catch {}
1234
+ target.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "deleteContentBackward" }));
1235
+ }
1236
+ }
1237
+ }
1238
+ await sleepPage(rand(25, 95));
1239
+ const up = dispatchKeyEvent(target, "keyup", normalized);
934
1240
  if (normalized === "Enter") {
935
1241
  const form = target.closest?.("form");
936
1242
  if (form) form.requestSubmit?.();
@@ -939,7 +1245,7 @@ function pressKeyInPage(key) {
939
1245
  key: normalized,
940
1246
  isTrusted: false,
941
1247
  defaultPrevented: down.defaultPrevented || up.defaultPrevented,
942
- pageMutated: (typeof pageHash === "function") ? pageHash() !== before : undefined,
1248
+ pageMutated: pageHash() !== before,
943
1249
  };
944
1250
  }
945
1251
 
@@ -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,6 +505,8 @@ 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
512
  // CDP availability hint.
@@ -977,7 +1016,7 @@ Usage rules:
977
1016
  pi.registerTool({
978
1017
  name: "chrome_drag",
979
1018
  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.",
1019
+ 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
1020
  promptSnippet: "Drag a Chrome element from one point to another.",
982
1021
  parameters: Type.Object({
983
1022
  fromUid: Type.Optional(Type.String()),
@@ -1000,6 +1039,28 @@ Usage rules:
1000
1039
  },
1001
1040
  });
1002
1041
 
1042
+ pi.registerTool({
1043
+ name: "chrome_scroll",
1044
+ label: "Chrome Scroll",
1045
+ 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.",
1046
+ promptSnippet: "Scroll a Chrome page or container via wheel events (not raw scrollTop).",
1047
+ parameters: Type.Object({
1048
+ uid: Type.Optional(Type.String()),
1049
+ selector: Type.Optional(Type.String()),
1050
+ deltaY: Type.Optional(Type.Number({ description: "Pixels to scroll vertically. Positive = down." })),
1051
+ deltaX: Type.Optional(Type.Number({ description: "Pixels to scroll horizontally. Positive = right." })),
1052
+ steps: Type.Optional(Type.Number({ description: "Number of wheel events to dispatch. Defaults to ceil(|deltaY|/100)." })),
1053
+ targetId: Type.Optional(Type.String()),
1054
+ urlIncludes: Type.Optional(Type.String()),
1055
+ titleIncludes: Type.Optional(Type.String()),
1056
+ background: Type.Optional(Type.Boolean()),
1057
+ }),
1058
+ async execute(_id, params): Promise<ToolTextResult> {
1059
+ const result = await bridge.send("page.scroll", withBackground(params), DEFAULT_TIMEOUT_MS);
1060
+ return { content: [{ type: "text", text: `Scrolled dy=${params.deltaY ?? 0} dx=${params.deltaX ?? 0}` }], details: { result: result as Json } };
1061
+ },
1062
+ });
1063
+
1003
1064
  pi.registerTool({
1004
1065
  name: "chrome_upload_file",
1005
1066
  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.9.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",