cdp-toolkit 1.7.0 → 1.8.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/dist/mcp.js CHANGED
@@ -486,6 +486,141 @@ async function withPage(selector, fn, opts = {}) {
486
486
  }
487
487
  }
488
488
 
489
+ // src/activity.ts
490
+ var BEACON_DATA_GLOBAL = "__cdpToolkitLastInput";
491
+ var BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon";
492
+ var BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"];
493
+ var BEACON_BODY = `
494
+ if (!window.${BEACON_INSTALLED_GLOBAL}) {
495
+ window.${BEACON_INSTALLED_GLOBAL} = true;
496
+ var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
497
+ var events = ${JSON.stringify([...BEACON_EVENTS])};
498
+ for (var i = 0; i < events.length; i++) {
499
+ window.addEventListener(events[i], mark, { capture: true, passive: true });
500
+ }
501
+ }`;
502
+ var BEACON_SOURCE = `(function () {${BEACON_BODY}
503
+ })();`;
504
+ var BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}
505
+ }`;
506
+ var BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
507
+ var DISPATCH_ATTRIBUTION_WINDOW_MS = 1500;
508
+ function dispatchKey(backend, targetId) {
509
+ return `${backend}:${targetId}`;
510
+ }
511
+ var dispatchLog = new Map;
512
+ var DISPATCH_LOG_LIMIT = 512;
513
+ function recordDispatch(backend, targetId, now = Date.now()) {
514
+ const key = dispatchKey(backend, targetId);
515
+ dispatchLog.delete(key);
516
+ dispatchLog.set(key, now);
517
+ while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
518
+ const oldest = dispatchLog.keys().next();
519
+ if (oldest.done)
520
+ break;
521
+ dispatchLog.delete(oldest.value);
522
+ }
523
+ }
524
+ var NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
525
+ async function sendInput(conn, backend, targetId, method, params = {}) {
526
+ if (!NON_SYNTHESIZING_INPUT_METHODS.has(method))
527
+ recordDispatch(backend, targetId);
528
+ return conn.send(method, params);
529
+ }
530
+ function lastDispatchAt(backend, targetId) {
531
+ return dispatchLog.get(dispatchKey(backend, targetId));
532
+ }
533
+ function isHumanAttributed(beaconTs, dispatchAt, windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
534
+ if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs))
535
+ return false;
536
+ return beaconTs > (dispatchAt ?? 0) + windowMs;
537
+ }
538
+ function humanActiveMs(beaconTs, dispatchAt, now = Date.now(), windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
539
+ if (!isHumanAttributed(beaconTs, dispatchAt, windowMs))
540
+ return null;
541
+ return Math.max(0, now - beaconTs);
542
+ }
543
+ var CONTENTION_WINDOW_MS = 30000;
544
+ function contentionWarning(ms, opts) {
545
+ if (!opts.takeover)
546
+ return;
547
+ if (ms === null || ms >= CONTENTION_WINDOW_MS)
548
+ return;
549
+ return `a person appears to be using this tab right now: input not dispatched by this server landed ${ms}ms ago. ` + `The claim SUCCEEDED and you hold the lease — this is a warning, not a refusal. ` + `Driving the tab now will fight them for the keyboard and mouse. ` + `Consider claim_page with no target to work in your own tab instead, or ask before continuing.`;
550
+ }
551
+ function beaconSupported(driver) {
552
+ return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
553
+ }
554
+ async function installBeacon(driver, targetId) {
555
+ if (typeof driver.installActivityBeacon !== "function")
556
+ return false;
557
+ return driver.installActivityBeacon(targetId).catch(() => false);
558
+ }
559
+ async function readHumanActiveMs(driver, backend, targetId, now = Date.now()) {
560
+ if (typeof driver.readActivityBeacon !== "function")
561
+ return null;
562
+ const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
563
+ return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
564
+ }
565
+ var RENDERER_PROBE_TIMEOUT_MS = 500;
566
+ var RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
567
+ function rendererProbeSupported(driver) {
568
+ return typeof driver.probeRenderer === "function";
569
+ }
570
+ async function probeRendererActivity(driver, backend, targetId, now = Date.now()) {
571
+ if (typeof driver.probeRenderer !== "function")
572
+ return { responsive: false };
573
+ const { responsive, beaconTs } = await driver.probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS).catch(() => ({ responsive: false, beaconTs: null }));
574
+ const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
575
+ return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
576
+ }
577
+
578
+ class BeaconSessions {
579
+ close;
580
+ limit;
581
+ live = new Map;
582
+ constructor(close, limit = 32) {
583
+ this.close = close;
584
+ this.limit = limit;
585
+ }
586
+ get(key) {
587
+ return this.live.get(key);
588
+ }
589
+ set(key, value) {
590
+ const previous = this.live.get(key);
591
+ if (previous !== undefined) {
592
+ this.live.delete(key);
593
+ this.safeClose(previous);
594
+ }
595
+ this.live.set(key, value);
596
+ while (this.live.size > this.limit) {
597
+ const oldest = this.live.keys().next();
598
+ if (oldest.done)
599
+ break;
600
+ this.drop(oldest.value);
601
+ }
602
+ }
603
+ drop(key) {
604
+ const value = this.live.get(key);
605
+ if (value === undefined)
606
+ return;
607
+ this.live.delete(key);
608
+ this.safeClose(value);
609
+ }
610
+ clear() {
611
+ for (const key of [...this.live.keys()])
612
+ this.drop(key);
613
+ }
614
+ get size() {
615
+ return this.live.size;
616
+ }
617
+ safeClose(value) {
618
+ try {
619
+ this.close(value);
620
+ } catch {}
621
+ }
622
+ }
623
+
489
624
  // src/tools/snapshot.ts
490
625
  var INTERACTIVE_ROLES = new Set([
491
626
  "button",
@@ -709,11 +844,22 @@ var REQUIRED_CAPABILITIES = {
709
844
  stop_screen_recording: ["capture.screencast"],
710
845
  mock_request: ["network.intercept"],
711
846
  list_mocks: ["network.intercept"],
712
- clear_mocks: ["network.intercept"]
847
+ clear_mocks: ["network.intercept"],
848
+ dispatch_mouse: ["input.raw"],
849
+ wait_for_download: ["browser.downloads"],
850
+ grant_permissions: ["browser.permissions"]
713
851
  };
714
852
  function isDriverError(e) {
715
853
  return e instanceof Error && typeof e.code === "string";
716
854
  }
855
+ function interpolatePoints(from, to, steps) {
856
+ const out = [];
857
+ for (let i = 1;i < steps; i++) {
858
+ out.push({ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps });
859
+ }
860
+ out.push({ x: to.x, y: to.y });
861
+ return out;
862
+ }
717
863
 
718
864
  // src/cdp/driver.ts
719
865
  function driverError(code, message, data) {
@@ -769,12 +915,21 @@ async function centerOf(conn, objectId) {
769
915
  async function focusElement(conn, objectId) {
770
916
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
771
917
  }
772
- async function setValueOnObject(conn, objectId, value) {
918
+ var ARM_SCROLL_SETTLE_WATCH = "(function(){window.__cdpScrollSettle=new Promise((resolve)=>{let t;const done=()=>{window.removeEventListener('scroll',on,true);resolve(true);};" + "const on=()=>{clearTimeout(t);t=setTimeout(done,60);};window.addEventListener('scroll',on,true);t=setTimeout(done,60);setTimeout(done,500);});})()";
919
+ async function armScrollSettleWatch(conn) {
920
+ await conn.send("Runtime.evaluate", { expression: ARM_SCROLL_SETTLE_WATCH, returnByValue: true });
921
+ }
922
+ async function awaitScrollSettle(conn) {
923
+ await conn.send("Runtime.evaluate", { expression: "window.__cdpScrollSettle", awaitPromise: true, returnByValue: true }).catch(() => {
924
+ return;
925
+ });
926
+ }
927
+ async function setValueOnObject(conn, objectId, value, sendInput2) {
773
928
  await focusElement(conn, objectId);
774
929
  const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
775
930
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
776
931
  if (value.length)
777
- await conn.send("Input.insertText", { text: value });
932
+ await sendInput2("Input.insertText", { text: value });
778
933
  }
779
934
  var NAMED_KEYS = {
780
935
  enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
@@ -799,6 +954,18 @@ var NAMED_KEYS = {
799
954
  pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
800
955
  };
801
956
  var MODIFIER_BITS = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
957
+ var CLICK_MODIFIER_BITS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
958
+ function inputModifierBits(mods) {
959
+ let bits = 0;
960
+ for (const m of mods ?? []) {
961
+ const bit = CLICK_MODIFIER_BITS[m];
962
+ if (bit === undefined)
963
+ throw driverError("page-error", `unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
964
+ bits |= bit;
965
+ }
966
+ return bits;
967
+ }
968
+ var HTML5_DRAG_INTERCEPT_TIMEOUT_MS = 5000;
802
969
  function resolveKey(key) {
803
970
  const named = NAMED_KEYS[key.toLowerCase()];
804
971
  if (named)
@@ -963,7 +1130,11 @@ var CDP_CAPABILITIES = new Set([
963
1130
  "input.insertTextAtomic",
964
1131
  "locate.text",
965
1132
  "locate.xpath",
966
- "capture.screencast"
1133
+ "capture.screencast",
1134
+ "input.raw",
1135
+ "input.html5Drag",
1136
+ "browser.downloads",
1137
+ "browser.permissions"
967
1138
  ]);
968
1139
 
969
1140
  class CdpPageDriver {
@@ -983,6 +1154,9 @@ class CdpPageDriver {
983
1154
  this.enabledDomains.add(name);
984
1155
  await this.conn.send(`${name}.enable`);
985
1156
  }
1157
+ async dispatchInput(method, params = {}) {
1158
+ return sendInput(this.conn, "chrome", this.info.id, method, params);
1159
+ }
986
1160
  async ensureDomainSoft(name) {
987
1161
  if (this.enabledDomains.has(name))
988
1162
  return;
@@ -991,11 +1165,22 @@ class CdpPageDriver {
991
1165
  return;
992
1166
  });
993
1167
  }
1168
+ async historyEntry(direction) {
1169
+ const h = await this.conn.send("Page.getNavigationHistory");
1170
+ const entries = h.entries ?? [];
1171
+ const entry = entries[h.currentIndex + (direction === "back" ? -1 : 1)];
1172
+ if (!entry) {
1173
+ throw driverError("page-error", `navigate: no history entry to go ${direction} to (at entry ${h.currentIndex + 1} of ${entries.length})`);
1174
+ }
1175
+ return entry;
1176
+ }
994
1177
  async navigate(opts) {
995
1178
  await this.ensureDomain("Page");
996
1179
  const reload = opts.reload === true;
997
- if (!reload && !opts.url)
1180
+ const history = opts.history;
1181
+ if (!reload && !history && !opts.url)
998
1182
  throw driverError("page-error", "navigate: 'url' is required unless reload:true");
1183
+ const entry = history ? await this.historyEntry(history) : undefined;
999
1184
  const waitUntil = opts.waitUntil ?? "load";
1000
1185
  const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
1001
1186
  const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
@@ -1013,6 +1198,10 @@ class CdpPageDriver {
1013
1198
  contextId = tree?.frameTree?.frame?.id ?? "";
1014
1199
  url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
1015
1200
  reloaded = true;
1201
+ } else if (entry) {
1202
+ await this.conn.send("Page.navigateToHistoryEntry", { entryId: entry.id }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
1203
+ contextId = "";
1204
+ url = entry.url;
1016
1205
  } else {
1017
1206
  const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
1018
1207
  if (nav.errorText)
@@ -1021,7 +1210,20 @@ class CdpPageDriver {
1021
1210
  url = opts.url;
1022
1211
  }
1023
1212
  const settled = await Promise.race([milestone, stopped]);
1024
- return { url, contextId, ...reloaded ? { reloaded } : {}, waitedFor: settled ?? "timeout" };
1213
+ if (entry) {
1214
+ const tree = await this.conn.send("Page.getFrameTree").catch(() => {
1215
+ return;
1216
+ });
1217
+ contextId = tree?.frameTree?.frame?.id ?? contextId;
1218
+ url = tree?.frameTree?.frame?.url ?? url;
1219
+ }
1220
+ return {
1221
+ url,
1222
+ contextId,
1223
+ ...reloaded ? { reloaded } : {},
1224
+ ...history ? { traversed: history } : {},
1225
+ waitedFor: settled ?? "timeout"
1226
+ };
1025
1227
  }
1026
1228
  async waitForText(text, timeoutMs = 15000, pollMs = 250) {
1027
1229
  await this.ensureDomain("Runtime");
@@ -1101,36 +1303,96 @@ class CdpPageDriver {
1101
1303
  async hover(loc) {
1102
1304
  const { objectId } = await resolveElementLocator(this.conn, loc);
1103
1305
  const { x, y } = await centerOf(this.conn, objectId);
1104
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1306
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1105
1307
  return { x, y };
1106
1308
  }
1107
1309
  async click(loc, opts) {
1108
1310
  const button = opts?.button ?? "left";
1109
1311
  const clickCount = opts?.clickCount ?? 1;
1312
+ const modifiers = inputModifierBits(opts?.modifiers);
1110
1313
  const { x, y } = await this.hover(loc);
1111
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
1112
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
1314
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount, modifiers });
1315
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount, modifiers });
1316
+ return { x, y };
1317
+ }
1318
+ async scroll(anchor, opts) {
1319
+ await this.ensureDomain("Runtime");
1320
+ const { x, y } = await this.resolveScrollPoint(anchor);
1321
+ await armScrollSettleWatch(this.conn);
1322
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: opts.deltaX ?? 0, deltaY: opts.deltaY ?? 0 });
1323
+ await awaitScrollSettle(this.conn);
1113
1324
  return { x, y };
