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/index.js CHANGED
@@ -11,6 +11,16 @@ function leaseTtlMs() {
11
11
  const raw = Number(process.env.CDP_LEASE_TTL_MS);
12
12
  return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_LEASE_TTL_MS;
13
13
  }
14
+ var longLived = false;
15
+ function markLongLivedProcess(value = true) {
16
+ longLived = value;
17
+ }
18
+ function requireLease() {
19
+ if (!longLived)
20
+ return false;
21
+ const raw = (process.env.CDP_REQUIRE_LEASE ?? "").trim().toLowerCase();
22
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
23
+ }
14
24
  function defaultLabel() {
15
25
  return `pid-${process.pid}`;
16
26
  }
@@ -97,7 +107,8 @@ async function claimLease(backend, targetId, opts) {
97
107
  label: opts.label,
98
108
  createdAt: now,
99
109
  lastUsedAt: now,
100
- ttlMs: opts.ttlMs ?? leaseTtlMs()
110
+ ttlMs: opts.ttlMs ?? leaseTtlMs(),
111
+ auto: opts.auto === true
101
112
  };
102
113
  try {
103
114
  await writeFile(leaseFile(backend, targetId), JSON.stringify(record), { flag: "wx" });
@@ -173,8 +184,16 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
173
184
  const held = rec && !staleReason(rec, { now, liveIds: ctx.liveIds }) ? rec : undefined;
174
185
  const where = describeTarget(targetId, ctx);
175
186
  if (token === undefined) {
176
- if (!held)
187
+ if (!held) {
188
+ if (requireLease()) {
189
+ await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
190
+ }
191
+ return;
192
+ }
193
+ if (held.auto === true && held.pid === process.pid) {
194
+ await touchLease(held, now);
177
195
  return;
196
+ }
178
197
  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);
179
198
  }
180
199
  const parts = tokenParts(token);
@@ -182,8 +201,12 @@ async function assertLeaseOk(backend, targetId, ctx = {}) {
182
201
  throw new LeaseConflictError(`malformed lease token for ${where}. Pass back the token claim_page returned, never a constructed one.`, targetId);
183
202
  }
184
203
  if (parts.backend !== backend || parts.targetId !== targetId) {
185
- if (!held)
204
+ if (!held) {
205
+ if (requireLease()) {
206
+ await claimLease(backend, targetId, { label: defaultLabel(), ttlMs: leaseTtlMs(), auto: true, now });
207
+ }
186
208
  return;
209
+ }
187
210
  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);
188
211
  }
189
212
  if (!held) {
@@ -461,6 +484,141 @@ async function withPage(selector, fn, opts = {}) {
461
484
  }
462
485
  }
463
486
 
487
+ // src/activity.ts
488
+ var BEACON_DATA_GLOBAL = "__cdpToolkitLastInput";
489
+ var BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon";
490
+ var BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"];
491
+ var BEACON_BODY = `
492
+ if (!window.${BEACON_INSTALLED_GLOBAL}) {
493
+ window.${BEACON_INSTALLED_GLOBAL} = true;
494
+ var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
495
+ var events = ${JSON.stringify([...BEACON_EVENTS])};
496
+ for (var i = 0; i < events.length; i++) {
497
+ window.addEventListener(events[i], mark, { capture: true, passive: true });
498
+ }
499
+ }`;
500
+ var BEACON_SOURCE = `(function () {${BEACON_BODY}
501
+ })();`;
502
+ var BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}
503
+ }`;
504
+ var BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
505
+ var DISPATCH_ATTRIBUTION_WINDOW_MS = 1500;
506
+ function dispatchKey(backend, targetId) {
507
+ return `${backend}:${targetId}`;
508
+ }
509
+ var dispatchLog = new Map;
510
+ var DISPATCH_LOG_LIMIT = 512;
511
+ function recordDispatch(backend, targetId, now = Date.now()) {
512
+ const key = dispatchKey(backend, targetId);
513
+ dispatchLog.delete(key);
514
+ dispatchLog.set(key, now);
515
+ while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
516
+ const oldest = dispatchLog.keys().next();
517
+ if (oldest.done)
518
+ break;
519
+ dispatchLog.delete(oldest.value);
520
+ }
521
+ }
522
+ var NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
523
+ async function sendInput(conn, backend, targetId, method, params = {}) {
524
+ if (!NON_SYNTHESIZING_INPUT_METHODS.has(method))
525
+ recordDispatch(backend, targetId);
526
+ return conn.send(method, params);
527
+ }
528
+ function lastDispatchAt(backend, targetId) {
529
+ return dispatchLog.get(dispatchKey(backend, targetId));
530
+ }
531
+ function isHumanAttributed(beaconTs, dispatchAt, windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
532
+ if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs))
533
+ return false;
534
+ return beaconTs > (dispatchAt ?? 0) + windowMs;
535
+ }
536
+ function humanActiveMs(beaconTs, dispatchAt, now = Date.now(), windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
537
+ if (!isHumanAttributed(beaconTs, dispatchAt, windowMs))
538
+ return null;
539
+ return Math.max(0, now - beaconTs);
540
+ }
541
+ var CONTENTION_WINDOW_MS = 30000;
542
+ function contentionWarning(ms, opts) {
543
+ if (!opts.takeover)
544
+ return;
545
+ if (ms === null || ms >= CONTENTION_WINDOW_MS)
546
+ return;
547
+ 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.`;
548
+ }
549
+ function beaconSupported(driver) {
550
+ return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
551
+ }
552
+ async function installBeacon(driver, targetId) {
553
+ if (typeof driver.installActivityBeacon !== "function")
554
+ return false;
555
+ return driver.installActivityBeacon(targetId).catch(() => false);
556
+ }
557
+ async function readHumanActiveMs(driver, backend, targetId, now = Date.now()) {
558
+ if (typeof driver.readActivityBeacon !== "function")
559
+ return null;
560
+ const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
561
+ return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
562
+ }
563
+ var RENDERER_PROBE_TIMEOUT_MS = 500;
564
+ var RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
565
+ function rendererProbeSupported(driver) {
566
+ return typeof driver.probeRenderer === "function";
567
+ }
568
+ async function probeRendererActivity(driver, backend, targetId, now = Date.now()) {
569
+ if (typeof driver.probeRenderer !== "function")
570
+ return { responsive: false };
571
+ const { responsive, beaconTs } = await driver.probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS).catch(() => ({ responsive: false, beaconTs: null }));
572
+ const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
573
+ return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
574
+ }
575
+
576
+ class BeaconSessions {
577
+ close;
578
+ limit;
579
+ live = new Map;
580
+ constructor(close, limit = 32) {
581
+ this.close = close;
582
+ this.limit = limit;
583
+ }
584
+ get(key) {
585
+ return this.live.get(key);
586
+ }
587
+ set(key, value) {
588
+ const previous = this.live.get(key);
589
+ if (previous !== undefined) {
590
+ this.live.delete(key);
591
+ this.safeClose(previous);
592
+ }
593
+ this.live.set(key, value);
594
+ while (this.live.size > this.limit) {
595
+ const oldest = this.live.keys().next();
596
+ if (oldest.done)
597
+ break;
598
+ this.drop(oldest.value);
599
+ }
600
+ }
601
+ drop(key) {
602
+ const value = this.live.get(key);
603
+ if (value === undefined)
604
+ return;
605
+ this.live.delete(key);
606
+ this.safeClose(value);
607
+ }
608
+ clear() {
609
+ for (const key of [...this.live.keys()])
610
+ this.drop(key);
611
+ }
612
+ get size() {
613
+ return this.live.size;
614
+ }
615
+ safeClose(value) {
616
+ try {
617
+ this.close(value);
618
+ } catch {}
619
+ }
620
+ }
621
+
464
622
  // src/tools/snapshot.ts
