cdp-toolkit 1.6.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
@@ -13,6 +13,16 @@ function leaseTtlMs() {
13
13
  const raw = Number(process.env.CDP_LEASE_TTL_MS);
14
14
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
15
15
  }
16
+ var longLived = false;
17
+ function markLongLivedProcess(value = true) {
18
+ longLived = value;
19
+ }
20
+ function requireLease() {
21
+ if (!longLived)
22
+ return false;
23
+ const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
24
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
25
+ }
16
26
  function defaultLabel() {
17
27
  return `pid-${process.pid}`;
18
28
  }
@@ -99,7 +109,8 @@ async function claimLease(backend, targetId, opts) {
99
109
  label: opts.label,
100
110
  createdAt: now,
101
111
  lastUsedAt: now,
102
- ttlMs: opts.ttlMs ?? leaseTtlMs()
112
+ ttlMs: opts.ttlMs ?? leaseTtlMs(),
113
+ auto: opts.auto === true
103
114
  };
104
115
  try {
105
116
  await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
@@ -175,8 +186,16 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
175
186
  const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
176
187
  const where = describeTarget(targetId, ctx);
177
188
  if (token === undefined) {
178
- if (!held)
189
+ if (!held) {
190
+ if (requireLease()) {
191
+ await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
192
+ }
179
193
  return;
194
+ }
195
+ if (held.auto === true && held.pid === process.pid) {
196
+ await touchLease(held, now);
197
+ return;
198
+ }
180
199
  throw new LeaseConflictError(`${where} is leased by '${held.label}' (pid ${held.pid}). Pass that lease's token as the 'lease' argument, or call release_page to free it. list_leases shows every active lease.`, targetId, held.label);
181
200
  }
182
201
  const parts = tokenParts(token);
@@ -184,8 +203,12 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
184
203
  throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
185
204
  }
186
205
  if (parts.backend !== backend || parts.targetId !== targetId) {
187
- if (!held)
206
+ if (!held) {
207
+ if (requireLease()) {
208
+ await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
209
+ }
188
210
  return;
211
+ }
189
212
  throw new LeaseConflictError(`the lease token you passed is for ${parts.backend} target '${parts.targetId}', not ${where}, which is leased by '${held.label}'. Resolve the tab you actually hold, or claim this one.`, targetId, held.label);
190
213
  }
191
214
  if (!held) {
@@ -463,6 +486,141 @@ async function withPage(selector, fn, opts = {}) {
463
486
  }
464
487
  }
465
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
+
466
624
  // src/tools/snapshot.ts