1114
1325
  }
1115
- async drag(from, to) {
1326
+ async resolveScrollPoint(anchor) {
1327
+ if (anchor === undefined) {
1328
+ await this.ensureDomain("Runtime");
1329
+ const { result, exceptionDetails } = await this.conn.send("Runtime.evaluate", { expression: "({x: window.innerWidth / 2, y: window.innerHeight / 2})", returnByValue: true });
1330
+ if (exceptionDetails || !result.value)
1331
+ throw driverError("page-error", `could not measure viewport for scroll center: ${exceptionDetails?.text ?? "no value"}`);
1332
+ return result.value;
1333
+ }
1334
+ if ("x" in anchor)
1335
+ return anchor;
1336
+ const { objectId } = await resolveElementLocator(this.conn, anchor);
1337
+ return centerOf(this.conn, objectId);
1338
+ }
1339
+ async drag(from, to, opts) {
1116
1340
  const fromPt = await this.hover(from);
1117
- const { objectId: dstId } = await resolveElementLocator(this.conn, to);
1118
- const toPt = await centerOf(this.conn, dstId);
1119
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1120
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
1121
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
1122
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1341
+ const toPt = await this.resolveDragDestination(to, fromPt);
1342
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
1343
+ if ((opts?.mode ?? "mouse") === "html5") {
1344
+ await this.html5Drag(fromPt, toPt, steps);
1345
+ return { from: fromPt, to: toPt };
1346
+ }
1347
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1348
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1349
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1350
+ }
1351
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1123
1352
  return { from: fromPt, to: toPt };
1124
1353
  }
1354
+ async resolveDragDestination(to, fromPt) {
1355
+ if ("dx" in to)
1356
+ return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
1357
+ if ("x" in to)
1358
+ return to;
1359
+ const { objectId } = await resolveElementLocator(this.conn, to);
1360
+ return centerOf(this.conn, objectId);
1361
+ }
1362
+ async html5Drag(fromPt, toPt, steps) {
1363
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: true });
1364
+ try {
1365
+ const intercepted = this.conn.waitFor("Input.dragIntercepted", undefined, HTML5_DRAG_INTERCEPT_TIMEOUT_MS).catch(() => {
1366
+ return;
1367
+ });
1368
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1369
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1370
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1371
+ }
1372
+ const event = await intercepted;
1373
+ if (!event?.data) {
1374
+ throw driverError("page-error", `drag mode:"html5": the page never started a drag (no Input.dragIntercepted within ${HTML5_DRAG_INTERCEPT_TIMEOUT_MS}ms). ` + 'The source element is probably not draggable — HTML5 drag-and-drop needs draggable="true" (or a native draggable element) ' + 'plus a dragstart handler. Use the default mode:"mouse" for widgets built on raw pointer events.');
1375
+ }
1376
+ const data = event.data;
1377
+ for (const type of ["dragEnter", "dragOver", "drop"]) {
1378
+ await this.dispatchInput("Input.dispatchDragEvent", { type, x: toPt.x, y: toPt.y, data });
1379
+ }
1380
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1381
+ } finally {
1382
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: false }).catch(() => {
1383
+ return;
1384
+ });
1385
+ }
1386
+ }
1125
1387
  async setValue(loc, value) {
1126
1388
  const { objectId } = await resolveElementLocator(this.conn, loc);
1127
- await setValueOnObject(this.conn, objectId, value);
1389
+ await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
1128
1390
  }
1129
1391
  async typeText(loc, text) {
1130
1392
  const { objectId } = await resolveElementLocator(this.conn, loc);
1131
1393
  await focusElement(this.conn, objectId);
1132
1394
  if (text.length)
1133
- await this.conn.send("Input.insertText", { text });
1395
+ await this.dispatchInput("Input.insertText", { text });
1134
1396
  }
1135
1397
  async pressKey(press) {
1136
1398
  const spec = resolveKey(press.key);
@@ -1145,8 +1407,8 @@ class CdpPageDriver {
1145
1407
  const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
1146
1408
  if (spec.text && !suppressText)
1147
1409
  down.text = spec.text;
1148
- await this.conn.send("Input.dispatchKeyEvent", down);
1149
- await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1410
+ await this.dispatchInput("Input.dispatchKeyEvent", down);
1411
+ await this.dispatchInput("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1150
1412
  }
1151
1413
  async setFiles(loc, files) {
1152
1414
  const { objectId } = await resolveElementLocator(this.conn, loc);
@@ -1362,6 +1624,36 @@ class CdpPageDriver {
1362
1624
  this.conn.close();
1363
1625
  }
1364
1626
  }
1627
+ var beaconSessions = new BeaconSessions((conn) => conn.close());
1628
+ async function targetWsUrl(targetId) {
1629
+ const hit = (await listTargets()).find((t) => t.id === targetId);
1630
+ return hit?.webSocketDebuggerUrl;
1631
+ }
1632
+ async function readBeaconOver(conn) {
1633
+ try {
1634
+ const res = await conn.send("Runtime.evaluate", {
1635
+ expression: BEACON_READ_EXPRESSION,
1636
+ returnByValue: true
1637
+ });
1638
+ const value = res.result?.value;
1639
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1640
+ } catch {
1641
+ return;
1642
+ }
1643
+ }
1644
+ async function probeOver(conn, timeoutMs) {
1645
+ try {
1646
+ const res = await conn.send("Runtime.evaluate", { expression: RENDERER_PROBE_EXPRESSION, returnByValue: true }, { timeoutMs });
1647
+ const value = res.result?.value;
1648
+ if (Array.isArray(value) && value[0] === 1) {
1649
+ const ts = value[1];
1650
+ return { responsive: true, beaconTs: typeof ts === "number" && Number.isFinite(ts) ? ts : null };
1651
+ }
1652
+ return { responsive: false, beaconTs: null };
1653
+ } catch {
1654
+ return { responsive: false, beaconTs: null };
1655
+ }
1656
+ }
1365
1657
  async function withBrowserConn(fn) {
1366
1658
  const conn = await openBrowser();
1367
1659
  try {
@@ -1400,6 +1692,8 @@ class CdpBrowserDriver {
1400
1692
  async closePage(id) {
1401
1693
  return withBrowserConn(async (conn) => {
1402
1694
  const res = await conn.send("Target.closeTarget", { targetId: id });
1695
+ if (res.success !== false)
1696
+ beaconSessions.drop(id);
1403
1697
  return { success: res.success ?? true };
1404
1698
  });
1405
1699
  }
@@ -1412,7 +1706,83 @@ class CdpBrowserDriver {
1412
1706
  const { conn, target } = await openPage(selector).catch(noSuchTarget);
1413
1707
  return new CdpPageDriver(conn, target, this);
1414
1708
  }
1415
- async dispose() {}
1709
+ async installActivityBeacon(targetId) {
1710
+ const held = beaconSessions.get(targetId);
1711
+ if (held) {
1712
+ const rearmed = await held.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true }).then(() => true).catch(() => false);
1713
+ if (rearmed)
1714
+ return true;
1715
+ beaconSessions.drop(targetId);
1716
+ }
1717
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1718
+ return;
1719
+ });
1720
+ if (!wsUrl)
1721
+ return false;
1722
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
1723
+ return;
1724
+ });
1725
+ if (!conn)
1726
+ return false;
1727
+ try {
1728
+ await conn.send("Page.enable");
1729
+ await conn.send("Page.addScriptToEvaluateOnNewDocument", { source: BEACON_SOURCE });
1730
+ await conn.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true });
1731
+ } catch {
1732
+ conn.close();
1733
+ return false;
1734
+ }
1735
+ beaconSessions.set(targetId, conn);
1736
+ return true;
1737
+ }
1738
+ async readActivityBeacon(targetId) {
1739
+ const held = beaconSessions.get(targetId);
1740
+ if (held) {
1741
+ const value = await readBeaconOver(held);
1742
+ if (value !== undefined)
1743
+ return value;
1744
+ beaconSessions.drop(targetId);
1745
+ }
1746
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1747
+ return;
1748
+ });
1749
+ if (!wsUrl)
1750
+ return null;
1751
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
1752
+ return;
1753
+ });
1754
+ if (!conn)
1755
+ return null;
1756
+ try {
1757
+ return await readBeaconOver(conn) ?? null;
1758
+ } finally {
1759
+ conn.close();
1760
+ }
1761
+ }
1762
+ async probeRenderer(targetId, timeoutMs = RENDERER_PROBE_TIMEOUT_MS) {
1763
+ const held = beaconSessions.get(targetId);
1764
+ if (held)
1765
+ return probeOver(held, timeoutMs);
1766
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1767
+ return;
1768
+ });
1769
+ if (!wsUrl)
1770
+ return { responsive: false, beaconTs: null };
1771
+ const conn = new CdpConnection(wsUrl, { timeoutMs });
1772
+ try {
1773
+ await conn.connect();
1774
+ } catch {
1775
+ return { responsive: false, beaconTs: null };
1776
+ }
1777
+ try {
1778
+ return await probeOver(conn, timeoutMs);
1779
+ } finally {
1780
+ conn.close();
1781
+ }
1782
+ }
1783
+ async dispose() {
1784
+ beaconSessions.clear();
1785
+ }
1416
1786
  }
1417
1787
  function createCdpDriver() {
1418
1788
  return new CdpBrowserDriver;
@@ -1534,6 +1904,8 @@ function staleAgentTabs(input) {
1534
1904
  continue;
1535
1905
  if (row.stale !== "dead-pid" && row.stale !== "expired")
1536
1906
  continue;
1907
+ if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs)
1908
+ continue;
1537
1909
  if (!live.has(row.targetId))
1538
1910
  continue;
1539
1911
  const origin = input.origins.get(row.targetId);
@@ -1543,13 +1915,19 @@ function staleAgentTabs(input) {
1543
1915
  }
1544
1916
  return out;
1545
1917
  }