465
623
  var INTERACTIVE_ROLES = new Set([
466
624
  "button",
@@ -684,11 +842,22 @@ var REQUIRED_CAPABILITIES = {
684
842
  stop_screen_recording: ["capture.screencast"],
685
843
  mock_request: ["network.intercept"],
686
844
  list_mocks: ["network.intercept"],
687
- clear_mocks: ["network.intercept"]
845
+ clear_mocks: ["network.intercept"],
846
+ dispatch_mouse: ["input.raw"],
847
+ wait_for_download: ["browser.downloads"],
848
+ grant_permissions: ["browser.permissions"]
688
849
  };
689
850
  function isDriverError(e) {
690
851
  return e instanceof Error && typeof e.code === "string";
691
852
  }
853
+ function interpolatePoints(from, to, steps) {
854
+ const out = [];
855
+ for (let i = 1;i < steps; i++) {
856
+ out.push({ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps });
857
+ }
858
+ out.push({ x: to.x, y: to.y });
859
+ return out;
860
+ }
692
861
 
693
862
  // src/cdp/driver.ts
694
863
  function driverError(code, message, data) {
@@ -744,12 +913,21 @@ async function centerOf(conn, objectId) {
744
913
  async function focusElement(conn, objectId) {
745
914
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
746
915
  }
747
- async function setValueOnObject(conn, objectId, value) {
916
+ 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);});})()";
917
+ async function armScrollSettleWatch(conn) {
918
+ await conn.send("Runtime.evaluate", { expression: ARM_SCROLL_SETTLE_WATCH, returnByValue: true });
919
+ }
920
+ async function awaitScrollSettle(conn) {
921
+ await conn.send("Runtime.evaluate", { expression: "window.__cdpScrollSettle", awaitPromise: true, returnByValue: true }).catch(() => {
922
+ return;
923
+ });
924
+ }
925
+ async function setValueOnObject(conn, objectId, value, sendInput2) {
748
926
  await focusElement(conn, objectId);
749
927
  const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
750
928
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
751
929
  if (value.length)
752
- await conn.send("Input.insertText", { text: value });
930
+ await sendInput2("Input.insertText", { text: value });
753
931
  }
