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
|
-
- **
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
784
|
-
|
|
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
|
-
|
|
814
|
-
|
|
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
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
875
|
-
|
|
876
|
-
const
|
|
877
|
-
|
|
878
|
-
if (
|
|
879
|
-
|
|
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
|
-
|
|
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
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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 =
|
|
930
|
-
const down =
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
target.
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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",
|