1918
+ var DEFAULT_REAP_GRACE_MS = 2700000;
1919
+ function reapGraceMs() {
1920
+ const raw = Number(process.env.CDP_REAP_GRACE_MS);
1921
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
1922
+ }
1546
1923
  async function reapStaleAgentTabs(driver, backend) {
1924
+ const now = Date.now();
1547
1925
  const livePageIds = (await driver.listPages()).map((p) => p.id);
1548
1926
  const [origins, leases] = await Promise.all([
1549
1927
  originIndex(backend, undefined),
1550
- listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [])
1928
+ listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [])
1551
1929
  ]);
1552
- const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
1930
+ const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
1553
1931
  const closed = [];
1554
1932
  for (const c of candidates) {
1555
1933
  const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
@@ -1641,18 +2019,29 @@ async function reapSet(driver, pages, all) {
1641
2019
  }
1642
2020
  }
1643
2021
  async function listPages(driver, args = {}) {
1644
- const reaped = requireLease() ? await reapStaleAgentTabs(driver, backendOf(driver)) : [];
2022
+ const backend = backendOf(driver);
2023
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
1645
2024
  const reapedIds = new Set(reaped.map((r) => r.targetId));
1646
2025
  const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
1647
- const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
1648
- const annotated = pages.map((p) => {
2026
+ const ledger = await originIndex(backend, await reapSet(driver, pages, args.all));
2027
+ const now = Date.now();
2028
+ const leaseByTarget = new Map((await listLeases({ now, liveIds: pages.map((p) => p.id), liveBackend: backend })).filter((l) => l.backend === backend && l.unreadable === undefined).map((l) => [l.targetId, l]));
2029
+ const doProbe = args.probe === true && rendererProbeSupported(driver);
2030
+ const annotated = await Promise.all(pages.map(async (p) => {
1649
2031
  const rec = ledger.get(p.id);
1650
- if (!rec)
1651
- return { ...p, origin: "unknown" };
1652
- if (rec.unreadable !== undefined)
1653
- return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
1654
- return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
1655
- });
2032
+ const base = !rec ? { ...p, origin: "unknown" } : rec.unreadable !== undefined ? { ...p, origin: "unknown", originUnreadable: rec.unreadable } : { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
2033
+ const leaseRow = leaseByTarget.get(p.id);
2034
+ if (leaseRow) {
2035
+ base.lease = { label: leaseRow.label, pid: leaseRow.pid, idleMs: now - leaseRow.lastUsedAt, expiresAt: leaseRow.lastUsedAt + leaseRow.ttlMs, stale: leaseRow.stale };
2036
+ }
2037
+ if (doProbe && (p.type === undefined || p.type === "page")) {
2038
+ const probe = await probeRendererActivity(driver, backend, p.id, now);
2039
+ base.responsive = probe.responsive;
2040
+ if (probe.humanActiveMs !== undefined)
2041
+ base.humanActiveMs = probe.humanActiveMs;
2042
+ }
2043
+ return base;
2044
+ }));
1656
2045
  return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
1657
2046
  }
1658
2047
  async function newPage(driver, args = {}) {
@@ -1666,6 +2055,7 @@ async function newPage(driver, args = {}) {
1666
2055
  ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
1667
2056
  auto: !explicit
1668
2057
  });
2058
+ await installBeacon(driver, p.id);
1669
2059
  return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
1670
2060
  }