754
932
  var NAMED_KEYS = {
755
933
  enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
@@ -774,6 +952,18 @@ var NAMED_KEYS = {
774
952
  pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
775
953
  };
776
954
  var MODIFIER_BITS = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
955
+ var CLICK_MODIFIER_BITS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
956
+ function inputModifierBits(mods) {
957
+ let bits = 0;
958
+ for (const m of mods ?? []) {
959
+ const bit = CLICK_MODIFIER_BITS[m];
960
+ if (bit === undefined)
961
+ throw driverError("page-error", `unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
962
+ bits |= bit;
963
+ }
964
+ return bits;
965
+ }
966
+ var HTML5_DRAG_INTERCEPT_TIMEOUT_MS = 5000;
777
967
  function resolveKey(key) {
778
968
  const named = NAMED_KEYS[key.toLowerCase()];
779
969
  if (named)
@@ -938,7 +1128,11 @@ var CDP_CAPABILITIES = new Set([
938
1128
  "input.insertTextAtomic",
939
1129
  "locate.text",
940
1130
  "locate.xpath",
941
- "capture.screencast"
1131
+ "capture.screencast",
1132
+ "input.raw",
1133
+ "input.html5Drag",
1134
+ "browser.downloads",
1135
+ "browser.permissions"
942
1136
  ]);
943
1137
 
944
1138
  class CdpPageDriver {
@@ -958,6 +1152,9 @@ class CdpPageDriver {
958
1152
  this.enabledDomains.add(name);
959
1153
  await this.conn.send(`${name}.enable`);
960
1154
  }
1155
+ async dispatchInput(method, params = {}) {
1156
+ return sendInput(this.conn, "chrome", this.info.id, method, params);
1157
+ }
961
1158
  async ensureDomainSoft(name) {
962
1159
  if (this.enabledDomains.has(name))
963
1160
  return;
@@ -966,11 +1163,22 @@ class CdpPageDriver {
966
1163
  return;
967
1164
  });
968
1165
  }
1166
+ async historyEntry(direction) {
1167
+ const h = await this.conn.send("Page.getNavigationHistory");
1168
+ const entries = h.entries ?? [];
1169
+ const entry = entries[h.currentIndex + (direction === "back" ? -1 : 1)];
1170
+ if (!entry) {
1171
+ throw driverError("page-error", `navigate: no history entry to go ${direction} to (at entry ${h.currentIndex + 1} of ${entries.length})`);
1172
+ }
1173
+ return entry;
1174
+ }
969
1175
  async navigate(opts) {
970
1176
  await this.ensureDomain("Page");
971
1177
  const reload = opts.reload === true;
972
- if (!reload && !opts.url)
1178
+ const history = opts.history;
1179
+ if (!reload && !history && !opts.url)
973
1180
  throw driverError("page-error", "navigate: 'url' is required unless reload:true");
1181
+ const entry = history ? await this.historyEntry(history) : undefined;
974
1182
  const waitUntil = opts.waitUntil ?? "load";
975
1183
  const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
976
1184
  const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
@@ -988,6 +1196,10 @@ class CdpPageDriver {
988
1196
  contextId = tree?.frameTree?.frame?.id ?? "";
989
1197
  url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
990
1198
  reloaded = true;
1199
+ } else if (entry) {
1200
+ await this.conn.send("Page.navigateToHistoryEntry", { entryId: entry.id }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
1201
+ contextId = "";
1202
+ url = entry.url;
991
1203
  } else {
992
1204
  const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
993
1205
  if (nav.errorText)
@@ -996,7 +1208,20 @@ class CdpPageDriver {
996
1208
  url = opts.url;
997
1209
  }
998
1210
  const settled = await Promise.race([milestone, stopped]);
999
- return { url, contextId, ...reloaded ? { reloaded } : {}, waitedFor: settled ?? "timeout" };
1211
+ if (entry) {
1212
+ const tree = await this.conn.send("Page.getFrameTree").catch(() => {
1213
+ return;
1214
+ });
1215
+ contextId = tree?.frameTree?.frame?.id ?? contextId;
1216
+ url = tree?.frameTree?.frame?.url ?? url;
1217
+ }
1218
+ return {
1219
+ url,
1220
+ contextId,
1221
+ ...reloaded ? { reloaded } : {},
1222
+ ...history ? { traversed: history } : {},
1223
+ waitedFor: settled ?? "timeout"
1224
+ };
1000
1225
  }
1001
1226
  async waitForText(text, timeoutMs = 15000, pollMs = 250) {
1002
1227
  await this.ensureDomain("Runtime");
@@ -1076,36 +1301,96 @@ class CdpPageDriver {
1076
1301
  async hover(loc) {
1077
1302
  const { objectId } = await resolveElementLocator(this.conn, loc);
1078
1303
  const { x, y } = await centerOf(this.conn, objectId);
1079
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1304
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1080
1305
  return { x, y };
1081
1306
  }
1082
1307
  async click(loc, opts) {
1083
1308
  const button = opts?.button ?? "left";
1084
1309
  const clickCount = opts?.clickCount ?? 1;
1310
+ const modifiers = inputModifierBits(opts?.modifiers);
1085
1311
  const { x, y } = await this.hover(loc);
1086
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
1087
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
1312
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount, modifiers });
1313
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount, modifiers });
1088
1314
  return { x, y };
1089
1315
  }
1090
- async drag(from, to) {
1316
+ async scroll(anchor, opts) {
1317
+ await this.ensureDomain("Runtime");
1318
+ const { x, y } = await this.resolveScrollPoint(anchor);
1319
+ await armScrollSettleWatch(this.conn);
1320
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: opts.deltaX ?? 0, deltaY: opts.deltaY ?? 0 });
1321
+ await awaitScrollSettle(this.conn);
1322
+ return { x, y };
1323
+ }
1324
+ async resolveScrollPoint(anchor) {
1325
+ if (anchor === undefined) {
1326
+ await this.ensureDomain("Runtime");
1327
+ const { result, exceptionDetails } = await this.conn.send("Runtime.evaluate", { expression: "({x: window.innerWidth / 2, y: window.innerHeight / 2})", returnByValue: true });
1328
+ if (exceptionDetails || !result.value)
1329
+ throw driverError("page-error", `could not measure viewport for scroll center: ${exceptionDetails?.text ?? "no value"}`);
1330
+ return result.value;
1331
+ }
1332
+ if ("x" in anchor)
1333
+ return anchor;
1334
+ const { objectId } = await resolveElementLocator(this.conn, anchor);
1335
+ return centerOf(this.conn, objectId);
1336
+ }
1337
+ async drag(from, to, opts) {
1091
1338
  const fromPt = await this.hover(from);
1092
- const { objectId: dstId } = await resolveElementLocator(this.conn, to);
1093
- const toPt = await centerOf(this.conn, dstId);
1094
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1095
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
1096
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
1097
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1339
+ const toPt = await this.resolveDragDestination(to, fromPt);
1340
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
1341
+ if ((opts?.mode ?? "mouse") === "html5") {
1342
+ await this.html5Drag(fromPt, toPt, steps);
1343
+ return { from: fromPt, to: toPt };
1344
+ }
1345
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1346
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1347
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1348
+ }
1349
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1098
1350
  return { from: fromPt, to: toPt };
1099
1351
  }
1352
+ async resolveDragDestination(to, fromPt) {
1353
+ if ("dx" in to)
1354
+ return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
1355
+ if ("x" in to)
1356
+ return to;
1357
+ const { objectId } = await resolveElementLocator(this.conn, to);
1358
+ return centerOf(this.conn, objectId);
1359
+ }
1360
+ async html5Drag(fromPt, toPt, steps) {
1361
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: true });
1362
+ try {
1363
+ const intercepted = this.conn.waitFor("Input.dragIntercepted", undefined, HTML5_DRAG_INTERCEPT_TIMEOUT_MS).catch(() => {
1364
+ return;
1365
+ });
1366
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1367
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1368
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1369
+ }
1370
+ const event = await intercepted;
1371
+ if (!event?.data) {
1372
+ 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.');
1373
+ }
1374
+ const data = event.data;
1375
+ for (const type of ["dragEnter", "dragOver", "drop"]) {
1376
+ await this.dispatchInput("Input.dispatchDragEvent", { type, x: toPt.x, y: toPt.y, data });
1377
+ }
1378
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1379
+ } finally {
1380
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: false }).catch(() => {
1381
+ return;
1382
+ });
1383
+ }
1384
+ }
1100
1385
  async setValue(loc, value) {
1101
1386
  const { objectId } = await resolveElementLocator(this.conn, loc);
1102
- await setValueOnObject(this.conn, objectId, value);
1387
+ await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
1103
1388
  }
1104
1389
  async typeText(loc, text) {
1105
1390
  const { objectId } = await resolveElementLocator(this.conn, loc);
1106
1391
  await focusElement(this.conn, objectId);
1107
1392
  if (text.length)
1108
- await this.conn.send("Input.insertText", { text });
1393
+ await this.dispatchInput("Input.insertText", { text });
1109
1394
  }
1110
1395
  async pressKey(press) {
1111
1396
  const spec = resolveKey(press.key);
@@ -1120,8 +1405,8 @@ class CdpPageDriver {
1120
1405
  const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
1121
1406
  if (spec.text && !suppressText)
1122
1407
  down.text = spec.text;
1123
- await this.conn.send("Input.dispatchKeyEvent", down);
1124
- await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1408
+ await this.dispatchInput("Input.dispatchKeyEvent", down);
1409
+ await this.dispatchInput("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1125
1410
  }
1126
1411
  async setFiles(loc, files) {
1127
1412
  const { objectId } = await resolveElementLocator(this.conn, loc);
@@ -1337,6 +1622,36 @@ class CdpPageDriver {
1337
1622
  this.conn.close();
1338
1623
  }
1339
1624
  }
1625
+ var beaconSessions = new BeaconSessions((conn) => conn.close());
1626
+ async function targetWsUrl(targetId) {
1627
+ const hit = (await listTargets()).find((t) => t.id === targetId);
1628
+ return hit?.webSocketDebuggerUrl;
1629
+ }
1630
+ async function readBeaconOver(conn) {
1631
+ try {
1632
+ const res = await conn.send("Runtime.evaluate", {
1633
+ expression: BEACON_READ_EXPRESSION,
1634
+ returnByValue: true
1635
+ });
1636
+ const value = res.result?.value;
1637
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1638
+ } catch {
1639
+ return;
1640
+ }
1641
+ }
1642
+ async function probeOver(conn, timeoutMs) {
1643
+ try {
1644
+ const res = await conn.send("Runtime.evaluate", { expression: RENDERER_PROBE_EXPRESSION, returnByValue: true }, { timeoutMs });
1645
+ const value = res.result?.value;
1646
+ if (Array.isArray(value) && value[0] === 1) {
1647
+ const ts = value[1];
1648
+ return { responsive: true, beaconTs: typeof ts === "number" && Number.isFinite(ts) ? ts : null };
1649
+ }
1650
+ return { responsive: false, beaconTs: null };
1651
+ } catch {
1652
+ return { responsive: false, beaconTs: null };
1653
+ }
1654
+ }
1340
1655
  async function withBrowserConn(fn) {
1341
1656
  const conn = await openBrowser();
1342
1657
  try {
@@ -1375,6 +1690,8 @@ class CdpBrowserDriver {
1375
1690
  async closePage(id) {
1376
1691
  return withBrowserConn(async (conn) => {
1377
1692
  const res = await conn.send("Target.closeTarget", { targetId: id });
1693
+ if (res.success !== false)
1694
+ beaconSessions.drop(id);
1378
1695
  return { success: res.success ?? true };
1379
1696
  });
1380
1697
  }
@@ -1387,7 +1704,83 @@ class CdpBrowserDriver {
1387
1704
  const { conn, target } = await openPage(selector).catch(noSuchTarget);
1388
1705
  return new CdpPageDriver(conn, target, this);
1389
1706
  }
1390
- async dispose() {}
1707
+ async installActivityBeacon(targetId) {
1708
+ const held = beaconSessions.get(targetId);
1709
+ if (held) {
1710
+ const rearmed = await held.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true }).then(() => true).catch(() => false);
1711
+ if (rearmed)
1712
+ return true;
1713
+ beaconSessions.drop(targetId);
1714
+ }
1715
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1716
+ return;
1717
+ });
1718
+ if (!wsUrl)
1719
+ return false;
1720
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
1721
+ return;
1722
+ });
1723
+ if (!conn)
1724
+ return false;
1725
+ try {
1726
+ await conn.send("Page.enable");
1727
+ await conn.send("Page.addScriptToEvaluateOnNewDocument", { source: BEACON_SOURCE });
1728
+ await conn.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true });
1729
+ } catch {
1730
+ conn.close();
1731
+ return false;
1732
+ }
1733
+ beaconSessions.set(targetId, conn);
1734
+ return true;
1735
+ }
1736
+ async readActivityBeacon(targetId) {
1737
+ const held = beaconSessions.get(targetId);
1738
+ if (held) {
1739
+ const value = await readBeaconOver(held);
1740
+ if (value !== undefined)
1741
+ return value;
1742
+ beaconSessions.drop(targetId);
1743
+ }
1744
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1745
+ return;
1746
+ });
1747
+ if (!wsUrl)
1748
+ return null;
1749
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
1750
+ return;
1751
+ });
1752
+ if (!conn)
1753
+ return null;
1754
+ try {
1755
+ return await readBeaconOver(conn) ?? null;
1756
+ } finally {
1757
+ conn.close();
1758
+ }
1759
+ }
1760
+ async probeRenderer(targetId, timeoutMs = RENDERER_PROBE_TIMEOUT_MS) {
1761
+ const held = beaconSessions.get(targetId);
1762
+ if (held)
1763
+ return probeOver(held, timeoutMs);
1764
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1765
+ return;
1766
+ });
1767
+ if (!wsUrl)
1768
+ return { responsive: false, beaconTs: null };
1769
+ const conn = new CdpConnection(wsUrl, { timeoutMs });
1770
+ try {
1771
+ await conn.connect();
1772
+ } catch {
1773
+ return { responsive: false, beaconTs: null };
1774
+ }
1775
+ try {
1776
+ return await probeOver(conn, timeoutMs);
1777
+ } finally {
1778
+ conn.close();
1779
+ }
1780
+ }
1781
+ async dispose() {
1782
+ beaconSessions.clear();
1783
+ }
1391
1784
  }
1392
1785
  function createCdpDriver() {
1393
1786
  return new CdpBrowserDriver;
@@ -1427,6 +1820,20 @@ async function recordOrigin(backend, targetId, opts = {}) {
1427
1820
  await writeFile2(file, JSON.stringify(record), { flag: "wx" });
1428
1821
  } catch {}
1429
1822
  }
1823
+ async function readOrigin(backend, targetId) {
1824
+ let raw;
1825
+ try {
1826
+ raw = await readFile2(originFile(backend, targetId), "utf8");
1827
+ } catch {
1828
+ return;
1829
+ }
1830
+ try {
1831
+ const rec = JSON.parse(raw);
1832
+ if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
1833
+ return rec;
1834
+ } catch {}
1835
+ return;
1836
+ }
1430
1837
  async function listOrigins(opts = {}) {
1431
1838
  let names = [];
1432
1839
  try {
@@ -1484,59 +1891,51 @@ async function newTrackedPage(driver, backend, opts = {}) {
1484
1891
  return page;
1485
1892
  }
1486
1893
 
1487
- // src/leases-tools.ts
1488
- class LeaseToolError extends Error {
1489
- }
1490
- function backendOf(driver) {
1491
- return driver.scheme === "bidi" ? "firefox" : "chrome";
1492
- }
1493
- async function claimPage(driver, args = {}) {
1494
- const backend = backendOf(driver);
1495
- const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
1496
- const pages = await driver.listPages();
1497
- let targetId = args.targetId;
1498
- let url = args.url ?? "about:blank";
1499
- if (targetId === undefined || targetId === "") {
1500
- const page = await newTrackedPage(driver, backend, { url: args.url, label });
1501
- targetId = page.id;
1502
- url = page.url;
1503
- } else {
1504
- const existing = pages.find((p) => p.id === targetId);
1505
- if (!existing)
1506
- throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
1507
- url = existing.url;
1894
+ // src/reap.ts
1895
+ function staleAgentTabs(input) {
1896
+ const live = new Set(input.livePageIds);
1897
+ const out = [];
1898
+ for (const row of input.leases) {
1899
+ if (row.backend !== input.backend)
1900
+ continue;
1901
+ if (row.unreadable !== undefined)
1902
+ continue;
1903
+ if (row.stale !== "dead-pid" && row.stale !== "expired")
1904
+ continue;
1905
+ if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs)
1906
+ continue;
1907
+ if (!live.has(row.targetId))
1908
+ continue;
1909
+ const origin = input.origins.get(row.targetId);
1910
+ if (origin === undefined || origin.unreadable !== undefined)
1911
+ continue;
1912
+ out.push({ targetId: row.targetId, label: row.label, reason: row.stale });
1508
1913
  }
1509
- const liveIds = [...pages.map((p) => p.id), targetId];
1510
- const { record, token } = await claimLease(backend, targetId, {
1511
- label,
1512
- ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
1513
- liveIds
1514
- });
1515
- return {
1516
- lease: token,
1517
- targetId,
1518
- url,
1519
- label: record.label,
1520
- ttlMs: record.ttlMs,
1521
- expiresAt: record.lastUsedAt + record.ttlMs
1522
- };
1914
+ return out;
1523
1915
  }
1524
- async function releasePage(_driver, args) {
1525
- if (typeof args.lease !== "string" || !args.lease.length) {
1526
- throw new LeaseToolError("release_page requires the 'lease' token returned by claim_page");
1916
+ var DEFAULT_REAP_GRACE_MS = 2700000;
1917
+ function reapGraceMs() {
1918
+ const raw = Number(process.env.CDP_REAP_GRACE_MS);
1919
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
1920
+ }
1921
+ async function reapStaleAgentTabs(driver, backend) {
1922
+ const now = Date.now();
1923
+ const livePageIds = (await driver.listPages()).map((p) => p.id);
1924
+ const [origins, leases] = await Promise.all([
1925
+ originIndex(backend, undefined),
1926
+ listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [])
1927
+ ]);
1928
+ const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
1929
+ const closed = [];
1930
+ for (const c of candidates) {
1931
+ const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
1932
+ if (res.success !== true)
1933
+ continue;
1934
+ await releaseLeaseFor(backend, c.targetId);
1935
+ closed.push(c);
1527
1936
  }
1528
- return releaseLease(args.lease);
1529
- }
1530
- async function listLeasesTool(driver, _args = {}) {
1531
- const liveIds = (await driver.listPages()).map((p) => p.id);
1532
- const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
1533
- return { leases, count: leases.length };
1937
+ return closed;
1534
1938
  }
1535
- var LEASE_TOOLS = {
1536
- claim_page: claimPage,
1537
- release_page: releasePage,
1538
- list_leases: listLeasesTool
1539
- };
1540
1939
 
1541
1940
  // src/shared-tools.ts
1542
1941
  import { mkdir as mkdir3, writeFile as writeFile3 } from "node:fs/promises";
@@ -1549,7 +1948,7 @@ class SharedToolError extends Error {
1549
1948
  function target3(p) {
1550
1949
  return { id: p.id, url: p.url, title: p.title };
1551
1950
  }
1552
- function backendOf2(driver) {
1951
+ function backendOf(driver) {
1553
1952
  return driver.scheme === "bidi" ? "firefox" : "chrome";
1554
1953
  }
1555
1954
  async function withPage2(driver, target, fn) {
@@ -1563,7 +1962,7 @@ async function withPage2(driver, target, fn) {
1563
1962
  async function resolvePage(driver, selector) {
1564
1963
  const pages = await driver.listPages();
1565
1964
  const hit = await pickPage(driver, pages, selector);
1566
- await assertLeaseOk(backendOf2(driver), hit.id, { url: hit.url, title: hit.title });
1965
+ await assertLeaseOk(backendOf(driver), hit.id, { url: hit.url, title: hit.title });
1567
1966
  return hit;
1568
1967
  }
1569
1968
  async function pickPage(driver, pages, selector) {
@@ -1618,27 +2017,43 @@ async function reapSet(driver, pages, all) {
1618
2017
  }
1619
2018
  }
1620
2019
  async function listPages(driver, args = {}) {
1621
- const pages = await driver.listPages({ all: args.all });
1622
- const ledger = await originIndex(backendOf2(driver), await reapSet(driver, pages, args.all));
1623
- const annotated = pages.map((p) => {
2020
+ const backend = backendOf(driver);
2021
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
2022
+ const reapedIds = new Set(reaped.map((r) => r.targetId));
2023
+ const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
2024
+ const ledger = await originIndex(backend, await reapSet(driver, pages, args.all));
2025
+ const now = Date.now();
2026
+ 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]));
2027
+ const doProbe = args.probe === true && rendererProbeSupported(driver);
2028
+ const annotated = await Promise.all(pages.map(async (p) => {
1624
2029
  const rec = ledger.get(p.id);
1625
- if (!rec)
1626
- return { ...p, origin: "unknown" };
1627
- if (rec.unreadable !== undefined)
1628
- return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
1629
- return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
1630
- });
1631
- return { pages: annotated, count: annotated.length };
2030
+ const base = !rec ? { ...p, origin: "unknown" } : rec.unreadable !== undefined ? { ...p, origin: "unknown", originUnreadable: rec.unreadable } : { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
2031
+ const leaseRow = leaseByTarget.get(p.id);
2032
+ if (leaseRow) {
2033
+ base.lease = { label: leaseRow.label, pid: leaseRow.pid, idleMs: now - leaseRow.lastUsedAt, expiresAt: leaseRow.lastUsedAt + leaseRow.ttlMs, stale: leaseRow.stale };
2034
+ }
2035
+ if (doProbe && (p.type === undefined || p.type === "page")) {
2036
+ const probe = await probeRendererActivity(driver, backend, p.id, now);
2037
+ base.responsive = probe.responsive;
2038
+ if (probe.humanActiveMs !== undefined)
2039
+ base.humanActiveMs = probe.humanActiveMs;
2040
+ }
2041
+ return base;
2042
+ }));
2043
+ return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
1632
2044
  }
1633
2045
  async function newPage(driver, args = {}) {
1634
2046
  const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
1635
- const p = await newTrackedPage(driver, backendOf2(driver), { url: args.url, label });
1636
- if (args.claim !== true)
2047
+ const p = await newTrackedPage(driver, backendOf(driver), { url: args.url, label });
2048
+ const explicit = args.claim === true;
2049
+ if (!explicit && !requireLease())
1637
2050
  return { targetId: p.id, url: p.url };
1638
- const { record, token } = await claimLease(backendOf2(driver), p.id, {
2051
+ const { record, token } = await claimLease(backendOf(driver), p.id, {
1639
2052
  label,
1640
- ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs()
2053
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
2054
+ auto: !explicit
1641
2055
  });
2056
+ await installBeacon(driver, p.id);
1642
2057
  return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
1643
2058
  }
1644
2059
  async function closePage(driver, args) {
@@ -1648,7 +2063,7 @@ async function closePage(driver, args) {
1648
2063
  const res = await driver.closePage(p.id);
1649
2064
  if (!res.success)
1650
2065
  return { closed: p.id, success: false, leaseReleased: false };
1651
- const released = await releaseLeaseFor(backendOf2(driver), p.id);
2066
+ const released = await releaseLeaseFor(backendOf(driver), p.id);
1652
2067
  return { closed: p.id, success: true, ...released.released ? { leaseReleased: true } : {} };
1653
2068
  }
1654
2069
  async function writeSelectedFile(id) {
@@ -1670,17 +2085,36 @@ function toLegacyWaitedFor(w) {
1670
2085
  return "navigate-only";
1671
2086
  return w;
1672
2087
  }
1673
- async function navigatePage(driver, args) {
2088
+ function resolveNavigateMode(args) {
1674
2089
  const reload = args.reload === true;
1675
- if (!reload && (!args.url || typeof args.url !== "string")) {
1676
- throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
2090
+ const hasUrl = typeof args.url === "string" && args.url.length > 0;
2091
+ const hasHistory = args.history !== undefined && args.history !== null;
2092
+ const given = [hasUrl ? "url" : "", reload ? "reload" : "", hasHistory ? "history" : ""].filter(Boolean);
2093
+ if (given.length > 1) {
2094
+ throw new SharedToolError(`navigate_page: 'url', 'reload' and 'history' are mutually exclusive (got ${given.join(" + ")})`);
2095
+ }
2096
+ if (hasHistory) {
2097
+ if (args.history !== "back" && args.history !== "forward") {
2098
+ throw new SharedToolError(`navigate_page: 'history' must be 'back' or 'forward' (got ${JSON.stringify(args.history)})`);
2099
+ }
2100
+ return { mode: "history", history: args.history };
2101
+ }
2102
+ if (reload)
2103
+ return { mode: "reload" };
2104
+ if (!hasUrl) {
2105
+ 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)");
1677
2106
  }
2107
+ return { mode: "url", url: args.url };
2108
+ }
2109
+ async function navigatePage(driver, args) {
2110
+ const mode = resolveNavigateMode(args);
1678
2111
  return withPage2(driver, args.target, async (page) => {
1679
- const r = await page.navigate(args);
2112
+ const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
1680
2113
  return {
1681
2114
  url: r.url,
1682
2115
  frameId: r.contextId,
1683
2116
  ...r.reloaded ? { reloaded: r.reloaded } : {},
2117
+ ...r.traversed ? { traversed: r.traversed } : {},
1684
2118
  waitedFor: toLegacyWaitedFor(r.waitedFor)
1685
2119
  };
1686
2120
  });
@@ -1826,22 +2260,113 @@ async function takeSnapshot(driver, args = {}) {
1826
2260
  }
1827
2261
  async function click(driver, args) {
1828
2262
  return withPage2(driver, args.target, async (page) => {
1829
- const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
2263
+ const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1, modifiers: args.modifiers });
1830
2264
  return { clicked: true, x, y };
1831
2265
  });
1832
2266
  }
2267
+ function resolveScrollAnchor(args) {
2268
+ const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
2269
+ const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
2270
+ const hasX = args.x !== undefined;
2271
+ const hasY = args.y !== undefined;
2272
+ if (hasX !== hasY)
2273
+ throw new SharedToolError("scroll: 'x' and 'y' must be provided together");
2274
+ const hasXY = hasX && hasY;
2275
+ const anchorCount = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2276
+ if (anchorCount > 1)
2277
+ throw new SharedToolError("scroll: provide at most one of { uid }, { selector }, or { x, y } (omit all three to scroll at the viewport center)");
2278
+ if (hasUid || hasSelector)
2279
+ return locatorOf({ uid: args.uid, selector: args.selector });
2280
+ if (hasXY)
2281
+ return { x: args.x, y: args.y };
2282
+ return;
2283
+ }
2284
+ function resolveScrollDelta(args) {
2285
+ const deltaX = args.deltaX ?? 0;
2286
+ const deltaY = args.deltaY ?? 0;
2287
+ if (deltaX === 0 && deltaY === 0)
2288
+ throw new SharedToolError("scroll requires at least one of { deltaX } or { deltaY }");
2289
+ return { deltaX, deltaY };
2290
+ }
2291
+ async function scroll(driver, args) {
2292
+ const anchor = resolveScrollAnchor(args);
2293
+ const { deltaX, deltaY } = resolveScrollDelta(args);
2294
+ return withPage2(driver, args.target, async (page) => {
2295
+ const { x, y } = await page.scroll(anchor, { deltaX, deltaY });
2296
+ return { x, y, deltaX, deltaY, target: target3(page.info) };
2297
+ });
2298
+ }
1833
2299
  async function hover(driver, args) {
1834
2300
  return withPage2(driver, args.target, async (page) => {
1835
2301
  const { x, y } = await page.hover(locatorOf(args));
1836
2302
  return { hovered: true, x, y };
1837
2303
  });
1838
2304
  }
2305
+ function resolveDragTo(to) {
2306
+ const hasUid = to.uid !== undefined && to.uid !== null && to.uid !== "";
2307
+ const hasSelector = typeof to.selector === "string" && to.selector.length > 0;
2308
+ const hasX = to.x !== undefined;
2309
+ const hasY = to.y !== undefined;
2310
+ if (hasX !== hasY)
2311
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be provided together");
2312
+ const hasXY = hasX && hasY;
2313
+ const count = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2314
+ if (count !== 1)
2315
+ throw new SharedToolError("drag: 'to' takes exactly one of { uid }, { selector }, or { x, y }");
2316
+ if (hasXY) {
2317
+ if (!Number.isFinite(to.x) || !Number.isFinite(to.y))
2318
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be finite numbers");
2319
+ return { x: to.x, y: to.y };
2320
+ }
2321
+ return locatorOf({ uid: to.uid, selector: to.selector });
2322
+ }
2323
+ function resolveDragDestination(args) {
2324
+ const hasTo = args.to !== undefined && args.to !== null;
2325
+ const hasBy = args.by !== undefined && args.by !== null;
2326
+ if (hasTo === hasBy)
2327
+ throw new SharedToolError("drag requires exactly one of { to } or { by }");
2328
+ if (hasBy) {
2329
+ const by = args.by;
2330
+ const hasDx = by.dx !== undefined;
2331
+ const hasDy = by.dy !== undefined;
2332
+ if (!hasDx && !hasDy)
2333
+ throw new SharedToolError("drag: 'by' requires at least one of { dx } or { dy }");
2334
+ const dx = by.dx ?? 0;
2335
+ const dy = by.dy ?? 0;
2336
+ if (!Number.isFinite(dx) || !Number.isFinite(dy))
2337
+ throw new SharedToolError("drag: 'by.dx' and 'by.dy' must be finite numbers");
2338
+ return { dx, dy };
2339
+ }
2340
+ return resolveDragTo(args.to);
2341
+ }
2342
+ var MAX_DRAG_STEPS = 500;
2343
+ function resolveDragSteps(steps) {
2344
+ if (steps === undefined)
2345
+ return 2;
2346
+ if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_DRAG_STEPS) {
2347
+ throw new SharedToolError(`drag: 'steps' must be an integer between 1 and ${MAX_DRAG_STEPS} (default 2)`);
2348
+ }
2349
+ return steps;
2350
+ }
2351
+ function resolveDragMode(mode, capabilities) {
2352
+ if (mode === undefined || mode === "mouse")
2353
+ return "mouse";
2354
+ if (mode !== "html5")
2355
+ throw new SharedToolError(`drag: unknown mode '${mode}' (use 'mouse' or 'html5')`);
2356
+ if (!capabilities.has("input.html5Drag")) {
2357
+ 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.");
2358
+ }
2359
+ return "html5";
2360
+ }
1839
2361
  async function drag(driver, args) {
1840
- if (!args.from || !args.to)
1841
- throw new SharedToolError("drag requires { from } and { to }");
2362
+ if (!args.from)
2363
+ throw new SharedToolError("drag requires { from }");
2364
+ const destination = resolveDragDestination(args);
2365
+ const steps = resolveDragSteps(args.steps);
2366
+ const mode = resolveDragMode(args.mode, driver.capabilities);
1842
2367
  return withPage2(driver, args.target, async (page) => {
1843
- const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
1844
- return { dragged: true, from, to };
2368
+ const { from, to } = await page.drag(locatorOf(args.from), destination, { mode, steps });
2369
+ return { dragged: true, mode, steps, from, to };
1845
2370
  });
1846
2371
  }
1847
2372
  async function fill(driver, args) {
@@ -1962,6 +2487,7 @@ var SHARED_TOOLS = {
1962
2487
  click,
1963
2488
  hover,
1964
2489
  drag,
2490
+ scroll,
1965
2491
  fill,
1966
2492
  fill_form: fillForm,
1967
2493
  type_text: typeText,
@@ -1973,6 +2499,121 @@ var SHARED_TOOLS = {
1973
2499
  handle_dialog: handleDialog
1974
2500
  };
1975
2501
 
2502
+ // src/leases-tools.ts
2503
+ class LeaseToolError extends Error {
2504
+ }
2505
+ function backendOf2(driver) {
2506
+ return driver.scheme === "bidi" ? "firefox" : "chrome";
2507
+ }
2508
+ async function claimPage(driver, args = {}) {
2509
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
2510
+ const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
2511
+ if (hasTarget && hasTargetId) {
2512
+ 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.");
2513
+ }
2514
+ const backend = backendOf2(driver);
2515
+ const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
2516
+ const pages = await driver.listPages();
2517
+ let targetId;
2518
+ let url;
2519
+ let opened;
2520
+ if (hasTarget) {
2521
+ const hit = await pickPage(driver, pages, args.target).catch((err) => {
2522
+ 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.`);
2523
+ });
2524
+ targetId = hit.id;
2525
+ url = hit.url;
2526
+ opened = false;
2527
+ } else if (hasTargetId) {
2528
+ const existing = pages.find((p) => p.id === args.targetId);
2529
+ if (!existing)
2530
+ throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
2531
+ targetId = existing.id;
2532
+ url = existing.url;
2533
+ opened = false;
2534
+ } else {
2535
+ const page = await newTrackedPage(driver, backend, { url: args.url, label });
2536
+ targetId = page.id;
2537
+ url = page.url;
2538
+ opened = true;
2539
+ }
2540
+ const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
2541
+ const liveIds = [...pages.map((p) => p.id), targetId];
2542
+ const { record, token } = await claimLease(backend, targetId, {
2543
+ label,
2544
+ ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
2545
+ liveIds
2546
+ });
2547
+ await installBeacon(driver, targetId);
2548
+ const contention = contentionWarning(humanMs, { takeover: !opened });
2549
+ return {
2550
+ lease: token,
2551
+ targetId,
2552
+ url,
2553
+ label: record.label,
2554
+ ttlMs: record.ttlMs,
2555
+ expiresAt: record.lastUsedAt + record.ttlMs,
2556
+ opened,
2557
+ ...beaconSupported(driver) ? { humanActiveMs: humanMs } : {},
2558
+ ...contention !== undefined ? { contention } : {}
2559
+ };
2560
+ }
2561
+ async function releasePage(driver, args = {}) {
2562
+ const hasLease = typeof args.lease === "string" && args.lease.length > 0;
2563
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
2564
+ if (hasLease === hasTarget) {
2565
+ 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)");
2566
+ }
2567
+ const backend = backendOf2(driver);
2568
+ let targetId;
2569
+ let released;
2570
+ if (hasLease) {
2571
+ const parts = tokenParts(args.lease);
2572
+ if (!parts)
2573
+ return { released: false, closed: false };
2574
+ targetId = parts.targetId;
2575
+ released = (await releaseLease(args.lease)).released;
2576
+ } else {
2577
+ const page = await resolvePage(driver, args.target);
2578
+ targetId = page.id;
2579
+ released = (await releaseLeaseFor(backend, targetId)).released;
2580
+ }
2581
+ if (!released)
2582
+ return { released: false, closed: false, ...hasTarget ? { targetId } : {} };
2583
+ const shouldClose = args.close === false ? false : args.close === true ? true : await readOrigin(backend, targetId) !== undefined;
2584
+ if (!shouldClose)
2585
+ return { released: true, closed: false, targetId };
2586
+ const res = await driver.closePage(targetId).catch(() => ({ success: false }));
2587
+ return { released: true, closed: res.success === true, targetId };
2588
+ }
2589
+ async function listLeasesTool(driver, _args = {}) {
2590
+ const backend = backendOf2(driver);
2591
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
2592
+ const reapedIds = new Set(reaped.map((r) => r.targetId));
2593
+ const livePages = await driver.listPages();
2594
+ const liveIds = livePages.map((p) => p.id);
2595
+ const all = await listLeases({ liveIds, liveBackend: backend });
2596
+ const kept = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
2597
+ const liveSet = new Set(liveIds);
2598
+ const now = Date.now();
2599
+ const leases = [];
2600
+ for (const row of kept) {
2601
+ const computed = row.unreadable !== undefined ? row : { ...row, idleMs: now - row.lastUsedAt, expiresAt: row.lastUsedAt + row.ttlMs };
2602
+ if (row.backend !== backend || row.unreadable !== undefined || !liveSet.has(row.targetId)) {
2603
+ leases.push(computed);
2604
+ continue;
2605
+ }
2606
+ const humanMs = await readHumanActiveMs(driver, backend, row.targetId, now).catch(() => null);
2607
+ leases.push(humanMs === null ? computed : { ...computed, humanActiveMs: humanMs });
2608
+ }
2609
+ return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
2610
+ }
2611
+ var LEASE_TOOLS = {
2612
+ claim_page: claimPage,
2613
+ release_page: releasePage,
2614
+ list_leases: listLeasesTool
2615
+ };
2616
+
1976
2617
  // src/tools/console.ts