467
625
  var INTERACTIVE_ROLES = new Set([
468
626
  "button",
@@ -686,11 +844,22 @@ var REQUIRED_CAPABILITIES = {
686
844
  stop_screen_recording: ["capture.screencast"],
687
845
  mock_request: ["network.intercept"],
688
846
  list_mocks: ["network.intercept"],
689
- 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"]
690
851
  };
691
852
  function isDriverError(e) {
692
853
  return e instanceof Error && typeof e.code === "string";
693
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
+ }
694
863
 
695
864
  // src/cdp/driver.ts
696
865
  function driverError(code, message, data) {
@@ -746,12 +915,21 @@ async function centerOf(conn, objectId) {
746
915
  async function focusElement(conn, objectId) {
747
916
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
748
917
  }
749
- 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) {
750
928
  await focusElement(conn, objectId);
751
929
  const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
752
930
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
753
931
  if (value.length)
754
- await conn.send("Input.insertText", { text: value });
932
+ await sendInput2("Input.insertText", { text: value });
755
933
  }
756
934
  var NAMED_KEYS = {
757
935
  enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
@@ -776,6 +954,18 @@ var NAMED_KEYS = {
776
954
  pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
777
955
  };
778
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;
779
969
  function resolveKey(key) {
780
970
  const named = NAMED_KEYS[key.toLowerCase()];
781
971
  if (named)
@@ -940,7 +1130,11 @@ var CDP_CAPABILITIES = new Set([
940
1130
  "input.insertTextAtomic",
941
1131
  "locate.text",
942
1132
  "locate.xpath",
943
- "capture.screencast"
1133
+ "capture.screencast",
1134
+ "input.raw",
1135
+ "input.html5Drag",
1136
+ "browser.downloads",
1137
+ "browser.permissions"
944
1138
  ]);
945
1139
 
946
1140
  class CdpPageDriver {
@@ -960,6 +1154,9 @@ class CdpPageDriver {
960
1154
  this.enabledDomains.add(name);
961
1155
  await this.conn.send(`${name}.enable`);
962
1156
  }
1157
+ async dispatchInput(method, params = {}) {
1158
+ return sendInput(this.conn, "chrome", this.info.id, method, params);
1159
+ }
963
1160
  async ensureDomainSoft(name) {
964
1161
  if (this.enabledDomains.has(name))
965
1162
  return;
@@ -968,11 +1165,22 @@ class CdpPageDriver {
968
1165
  return;
969
1166
  });
970
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
+ }
971
1177
  async navigate(opts) {
972
1178
  await this.ensureDomain("Page");
973
1179
  const reload = opts.reload === true;
974
- if (!reload && !opts.url)
1180
+ const history = opts.history;
1181
+ if (!reload && !history && !opts.url)
975
1182
  throw driverError("page-error", "navigate: 'url' is required unless reload:true");
1183
+ const entry = history ? await this.historyEntry(history) : undefined;
976
1184
  const waitUntil = opts.waitUntil ?? "load";
977
1185
  const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
978
1186
  const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
@@ -990,6 +1198,10 @@ class CdpPageDriver {
990
1198
  contextId = tree?.frameTree?.frame?.id ?? "";
991
1199
  url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
992
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;
993
1205
  } else {
994
1206
  const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
995
1207
  if (nav.errorText)
@@ -998,7 +1210,20 @@ class CdpPageDriver {
998
1210
  url = opts.url;
999
1211
  }
1000
1212
  const settled = await Promise.race([milestone, stopped]);
1001
- 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
+ };
1002
1227
  }
1003
1228
  async waitForText(text, timeoutMs = 15000, pollMs = 250) {
1004
1229
  await this.ensureDomain("Runtime");
@@ -1078,36 +1303,96 @@ class CdpPageDriver {
1078
1303
  async hover(loc) {
1079
1304
  const { objectId } = await resolveElementLocator(this.conn, loc);
1080
1305
  const { x, y } = await centerOf(this.conn, objectId);
1081
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1306
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1082
1307
  return { x, y };
1083
1308
  }
1084
1309
  async click(loc, opts) {
1085
1310
  const button = opts?.button ?? "left";
1086
1311
  const clickCount = opts?.clickCount ?? 1;
1312
+ const modifiers = inputModifierBits(opts?.modifiers);
1087
1313
  const { x, y } = await this.hover(loc);
1088
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
1089
- 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);
1090
1324
  return { x, y };
1091
1325
  }
1092
- 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) {
1093
1340
  const fromPt = await this.hover(from);
1094
- const { objectId: dstId } = await resolveElementLocator(this.conn, to);
1095
- const toPt = await centerOf(this.conn, dstId);
1096
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1097
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
1098
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
1099
- 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 });
1100
1352
  return { from: fromPt, to: toPt };
1101
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
+ }
1102
1387
  async setValue(loc, value) {
1103
1388
  const { objectId } = await resolveElementLocator(this.conn, loc);
1104
- await setValueOnObject(this.conn, objectId, value);
1389
+ await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
1105
1390
  }
1106
1391
  async typeText(loc, text) {
1107
1392
  const { objectId } = await resolveElementLocator(this.conn, loc);
1108
1393
  await focusElement(this.conn, objectId);
1109
1394
  if (text.length)
1110
- await this.conn.send("Input.insertText", { text });
1395
+ await this.dispatchInput("Input.insertText", { text });
1111
1396
  }
1112
1397
  async pressKey(press) {
1113
1398
  const spec = resolveKey(press.key);
@@ -1122,8 +1407,8 @@ class CdpPageDriver {
1122
1407
  const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
1123
1408
  if (spec.text && !suppressText)
1124
1409
  down.text = spec.text;
1125
- await this.conn.send("Input.dispatchKeyEvent", down);
1126
- 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 });
1127
1412
  }
1128
1413
  async setFiles(loc, files) {
1129
1414
  const { objectId } = await resolveElementLocator(this.conn, loc);
@@ -1339,6 +1624,36 @@ class CdpPageDriver {
1339
1624
  this.conn.close();
1340
1625
  }
1341
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
+ }
1342
1657
  async function withBrowserConn(fn) {
1343
1658
  const conn = await openBrowser();
1344
1659
  try {
@@ -1377,6 +1692,8 @@ class CdpBrowserDriver {
1377
1692
  async closePage(id) {
1378
1693
  return withBrowserConn(async (conn) => {
1379
1694
  const res = await conn.send("Target.closeTarget", { targetId: id });
1695
+ if (res.success !== false)
1696
+ beaconSessions.drop(id);
1380
1697
  return { success: res.success ?? true };
1381
1698
  });
1382
1699
  }
@@ -1389,7 +1706,83 @@ class CdpBrowserDriver {
1389
1706
  const { conn, target } = await openPage(selector).catch(noSuchTarget);
1390
1707
  return new CdpPageDriver(conn, target, this);
1391
1708
  }
1392
- 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
+ }
1393
1786
  }
1394
1787
  function createCdpDriver() {
1395
1788
  return new CdpBrowserDriver;
@@ -1429,6 +1822,20 @@ async function recordOrigin(backend, targetId, opts = {}) {
1429
1822
  await writeFile2(file, JSON.stringify(record), { flag: "wx" });
1430
1823
  } catch {}
1431
1824
  }
1825
+ async function readOrigin(backend, targetId) {
1826
+ let raw;
1827
+ try {
1828
+ raw = await readFile2(originFile(backend, targetId), "utf8");
1829
+ } catch {
1830
+ return;
1831
+ }
1832
+ try {
1833
+ const rec = JSON.parse(raw);
1834
+ if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
1835
+ return rec;
1836
+ } catch {}
1837
+ return;
1838
+ }
1432
1839
  async function listOrigins(opts = {}) {
1433
1840
  let names = [];
1434
1841
  try {
@@ -1486,59 +1893,51 @@ async function newTrackedPage(driver, backend, opts = {}) {
1486
1893
  return page;
1487
1894
  }
1488
1895
 
1489
- // src/leases-tools.ts
1490
- class LeaseToolError extends Error {
1491
- }
1492
- function backendOf(driver) {
1493
- return driver.scheme === "bidi" ? "firefox" : "chrome";
1494
- }
1495
- async function claimPage(driver, args = {}) {
1496
- const backend = backendOf(driver);
1497
- const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
1498
- const pages = await driver.listPages();
1499
- let targetId = args.targetId;
1500
- let url = args.url ?? "about:blank";
1501
- if (targetId === undefined || targetId === "") {
1502
- const page = await newTrackedPage(driver, backend, { url: args.url, label });
1503
- targetId = page.id;
1504
- url = page.url;
1505
- } else {
1506
- const existing = pages.find((p) => p.id === targetId);
1507
- if (!existing)
1508
- throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
1509
- url = existing.url;
1896
+ // src/reap.ts
1897
+ function staleAgentTabs(input) {
1898
+ const live = new Set(input.livePageIds);
1899
+ const out = [];
1900
+ for (const row of input.leases) {
1901
+ if (row.backend !== input.backend)
1902
+ continue;
1903
+ if (row.unreadable !== undefined)
1904
+ continue;
1905
+ if (row.stale !== "dead-pid" && row.stale !== "expired")
1906
+ continue;
1907
+ if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs)
1908
+ continue;
1909
+ if (!live.has(row.targetId))
1910
+ continue;
1911
+ const origin = input.origins.get(row.targetId);
1912
+ if (origin === undefined || origin.unreadable !== undefined)
1913
+ continue;
1914
+ out.push({ targetId: row.targetId, label: row.label, reason: row.stale });
1510
1915
  }
1511
- const liveIds = [...pages.map((p) => p.id), targetId];
1512
- const { record, token } = await claimLease(backend, targetId, {
1513
- label,
1514
- ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
1515
- liveIds
1516
- });
1517
- return {
1518
- lease: token,
1519
- targetId,
1520
- url,
1521
- label: record.label,
1522
- ttlMs: record.ttlMs,
1523
- expiresAt: record.lastUsedAt + record.ttlMs
1524
- };
1916
+ return out;
1525
1917
  }
1526
- async function releasePage(_driver, args) {
1527
- if (typeof args.lease !== "string" || !args.lease.length) {
1528
- throw new LeaseToolError("release_page requires the 'lease' token returned by claim_page");
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
+ }
1923
+ async function reapStaleAgentTabs(driver, backend) {
1924
+ const now = Date.now();
1925
+ const livePageIds = (await driver.listPages()).map((p) => p.id);
1926
+ const [origins, leases] = await Promise.all([
1927
+ originIndex(backend, undefined),
1928
+ listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [])
1929
+ ]);
1930
+ const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
1931
+ const closed = [];
1932
+ for (const c of candidates) {
1933
+ const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
1934
+ if (res.success !== true)
1935
+ continue;
1936
+ await releaseLeaseFor(backend, c.targetId);
1937
+ closed.push(c);
1529
1938
  }
1530
- return releaseLease(args.lease);
1531
- }
1532
- async function listLeasesTool(driver, _args = {}) {
1533
- const liveIds = (await driver.listPages()).map((p) => p.id);
1534
- const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
1535
- return { leases, count: leases.length };
1939
+ return closed;
1536
1940
  }
1537
- var LEASE_TOOLS = {
1538
- claim_page: claimPage,
1539
- release_page: releasePage,
1540
- list_leases: listLeasesTool
1541
- };
1542
1941
 
1543
1942
  // src/shared-tools.ts
1544
1943
  import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
@@ -1551,7 +1950,7 @@ class SharedToolError extends Error {
1551
1950
  function target3(p) {
1552
1951
  return { id: p.id, url: p.url, title: p.title };
1553
1952
  }
1554
- function backendOf2(driver) {
1953
+ function backendOf(driver) {
1555
1954
  return driver.scheme === "bidi" ? "firefox" : "chrome";
1556
1955
  }
1557
1956
  async function withPage2(driver, target, fn) {
@@ -1565,7 +1964,7 @@ async function withPage2(driver, target, fn) {
1565
1964
  async function resolvePage(driver, selector) {
1566
1965
  const pages = await driver.listPages();
1567
1966
  const hit = await pickPage(driver, pages, selector);
1568
- await assertLeaseOk(backendOf2(driver), hit.id, { url: hit.url, title: hit.title });
1967
+ await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
1569
1968
  return hit;
1570
1969
  }
1571
1970
  async function pickPage(driver, pages, selector) {
@@ -1620,27 +2019,43 @@ async function reapSet(driver, pages, all) {
1620
2019
  }
1621
2020
  }
1622
2021
  async function listPages(driver, args = {}) {
1623
- const pages = await driver.listPages({ all: args.all });
1624
- const ledger = await originIndex(backendOf2(driver), await reapSet(driver, pages, args.all));
1625
- const annotated = pages.map((p) => {
2022
+ const backend = backendOf(driver);
2023
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
2024
+ const reapedIds = new Set(reaped.map((r) => r.targetId));
2025
+ const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
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) => {
1626
2031
  const rec = ledger.get(p.id);
1627
- if (!rec)
1628
- return { ...p, origin: "unknown" };
1629
- if (rec.unreadable !== undefined)
1630
- return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
1631
- return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
1632
- });
1633
- return { pages: annotated, count: annotated.length };
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
+ }));
2045
+ return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
1634
2046
  }
1635
2047
  async function newPage(driver, args = {}) {
1636
2048
  const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
1637
- const p = await newTrackedPage(driver, backendOf2(driver), { url: args.url, label });
1638
- if (args.claim !== true)
2049
+ const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
2050
+ const explicit = args.claim === true;
2051
+ if (!explicit && !requireLease())
1639
2052
  return { targetId: p.id, url: p.url };
1640
- const { record, token } = await claimLease(backendOf2(driver), p.id, {
2053
+ const { record, token } = await claimLease(backendOf(driver), p.id, {
1641
2054
  label,
1642
- ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
2055
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
2056
+ auto: !explicit
1643
2057
  });
2058
+ await installBeacon(driver, p.id);
1644
2059
  return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
1645
2060
  }
1646
2061
  async function closePage(driver, args) {
@@ -1650,7 +2065,7 @@ async function closePage(driver, args) {
1650
2065
  const res = await driver.closePage(p.id);
1651
2066
  if (!res.success)
1652
2067
  return { closed: p.id, success: false, leaseReleased: false };
1653
- const released = await releaseLeaseFor(backendOf2(driver), p.id);
2068
+ const released = await releaseLeaseFor(backendOf(driver), p.id);
1654
2069
  return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
1655
2070
  }
1656
2071
  async function writeSelectedFile(id) {
@@ -1672,17 +2087,36 @@ function toLegacyWaitedFor(w) {
1672
2087
  return "navigate-only";
1673
2088
  return w;
1674
2089
  }
1675
- async function navigatePage(driver, args) {
2090
+ function resolveNavigateMode(args) {
1676
2091
  const reload = args.reload === true;
1677
- if (!reload && (!args.url || typeof args.url !== "string")) {
1678
- 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(" + ")})`);
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)");
1679
2108
  }
2109
+ return { mode: "url", url: args.url };
2110
+ }
2111
+ async function navigatePage(driver, args) {
2112
+ const mode = resolveNavigateMode(args);
1680
2113
  return withPage2(driver, args.target, async (page) => {
1681
- const r = await page.navigate(args);
2114
+ const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
1682
2115
  return {
1683
2116
  url: r.url,
1684
2117
  frameId: r.contextId,
1685
2118
  ...r.reloaded ? { reloaded: r.reloaded } : {},
2119
+ ...r.traversed ? { traversed: r.traversed } : {},
1686
2120
  waitedFor: toLegacyWaitedFor(r.waitedFor)
1687
2121
  };
1688
2122
  });
@@ -1828,22 +2262,113 @@ async function takeSnapshot(driver, args = {}) {
1828
2262
  }
1829
2263
  async function click(driver, args) {
1830
2264
  return withPage2(driver, args.target, async (page) => {
1831
- 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 });
1832
2266
  return { clicked: true, x, y };
1833
2267
  });
1834
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
+ }
1835
2301
  async function hover(driver, args) {
1836
2302
  return withPage2(driver, args.target, async (page) => {
1837
2303
  const { x, y } = await page.hover(locatorOf(args));
1838
2304
  return { hovered: true, x, y };
1839
2305
  });
1840
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
+ }
1841
2363
  async function drag(driver, args) {
1842
- if (!args.from || !args.to)
1843
- 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);
1844
2369
  return withPage2(driver, args.target, async (page) => {
1845
- const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
1846
- 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 };
1847
2372
  });
1848
2373
  }
1849
2374
  async function fill(driver, args) {
@@ -1964,6 +2489,7 @@ var SHARED_TOOLS = {
1964
2489
  click,
1965
2490
  hover,
1966
2491
  drag,
2492
+ scroll,
1967
2493
  fill,
1968
2494
  fill_form: fillForm,
1969
2495
  type_text: typeText,
@@ -1975,6 +2501,121 @@ var SHARED_TOOLS = {
1975
2501
  handle_dialog: handleDialog
1976
2502
  };
1977
2503
 
2504
+ // src/leases-tools.ts
2505
+ class LeaseToolError extends Error {
2506
+ }
2507
+ function backendOf2(driver) {
2508
+ return driver.scheme === "bidi" ? "firefox" : "chrome";
2509
+ }
2510
+ async function claimPage(driver, args = {}) {
2511
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
2512
+ const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
2513
+ if (hasTarget && hasTargetId) {
2514
+ throw new LeaseToolError("claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.");
2515
+ }
2516
+ const backend = backendOf2(driver);
2517
+ const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
2518
+ const pages = await driver.listPages();
2519
+ let targetId;
2520
+ let url;
2521
+ let opened;
2522
+ if (hasTarget) {
2523
+ const hit = await pickPage(driver, pages, args.target).catch((err) => {
2524
+ throw new LeaseToolError(`claim_page: no open tab matches target '${args.target}' (${err.message}). No tab was opened: claim_page never creates one when 'target' is given.`);
2525
+ });
2526
+ targetId = hit.id;
2527
+ url = hit.url;
2528
+ opened = false;
2529
+ } else if (hasTargetId) {
2530
+ const existing = pages.find((p) => p.id === args.targetId);
2531
+ if (!existing)
2532
+ throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
2533
+ targetId = existing.id;
2534
+ url = existing.url;
2535
+ opened = false;
2536
+ } else {
2537
+ const page = await newTrackedPage(driver, backend, { url: args.url, label });
2538
+ targetId = page.id;
2539
+ url = page.url;
2540
+ opened = true;
2541
+ }
2542
+ const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
2543
+ const liveIds = [...pages.map((p) => p.id), targetId];
2544
+ const { record, token } = await claimLease(backend, targetId, {
2545
+ label,
2546
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
2547
+ liveIds
2548
+ });
2549
+ await installBeacon(driver, targetId);
2550
+ const contention = contentionWarning(humanMs, { takeover: !opened });
2551
+ return {
2552
+ lease: token,
2553
+ targetId,
2554
+ url,
2555
+ label: record.label,
2556
+ ttlMs: record.ttlMs,
2557
+ expiresAt: record.lastUsedAt + record.ttlMs,
2558
+ opened,
2559
+ ...beaconSupported(driver) ? { humanActiveMs: humanMs } : {},
2560
+ ...contention !== undefined ? { contention } : {}
2561
+ };
2562
+ }
2563
+ async function releasePage(driver, args = {}) {
2564
+ const hasLease = typeof args.lease === "string" && args.lease.length > 0;
2565
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
2566
+ if (hasLease === hasTarget) {
2567
+ throw new LeaseToolError("release_page takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds)");
2568
+ }
2569
+ const backend = backendOf2(driver);
2570
+ let targetId;
2571
+ let released;
2572
+ if (hasLease) {
2573
+ const parts = tokenParts(args.lease);
2574
+ if (!parts)
2575
+ return { released: false, closed: false };
2576
+ targetId = parts.targetId;
2577
+ released = (await releaseLease(args.lease)).released;
2578
+ } else {
2579
+ const page = await resolvePage(driver, args.target);
2580
+ targetId = page.id;
2581
+ released = (await releaseLeaseFor(backend, targetId)).released;
2582
+ }
2583
+ if (!released)
2584
+ return { released: false, closed: false, ...hasTarget ? { targetId } : {} };
2585
+ const shouldClose = args.close === false ? false : args.close === true ? true : await readOrigin(backend, targetId) !== undefined;
2586
+ if (!shouldClose)
2587
+ return { released: true, closed: false, targetId };
2588
+ const res = await driver.closePage(targetId).catch(() => ({ success: false }));
2589
+ return { released: true, closed: res.success === true, targetId };
2590
+ }
2591
+ async function listLeasesTool(driver, _args = {}) {
2592
+ const backend = backendOf2(driver);
2593
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
2594
+ const reapedIds = new Set(reaped.map((r) => r.targetId));
2595
+ const livePages = await driver.listPages();
2596
+ const liveIds = livePages.map((p) => p.id);
2597
+ const all = await listLeases({ liveIds, liveBackend: backend });
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
+ }
2611
+ return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
2612
+ }
2613
+ var LEASE_TOOLS = {
2614
+ claim_page: claimPage,
2615
+ release_page: releasePage,
2616
+ list_leases: listLeasesTool
2617
+ };
2618
+
1978
2619
  // src/tools/console.ts
1979
2620
  import { readFile as readFile3 } from "node:fs/promises";
1980
2621
 
@@ -3122,6 +3763,273 @@ function truncate(s, max = 600) {
3122
3763
  return t.length > max ? `${t.slice(0, max)}…` : t;
3123
3764
  }
3124
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
+
3125
4033
  // src/index.ts
3126
4034
  var cdpDriver = createCdpDriver();
3127
4035
  function onCdp(fn) {
@@ -3142,6 +4050,7 @@ var TOOLS = {
3142
4050
  click: onCdp(SHARED_TOOLS.click),
3143
4051
  hover: onCdp(SHARED_TOOLS.hover),
3144
4052
  drag: onCdp(SHARED_TOOLS.drag),
4053
+ scroll: onCdp(SHARED_TOOLS.scroll),
3145
4054
  fill: onCdp(SHARED_TOOLS.fill),
3146
4055
  fill_form: onCdp(SHARED_TOOLS.fill_form),
3147
4056
  type_text: onCdp(SHARED_TOOLS.type_text),
@@ -3166,6 +4075,9 @@ var TOOLS = {
3166
4075
  mock_request: mockRequest,
3167
4076
  list_mocks: listMocks,
3168
4077
  clear_mocks: clearMocks,
4078
+ dispatch_mouse: dispatchMouse,
4079
+ wait_for_download: waitForDownload,
4080
+ grant_permissions: grantPermissions,
3169
4081
  claim_page: onCdp(LEASE_TOOLS.claim_page),
3170
4082
  release_page: onCdp(LEASE_TOOLS.release_page),
3171
4083
  list_leases: onCdp(LEASE_TOOLS.list_leases)
@@ -3181,17 +4093,21 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
3181
4093
  var MANIFEST = [
3182
4094
  {
3183
4095
  name: "list_pages",
3184
- 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.",
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.",
3185
4097
  inputSchema: {
3186
4098
  type: "object",
3187
4099
  properties: {
3188
4100
  lease: {
3189
4101
  type: "string",
3190
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4102
+ 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."
3191
4103
  },
3192
4104
  all: {
3193
4105
  type: "boolean",
3194
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."
3195
4111
  }
3196
4112
  },
3197
4113
  required: [],
@@ -3206,7 +4122,7 @@ var MANIFEST = [
3206
4122
  properties: {
3207
4123
  lease: {
3208
4124
  type: "string",
3209
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4125
+ 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."
3210
4126
  },
3211
4127
  url: {
3212
4128
  type: "string",
@@ -3214,7 +4130,7 @@ var MANIFEST = [
3214
4130
  },
3215
4131
  claim: {
3216
4132
  type: "boolean",
3217
- description: "Claim the new tab atomically as part of creating it and return a lease token alongside {targetId,url}. Default false, which is byte-identical to the pre-1.2 behavior."
4133
+ description: "Claim the new tab atomically as part of creating it and return a lease token alongside {targetId,url}. Default false, which is byte-identical to the pre-1.2 behavior. Under CDP_REQUIRE_LEASE the new tab is claimed and a lease returned even without claim:true, because a tab nobody holds is a tab nobody may drive; passing claim:true additionally makes the lease an explicit one, which requires its token on every later call even from this same process."
3218
4134
  },
3219
4135
  label: {
3220
4136
  type: "string",
@@ -3237,7 +4153,7 @@ var MANIFEST = [
3237
4153
  properties: {
3238
4154
  lease: {
3239
4155
  type: "string",
3240
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4156
+ 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."
3241
4157
  },
3242
4158
  target: {
3243
4159
  type: "string",
@@ -3258,7 +4174,7 @@ var MANIFEST = [
3258
4174
  properties: {
3259
4175
  lease: {
3260
4176
  type: "string",
3261
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4177
+ 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."
3262
4178
  },
3263
4179
  target: {
3264
4180
  type: "string",
@@ -3273,13 +4189,13 @@ var MANIFEST = [
3273
4189
  },
3274
4190
  {
3275
4191
  name: "navigate_page",
3276
- 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).",
3277
4193
  inputSchema: {
3278
4194
  type: "object",
3279
4195
  properties: {
3280
4196
  lease: {
3281
4197
  type: "string",
3282
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4198
+ 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."
3283
4199
  },
3284
4200
  target: {
3285
4201
  type: "string",
@@ -3293,6 +4209,14 @@ var MANIFEST = [
3293
4209
  type: "boolean",
3294
4210
  description: "Reload the current page (Page.reload) instead of navigating to url. Default false."
3295
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
+ },
3296
4220
  ignoreCache: {
3297
4221
  type: "boolean",
3298
4222
  description: "On reload, bypass the HTTP cache (hard reload) so subresources are refetched. Ignored unless reload:true. Default false."
@@ -3321,7 +4245,7 @@ var MANIFEST = [
3321
4245
  properties: {
3322
4246
  lease: {
3323
4247
  type: "string",
3324
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4248
+ 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."
3325
4249
  },
3326
4250
  target: {
3327
4251
  type: "string",
@@ -3354,7 +4278,7 @@ var MANIFEST = [
3354
4278
  properties: {
3355
4279
  lease: {
3356
4280
  type: "string",
3357
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4281
+ 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."
3358
4282
  },
3359
4283
  target: {
3360
4284
  type: "string",
@@ -3391,7 +4315,7 @@ var MANIFEST = [
3391
4315
  properties: {
3392
4316
  lease: {
3393
4317
  type: "string",
3394
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs."
4318
+ 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."
3395
4319
  },
3396
4320
  target: {
3397
4321
  type: "string",
@@ -3422,7 +4346,7 @@ var MANIFEST = [
3422
4346
  properties: {
3423
4347
  lease: {
3424
4348
  type: "string",
3425
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs."
4349
+ 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."
3426
4350
  },
3427
4351
  target: {
3428
4352
  type: "string",
@@ -3478,7 +4402,7 @@ var MANIFEST = [
3478
4402
  properties: {
3479
4403
  lease: {
3480
4404
  type: "string",
3481
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs."
4405
+ 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."
3482
4406
  },
3483
4407
  target: {
3484
4408
  type: "string",
@@ -3513,7 +4437,7 @@ var MANIFEST = [
3513
4437
  properties: {
3514
4438
  lease: {
3515
4439
  type: "string",
3516
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4440
+ 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."
3517
4441
  },
3518
4442
  target: {
3519
4443
  type: "string",
@@ -3530,13 +4454,13 @@ var MANIFEST = [
3530
4454
  },
3531
4455
  {
3532
4456
  name: "click",
3533
- 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.",
3534
4458
  inputSchema: {
3535
4459
  type: "object",
3536
4460
  properties: {
3537
4461
  lease: {
3538
4462
  type: "string",
3539
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4463
+ 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."
3540
4464
  },
3541
4465
  target: {
3542
4466
  type: "string",
@@ -3561,7 +4485,20 @@ var MANIFEST = [
3561
4485
  },
3562
4486
  clickCount: {
3563
4487
  type: "number",
3564
- 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."
3565
4502
  }
3566
4503
  },
3567
4504
  required: [],
@@ -3576,7 +4513,7 @@ var MANIFEST = [
3576
4513
  properties: {
3577
4514
  lease: {
3578
4515
  type: "string",
3579
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4516
+ 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."
3580
4517
  },
3581
4518
  target: {
3582
4519
  type: "string",
@@ -3597,13 +4534,13 @@ var MANIFEST = [
3597
4534
  },
3598
4535
  {
3599
4536
  name: "drag",
3600
- 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.`,
3601
4538
  inputSchema: {
3602
4539
  type: "object",
3603
4540
  properties: {
3604
4541
  lease: {
3605
4542
  type: "string",
3606
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4543
+ 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."
3607
4544
  },
3608
4545
  target: {
3609
4546
  type: "string",
@@ -3626,7 +4563,7 @@ var MANIFEST = [
3626
4563
  },
3627
4564
  to: {
3628
4565
  type: "object",
3629
- 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.",
3630
4567
  properties: {
3631
4568
  uid: {
3632
4569
  type: "number",
@@ -3635,18 +4572,222 @@ var MANIFEST = [
3635
4572
  selector: {
3636
4573
  type: "string",
3637
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."
3638
4598
  }
3639
4599
  },
3640
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']."
3641
4720
  }
3642
4721
  },
3643
4722
  required: [
3644
- "from",
3645
- "to"
4723
+ "action",
4724
+ "x",
4725
+ "y"
3646
4726
  ],
3647
4727
  additionalProperties: false
3648
4728
  }
3649
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
+ },
3650
4791
  {
3651
4792
  name: "fill",
3652
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).",
@@ -3655,7 +4796,7 @@ var MANIFEST = [
3655
4796
  properties: {
3656
4797
  lease: {
3657
4798
  type: "string",
3658
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4799
+ 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."
3659
4800
  },
3660
4801
  target: {
3661
4802
  type: "string",
@@ -3688,7 +4829,7 @@ var MANIFEST = [
3688
4829
  properties: {
3689
4830
  lease: {
3690
4831
  type: "string",
3691
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4832
+ 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."
3692
4833
  },
3693
4834
  target: {
3694
4835
  type: "string",
@@ -3734,7 +4875,7 @@ var MANIFEST = [
3734
4875
  properties: {
3735
4876
  lease: {
3736
4877
  type: "string",
3737
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4878
+ 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."
3738
4879
  },
3739
4880
  target: {
3740
4881
  type: "string",
@@ -3767,7 +4908,7 @@ var MANIFEST = [
3767
4908
  properties: {
3768
4909
  lease: {
3769
4910
  type: "string",
3770
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4911
+ 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."
3771
4912
  },
3772
4913
  target: {
3773
4914
  type: "string",
@@ -3799,7 +4940,7 @@ var MANIFEST = [
3799
4940
  properties: {
3800
4941
  lease: {
3801
4942
  type: "string",
3802
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4943
+ 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."
3803
4944
  },
3804
4945
  target: {
3805
4946
  type: "string",
@@ -3838,7 +4979,7 @@ var MANIFEST = [
3838
4979
  properties: {
3839
4980
  lease: {
3840
4981
  type: "string",
3841
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
4982
+ 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."
3842
4983
  },
3843
4984
  target: {
3844
4985
  type: "string",
@@ -3889,7 +5030,7 @@ var MANIFEST = [
3889
5030
  properties: {
3890
5031
  lease: {
3891
5032
  type: "string",
3892
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5033
+ 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."
3893
5034
  },
3894
5035
  target: {
3895
5036
  type: "string",
@@ -3989,7 +5130,7 @@ var MANIFEST = [
3989
5130
  properties: {
3990
5131
  lease: {
3991
5132
  type: "string",
3992
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5133
+ 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."
3993
5134
  },
3994
5135
  target: {
3995
5136
  type: "string",
@@ -4027,7 +5168,7 @@ var MANIFEST = [
4027
5168
  properties: {
4028
5169
  lease: {
4029
5170
  type: "string",
4030
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5171
+ 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."
4031
5172
  },
4032
5173
  target: {
4033
5174
  type: "string",
@@ -4064,7 +5205,7 @@ var MANIFEST = [
4064
5205
  properties: {
4065
5206
  lease: {
4066
5207
  type: "string",
4067
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5208
+ 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."
4068
5209
  },
4069
5210
  target: {
4070
5211
  type: "string",
@@ -4091,7 +5232,7 @@ var MANIFEST = [
4091
5232
  properties: {
4092
5233
  lease: {
4093
5234
  type: "string",
4094
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5235
+ 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."
4095
5236
  },
4096
5237
  target: {
4097
5238
  type: "string",
@@ -4114,7 +5255,7 @@ var MANIFEST = [
4114
5255
  properties: {
4115
5256
  lease: {
4116
5257
  type: "string",
4117
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5258
+ 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."
4118
5259
  },
4119
5260
  target: {
4120
5261
  type: "string",
@@ -4145,7 +5286,7 @@ var MANIFEST = [
4145
5286
  properties: {
4146
5287
  lease: {
4147
5288
  type: "string",
4148
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5289
+ 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."
4149
5290
  },
4150
5291
  target: {
4151
5292
  type: "string",
@@ -4180,7 +5321,7 @@ var MANIFEST = [
4180
5321
  properties: {
4181
5322
  lease: {
4182
5323
  type: "string",
4183
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5324
+ 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."
4184
5325
  },
4185
5326
  target: {
4186
5327
  type: "string",
@@ -4206,7 +5347,7 @@ var MANIFEST = [
4206
5347
  properties: {
4207
5348
  lease: {
4208
5349
  type: "string",
4209
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5350
+ 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."
4210
5351
  },
4211
5352
  target: {
4212
5353
  type: "string",
@@ -4225,7 +5366,7 @@ var MANIFEST = [
4225
5366
  properties: {
4226
5367
  lease: {
4227
5368
  type: "string",
4228
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5369
+ 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."
4229
5370
  },
4230
5371
  tracePath: {
4231
5372
  type: "string",
@@ -4244,7 +5385,7 @@ var MANIFEST = [
4244
5385
  properties: {
4245
5386
  lease: {
4246
5387
  type: "string",
4247
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5388
+ 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."
4248
5389
  },
4249
5390
  target: {
4250
5391
  type: "string",
@@ -4282,7 +5423,7 @@ var MANIFEST = [
4282
5423
  properties: {
4283
5424
  lease: {
4284
5425
  type: "string",
4285
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5426
+ 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."
4286
5427
  },
4287
5428
  target: {
4288
5429
  type: "string",
@@ -4329,7 +5470,7 @@ var MANIFEST = [
4329
5470
  properties: {
4330
5471
  lease: {
4331
5472
  type: "string",
4332
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5473
+ 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."
4333
5474
  },
4334
5475
  target: {
4335
5476
  type: "string",
@@ -4352,7 +5493,7 @@ var MANIFEST = [
4352
5493
  properties: {
4353
5494
  lease: {
4354
5495
  type: "string",
4355
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5496
+ 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."
4356
5497
  },
4357
5498
  target: {
4358
5499
  type: "string",
@@ -4375,7 +5516,7 @@ var MANIFEST = [
4375
5516
  properties: {
4376
5517
  lease: {
4377
5518
  type: "string",
4378
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5519
+ 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."
4379
5520
  },
4380
5521
  url: {
4381
5522
  type: "string",
@@ -4419,7 +5560,7 @@ var MANIFEST = [
4419
5560
  properties: {
4420
5561
  lease: {
4421
5562
  type: "string",
4422
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5563
+ 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."
4423
5564
  },
4424
5565
  target: {
4425
5566
  type: "string",
@@ -4489,7 +5630,7 @@ var MANIFEST = [
4489
5630
  properties: {
4490
5631
  lease: {
4491
5632
  type: "string",
4492
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5633
+ 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."
4493
5634
  },
4494
5635
  target: {
4495
5636
  type: "string",
@@ -4508,7 +5649,7 @@ var MANIFEST = [
4508
5649
  properties: {
4509
5650
  lease: {
4510
5651
  type: "string",
4511
- description: "Opaque lease token from claim_page. Required only when the tab this call resolves to is leased by someone; omit it for unleased tabs, which behave exactly as before."
5652
+ 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."
4512
5653
  },
4513
5654
  target: {
4514
5655
  type: "string",
@@ -4525,17 +5666,21 @@ var MANIFEST = [
4525
5666
  },
4526
5667
  {
4527
5668
  name: "claim_page",
4528
- description: "Take exclusive ownership of a browser tab and return an opaque lease token. With no targetId, opens a fresh tab (optionally at url) and claims it, so 'give me my own tab' is one call. 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.",
4529
5670
  inputSchema: {
4530
5671
  type: "object",
4531
5672
  properties: {
5673
+ target: {
5674
+ type: "string",
5675
+ description: "Take over an already-open tab, e.g. one the human has open, when asked to: pass any target selector — active | index:N | url:<substr> | title:<substr> | <targetId>. Resolved against the live page list only, so it NEVER opens a tab: a selector that matches nothing is an error, not a new blank tab. The tab is left open on release because the toolkit did not create it. Refused if another live process holds it, including a lease the gate acquired for that process automatically under CDP_REQUIRE_LEASE: this takes over unleased (human) tabs and never steals one from a live agent. Mutually exclusive with targetId."
5676
+ },
4532
5677
  targetId: {
4533
5678
  type: "string",
4534
- description: "Claim this already-open page target instead of opening a new tab. Must be an exact target id, not a selector."
5679
+ description: "Claim this already-open page target instead of opening a new tab. Must be an exact target id, not a selector. Kept for back-compat: prefer 'target', which accepts an exact id too plus every other selector form. Mutually exclusive with target."
4535
5680
  },
4536
5681
  url: {
4537
5682
  type: "string",
4538
- description: "When opening a fresh tab, navigate it here. Ignored when targetId is given."
5683
+ description: "When opening a fresh tab, navigate it here. Ignored when target or targetId is given, since both of those claim a tab that is already open."
4539
5684
  },
4540
5685
  label: {
4541
5686
  type: "string",
@@ -4552,24 +5697,30 @@ var MANIFEST = [
4552
5697
  },
4553
5698
  {
4554
5699
  name: "release_page",
4555
- description: "Release a held lease, given its token. Idempotent: releasing an already-released, reclaimed, or expired lease reports released:false rather than erroring. Does not close the tab.",
5700
+ description: "Give a lease back, and close the tab if this toolkit opened it. Takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds, which is how you release a lease the gate acquired for you automatically, since that path never hands you a token). A tab with a creation record from this toolkit is closed; a tab that was already open and merely claimed is released and left alone. Override either way with 'close'. Idempotent: an already-released, reclaimed, or expired lease reports released:false, and a release that did not happen never closes anything, because by then the tab may belong to another agent. Answers {released, closed, targetId}.",
4556
5701
  inputSchema: {
4557
5702
  type: "object",
4558
5703
  properties: {
4559
5704
  lease: {
4560
5705
  type: "string",
4561
- description: "The opaque token claim_page (or new_page with claim:true) returned."
5706
+ description: "The opaque token claim_page (or new_page with claim:true) returned. Mutually exclusive with 'target'. Under CDP_REQUIRE_LEASE a lease the gate acquired for you automatically never produced a token in the first place, so give 'target' instead."
5707
+ },
5708
+ target: {
5709
+ type: "string",
5710
+ description: "Target selector for a tab this process holds: active | index:N | url:<substr> | title:<substr> | <targetId>. Mutually exclusive with 'lease'. Refused if another process holds the tab."
5711
+ },
5712
+ close: {
5713
+ type: "boolean",
5714
+ description: "Force the close decision instead of letting the creation ledger decide. true closes even a tab this toolkit did not open; false keeps an agent-created tab open. Omit for the default, which closes only tabs this toolkit created."
4562
5715
  }
4563
5716
  },
4564
- required: [
4565
- "lease"
4566
- ],
5717
+ required: [],
4567
5718
  additionalProperties: false
4568
5719
  }
4569
5720
  },
4570
5721
  {
4571
5722
  name: "list_leases",
4572
- 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.',
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.",
4573
5724
  inputSchema: {
4574
5725
  type: "object",
4575
5726
  properties: {},
@@ -5333,6 +6484,8 @@ var KEY_VALUES = {
5333
6484
  };
5334
6485
  var MODIFIER_VALUES = { shift: "", control: "", ctrl: "", alt: "", meta: "", cmd: "", command: "" };
5335
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;}";
5336
6489
  var BIDI_CAPABILITIES = new Set([
5337
6490
  "emulate.deviceMetrics",
5338
6491
  "screenshot.fullPage",
@@ -5354,6 +6507,7 @@ async function getConnection(port, timeoutMs) {
5354
6507
  connections.set(port, conn);
5355
6508
  return conn;
5356
6509
  }
6510
+ var bidiBeaconScripts = new Map;
5357
6511
  async function safeTitle(conn, contextId) {
5358
6512
  try {
5359
6513
  const res = await conn.send("script.evaluate", { expression: "document.title", target: { context: contextId }, awaitPromise: false });
@@ -5417,9 +6571,31 @@ class BidiPageDriver {
5417
6571
  this.info = info;
5418
6572
  this.browser = browser;
5419
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
+ }
5420
6595
  async navigate(opts) {
5421
6596
  const reload = opts.reload === true;
5422
- if (!reload && !opts.url)
6597
+ const history = opts.history;
6598
+ if (!reload && !history && !opts.url)
5423
6599
  throw driverError2("page-error", "navigate: 'url' is required unless reload:true");
5424
6600
  const waitUntil = opts.waitUntil ?? "load";
5425
6601
  const wait = waitUntil === "domcontentloaded" ? "interactive" : "complete";
@@ -5429,6 +6605,17 @@ class BidiPageDriver {
5429
6605
  const r2 = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
5430
6606
  return { url: r2.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
5431
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
+ }
5432
6619
  const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url, wait }, sendOpts);
5433
6620
  return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
5434
6621
  } catch (e) {
@@ -5511,6 +6698,7 @@ class BidiPageDriver {
5511
6698
  throw driverError2("stale-uid", `uid does not resolve: ${uid}`);
5512
6699
  }
5513
6700
  async performActions(actions) {
6701
+ recordDispatch("firefox", this.contextId);
5514
6702
  try {
5515
6703
  await this.conn.send("input.performActions", { context: this.contextId, actions });
5516
6704
  } catch (e) {
@@ -5531,6 +6719,9 @@ class BidiPageDriver {
5531
6719
  return { x, y };
5532
6720
  }
5533
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
+ }
5534
6725
  const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
5535
6726
  const { x, y } = await this.centerOf(ref);
5536
6727
  const button = BUTTON_CODES[opts?.button ?? "left"];
@@ -5540,19 +6731,53 @@ class BidiPageDriver {
5540
6731
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
5541
6732
  return { x, y };
5542
6733
  }
5543
- 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
+ }
5544
6761
  const fromPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, from));
5545
- 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));
5546
6764
  const actions = [
5547
6765
  { type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 },
5548
- { type: "pointerDown", button: 0 },
5549
- { type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
5550
- { type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 },
5551
- { type: "pointerUp", button: 0 }
6766
+ { type: "pointerDown", button: 0 }
5552
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 });
5553
6771
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
5554
6772
  return { from: fromPt, to: toPt };
5555
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
+ }
5556
6781
  async sendKeys(text) {
5557
6782
  if (!text.length)
5558
6783
  return;
@@ -5922,7 +7147,60 @@ class BidiBrowserDriver {
5922
7147
  const info = await pageInfoOf(conn, ctx);
5923
7148
  return new BidiPageDriver(conn, ctx.context, info, this);
5924
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
+ }
5925
7199
  async dispose() {
7200
+ for (const key of [...bidiBeaconScripts.keys()]) {
7201
+ if (key.startsWith(`${this.port}:`))
7202
+ bidiBeaconScripts.delete(key);
7203
+ }
5926
7204
  const conn = connections.get(this.port);
5927
7205
  if (!conn)
5928
7206
  return;
@@ -5939,7 +7217,7 @@ import { spawn as spawn3 } from "node:child_process";
5939
7217
  import { mkdtempSync, rmSync } from "node:fs";
5940
7218
  import { createConnection, createServer } from "node:net";
5941
7219
  import { tmpdir } from "node:os";
5942
- import { join as join7 } from "node:path";
7220
+ import { join as join9 } from "node:path";
5943
7221
  function resolveBinary() {
5944
7222
  const candidates = [
5945
7223
  process.env.FIREFOX_BIN,
@@ -6013,7 +7291,7 @@ async function launchFirefox(opts = {}) {
6013
7291
  const binary = resolveBinary();
6014
7292
  const port = await pickPort(opts.port);
6015
7293
  const isThrowaway = opts.profilePath === undefined;
6016
- const profilePath = opts.profilePath ?? mkdtempSync(join7(tmpdir(), "cdp-toolkit-ff-profile-"));
7294
+ const profilePath = opts.profilePath ?? mkdtempSync(join9(tmpdir(), "cdp-toolkit-ff-profile-"));
6017
7295
  const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
6018
7296
  if (opts.headless)
6019
7297
  args.push("--headless");
@@ -6283,7 +7561,7 @@ var FIREFOX_TOOLS = {
6283
7561
  };
6284
7562
 
6285
7563
  // src/mcp.ts
6286
- var VERSION = "1.6.0";
7564
+ var VERSION = "1.8.0";
6287
7565
  var BROWSER = resolveBrowserKind(stripBrowserFlag(process.argv.slice(2)));
6288
7566
  var AVAILABILITY = toolAvailability(BROWSER);
6289
7567
  var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
@@ -6330,8 +7608,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6330
7608
  if (!neutralFn)
6331
7609
  return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
6332
7610
  try {
6333
- const session = await getOrCreateFirefoxSession();
6334
- const result = await neutralFn(session.driver, args);
7611
+ const session2 = await getOrCreateFirefoxSession();
7612
+ const result = await neutralFn(session2.driver, args);
6335
7613
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
6336
7614
  } catch (err) {
6337
7615
  const message = err instanceof Error ? err.message : String(err);
@@ -6340,6 +7618,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6340
7618
  });
6341
7619
  });
6342
7620
  auditCoverage();
7621
+ markLongLivedProcess();
6343
7622
  await server.connect(new StdioServerTransport);
6344
7623
  console.error(`[cdp-toolkit] MCP server v${VERSION} ready, browser=${BROWSER}, ${AVAILABILITY.available.length} tools, CDP_BASE=${BASE}`);
6345
7624
  var shuttingDown = false;
@@ -6348,6 +7627,7 @@ async function shutdown() {
6348
7627
  return;
6349
7628
  shuttingDown = true;
6350
7629
  await disposeFirefoxSession();
7630
+ await disposeBrowserSession();
6351
7631
  process.exit(0);
6352
7632
  }
6353
7633
  process.on("SIGINT", () => void shutdown());