1671
2061
  async function closePage(driver, args) {
@@ -1697,17 +2087,36 @@ function toLegacyWaitedFor(w) {
1697
2087
  return "navigate-only";
1698
2088
  return w;
1699
2089
  }
1700
- async function navigatePage(driver, args) {
2090
+ function resolveNavigateMode(args) {
1701
2091
  const reload = args.reload === true;
1702
- if (!reload && (!args.url || typeof args.url !== "string")) {
1703
- throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
2092
+ const hasUrl = typeof args.url === "string" && args.url.length > 0;
2093
+ const hasHistory = args.history !== undefined && args.history !== null;
2094
+ const given = [hasUrl ? "url" : "", reload ? "reload" : "", hasHistory ? "history" : ""].filter(Boolean);
2095
+ if (given.length > 1) {
2096
+ throw new SharedToolError(`navigate_page: 'url', 'reload' and 'history' are mutually exclusive (got ${given.join(" + ")})`);
1704
2097
  }
2098
+ if (hasHistory) {
2099
+ if (args.history !== "back" && args.history !== "forward") {
2100
+ throw new SharedToolError(`navigate_page: 'history' must be 'back' or 'forward' (got ${JSON.stringify(args.history)})`);
2101
+ }
2102
+ return { mode: "history", history: args.history };
2103
+ }
2104
+ if (reload)
2105
+ return { mode: "reload" };
2106
+ if (!hasUrl) {
2107
+ throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page, or history:'back'|'forward' to go back/forward in this tab's session history)");
2108
+ }
2109
+ return { mode: "url", url: args.url };
2110
+ }
2111
+ async function navigatePage(driver, args) {
2112
+ const mode = resolveNavigateMode(args);
1705
2113
  return withPage2(driver, args.target, async (page) => {
1706
- const r = await page.navigate(args);
2114
+ const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
1707
2115
  return {
1708
2116
  url: r.url,
1709
2117
  frameId: r.contextId,
1710
2118
  ...r.reloaded ? { reloaded: r.reloaded } : {},
2119
+ ...r.traversed ? { traversed: r.traversed } : {},
1711
2120
  waitedFor: toLegacyWaitedFor(r.waitedFor)
1712
2121
  };
1713
2122
  });
@@ -1853,22 +2262,113 @@ async function takeSnapshot(driver, args = {}) {
1853
2262
  }
1854
2263
  async function click(driver, args) {
1855
2264
  return withPage2(driver, args.target, async (page) => {
1856
- const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
2265
+ const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1, modifiers: args.modifiers });
1857
2266
  return { clicked: true, x, y };
1858
2267
  });
1859
2268
  }
2269
+ function resolveScrollAnchor(args) {
2270
+ const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
2271
+ const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
2272
+ const hasX = args.x !== undefined;
2273
+ const hasY = args.y !== undefined;
2274
+ if (hasX !== hasY)
2275
+ throw new SharedToolError("scroll: 'x' and 'y' must be provided together");
2276
+ const hasXY = hasX && hasY;
2277
+ const anchorCount = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2278
+ if (anchorCount > 1)
2279
+ throw new SharedToolError("scroll: provide at most one of { uid }, { selector }, or { x, y } (omit all three to scroll at the viewport center)");
2280
+ if (hasUid || hasSelector)
2281
+ return locatorOf({ uid: args.uid, selector: args.selector });
2282
+ if (hasXY)
2283
+ return { x: args.x, y: args.y };
2284
+ return;
2285
+ }
2286
+ function resolveScrollDelta(args) {
2287
+ const deltaX = args.deltaX ?? 0;
2288
+ const deltaY = args.deltaY ?? 0;
2289
+ if (deltaX === 0 && deltaY === 0)
2290
+ throw new SharedToolError("scroll requires at least one of { deltaX } or { deltaY }");
2291
+ return { deltaX, deltaY };
2292
+ }
2293
+ async function scroll(driver, args) {
2294
+ const anchor = resolveScrollAnchor(args);
2295
+ const { deltaX, deltaY } = resolveScrollDelta(args);
2296
+ return withPage2(driver, args.target, async (page) => {
2297
+ const { x, y } = await page.scroll(anchor, { deltaX, deltaY });
2298
+ return { x, y, deltaX, deltaY, target: target3(page.info) };
2299
+ });
2300
+ }
1860
2301
  async function hover(driver, args) {
1861
2302
  return withPage2(driver, args.target, async (page) => {
1862
2303
  const { x, y } = await page.hover(locatorOf(args));
1863
2304
  return { hovered: true, x, y };
1864
2305
  });
1865
2306
  }
2307
+ function resolveDragTo(to) {
2308
+ const hasUid = to.uid !== undefined && to.uid !== null && to.uid !== "";
2309
+ const hasSelector = typeof to.selector === "string" && to.selector.length > 0;
2310
+ const hasX = to.x !== undefined;
2311
+ const hasY = to.y !== undefined;
2312
+ if (hasX !== hasY)
2313
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be provided together");
2314
+ const hasXY = hasX && hasY;
2315
+ const count = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2316
+ if (count !== 1)
2317
+ throw new SharedToolError("drag: 'to' takes exactly one of { uid }, { selector }, or { x, y }");
2318
+ if (hasXY) {
2319
+ if (!Number.isFinite(to.x) || !Number.isFinite(to.y))
2320
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be finite numbers");
2321
+ return { x: to.x, y: to.y };
2322
+ }
2323
+ return locatorOf({ uid: to.uid, selector: to.selector });
2324
+ }
2325
+ function resolveDragDestination(args) {
2326
+ const hasTo = args.to !== undefined && args.to !== null;
2327
+ const hasBy = args.by !== undefined && args.by !== null;
2328
+ if (hasTo === hasBy)
2329
+ throw new SharedToolError("drag requires exactly one of { to } or { by }");
2330
+ if (hasBy) {
2331
+ const by = args.by;
2332
+ const hasDx = by.dx !== undefined;
2333
+ const hasDy = by.dy !== undefined;
2334
+ if (!hasDx && !hasDy)
2335
+ throw new SharedToolError("drag: 'by' requires at least one of { dx } or { dy }");
2336
+ const dx = by.dx ?? 0;
2337
+ const dy = by.dy ?? 0;
2338
+ if (!Number.isFinite(dx) || !Number.isFinite(dy))
2339
+ throw new SharedToolError("drag: 'by.dx' and 'by.dy' must be finite numbers");
2340
+ return { dx, dy };
2341
+ }
2342
+ return resolveDragTo(args.to);
2343
+ }
2344
+ var MAX_DRAG_STEPS = 500;
2345
+ function resolveDragSteps(steps) {
2346
+ if (steps === undefined)
2347
+ return 2;
2348
+ if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_DRAG_STEPS) {
2349
+ throw new SharedToolError(`drag: 'steps' must be an integer between 1 and ${MAX_DRAG_STEPS} (default 2)`);
2350
+ }
2351
+ return steps;
2352
+ }
2353
+ function resolveDragMode(mode, capabilities) {
2354
+ if (mode === undefined || mode === "mouse")
2355
+ return "mouse";
2356
+ if (mode !== "html5")
2357
+ throw new SharedToolError(`drag: unknown mode '${mode}' (use 'mouse' or 'html5')`);
2358
+ if (!capabilities.has("input.html5Drag")) {
2359
+ throw new SharedToolError("drag: mode:'html5' is not supported by this backend (WebDriver BiDi has no drag-interception primitive). " + "Omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.");
2360
+ }
2361
+ return "html5";
2362
+ }
1866
2363
  async function drag(driver, args) {
1867
- if (!args.from || !args.to)
1868
- throw new SharedToolError("drag requires { from } and { to }");
2364
+ if (!args.from)
2365
+ throw new SharedToolError("drag requires { from }");
2366
+ const destination = resolveDragDestination(args);
2367
+ const steps = resolveDragSteps(args.steps);
2368
+ const mode = resolveDragMode(args.mode, driver.capabilities);
1869
2369
  return withPage2(driver, args.target, async (page) => {
1870
- const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
1871
- return { dragged: true, from, to };
2370
+ const { from, to } = await page.drag(locatorOf(args.from), destination, { mode, steps });
2371
+ return { dragged: true, mode, steps, from, to };
1872
2372
  });
1873
2373
  }
1874
2374
  async function fill(driver, args) {
@@ -1989,6 +2489,7 @@ var SHARED_TOOLS = {
1989
2489
  click,
1990
2490
  hover,
1991
2491
  drag,
2492
+ scroll,
1992
2493
  fill,
1993
2494
  fill_form: fillForm,
1994
2495
  type_text: typeText,
@@ -2038,12 +2539,15 @@ async function claimPage(driver, args = {}) {
2038
2539
  url = page.url;
2039
2540
  opened = true;
2040
2541
  }
2542
+ const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
2041
2543
  const liveIds = [...pages.map((p) => p.id), targetId];
2042
2544
  const { record, token } = await claimLease(backend, targetId, {
2043
2545
  label,
2044
2546
  ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
2045
2547
  liveIds
2046
2548
  });
2549
+ await installBeacon(driver, targetId);
2550
+ const contention = contentionWarning(humanMs, { takeover: !opened });
2047
2551
  return {
2048
2552
  lease: token,
2049
2553
  targetId,
@@ -2051,7 +2555,9 @@ async function claimPage(driver, args = {}) {
2051
2555
  label: record.label,
2052
2556
  ttlMs: record.ttlMs,
2053
2557
  expiresAt: record.lastUsedAt + record.ttlMs,
2054
- opened
2558
+ opened,
2559
+ ...beaconSupported(driver) ? { humanActiveMs: humanMs } : {},
2560
+ ...contention !== undefined ? { contention } : {}
2055
2561
  };
2056
2562
  }
2057
2563
  async function releasePage(driver, args = {}) {
@@ -2086,9 +2592,22 @@ async function listLeasesTool(driver, _args = {}) {
2086
2592
  const backend = backendOf2(driver);
2087
2593
  const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
2088
2594
  const reapedIds = new Set(reaped.map((r) => r.targetId));
2089
- const liveIds = (await driver.listPages()).map((p) => p.id);
2595
+ const livePages = await driver.listPages();
2596
+ const liveIds = livePages.map((p) => p.id);
2090
2597
  const all = await listLeases({ liveIds, liveBackend: backend });
2091
- const leases = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
2598
+ const kept = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
2599
+ const liveSet = new Set(liveIds);
2600
+ const now = Date.now();
2601
+ const leases = [];
2602
+ for (const row of kept) {
2603
+ const computed = row.unreadable !== undefined ? row : { ...row, idleMs: now - row.lastUsedAt, expiresAt: row.lastUsedAt + row.ttlMs };
2604
+ if (row.backend !== backend || row.unreadable !== undefined || !liveSet.has(row.targetId)) {
2605
+ leases.push(computed);
2606
+ continue;
2607
+ }
2608
+ const humanMs = await readHumanActiveMs(driver, backend, row.targetId, now).catch(() => null);
2609
+ leases.push(humanMs === null ? computed : { ...computed, humanActiveMs: humanMs });
2610
+ }
2092
2611
  return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
2093
2612
  }
2094
2613
  var LEASE_TOOLS = {
@@ -3244,6 +3763,273 @@ function truncate(s, max = 600) {
3244
3763
  return t.length > max ? `${t.slice(0, max)}…` : t;
3245
3764
  }
3246
3765
 
3766
+ // src/tools/dispatch-mouse.ts
3767
+ var CLICK_MODIFIER_BITS2 = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
3768
+ var BUTTONS = new Set(["left", "right", "middle"]);
3769
+ var CDP_EVENT_TYPE = {
3770
+ move: "mouseMoved",
3771
+ down: "mousePressed",
3772
+ up: "mouseReleased"
3773
+ };
3774
+ function modifierBits(mods) {
3775
+ let bits = 0;
3776
+ for (const m of mods ?? []) {
3777
+ const bit = CLICK_MODIFIER_BITS2[m];
3778
+ if (bit === undefined)
3779
+ throw new Error(`dispatch_mouse: unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
3780
+ bits |= bit;
3781
+ }
3782
+ return bits;
3783
+ }
3784
+ function validateDispatchMouseArgs(args) {
3785
+ if (args.action !== "move" && args.action !== "down" && args.action !== "up") {
3786
+ throw new Error("dispatch_mouse requires { action } of 'move', 'down', or 'up'");
3787
+ }
3788
+ if (typeof args.x !== "number" || typeof args.y !== "number" || !Number.isFinite(args.x) || !Number.isFinite(args.y)) {
3789
+ throw new Error("dispatch_mouse requires numeric { x } and { y } on every call");
3790
+ }
3791
+ const button = args.button ?? "left";
3792
+ if (!BUTTONS.has(button))
3793
+ throw new Error(`dispatch_mouse: unknown button '${button}' (use left, right, or middle)`);
3794
+ }
3795
+ async function dispatchMouse(args) {
3796
+ validateDispatchMouseArgs(args);
3797
+ const button = args.button ?? "left";
3798
+ const modifiers = modifierBits(args.modifiers);
3799
+ return withPage(args.target, async (conn, target) => {
3800
+ const params = { type: CDP_EVENT_TYPE[args.action], x: args.x, y: args.y, button, modifiers };
3801
+ if (args.action !== "move")
3802
+ params.clickCount = args.clickCount ?? 1;
3803
+ await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", params);
3804
+ return { dispatched: args.action, x: args.x, y: args.y };
3805
+ });
3806
+ }
3807
+
3808
+ // src/tools/downloads.ts
3809
+ import { readdir as readdir3, rename, stat as stat4 } from "node:fs/promises";
3810
+ import { basename, extname, join as join8 } from "node:path";
3811
+
3812
+ // src/tools/browser-session.ts
3813
+ import { mkdir as mkdir9 } from "node:fs/promises";
3814
+ import { join as join7 } from "node:path";
3815
+ var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
3816
+ var DOWNLOADS_DIR = join7(ARTIFACT_DIR7, "downloads");
3817
+ var DOWNLOAD_BUFFER_LIMIT = 50;
3818
+ var session;
3819
+ function isDisconnect(e) {
3820
+ const m = e instanceof Error ? e.message : String(e);
3821
+ return /connection (not open|closed)|closed by caller|websocket|ECONNREFUSED|socket/i.test(m);
3822
+ }
3823
+ function record(s, guid) {
3824
+ let r = s.downloads.find((d) => d.guid === guid);
3825
+ if (!r) {
3826
+ r = { guid, state: "inProgress", consumed: false };
3827
+ s.downloads.push(r);
3828
+ while (s.downloads.length > DOWNLOAD_BUFFER_LIMIT) {
3829
+ const i = s.downloads.findIndex((d) => d.consumed);
3830
+ s.downloads.splice(i >= 0 ? i : 0, 1);
3831
+ }
3832
+ }
3833
+ return r;
3834
+ }
3835
+ function subscribe(s) {
3836
+ s.conn.on("Browser.downloadWillBegin", (p) => {
3837
+ const r = record(s, String(p.guid));
3838
+ if (typeof p.url === "string")
3839
+ r.url = p.url;
3840
+ if (typeof p.suggestedFilename === "string")
3841
+ r.suggestedFilename = p.suggestedFilename;
3842
+ });
3843
+ s.conn.on("Browser.downloadProgress", (p) => {
3844
+ const r = record(s, String(p.guid));
3845
+ if (typeof p.receivedBytes === "number")
3846
+ r.bytes = p.receivedBytes;
3847
+ if (typeof p.filePath === "string")
3848
+ r.filePath = p.filePath;
3849
+ const state = String(p.state);
3850
+ if (state === "completed" || state === "canceled") {
3851
+ r.state = state;
3852
+ if (state === "completed") {
3853
+ const waiter = s.waiters.shift();
3854
+ if (waiter) {
3855
+ r.consumed = true;
3856
+ waiter(r);
3857
+ }
3858
+ }
3859
+ }
3860
+ });
3861
+ }
3862
+ async function getBrowserSession() {
3863
+ if (session && !session.dead)
3864
+ return session;
3865
+ const previousPath = session?.armedPath;
3866
+ const conn = await openBrowser();
3867
+ const s = { conn, downloads: [], waiters: [], dead: false };
3868
+ subscribe(s);
3869
+ session = s;
3870
+ if (previousPath)
3871
+ await armDownloadsAt(previousPath);
3872
+ return s;
3873
+ }
3874
+ async function browserSend(method, params = {}) {
3875
+ const s = await getBrowserSession();
3876
+ try {
3877
+ return await s.conn.send(method, params);
3878
+ } catch (e) {
3879
+ if (isDisconnect(e)) {
3880
+ s.dead = true;
3881
+ const fresh = await getBrowserSession();
3882
+ return await fresh.conn.send(method, params);
3883
+ }
3884
+ throw e;
3885
+ }
3886
+ }
3887
+ async function armDownloadsAt(path) {
3888
+ await mkdir9(path, { recursive: true });
3889
+ const s = await getBrowserSession();
3890
+ await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
3891
+ s.armedPath = path;
3892
+ }
3893
+ async function armDownloads() {
3894
+ const s = await getBrowserSession();
3895
+ if (s.armedPath !== DOWNLOADS_DIR)
3896
+ await armDownloadsAt(DOWNLOADS_DIR);
3897
+ return { downloadPath: DOWNLOADS_DIR };
3898
+ }
3899
+ function pendingDownloads() {
3900
+ return (session?.downloads ?? []).filter((d) => d.state === "completed" && !d.consumed);
3901
+ }
3902
+ async function nextCompletedDownload(timeoutMs) {
3903
+ const s = await getBrowserSession();
3904
+ const buffered = s.downloads.find((d) => d.state === "completed" && !d.consumed);
3905
+ if (buffered) {
3906
+ buffered.consumed = true;
3907
+ return buffered;
3908
+ }
3909
+ return new Promise((resolve, reject) => {
3910
+ const waiter = (r) => {
3911
+ clearTimeout(timer);
3912
+ resolve(r);
3913
+ };
3914
+ const timer = setTimeout(() => {
3915
+ const i = s.waiters.indexOf(waiter);
3916
+ if (i >= 0)
3917
+ s.waiters.splice(i, 1);
3918
+ reject(new Error(`wait_for_download: no download completed within ${timeoutMs}ms. Downloads are only captured while this server holds the arming connection, so the triggering click must come AFTER a wait_for_download call (use arm:true to arm without waiting).`));
3919
+ }, timeoutMs);
3920
+ s.waiters.push(waiter);
3921
+ });
3922
+ }
3923
+ async function disposeBrowserSession() {
3924
+ const s = session;
3925
+ session = undefined;
3926
+ if (!s)
3927
+ return;
3928
+ s.dead = true;
3929
+ try {
3930
+ s.conn.close();
3931
+ } catch {}
3932
+ }
3933
+
3934
+ // src/tools/downloads.ts
3935
+ function safeFilename(suggested) {
3936
+ const raw = (suggested ?? "").replace(/[\u0000-\u001f]/g, "");
3937
+ const name = basename(raw.split("\\").pop() ?? "").trim();
3938
+ if (!name || name === "." || name === "..")
3939
+ return "download";
3940
+ return name;
3941
+ }
3942
+ function nextFreeName(name, taken) {
3943
+ if (!taken.has(name))
3944
+ return name;
3945
+ const ext = extname(name);
3946
+ const stem = ext ? name.slice(0, -ext.length) : name;
3947
+ for (let i = 1;; i++) {
3948
+ const candidate = `${stem}-${i}${ext}`;
3949
+ if (!taken.has(candidate))
3950
+ return candidate;
3951
+ }
3952
+ }
3953
+ function validateWaitForDownloadArgs(args) {
3954
+ if (args.timeoutMs !== undefined) {
3955
+ if (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
3956
+ throw new Error(`wait_for_download: 'timeoutMs' must be a positive number (got ${JSON.stringify(args.timeoutMs)})`);
3957
+ }
3958
+ }
3959
+ if (args.arm !== undefined && typeof args.arm !== "boolean") {
3960
+ throw new Error(`wait_for_download: 'arm' must be a boolean (got ${JSON.stringify(args.arm)})`);
3961
+ }
3962
+ }
3963
+ async function waitForDownload(args = {}) {
3964
+ validateWaitForDownloadArgs(args);
3965
+ const t = await resolveTarget(args.target);
3966
+ const target = { id: t.id, url: t.url, title: t.title };
3967
+ const { downloadPath } = await armDownloads();
3968
+ if (args.arm === true)
3969
+ return { armed: true, downloadPath, pending: pendingDownloads().length, target };
3970
+ const rec = await nextCompletedDownload(args.timeoutMs ?? 30000);
3971
+ if (!rec.filePath) {
3972
+ throw new Error(`wait_for_download: download ${rec.guid} completed without a file path (nothing to return)`);
3973
+ }
3974
+ const suggestedFilename = safeFilename(rec.suggestedFilename);
3975
+ const taken = new Set(await readdir3(DOWNLOADS_DIR).catch(() => []));
3976
+ taken.delete(basename(rec.filePath));
3977
+ const finalName = nextFreeName(suggestedFilename, taken);
3978
+ const path = join8(DOWNLOADS_DIR, finalName);
3979
+ await rename(rec.filePath, path);
3980
+ const bytes = (await stat4(path)).size;
3981
+ return { path, suggestedFilename, bytes, ...rec.url ? { url: rec.url } : {}, target };
3982
+ }
3983
+
3984
+ // src/tools/permissions.ts
3985
+ function originOf(url) {
3986
+ try {
3987
+ const o = new URL(url).origin;
3988
+ return o && o !== "null" ? o : undefined;
3989
+ } catch {
3990
+ return;
3991
+ }
3992
+ }
3993
+ function validateGrantPermissionsArgs(args) {
3994
+ const reset = args.reset === true;
3995
+ if (args.reset !== undefined && typeof args.reset !== "boolean") {
3996
+ throw new Error(`grant_permissions: 'reset' must be a boolean (got ${JSON.stringify(args.reset)})`);
3997
+ }
3998
+ if (args.permissions === undefined) {
3999
+ if (!reset)
4000
+ throw new Error("grant_permissions: 'permissions' is required (or pass reset:true to clear previous grants)");
4001
+ return;
4002
+ }
4003
+ if (!Array.isArray(args.permissions)) {
4004
+ throw new Error(`grant_permissions: 'permissions' must be an array of CDP PermissionType strings (got ${JSON.stringify(args.permissions)})`);
4005
+ }
4006
+ if (args.permissions.length === 0 && !reset) {
4007
+ throw new Error("grant_permissions: 'permissions' must not be empty (pass reset:true if the intent is to clear grants)");
4008
+ }
4009
+ for (const p of args.permissions) {
4010
+ if (typeof p !== "string" || p.length === 0) {
4011
+ throw new Error(`grant_permissions: every entry of 'permissions' must be a non-empty string (got ${JSON.stringify(p)})`);
4012
+ }
4013
+ }
4014
+ }
4015
+ async function grantPermissions(args = {}) {
4016
+ validateGrantPermissionsArgs(args);
4017
+ const t = await resolveTarget(args.target);
4018
+ const target = { id: t.id, url: t.url, title: t.title };
4019
+ const reset = args.reset === true;
4020
+ const permissions = args.permissions ?? [];
4021
+ if (reset)
4022
+ await browserSend("Browser.resetPermissions", {});
4023
+ if (permissions.length === 0)
4024
+ return { reset: true, target };
4025
+ const origin = args.origin ?? originOf(t.url);
4026
+ if (!origin) {
4027
+ throw new Error(`grant_permissions: cannot derive an origin from the target tab's url (${JSON.stringify(t.url)}); pass 'origin' explicitly, e.g. origin:"https://example.com"`);
4028
+ }
4029
+ await browserSend("Browser.grantPermissions", { origin, permissions });
4030
+ return { granted: [...permissions], origin, ...reset ? { reset: true } : {}, target };
4031
+ }
4032
+
3247
4033
  // src/index.ts
3248
4034
  var cdpDriver = createCdpDriver();
3249
4035
  function onCdp(fn) {
@@ -3264,6 +4050,7 @@ var TOOLS = {
3264
4050
  click: onCdp(SHARED_TOOLS.click),
3265
4051
  hover: onCdp(SHARED_TOOLS.hover),
3266
4052
  drag: onCdp(SHARED_TOOLS.drag),
4053
+ scroll: onCdp(SHARED_TOOLS.scroll),
3267
4054
  fill: onCdp(SHARED_TOOLS.fill),
3268
4055
  fill_form: onCdp(SHARED_TOOLS.fill_form),
3269
4056
  type_text: onCdp(SHARED_TOOLS.type_text),
@@ -3288,6 +4075,9 @@ var TOOLS = {
3288
4075
  mock_request: mockRequest,
3289
4076
  list_mocks: listMocks,
3290
4077
  clear_mocks: clearMocks,
4078
+ dispatch_mouse: dispatchMouse,
4079
+ wait_for_download: waitForDownload,
4080
+ grant_permissions: grantPermissions,
3291
4081
  claim_page: onCdp(LEASE_TOOLS.claim_page),
3292
4082
  release_page: onCdp(LEASE_TOOLS.release_page),
3293
4083
  list_leases: onCdp(LEASE_TOOLS.list_leases)
@@ -3303,7 +4093,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
3303
4093
  var MANIFEST = [
3304
4094
  {
3305
4095
  name: "list_pages",
3306
- description: "Enumerate browser page targets via the CDP browser endpoint (GET /json/list). By default returns only page-type tabs; set 'all' to also include workers and background pages. Each entry carries the targetId used as a target selector elsewhere, plus an 'origin' field recording where the tab came from: 'agent' means this toolkit created it (the entry then also carries the creating 'label' and 'createdAt'), and this stays true after the creating agent releases its lease or dies, which is what makes a stray agent tab findable later. 'unknown' means there is no creation record. It never says 'human': the toolkit cannot prove a person opened a tab, so 'unknown' is the honest word for a tab it did not create, one opened before the toolkit ran, or one whose record could not be written. A tab whose record exists but could not be read reports origin 'unknown' plus 'originUnreadable', so a broken record is never mistaken for no record. Under CDP_REQUIRE_LEASE this call also reaps: a tab this toolkit created whose lease is stale because the owning process died or the TTL elapsed is closed, and the closed tabs are reported in an additive 'reaped' array ({targetId,label,reason}) that is present only when something was actually closed. A tab with no lease is never reaped, so tabs opened by a caller that never uses leases are untouched.",
4096
+ description: "Enumerate browser page targets via the CDP browser endpoint (GET /json/list). By default returns only page-type tabs; set 'all' to also include workers and background pages. Each entry carries the targetId used as a target selector elsewhere, plus an 'origin' field recording where the tab came from: 'agent' means this toolkit created it (the entry then also carries the creating 'label' and 'createdAt'), and this stays true after the creating agent releases its lease or dies, which is what makes a stray agent tab findable later. 'unknown' means there is no creation record. It never says 'human': the toolkit cannot prove a person opened a tab, so 'unknown' is the honest word for a tab it did not create, one opened before the toolkit ran, or one whose record could not be written. A tab whose record exists but could not be read reports origin 'unknown' plus 'originUnreadable', so a broken record is never mistaken for no record. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs, but reap-CLOSE and lease-RECLAIMABLE (list_leases' 'stale') now fire at different times: a lease reads reclaimable the moment its TTL elapses, cheap and reversible since another agent may simply take it, while this call only CLOSES the tab, destructively, after an ADDITIONAL grace period past that TTL (CDP_REAP_GRACE_MS, default 2700000ms/45 minutes, so 60 minutes total after last use) — a tab an agent is mid-build between calls on is not destroyed out from under it. A dead-pid lease is reaped immediately regardless of grace: that process is never coming back. Closed tabs are reported in an additive 'reaped' array ({targetId,label,reason}) present only when something was actually closed; a tab with no lease is never reaped. Every entry under an active, readable lease of this backend also carries 'lease': {label, pid, idleMs, expiresAt, stale} — idleMs and expiresAt are computed fresh on every call (now-lastUsedAt and lastUsedAt+ttlMs), never read off disk, and this is unconditional on 'probe'. Set 'probe' to true to also ping each page-type target's renderer: one bounded (500ms) check per target, never more, so a single wedged tab cannot stall the listing beyond its own budget. This adds 'responsive' (true iff the ping answered in time; false on a timeout, a page-side exception, or an unreachable target — never an error for the whole call) and, where that same round trip found human-attributed input, 'humanActiveMs' (see claim_page and list_leases for what that field means and cannot prove). 'responsive' and 'humanActiveMs' are both ABSENT — never false/null — when 'probe' is not set, and 'responsive' is likewise absent on a backend that cannot answer this ping at all.",
3307
4097
  inputSchema: {
3308
4098
  type: "object",
3309
4099
  properties: {
@@ -3314,6 +4104,10 @@ var MANIFEST = [
3314
4104
  all: {
3315
4105
  type: "boolean",
3316
4106
  description: "Include non-page targets (service/shared workers, background pages) when true; otherwise only page-type tabs are listed."
4107
+ },
4108
+ probe: {
4109
+ type: "boolean",
4110
+ description: "Ping each page-type target's renderer before returning: one bounded (500ms) Runtime.evaluate per target, never more. Adds 'responsive' to every page-type entry and, where the same round trip finds human-attributed input, 'humanActiveMs'. A wedged or unreachable tab reports responsive:false rather than failing the call. Defaults to false, which is byte-identical to the pre-1.8.0 shape."
3317
4111
  }
3318
4112
  },
3319
4113
  required: [],
@@ -3395,7 +4189,7 @@ var MANIFEST = [
3395
4189
  },
3396
4190
  {
3397
4191
  name: "navigate_page",
3398
- description: "Navigate a target page to a URL (Page.navigate) OR reload it (reload:true) over raw CDP, then wait for the load milestone with a bounded timeout so a wedged renderer can't hang. Returns {url,frameId,waitedFor} (plus reloaded:true on a reload); waitUntil supports 'load'|'domcontentloaded' only (no 'networkidle'), and there is no auto-snapshot of the new page. Pass reload:true with ignoreCache:true for a hard reload that refetches every subresource (e.g. to pick up a freshly-deployed, non-content-hashed bundle the HTTP cache would serve stale).",
4192
+ description: "Navigate a target page to a URL (Page.navigate), reload it (reload:true), OR go back/forward in its session history (history:'back'|'forward'), then wait for the load milestone with a bounded timeout so a wedged renderer can't hang. Exactly one of url / reload / history per call. Returns {url,frameId,waitedFor} (plus reloaded:true on a reload, traversed:'back'|'forward' on a history move); waitUntil supports 'load'|'domcontentloaded' only (no 'networkidle'), and there is no auto-snapshot of the new page. Pass reload:true with ignoreCache:true for a hard reload that refetches every subresource (e.g. to pick up a freshly-deployed, non-content-hashed bundle the HTTP cache would serve stale).",
3399
4193
  inputSchema: {
3400
4194
  type: "object",
3401
4195
  properties: {
@@ -3415,6 +4209,14 @@ var MANIFEST = [
3415
4209
  type: "boolean",
3416
4210
  description: "Reload the current page (Page.reload) instead of navigating to url. Default false."
3417
4211
  },
4212
+ history: {
4213
+ type: "string",
4214
+ enum: [
4215
+ "back",
4216
+ "forward"
4217
+ ],
4218
+ description: "Traverse this tab's session history instead of loading a url: 'back' and 'forward' are the browser's Back and Forward buttons. Mutually exclusive with 'url' and 'reload' — passing two of the three is refused by name rather than resolved by precedence. Works on BOTH backends (Chrome: Page.getNavigationHistory + Page.navigateToHistoryEntry; Firefox: browsingContext.traverseHistory) and waits for the same load milestone as an ordinary navigation. Going back from the first entry, or forward from the last, is an ERROR naming the direction — never a silent success that navigated nowhere. The result carries traversed:'back'|'forward' alongside the resulting url."
4219
+ },
3418
4220
  ignoreCache: {
3419
4221
  type: "boolean",
3420
4222
  description: "On reload, bypass the HTTP cache (hard reload) so subresources are refetched. Ignored unless reload:true. Default false."
@@ -3652,7 +4454,7 @@ var MANIFEST = [
3652
4454
  },
3653
4455
  {
3654
4456
  name: "click",
3655
- description: "Click an element via a synthetic mouse press/release at the element's scrolled-into-view bounding-rect center. Target the element with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector).",
4457
+ description: "Click an element via a synthetic mouse press/release at the element's scrolled-into-view bounding-rect center. Target the element with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector). clickCount:3 triple-clicks (selects a paragraph/line in most editors). 'modifiers' holds Alt/Control/Meta/Shift for the press and release, like a real modifier-click; on the Firefox backend a non-empty 'modifiers' throws (not yet supported over BiDi), so a modifier click there needs --browser chrome.",
3656
4458
  inputSchema: {
3657
4459
  type: "object",
3658
4460
  properties: {
@@ -3683,7 +4485,20 @@ var MANIFEST = [
3683
4485
  },
3684
4486
  clickCount: {
3685
4487
  type: "number",
3686
- description: "Number of clicks: 1 = single (default), 2 = double-click."
4488
+ description: "Number of clicks: 1 = single (default), 2 = double-click, 3 = triple-click."
4489
+ },
4490
+ modifiers: {
4491
+ type: "array",
4492
+ items: {
4493
+ type: "string",
4494
+ enum: [
4495
+ "Alt",
4496
+ "Control",
4497
+ "Meta",
4498
+ "Shift"
4499
+ ]
4500
+ },
4501
+ description: "Modifier keys held for the click's press and release, e.g. ['Shift'] for a shift-click. Not supported on the Firefox backend: passing a non-empty array there throws."
3687
4502
  }
3688
4503
  },
3689
4504
  required: [],
@@ -3719,7 +4534,7 @@ var MANIFEST = [
3719
4534
  },
3720
4535
  {
3721
4536
  name: "drag",
3722
- description: "Drag from a source element to a destination element via synthetic mouse press/move/release (approximates HTML5 drag-and-drop; not guaranteed for every custom DnD library). 'from' and 'to' each take exactly one of uid (a CDP backendDOMNodeId from take_snapshot) or selector.",
4537
+ description: `Drag from a source element to a destination. 'from' takes exactly one of uid (a CDP backendDOMNodeId from take_snapshot) or selector. The destination is exactly one of 'to' (an element via uid/selector, or an absolute viewport point via x+y) or 'by' ({dx,dy} offset from the source point — sliders, map panning, resize handles). mode:'mouse' (default) dispatches synthetic mouse press/move/release: right for widgets built on raw pointer events, and Chrome does turn it into a real drag, but WHICH drag events reach the page depends on where the interpolated pointer path happens to land — measured on Chrome 151, the default steps:2 delivers ZERO dragover events, so an HTML5 drop zone written the standard way (preventDefault inside dragover) refuses the drop entirely. mode:'html5' performs the drag deterministically instead: Chrome's drag interception hands back the DragData the page's own dragstart built, and the toolkit replays it as dragEnter/dragOver/drop exactly at the destination, so a draggable="true" / dataTransfer drop zone works regardless of the pointer path. mode:'html5' is CHROME-ONLY: it requires capability 'input.html5Drag' and is rejected with a clear error under the Firefox backend, where the tool itself remains available for mouse-mode drags. 'steps' (default 2) sets how many interpolated mouse-move events are dispatched between source and destination; raise it for DnD libraries with a movement threshold or per-frame sampling.`,
3723
4538
  inputSchema: {
3724
4539
  type: "object",
3725
4540
  properties: {
@@ -3748,7 +4563,7 @@ var MANIFEST = [
3748
4563
  },
3749
4564
  to: {
3750
4565
  type: "object",
3751
- description: "Destination element to drag to. Provide exactly one of uid or selector.",
4566
+ description: "Where the drag ends: exactly one of uid, selector, or x+y. Mutually exclusive with 'by'; exactly one of to/by is required.",
3752
4567
  properties: {
3753
4568
  uid: {
3754
4569
  type: "number",
@@ -3757,18 +4572,222 @@ var MANIFEST = [
3757
4572
  selector: {
3758
4573
  type: "string",
3759
4574
  description: "CSS selector for the destination element (resolved via document.querySelector)."
4575
+ },
4576
+ x: {
4577
+ type: "number",
4578
+ description: "Absolute viewport x-coordinate to drop at. Must be given together with 'y'. Use this when there is no droppable element to name (a canvas, a slider track)."
4579
+ },
4580
+ y: {
4581
+ type: "number",
4582
+ description: "Absolute viewport y-coordinate to drop at. Must be given together with 'x'."
4583
+ }
4584
+ },
4585
+ additionalProperties: false
4586
+ },
4587
+ by: {
4588
+ type: "object",
4589
+ description: "Drag by an offset from the source element's center instead of to a destination: at least one of dx/dy, the other defaults to 0. Mutually exclusive with 'to'; exactly one of to/by is required. Use for sliders (by:{dx:40}), map panning, and resize handles.",
4590
+ properties: {
4591
+ dx: {
4592
+ type: "number",
4593
+ description: "Horizontal offset in CSS pixels; positive drags RIGHT."
4594
+ },
4595
+ dy: {
4596
+ type: "number",
4597
+ description: "Vertical offset in CSS pixels; positive drags DOWN."
3760
4598
  }
3761
4599
  },
3762
4600
  additionalProperties: false
4601
+ },
4602
+ mode: {
4603
+ type: "string",
4604
+ enum: [
4605
+ "mouse",
4606
+ "html5"
4607
+ ],
4608
+ description: "'mouse' (default): synthetic mouse press/move/release, right for widgets built on raw pointer events. Chrome does turn this into a real HTML5 drag too, but WHICH drag events reach the page depends on the interpolated pointer path: at the default steps:2, an HTML5 drop zone written the standard way (preventDefault inside dragover) sees zero dragover events and refuses the drop. 'html5': real HTML5 drag-and-drop (dragstart/dragEnter/dragOver/drop with the page's own dataTransfer), deterministic regardless of pointer path. 'html5' is CHROME-ONLY and is rejected with a clear error under the Firefox backend."
4609
+ },
4610
+ steps: {
4611
+ type: "number",
4612
+ description: "Number of interpolated mouse-move events dispatched between the source and destination points, evenly spaced, the last landing exactly on the destination. Integer 1-500, default 2 (midpoint then destination). Raise it for DnD libraries with a movement threshold or per-frame sampling."
4613
+ }
4614
+ },
4615
+ required: [
4616
+ "from"
4617
+ ],
4618
+ additionalProperties: false
4619
+ }
4620
+ },
4621
+ {
4622
+ name: "scroll",
4623
+ description: "Dispatch a wheel/scroll event at an anchor point: provide at most one of 'uid', 'selector', or 'x'+'y'; omit all three to scroll at the viewport center. An element anchor ('uid' or 'selector') is scrolled into view first, the same as click/hover. At least one of 'deltaX'/'deltaY' is required; positive 'deltaY' scrolls DOWN and positive 'deltaX' scrolls RIGHT (wheel-event convention). Chrome dispatches Input.dispatchMouseEvent{type:'mouseWheel'}; Firefox dispatches WebDriver BiDi's 'wheel' input source. Returns the resolved anchor point ({x,y}) plus the delta actually dispatched.",
4624
+ inputSchema: {
4625
+ type: "object",
4626
+ properties: {
4627
+ lease: {
4628
+ type: "string",
4629
+ description: "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
4630
+ },
4631
+ target: {
4632
+ type: "string",
4633
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4634
+ },
4635
+ uid: {
4636
+ type: "number",
4637
+ description: "CDP backendDOMNodeId of the element to scroll into view and anchor at, obtained from take_snapshot. Provide at most one of uid, selector, or x+y; omit all three to scroll at the viewport center."
4638
+ },
4639
+ selector: {
4640
+ type: "string",
4641
+ description: "CSS selector for the element to scroll into view and anchor at (resolved via document.querySelector). Provide at most one of uid, selector, or x+y; omit all three to scroll at the viewport center."
4642
+ },
4643
+ x: {
4644
+ type: "number",
4645
+ description: "Absolute viewport x-coordinate to anchor the scroll at. Must be given together with 'y'. Provide at most one of uid, selector, or x+y."
4646
+ },
4647
+ y: {
4648
+ type: "number",
4649
+ description: "Absolute viewport y-coordinate to anchor the scroll at. Must be given together with 'x'. Provide at most one of uid, selector, or x+y."
4650
+ },
4651
+ deltaX: {
4652
+ type: "number",
4653
+ description: "Horizontal scroll delta; positive scrolls RIGHT. At least one of deltaX/deltaY is required."
4654
+ },
4655
+ deltaY: {
4656
+ type: "number",
4657
+ description: "Vertical scroll delta; positive scrolls DOWN. At least one of deltaX/deltaY is required."
4658
+ }
4659
+ },
4660
+ required: [],
4661
+ additionalProperties: false
4662
+ }
4663
+ },
4664
+ {
4665
+ name: "dispatch_mouse",
4666
+ description: "Dispatch exactly one raw mouse event ('move', 'down', or 'up') at absolute viewport coordinates: the toolkit's lowest-level input primitive. Compose move/down/move/up calls yourself to reach anything a physical mouse can do that click/drag's fixed sequences cannot — canvas drag-painting, marquee/rubber-band selection, a custom widget with its own hit-testing. Chrome-only (capability 'input.raw'): absent from tools/list under the Firefox backend, never present-and-throwing.",
4667
+ inputSchema: {
4668
+ type: "object",
4669
+ properties: {
4670
+ lease: {
4671
+ type: "string",
4672
+ description: "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
4673
+ },
4674
+ target: {
4675
+ type: "string",
4676
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4677
+ },
4678
+ action: {
4679
+ type: "string",
4680
+ enum: [
4681
+ "move",
4682
+ "down",
4683
+ "up"
4684
+ ],
4685
+ description: "Which raw event to dispatch: 'move' (mouseMoved), 'down' (mousePressed), or 'up' (mouseReleased)."
4686
+ },
4687
+ x: {
4688
+ type: "number",
4689
+ description: "Viewport x-coordinate. Required on every call: CDP has no notion of a 'current pointer position' to default from."
4690
+ },
4691
+ y: {
4692
+ type: "number",
4693
+ description: "Viewport y-coordinate. Required on every call."
4694
+ },
4695
+ button: {
4696
+ type: "string",
4697
+ enum: [
4698
+ "left",
4699
+ "right",
4700
+ "middle"
4701
+ ],
4702
+ description: "Mouse button: 'left' (default), 'right', or 'middle'."
4703
+ },
4704
+ clickCount: {
4705
+ type: "number",
4706
+ description: "Click-run length for a 'down' or 'up' event (2 = the second half of a double-click, 3 = triple-click). Ignored for 'move'; defaults to 1."
4707
+ },
4708
+ modifiers: {
4709
+ type: "array",
4710
+ items: {
4711
+ type: "string",
4712
+ enum: [
4713
+ "Alt",
4714
+ "Control",
4715
+ "Meta",
4716
+ "Shift"
4717
+ ]
4718
+ },
4719
+ description: "Modifier keys held for this one event, e.g. ['Shift']."
3763
4720
  }
3764
4721
  },
3765
4722
  required: [
3766
- "from",
3767
- "to"
4723
+ "action",
4724
+ "x",
4725
+ "y"
3768
4726
  ],
3769
4727
  additionalProperties: false
3770
4728
  }
3771
4729
  },
4730
+ {
4731
+ name: "wait_for_download",
4732
+ description: "Wait for a file download to finish and return it as a real file on disk: {path,suggestedFilename,bytes,url,target}. The file is written under the artifact dir's downloads/ folder and renamed from Chrome's internal guid to the page's own filename, collision-suffixed (report.csv, report-1.csv, ...). ORDERING RULE, and it is not optional: download capture must be ARMED BEFORE the click that starts the download — call wait_for_download{arm:true} first (it arms and returns immediately, reporting {armed:true,downloadPath,pending}), then click, then call wait_for_download to collect the finished file. This is Chrome's behavior, not a preference: the download-behavior override is per-connection state that Chrome REVERTS the moment the arming client disconnects, and an unarmed headless Chrome denies the download outright, so a download triggered before anything armed is lost with no file anywhere. SIDE EFFECT, browser-global: arming redirects EVERY download in this browser (all tabs, all origins) into the toolkit's downloads directory for as long as this server runs, and downloads no longer land in the user's normal Downloads folder. Because the arm lives on a connection this server process holds open, this is an MCP-server capability: under the one-shot CLI the connection dies with the process and nothing is captured. Chrome-only (capability 'browser.downloads'): absent from tools/list under the Firefox backend, never present-and-throwing, because WebDriver BiDi has no command to redirect a download to a chosen directory.",
4733
+ inputSchema: {
4734
+ type: "object",
4735
+ properties: {
4736
+ lease: {
4737
+ type: "string",
4738
+ description: "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
4739
+ },
4740
+ target: {
4741
+ type: "string",
4742
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'. A download is browser-scoped, so this names the tab for the lease check and for the echoed target, not which download is returned."
4743
+ },
4744
+ arm: {
4745
+ type: "boolean",
4746
+ description: "Arm download capture and return immediately instead of waiting: {armed:true,downloadPath,pending}. Call this BEFORE the click that triggers the download. 'pending' counts downloads that already completed and have not been collected yet, so an arm call doubles as a peek."
4747
+ },
4748
+ timeoutMs: {
4749
+ type: "number",
4750
+ description: "How long to wait for a download to complete, in milliseconds. Default 30000. Ignored with arm:true. A download that already completed and was not yet collected is returned immediately, so this only bounds the wait for a download still in flight."
4751
+ }
4752
+ },
4753
+ required: [],
4754
+ additionalProperties: false
4755
+ }
4756
+ },
4757
+ {
4758
+ name: "grant_permissions",
4759
+ description: "Grant browser permissions for an origin up front, so a page asking for geolocation / notifications / clipboard gets an answer instead of showing a prompt no agent can click. 'permissions' takes CDP PermissionType values ('geolocation', 'notifications', 'clipboardReadWrite', 'camera', 'microphone', 'midi', ...); an unknown name is refused by Chrome with the bad value in the message. Returns {granted,origin,target}, or {reset:true,target} for a reset-only call. Grants are keyed by ORIGIN, not by tab: every tab on that origin is affected, including ones opened later. reset:true clears this server's previous grants first (or instead, when no permissions are given) — note CDP's reset is not origin-scoped, so it clears them for every origin at once. The grant lives on a connection this server process holds open and Chrome DISCARDS IT when that connection closes, so this is an MCP-server capability: under the one-shot CLI the grant dies with the process. Chrome-only (capability 'browser.permissions'): absent from tools/list under the Firefox backend, never present-and-throwing.",
4760
+ inputSchema: {
4761
+ type: "object",
4762
+ properties: {
4763
+ lease: {
4764
+ type: "string",
4765
+ description: "Opaque lease token from claim_page. Omit it for a tab this process already holds: under CDP_REQUIRE_LEASE the gate acquires a lease automatically and one acquired that way passes for any later call from the same process. Required when the tab is held by ANOTHER process, and required for a tab claimed explicitly via claim_page or new_page{claim:true}, which always demands its own token. Without CDP_REQUIRE_LEASE an unleased tab needs no token at all."
4766
+ },
4767
+ target: {
4768
+ type: "string",
4769
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'. Used for the lease check and, when 'origin' is omitted, as the source of the origin to grant for."
4770
+ },
4771
+ permissions: {
4772
+ type: "array",
4773
+ items: {
4774
+ type: "string"
4775
+ },
4776
+ description: "CDP PermissionType values to grant, e.g. ['geolocation'] or ['clipboardReadWrite','clipboardSanitizedWrite']. Required unless reset:true. An empty array is refused rather than treated as a successful no-op."
4777
+ },
4778
+ origin: {
4779
+ type: "string",
4780
+ description: "Origin the grant applies to, e.g. 'https://example.com'. Defaults to the target tab's own origin. Required explicitly when that tab has no grantable origin (data:, blob:, about:blank all serialize to the opaque origin 'null'), which is refused with a message naming the tab's url."
4781
+ },
4782
+ reset: {
4783
+ type: "boolean",
4784
+ description: "Clear this server's previous permission grants (Browser.resetPermissions). With 'permissions' it resets FIRST and then grants, so the result is exactly the listed permissions; on its own it is a reset-only call answering {reset:true}. Not scoped to 'origin': CDP resets every origin at once."
4785
+ }
4786
+ },
4787
+ required: [],
4788
+ additionalProperties: false
4789
+ }
4790
+ },
3772
4791
  {
3773
4792
  name: "fill",
3774
4793
  description: "Fill an element by focusing it, clearing existing content, then inserting 'value' via Input.insertText (atomic paste-like commit, not per-character keystrokes). Target with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector).",
@@ -4647,7 +5666,7 @@ var MANIFEST = [
4647
5666
  },
4648
5667
  {
4649
5668
  name: "claim_page",
4650
- description: "Take exclusive ownership of a browser tab and return an opaque lease token. Two modes. FRESH TAB: with no target and no targetId, opens a new tab (optionally at url) and claims it, so 'give me my own tab' is one call. TAKEOVER: with target, claims a tab that is already open — this is how you work in a tab the human already has open when they ask you to, instead of opening your own — resolving any target selector against the live page list and never creating anything. The answer's 'opened' flag says which happened (true only when this call created the tab), and that same creation record is what release_page consults, so a tab you took over is left open when you release it. Any later tool call against a leased tab must carry the matching token in its 'lease' argument or it is refused, naming the holder. The lease is reclaimable once its owning process dies, its ttlMs elapses without use, or its tab is closed. Refused from the CLI: a CLI invocation is one process per call, so its lease would be reclaimable immediately.",
5669
+ description: "Take exclusive ownership of a browser tab and return an opaque lease token. Two modes. FRESH TAB: with no target and no targetId, opens a new tab (optionally at url) and claims it, so 'give me my own tab' is one call. TAKEOVER: with target, claims a tab that is already open — this is how you work in a tab the human already has open when they ask you to, instead of opening your own — resolving any target selector against the live page list and never creating anything. The answer's 'opened' flag says which happened (true only when this call created the tab), and that same creation record is what release_page consults, so a tab you took over is left open when you release it. Any later tool call against a leased tab must carry the matching token in its 'lease' argument or it is refused, naming the holder. The lease is reclaimable once its owning process dies, its ttlMs elapses without use, or its tab is closed. Refused from the CLI: a CLI invocation is one process per call, so its lease would be reclaimable immediately. IS SOMEBODY ELSE IN THIS TAB: the answer also carries 'humanActiveMs', milliseconds since input that this server did not dispatch — i.e. since a person last clicked, typed, or scrolled here. null means NO DATA (the tab carries no activity beacon, which is normal the first time anyone claims a person's tab, or every input on it was this server's own); it never means 'no human'. The field is absent entirely on a backend that cannot answer. On a takeover of a tab a human used within the last 30 seconds the answer additionally carries 'contention', a warning string. THE CLAIM IS NEVER REFUSED FOR THIS: taking over a person's tab is what takeover mode is for, so by the time you read the warning you already hold the lease and the tab is yours to drive. It tells you that driving it now means fighting a live person for the keyboard and mouse, so you can open your own tab instead or ask first. Known limits: input inside a cross-origin iframe is invisible to it, and a second MCP server's clicks read as human to this one.",
4651
5670
  inputSchema: {
4652
5671
  type: "object",
4653
5672
  properties: {
@@ -4701,7 +5720,7 @@ var MANIFEST = [
4701
5720
  },
4702
5721
  {
4703
5722
  name: "list_leases",
4704
- description: "Enumerate every active lease for diagnosis: backend, target id, agent label, pid, createdAt, lastUsedAt, ttlMs, whether the owning process is still alive, and whether the lease is reclaimable and why. Requires no token. Never returns the lease nonce, so this cannot be used to forge a token. A row for a lease file that could not be read or parsed instead carries `unreadable` (the errno, or \"unparseable\"), with label, pid, and the timestamp fields as zero placeholders and stale always false, since stale is what marks a lease free to take and an unreadable one must never read that way. Under CDP_REQUIRE_LEASE this call reaps abandoned agent tabs exactly as list_pages does, reporting them in an additive 'reaped' array and omitting their rows from the listing.",
5723
+ description: "Enumerate every active lease for diagnosis: backend, target id, agent label, pid, createdAt, lastUsedAt, ttlMs, whether the owning process is still alive, and whether the lease is reclaimable and why. Requires no token. Never returns the lease nonce, so this cannot be used to forge a token. A row for a lease file that could not be read or parsed instead carries `unreadable` (the errno, or \"unparseable\"), with label, pid, and the timestamp fields as zero placeholders and stale always false, since stale is what marks a lease free to take and an unreadable one must never read that way. Under CDP_REQUIRE_LEASE this call reaps abandoned agent tabs exactly as list_pages does (see that tool's description for the grace period between a lease reading reclaimable and its tab actually being closed), reporting them in an additive 'reaped' array and omitting their rows from the listing. A row also carries 'humanActiveMs' — milliseconds since input this server did not dispatch, i.e. since a person last used that tab — WHERE THE TAB CAN ANSWER. The field is ABSENT rather than null when it cannot: no activity beacon on that tab, every input on it accounted for by this server, a row belonging to the other backend, a tab no longer open, or a page that would not evaluate. Absence therefore means 'no answer', never 'nobody is there'. Note that lastUsedAt measures TOOLKIT calls only, so a tab a person has been typing in for ten minutes looks perfectly idle by that field and is exactly the case humanActiveMs exists to expose. Two further fields are computed fresh on every call, never read off disk: 'idleMs' (now minus lastUsedAt) and 'expiresAt' (lastUsedAt plus ttlMs — the same value claim_page already returns under that name). Both are omitted on an 'unreadable' row, whose lastUsedAt and ttlMs are the zeroed placeholders described above.",
4705
5724
  inputSchema: {
4706
5725
  type: "object",
4707
5726
  properties: {},
@@ -5465,6 +6484,8 @@ var KEY_VALUES = {
5465
6484
  };
5466
6485
  var MODIFIER_VALUES = { shift: "", control: "", ctrl: "", alt: "", meta: "", cmd: "", command: "" };
5467
6486
  var BUTTON_CODES = { left: 0, middle: 1, right: 2 };
6487
+ var ARM_SCROLL_SETTLE_WATCH_SOURCE = "function(){window.__cdpScrollSettle=new Promise((resolve)=>{let t;const done=()=>{window.removeEventListener('scroll',on,true);resolve(true);};" + "const on=()=>{clearTimeout(t);t=setTimeout(done,60);};window.addEventListener('scroll',on,true);t=setTimeout(done,60);setTimeout(done,500);});}";
6488
+ var AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}";
5468
6489
  var BIDI_CAPABILITIES = new Set([
5469
6490
  "emulate.deviceMetrics",
5470
6491
  "screenshot.fullPage",
@@ -5486,6 +6507,7 @@ async function getConnection(port, timeoutMs) {
5486
6507
  connections.set(port, conn);
5487
6508
  return conn;
5488
6509
  }
6510
+ var bidiBeaconScripts = new Map;
5489
6511
  async function safeTitle(conn, contextId) {
5490
6512
  try {
5491
6513
  const res = await conn.send("script.evaluate", { expression: "document.title", target: { context: contextId }, awaitPromise: false });
@@ -5549,9 +6571,31 @@ class BidiPageDriver {
5549
6571
  this.info = info;
5550
6572
  this.browser = browser;
5551
6573
  }
6574
+ async urlAndReadyState() {
6575
+ const raw = await this.conn.send("script.evaluate", {
6576
+ expression: "location.href + '\\u0000' + document.readyState",
6577
+ target: { context: this.contextId },
6578
+ awaitPromise: false
6579
+ }).then((r) => r.type === "success" ? String(deserializeRemote(r.result) ?? "") : "").catch(() => "");
6580
+ const [url = "", ready = ""] = raw.split("\x00");
6581
+ return [url, ready];
6582
+ }
6583
+ async settleAfterTraversal(beforeUrl, waitUntil, timeoutMs) {
6584
+ const ready = waitUntil === "domcontentloaded" ? ["interactive", "complete"] : ["complete"];
6585
+ const deadline = Date.now() + timeoutMs;
6586
+ for (;; ) {
6587
+ const [url, state] = await this.urlAndReadyState();
6588
+ if (url && url !== beforeUrl && ready.includes(state))
6589
+ return { waitedFor: waitUntil, url };
6590
+ if (Date.now() >= deadline)
6591
+ return { waitedFor: "timeout", url: url || beforeUrl };
6592
+ await new Promise((r) => setTimeout(r, 50));
6593
+ }
6594
+ }
5552
6595
  async navigate(opts) {
5553
6596
  const reload = opts.reload === true;
5554
- if (!reload && !opts.url)
6597
+ const history = opts.history;
6598
+ if (!reload && !history && !opts.url)
5555
6599
  throw driverError2("page-error", "navigate: 'url' is required unless reload:true");
5556
6600
  const waitUntil = opts.waitUntil ?? "load";
5557
6601
  const wait = waitUntil === "domcontentloaded" ? "interactive" : "complete";
@@ -5561,6 +6605,17 @@ class BidiPageDriver {
5561
6605
  const r2 = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
5562
6606
  return { url: r2.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
5563
6607
  }
6608
+ if (history) {
6609
+ const [beforeUrl] = await this.urlAndReadyState();
6610
+ await this.conn.send("browsingContext.traverseHistory", { context: this.contextId, delta: history === "back" ? -1 : 1 }, sendOpts).catch((e) => {
6611
+ const msg = e instanceof Error ? e.message : String(e);
6612
+ if (/no such history entry|history/i.test(msg))
6613
+ throw driverError2("page-error", `navigate: no history entry to go ${history} to (${msg})`);
6614
+ throw e;
6615
+ });
6616
+ const settled = await this.settleAfterTraversal(beforeUrl, waitUntil, opts.timeoutMs ?? 15000);
6617
+ return { url: settled.url, contextId: this.contextId, traversed: history, waitedFor: settled.waitedFor };
6618
+ }
5564
6619
  const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url, wait }, sendOpts);
5565
6620
  return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
5566
6621
  } catch (e) {
@@ -5643,6 +6698,7 @@ class BidiPageDriver {
5643
6698
  throw driverError2("stale-uid", `uid does not resolve: ${uid}`);
5644
6699
  }
5645
6700
  async performActions(actions) {
6701
+ recordDispatch("firefox", this.contextId);
5646
6702
  try {
5647
6703
  await this.conn.send("input.performActions", { context: this.contextId, actions });
5648
6704
  } catch (e) {
@@ -5663,6 +6719,9 @@ class BidiPageDriver {
5663
6719
  return { x, y };
5664
6720
  }
5665
6721
  async click(loc, opts) {
6722
+ if (opts?.modifiers && opts.modifiers.length > 0) {
6723
+ throw driverError2("unsupported", "click: 'modifiers' is not supported by the Firefox/BiDi driver; omit 'modifiers' or use --browser chrome for a modifier click.");
6724
+ }
5666
6725
  const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
5667
6726
  const { x, y } = await this.centerOf(ref);
5668
6727
  const button = BUTTON_CODES[opts?.button ?? "left"];
@@ -5672,19 +6731,53 @@ class BidiPageDriver {
5672
6731
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
5673
6732
  return { x, y };
5674
6733
  }
5675
- async drag(from, to) {
6734
+ async scroll(anchor, opts) {
6735
+ const { x, y } = await this.resolveScrollPoint(anchor);
6736
+ const deltaX = Math.round(opts.deltaX ?? 0);
6737
+ const deltaY = Math.round(opts.deltaY ?? 0);
6738
+ await this.callFunctionValue(ARM_SCROLL_SETTLE_WATCH_SOURCE, []);
6739
+ const actions = [{ type: "scroll", x: Math.round(x), y: Math.round(y), deltaX, deltaY }];
6740
+ await this.performActions([{ type: "wheel", id: "cdp-wheel", actions }]);
6741
+ await this.callFunctionValue(AWAIT_SCROLL_SETTLE_SOURCE, []).catch(() => {
6742
+ return;
6743
+ });
6744
+ return { x, y };
6745
+ }
6746
+ async resolveScrollPoint(anchor) {
6747
+ if (anchor === undefined) {
6748
+ const v = await this.callFunctionValue("function(){return {x: window.innerWidth / 2, y: window.innerHeight / 2};}", []);
6749
+ if (!v || typeof v !== "object")
6750
+ throw driverError2("page-error", "could not measure viewport for scroll center");
6751
+ return v;
6752
+ }
6753
+ if ("x" in anchor)
6754
+ return anchor;
6755
+ return this.centerOf(await resolveElementLocator2(this.conn, this.contextId, anchor));
6756
+ }
6757
+ async drag(from, to, opts) {
6758
+ if (opts?.mode === "html5") {
6759
+ throw driverError2("unsupported", "drag: mode:'html5' is not supported by the Firefox/BiDi driver (WebDriver BiDi has no drag-interception primitive); " + "omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.");
6760
+ }
5676
6761
  const fromPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, from));
5677
- const toPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, to));
6762
+ const toPt = await this.resolveDragDestination(to, fromPt);
6763
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
5678
6764
  const actions = [
5679
6765
  { type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 },
5680
- { type: "pointerDown", button: 0 },
5681
- { type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
5682
- { type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 },
5683
- { type: "pointerUp", button: 0 }
6766
+ { type: "pointerDown", button: 0 }
5684
6767
  ];
6768
+ for (const pt of interpolatePoints(fromPt, toPt, steps))
6769
+ actions.push({ type: "pointerMove", x: pt.x, y: pt.y, duration: 50 });
6770
+ actions.push({ type: "pointerUp", button: 0 });
5685
6771
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
5686
6772
  return { from: fromPt, to: toPt };
5687
6773
  }
6774
+ async resolveDragDestination(to, fromPt) {
6775
+ if ("dx" in to)
6776
+ return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
6777
+ if ("x" in to)
6778
+ return to;
6779
+ return this.centerOf(await resolveElementLocator2(this.conn, this.contextId, to));
6780
+ }
5688
6781
  async sendKeys(text) {
5689
6782
  if (!text.length)
5690
6783
  return;
@@ -6054,7 +7147,60 @@ class BidiBrowserDriver {
6054
7147
  const info = await pageInfoOf(conn, ctx);
6055
7148
  return new BidiPageDriver(conn, ctx.context, info, this);
6056
7149
  }
7150
+ async installActivityBeacon(contextId) {
7151
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => {
7152
+ return;
7153
+ });
7154
+ if (!conn)
7155
+ return false;
7156
+ try {
7157
+ const key = `${this.port}:${contextId}`;
7158
+ if (!bidiBeaconScripts.has(key)) {
7159
+ const registered = await conn.send("script.addPreloadScript", {
7160
+ functionDeclaration: BEACON_FUNCTION_DECLARATION,
7161
+ contexts: [contextId]
7162
+ });
7163
+ bidiBeaconScripts.set(key, registered.script);
7164
+ }
7165
+ await conn.send("script.evaluate", {
7166
+ expression: BEACON_SOURCE,
7167
+ target: { context: contextId },
7168
+ awaitPromise: false
7169
+ });
7170
+ return true;
7171
+ } catch {
7172
+ return false;
7173
+ } finally {
7174
+ conn.release();
7175
+ }
7176
+ }
7177
+ async readActivityBeacon(contextId) {
7178
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => {
7179
+ return;
7180
+ });
7181
+ if (!conn)
7182
+ return null;
7183
+ try {
7184
+ const res = await conn.send("script.evaluate", {
7185
+ expression: BEACON_READ_EXPRESSION,
7186
+ target: { context: contextId },
7187
+ awaitPromise: false
7188
+ });
7189
+ if (res.type !== "success")
7190
+ return null;
7191
+ const value = deserializeRemote(res.result);
7192
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
7193
+ } catch {
7194
+ return null;
7195
+ } finally {
7196
+ conn.release();
7197
+ }
7198
+ }
6057
7199
  async dispose() {
7200
+ for (const key of [...bidiBeaconScripts.keys()]) {
7201
+ if (key.startsWith(`${this.port}:`))
7202
+ bidiBeaconScripts.delete(key);
7203
+ }
6058
7204
  const conn = connections.get(this.port);
6059
7205
  if (!conn)
6060
7206
  return;
@@ -6071,7 +7217,7 @@ import { spawn as spawn3 } from "node:child_process";
6071
7217
  import { mkdtempSync, rmSync } from "node:fs";
6072
7218
  import { createConnection, createServer } from "node:net";
6073
7219
  import { tmpdir } from "node:os";
6074
- import { join as join7 } from "node:path";
7220
+ import { join as join9 } from "node:path";
6075
7221
  function resolveBinary() {
6076
7222
  const candidates = [
6077
7223
  process.env.FIREFOX_BIN,
@@ -6145,7 +7291,7 @@ async function launchFirefox(opts = {}) {
6145
7291
  const binary = resolveBinary();
6146
7292
  const port = await pickPort(opts.port);
6147
7293
  const isThrowaway = opts.profilePath === undefined;
6148
- const profilePath = opts.profilePath ?? mkdtempSync(join7(tmpdir(), "cdp-toolkit-ff-profile-"));
7294
+ const profilePath = opts.profilePath ?? mkdtempSync(join9(tmpdir(), "cdp-toolkit-ff-profile-"));
6149
7295
  const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
6150
7296
  if (opts.headless)
6151
7297
  args.push("--headless");
@@ -6415,7 +7561,7 @@ var FIREFOX_TOOLS = {
6415
7561
  };
6416
7562
 
6417
7563
  // src/mcp.ts
6418
- var VERSION = "1.7.0";
7564
+ var VERSION = "1.8.0";
6419
7565
  var BROWSER = resolveBrowserKind(stripBrowserFlag(process.argv.slice(2)));
6420
7566
  var AVAILABILITY = toolAvailability(BROWSER);
6421
7567
  var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
@@ -6462,8 +7608,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6462
7608
  if (!neutralFn)
6463
7609
  return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
6464
7610
  try {
6465
- const session = await getOrCreateFirefoxSession();
6466
- const result = await neutralFn(session.driver, args);
7611
+ const session2 = await getOrCreateFirefoxSession();
7612
+ const result = await neutralFn(session2.driver, args);
6467
7613
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
6468
7614
  } catch (err) {
6469
7615
  const message = err instanceof Error ? err.message : String(err);
@@ -6481,6 +7627,7 @@ async function shutdown() {
6481
7627
  return;
6482
7628
  shuttingDown = true;
6483
7629
  await disposeFirefoxSession();
7630
+ await disposeBrowserSession();
6484
7631
  process.exit(0);
6485
7632
  }
6486
7633
  process.on("SIGINT", () => void shutdown());