1977
2618
  import { readFile as readFile3 } from "node:fs/promises";
1978
2619
 
@@ -3120,6 +3761,273 @@ function truncate(s, max = 600) {
3120
3761
  return t.length > max ? `${t.slice(0, max)}…` : t;
3121
3762
  }
3122
3763
 
3764
+ // src/tools/dispatch-mouse.ts
3765
+ var CLICK_MODIFIER_BITS2 = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
3766
+ var BUTTONS = new Set(["left", "right", "middle"]);
3767
+ var CDP_EVENT_TYPE = {
3768
+ move: "mouseMoved",
3769
+ down: "mousePressed",
3770
+ up: "mouseReleased"
3771
+ };
3772
+ function modifierBits(mods) {
3773
+ let bits = 0;
3774
+ for (const m of mods ?? []) {
3775
+ const bit = CLICK_MODIFIER_BITS2[m];
3776
+ if (bit === undefined)
3777
+ throw new Error(`dispatch_mouse: unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
3778
+ bits |= bit;
3779
+ }
3780
+ return bits;
3781
+ }
3782
+ function validateDispatchMouseArgs(args) {
3783
+ if (args.action !== "move" && args.action !== "down" && args.action !== "up") {
3784
+ throw new Error("dispatch_mouse requires { action } of 'move', 'down', or 'up'");
3785
+ }
3786
+ if (typeof args.x !== "number" || typeof args.y !== "number" || !Number.isFinite(args.x) || !Number.isFinite(args.y)) {
3787
+ throw new Error("dispatch_mouse requires numeric { x } and { y } on every call");
3788
+ }
3789
+ const button = args.button ?? "left";
3790
+ if (!BUTTONS.has(button))
3791
+ throw new Error(`dispatch_mouse: unknown button '${button}' (use left, right, or middle)`);
3792
+ }
3793
+ async function dispatchMouse(args) {
3794
+ validateDispatchMouseArgs(args);
3795
+ const button = args.button ?? "left";
3796
+ const modifiers = modifierBits(args.modifiers);
3797
+ return withPage(args.target, async (conn, target) => {
3798
+ const params = { type: CDP_EVENT_TYPE[args.action], x: args.x, y: args.y, button, modifiers };
3799
+ if (args.action !== "move")
3800
+ params.clickCount = args.clickCount ?? 1;
3801
+ await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", params);
3802
+ return { dispatched: args.action, x: args.x, y: args.y };
3803
+ });
3804
+ }
3805
+
3806
+ // src/tools/downloads.ts
3807
+ import { readdir as readdir3, rename, stat as stat4 } from "node:fs/promises";
3808
+ import { basename, extname, join as join8 } from "node:path";
3809
+
3810
+ // src/tools/browser-session.ts
3811
+ import { mkdir as mkdir9 } from "node:fs/promises";
3812
+ import { join as join7 } from "node:path";
3813
+ var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
3814
+ var DOWNLOADS_DIR = join7(ARTIFACT_DIR7, "downloads");
3815
+ var DOWNLOAD_BUFFER_LIMIT = 50;
3816
+ var session;
3817
+ function isDisconnect(e) {
3818
+ const m = e instanceof Error ? e.message : String(e);
3819
+ return /connection (not open|closed)|closed by caller|websocket|ECONNREFUSED|socket/i.test(m);
3820
+ }
3821
+ function record(s, guid) {
3822
+ let r = s.downloads.find((d) => d.guid === guid);
3823
+ if (!r) {
3824
+ r = { guid, state: "inProgress", consumed: false };
3825
+ s.downloads.push(r);
3826
+ while (s.downloads.length > DOWNLOAD_BUFFER_LIMIT) {
3827
+ const i = s.downloads.findIndex((d) => d.consumed);
3828
+ s.downloads.splice(i >= 0 ? i : 0, 1);
3829
+ }
3830
+ }
3831
+ return r;
3832
+ }
3833
+ function subscribe(s) {
3834
+ s.conn.on("Browser.downloadWillBegin", (p) => {
3835
+ const r = record(s, String(p.guid));
3836
+ if (typeof p.url === "string")
3837
+ r.url = p.url;
3838
+ if (typeof p.suggestedFilename === "string")
3839
+ r.suggestedFilename = p.suggestedFilename;
3840
+ });
3841
+ s.conn.on("Browser.downloadProgress", (p) => {
3842
+ const r = record(s, String(p.guid));
3843
+ if (typeof p.receivedBytes === "number")
3844
+ r.bytes = p.receivedBytes;
3845
+ if (typeof p.filePath === "string")
3846
+ r.filePath = p.filePath;
3847
+ const state = String(p.state);
3848
+ if (state === "completed" || state === "canceled") {
3849
+ r.state = state;
3850
+ if (state === "completed") {
3851
+ const waiter = s.waiters.shift();
3852
+ if (waiter) {
3853
+ r.consumed = true;
3854
+ waiter(r);
3855
+ }
3856
+ }
3857
+ }
3858
+ });
3859
+ }
3860
+ async function getBrowserSession() {
3861
+ if (session && !session.dead)
3862
+ return session;
3863
+ const previousPath = session?.armedPath;
3864
+ const conn = await openBrowser();
3865
+ const s = { conn, downloads: [], waiters: [], dead: false };
3866
+ subscribe(s);
3867
+ session = s;
3868
+ if (previousPath)
3869
+ await armDownloadsAt(previousPath);
3870
+ return s;
3871
+ }
3872
+ async function browserSend(method, params = {}) {
3873
+ const s = await getBrowserSession();
3874
+ try {
3875
+ return await s.conn.send(method, params);
3876
+ } catch (e) {
3877
+ if (isDisconnect(e)) {
3878
+ s.dead = true;
3879
+ const fresh = await getBrowserSession();
3880
+ return await fresh.conn.send(method, params);
3881
+ }
3882
+ throw e;
3883
+ }
3884
+ }
3885
+ async function armDownloadsAt(path) {
3886
+ await mkdir9(path, { recursive: true });
3887
+ const s = await getBrowserSession();
3888
+ await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
3889
+ s.armedPath = path;
3890
+ }
3891
+ async function armDownloads() {
3892
+ const s = await getBrowserSession();
3893
+ if (s.armedPath !== DOWNLOADS_DIR)
3894
+ await armDownloadsAt(DOWNLOADS_DIR);
3895
+ return { downloadPath: DOWNLOADS_DIR };
3896
+ }
3897
+ function pendingDownloads() {
3898
+ return (session?.downloads ?? []).filter((d) => d.state === "completed" && !d.consumed);
3899
+ }
3900
+ async function nextCompletedDownload(timeoutMs) {
3901
+ const s = await getBrowserSession();
3902
+ const buffered = s.downloads.find((d) => d.state === "completed" && !d.consumed);
3903
+ if (buffered) {
3904
+ buffered.consumed = true;
3905
+ return buffered;
3906
+ }
3907
+ return new Promise((resolve, reject) => {
3908
+ const waiter = (r) => {
3909
+ clearTimeout(timer);
3910
+ resolve(r);
3911
+ };
3912
+ const timer = setTimeout(() => {
3913
+ const i = s.waiters.indexOf(waiter);
3914
+ if (i >= 0)
3915
+ s.waiters.splice(i, 1);
3916
+ 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).`));
3917
+ }, timeoutMs);
3918
+ s.waiters.push(waiter);
3919
+ });
3920
+ }
3921
+ async function disposeBrowserSession() {
3922
+ const s = session;
3923
+ session = undefined;
3924
+ if (!s)
3925
+ return;
3926
+ s.dead = true;
3927
+ try {
3928
+ s.conn.close();
3929
+ } catch {}
3930
+ }
3931
+
3932
+ // src/tools/downloads.ts
3933
+ function safeFilename(suggested) {
3934
+ const raw = (suggested ?? "").replace(/[\u0000-\u001f]/g, "");
3935
+ const name = basename(raw.split("\\").pop() ?? "").trim();
3936
+ if (!name || name === "." || name === "..")
3937
+ return "download";
3938
+ return name;
3939
+ }
3940
+ function nextFreeName(name, taken) {
3941
+ if (!taken.has(name))
3942
+ return name;
3943
+ const ext = extname(name);
3944
+ const stem = ext ? name.slice(0, -ext.length) : name;
3945
+ for (let i = 1;; i++) {
3946
+ const candidate = `${stem}-${i}${ext}`;
3947
+ if (!taken.has(candidate))
3948
+ return candidate;
3949
+ }
3950
+ }
3951
+ function validateWaitForDownloadArgs(args) {
3952
+ if (args.timeoutMs !== undefined) {
3953
+ if (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
3954
+ throw new Error(`wait_for_download: 'timeoutMs' must be a positive number (got ${JSON.stringify(args.timeoutMs)})`);
3955
+ }
3956
+ }
3957
+ if (args.arm !== undefined && typeof args.arm !== "boolean") {
3958
+ throw new Error(`wait_for_download: 'arm' must be a boolean (got ${JSON.stringify(args.arm)})`);
3959
+ }
3960
+ }
3961
+ async function waitForDownload(args = {}) {
3962
+ validateWaitForDownloadArgs(args);
3963
+ const t = await resolveTarget(args.target);
3964
+ const target = { id: t.id, url: t.url, title: t.title };
3965
+ const { downloadPath } = await armDownloads();
3966
+ if (args.arm === true)
3967
+ return { armed: true, downloadPath, pending: pendingDownloads().length, target };
3968
+ const rec = await nextCompletedDownload(args.timeoutMs ?? 30000);
3969
+ if (!rec.filePath) {
3970
+ throw new Error(`wait_for_download: download ${rec.guid} completed without a file path (nothing to return)`);
3971
+ }
3972
+ const suggestedFilename = safeFilename(rec.suggestedFilename);
3973
+ const taken = new Set(await readdir3(DOWNLOADS_DIR).catch(() => []));
3974
+ taken.delete(basename(rec.filePath));
3975
+ const finalName = nextFreeName(suggestedFilename, taken);
3976
+ const path = join8(DOWNLOADS_DIR, finalName);
3977
+ await rename(rec.filePath, path);
3978
+ const bytes = (await stat4(path)).size;
3979
+ return { path, suggestedFilename, bytes, ...rec.url ? { url: rec.url } : {}, target };
3980
+ }
3981
+
3982
+ // src/tools/permissions.ts
3983
+ function originOf(url) {
3984
+ try {
3985
+ const o = new URL(url).origin;
3986
+ return o && o !== "null" ? o : undefined;
3987
+ } catch {
3988
+ return;
3989
+ }
3990
+ }
3991
+ function validateGrantPermissionsArgs(args) {
3992
+ const reset = args.reset === true;
3993
+ if (args.reset !== undefined && typeof args.reset !== "boolean") {
3994
+ throw new Error(`grant_permissions: 'reset' must be a boolean (got ${JSON.stringify(args.reset)})`);
3995
+ }
3996
+ if (args.permissions === undefined) {
3997
+ if (!reset)
3998
+ throw new Error("grant_permissions: 'permissions' is required (or pass reset:true to clear previous grants)");
3999
+ return;
4000
+ }
4001
+ if (!Array.isArray(args.permissions)) {
4002
+ throw new Error(`grant_permissions: 'permissions' must be an array of CDP PermissionType strings (got ${JSON.stringify(args.permissions)})`);
4003
+ }
4004
+ if (args.permissions.length === 0 && !reset) {
4005
+ throw new Error("grant_permissions: 'permissions' must not be empty (pass reset:true if the intent is to clear grants)");
4006
+ }
4007
+ for (const p of args.permissions) {
4008
+ if (typeof p !== "string" || p.length === 0) {
4009
+ throw new Error(`grant_permissions: every entry of 'permissions' must be a non-empty string (got ${JSON.stringify(p)})`);
4010
+ }
4011
+ }
4012
+ }
4013
+ async function grantPermissions(args = {}) {
4014
+ validateGrantPermissionsArgs(args);
4015
+ const t = await resolveTarget(args.target);
4016
+ const target = { id: t.id, url: t.url, title: t.title };
4017
+ const reset = args.reset === true;
4018
+ const permissions = args.permissions ?? [];
4019
+ if (reset)
4020
+ await browserSend("Browser.resetPermissions", {});
4021
+ if (permissions.length === 0)
4022
+ return { reset: true, target };
4023
+ const origin = args.origin ?? originOf(t.url);
4024
+ if (!origin) {
4025
+ 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"`);
4026
+ }
4027
+ await browserSend("Browser.grantPermissions", { origin, permissions });
4028
+ return { granted: [...permissions], origin, ...reset ? { reset: true } : {}, target };
4029
+ }
4030
+
3123
4031
  // src/index.ts
3124
4032
  var cdpDriver = createCdpDriver();
3125
4033
  function onCdp(fn) {
@@ -3140,6 +4048,7 @@ var TOOLS = {
3140
4048
  click: onCdp(SHARED_TOOLS.click),
3141
4049
  hover: onCdp(SHARED_TOOLS.hover),
3142
4050
  drag: onCdp(SHARED_TOOLS.drag),
4051
+ scroll: onCdp(SHARED_TOOLS.scroll),
3143
4052
  fill: onCdp(SHARED_TOOLS.fill),
3144
4053
  fill_form: onCdp(SHARED_TOOLS.fill_form),
3145
4054
  type_text: onCdp(SHARED_TOOLS.type_text),
@@ -3164,6 +4073,9 @@ var TOOLS = {
3164
4073
  mock_request: mockRequest,
3165
4074
  list_mocks: listMocks,
3166
4075
  clear_mocks: clearMocks,
4076
+ dispatch_mouse: dispatchMouse,
4077
+ wait_for_download: waitForDownload,
4078
+ grant_permissions: grantPermissions,
3167
4079
  claim_page: onCdp(LEASE_TOOLS.claim_page),
3168
4080
  release_page: onCdp(LEASE_TOOLS.release_page),
3169
4081
  list_leases: onCdp(LEASE_TOOLS.list_leases)