cdp-toolkit 1.7.0 → 1.9.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
@@ -278,6 +278,169 @@ async function listLeases(opts = {}) {
278
278
  return out;
279
279
  }
280
280
 
281
+ // src/origins.ts
282
+ import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
283
+ import { join as join2 } from "node:path";
284
+ function safeId2(id) {
285
+ return id.replace(/[^A-Za-z0-9._-]/g, "_");
286
+ }
287
+ function originFile(backend, targetId) {
288
+ return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
289
+ }
290
+ var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
291
+ async function recordOrigin(backend, targetId, opts = {}) {
292
+ const record = {
293
+ backend,
294
+ targetId,
295
+ label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
296
+ pid: process.pid,
297
+ createdAt: opts.now ?? Date.now()
298
+ };
299
+ const file = originFile(backend, targetId);
300
+ try {
301
+ await mkdir2(leaseDir(), { recursive: true });
302
+ try {
303
+ await writeFile2(file, JSON.stringify(record), { flag: "wx" });
304
+ return;
305
+ } catch (err) {
306
+ if (err.code !== "EEXIST")
307
+ throw err;
308
+ }
309
+ await unlink2(file).catch(() => {
310
+ return;
311
+ });
312
+ await writeFile2(file, JSON.stringify(record), { flag: "wx" });
313
+ } catch {}
314
+ }
315
+ async function readOrigin(backend, targetId) {
316
+ let raw;
317
+ try {
318
+ raw = await readFile2(originFile(backend, targetId), "utf8");
319
+ } catch {
320
+ return;
321
+ }
322
+ try {
323
+ const rec = JSON.parse(raw);
324
+ if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
325
+ return rec;
326
+ } catch {}
327
+ return;
328
+ }
329
+ async function listOrigins(opts = {}) {
330
+ let names = [];
331
+ try {
332
+ names = await readdir2(leaseDir());
333
+ } catch {
334
+ return [];
335
+ }
336
+ const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
337
+ const out = [];
338
+ for (const name of names) {
339
+ const parts = ORIGIN_FILE_RE.exec(name);
340
+ if (!parts)
341
+ continue;
342
+ const backend = parts[1];
343
+ const nameId = parts[2];
344
+ const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
345
+ if (scoped && !live.has(nameId)) {
346
+ await unlink2(join2(leaseDir(), name)).catch(() => {
347
+ return;
348
+ });
349
+ continue;
350
+ }
351
+ const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
352
+ let rec;
353
+ try {
354
+ rec = JSON.parse(await readFile2(join2(leaseDir(), name), "utf8"));
355
+ } catch (err) {
356
+ const code = err.code;
357
+ if (code === "ENOENT")
358
+ continue;
359
+ out.push({ ...fromName, unreadable: code ?? "unparseable" });
360
+ continue;
361
+ }
362
+ if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
363
+ out.push({ ...fromName, unreadable: "unparseable" });
364
+ continue;
365
+ }
366
+ out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
367
+ }
368
+ return out;
369
+ }
370
+ async function originIndex(backend, liveIds) {
371
+ const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
372
+ const index = new Map;
373
+ for (const row of rows) {
374
+ if (row.backend !== backend)
375
+ continue;
376
+ index.set(row.targetId, row);
377
+ }
378
+ return index;
379
+ }
380
+ async function newTrackedPage(driver, backend, opts = {}) {
381
+ const page = await driver.newPage(opts.url);
382
+ await recordOrigin(backend, page.id, { label: opts.label });
383
+ return page;
384
+ }
385
+ async function resolveLiveLabel(backend, label, liveIds) {
386
+ const live = new Set(liveIds);
387
+ const [origins, leases] = await Promise.all([originIndex(backend), listLeases({ liveBackend: backend })]);
388
+ const knownLabels = new Set;
389
+ const matchIds = new Set;
390
+ for (const rec of origins.values()) {
391
+ if (rec.unreadable !== undefined || !live.has(rec.targetId))
392
+ continue;
393
+ knownLabels.add(rec.label);
394
+ if (rec.label === label)
395
+ matchIds.add(rec.targetId);
396
+ }
397
+ for (const rec of leases) {
398
+ if (rec.backend !== backend || rec.unreadable !== undefined || !live.has(rec.targetId))
399
+ continue;
400
+ knownLabels.add(rec.label);
401
+ if (rec.label === label)
402
+ matchIds.add(rec.targetId);
403
+ }
404
+ return { matchIds: [...matchIds], knownLabels: [...knownLabels].sort() };
405
+ }
406
+
407
+ // src/workers.ts
408
+ var WORKER_SELECTOR_PREFIX = "worker:";
409
+ var WORKER_TARGET_TYPES = new Set(["service_worker", "shared_worker"]);
410
+ function isWorkerSelector(selector) {
411
+ return typeof selector === "string" && selector.startsWith(WORKER_SELECTOR_PREFIX);
412
+ }
413
+ function workerNeedle(selector) {
414
+ return selector.slice(WORKER_SELECTOR_PREFIX.length);
415
+ }
416
+ function isWorkerTargetType(type) {
417
+ return type !== undefined && WORKER_TARGET_TYPES.has(type);
418
+ }
419
+ function resolveWorkerTargets(targets, needle) {
420
+ const liveWorkers = targets.filter((t) => isWorkerTargetType(t.type));
421
+ return {
422
+ matches: needle === "" ? [] : liveWorkers.filter((t) => t.url.includes(needle)),
423
+ liveWorkers
424
+ };
425
+ }
426
+ var WORKER_EMPTY_NEEDLE_MESSAGE = "'worker:' needs a substring of the worker's url, e.g. worker:<extension-id> or worker:background.js";
427
+ var WORKER_SELECTOR_UNSUPPORTED_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, which this backend cannot address " + "(WebDriver BiDi has no extension service-worker target; Capability 'worker.targets' is Chrome-only). " + "Use --browser chrome to evaluate in an extension's background worker.";
428
+ var WORKER_SELECTOR_PAGE_ONLY_MESSAGE = "target 'worker:<substring>' selects a service/shared worker, and this tool is page-only " + "(closing, selecting or releasing a worker is meaningless). evaluate_script is the one tool that " + "accepts a worker: target.";
429
+ function urlList(workers) {
430
+ return workers.map((w) => w.url).join(", ");
431
+ }
432
+ function workerAmbiguityMessage(needle, matches) {
433
+ return `'worker:${needle}' matches more than one running worker: ` + matches.map((m) => `${m.url} (${m.id})`).join(", ") + ". Narrow the substring, or pass the worker's target id directly.";
434
+ }
435
+ function workerMissMessage(needle, opts) {
436
+ const running = opts.liveWorkers?.length ? ` (running workers: ${urlList(opts.liveWorkers)})` : " (no workers are running)";
437
+ if (!opts.wakeAttempted) {
438
+ return `no running worker whose url contains '${needle}'${running}. ` + "An MV3 extension service worker is idle-evicted after seconds of inactivity and then appears in NO target " + "listing, so this is usually a sleeping worker rather than a wrong substring. You passed wake:false; " + "wake:true (the default) asks Chrome to start it first.";
439
+ }
440
+ const scopes = opts.registeredScopes?.length ? ` Registered service-worker scopes in this browser: ${opts.registeredScopes.join(", ")}.` : " No service-worker registrations were reported by this browser at all.";
441
+ return `no worker whose url contains '${needle}' could be reached${running}. ` + `A start was requested (wake:true) and no matching worker appeared.${scopes} ` + "If the extension is installed but its worker never starts, check the worker script for a top-level throw.";
442
+ }
443
+
281
444
  // src/client.ts
282
445
  var BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
283
446
  var DEFAULT_TIMEOUT_MS = Number(process.env.CDP_TIMEOUT_MS ?? 15000);
@@ -433,11 +596,13 @@ async function browserWsUrl() {
433
596
  }
434
597
  async function resolveTarget(selector, opts = {}) {
435
598
  const targets = await listTargets();
436
- const hit = pickTarget(targets, selector);
599
+ const hit = await pickTarget(targets, selector);
600
+ if (isWorkerTargetType(hit.type))
601
+ return hit;
437
602
  await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
438
603
  return hit;
439
604
  }
440
- function pickTarget(targets, selector) {
605
+ async function pickTarget(targets, selector) {
441
606
  const pages = targets.filter((t) => t.type === "page");
442
607
  if (!selector || selector === "active") {
443
608
  if (!pages.length)
@@ -464,6 +629,30 @@ function pickTarget(targets, selector) {
464
629
  throw new CdpError(`no page title containing '${needle}'`);
465
630
  return hit;
466
631
  }
632
+ if (selector.startsWith("label:")) {
633
+ const label = selector.slice(6);
634
+ const { matchIds, knownLabels } = await resolveLiveLabel("chrome", label, pages.map((t) => t.id));
635
+ if (matchIds.length > 1) {
636
+ throw new CdpError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
637
+ }
638
+ if (matchIds.length === 1) {
639
+ const hit = pages.find((t) => t.id === matchIds[0]);
640
+ if (hit)
641
+ return hit;
642
+ }
643
+ throw new CdpError(knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
644
+ }
645
+ if (isWorkerSelector(selector)) {
646
+ const needle = workerNeedle(selector);
647
+ if (needle === "")
648
+ throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
649
+ const { matches, liveWorkers } = resolveWorkerTargets(targets, needle);
650
+ if (matches.length > 1)
651
+ throw new CdpError(workerAmbiguityMessage(needle, matches));
652
+ if (matches.length === 1)
653
+ return matches[0];
654
+ throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
655
+ }
467
656
  const byId = targets.find((t) => t.id === selector);
468
657
  if (!byId)
469
658
  throw new CdpError(`no target with id '${selector}'`);
@@ -486,6 +675,141 @@ async function withPage(selector, fn, opts = {}) {
486
675
  }
487
676
  }
488
677
 
678
+ // src/activity.ts
679
+ var BEACON_DATA_GLOBAL = "__cdpToolkitLastInput";
680
+ var BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon";
681
+ var BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"];
682
+ var BEACON_BODY = `
683
+ if (!window.${BEACON_INSTALLED_GLOBAL}) {
684
+ window.${BEACON_INSTALLED_GLOBAL} = true;
685
+ var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
686
+ var events = ${JSON.stringify([...BEACON_EVENTS])};
687
+ for (var i = 0; i < events.length; i++) {
688
+ window.addEventListener(events[i], mark, { capture: true, passive: true });
689
+ }
690
+ }`;
691
+ var BEACON_SOURCE = `(function () {${BEACON_BODY}
692
+ })();`;
693
+ var BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}
694
+ }`;
695
+ var BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
696
+ var DISPATCH_ATTRIBUTION_WINDOW_MS = 1500;
697
+ function dispatchKey(backend, targetId) {
698
+ return `${backend}:${targetId}`;
699
+ }
700
+ var dispatchLog = new Map;
701
+ var DISPATCH_LOG_LIMIT = 512;
702
+ function recordDispatch(backend, targetId, now = Date.now()) {
703
+ const key = dispatchKey(backend, targetId);
704
+ dispatchLog.delete(key);
705
+ dispatchLog.set(key, now);
706
+ while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
707
+ const oldest = dispatchLog.keys().next();
708
+ if (oldest.done)
709
+ break;
710
+ dispatchLog.delete(oldest.value);
711
+ }
712
+ }
713
+ var NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
714
+ async function sendInput(conn, backend, targetId, method, params = {}) {
715
+ if (!NON_SYNTHESIZING_INPUT_METHODS.has(method))
716
+ recordDispatch(backend, targetId);
717
+ return conn.send(method, params);
718
+ }
719
+ function lastDispatchAt(backend, targetId) {
720
+ return dispatchLog.get(dispatchKey(backend, targetId));
721
+ }
722
+ function isHumanAttributed(beaconTs, dispatchAt, windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
723
+ if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs))
724
+ return false;
725
+ return beaconTs > (dispatchAt ?? 0) + windowMs;
726
+ }
727
+ function humanActiveMs(beaconTs, dispatchAt, now = Date.now(), windowMs = DISPATCH_ATTRIBUTION_WINDOW_MS) {
728
+ if (!isHumanAttributed(beaconTs, dispatchAt, windowMs))
729
+ return null;
730
+ return Math.max(0, now - beaconTs);
731
+ }
732
+ var CONTENTION_WINDOW_MS = 30000;
733
+ function contentionWarning(ms, opts) {
734
+ if (!opts.takeover)
735
+ return;
736
+ if (ms === null || ms >= CONTENTION_WINDOW_MS)
737
+ return;
738
+ 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.`;
739
+ }
740
+ function beaconSupported(driver) {
741
+ return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
742
+ }
743
+ async function installBeacon(driver, targetId) {
744
+ if (typeof driver.installActivityBeacon !== "function")
745
+ return false;
746
+ return driver.installActivityBeacon(targetId).catch(() => false);
747
+ }
748
+ async function readHumanActiveMs(driver, backend, targetId, now = Date.now()) {
749
+ if (typeof driver.readActivityBeacon !== "function")
750
+ return null;
751
+ const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
752
+ return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
753
+ }
754
+ var RENDERER_PROBE_TIMEOUT_MS = 500;
755
+ var RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
756
+ function rendererProbeSupported(driver) {
757
+ return typeof driver.probeRenderer === "function";
758
+ }
759
+ async function probeRendererActivity(driver, backend, targetId, now = Date.now()) {
760
+ if (typeof driver.probeRenderer !== "function")
761
+ return { responsive: false };
762
+ const { responsive, beaconTs } = await driver.probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS).catch(() => ({ responsive: false, beaconTs: null }));
763
+ const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
764
+ return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
765
+ }
766
+
767
+ class BeaconSessions {
768
+ close;
769
+ limit;
770
+ live = new Map;
771
+ constructor(close, limit = 32) {
772
+ this.close = close;
773
+ this.limit = limit;
774
+ }
775
+ get(key) {
776
+ return this.live.get(key);
777
+ }
778
+ set(key, value) {
779
+ const previous = this.live.get(key);
780
+ if (previous !== undefined) {
781
+ this.live.delete(key);
782
+ this.safeClose(previous);
783
+ }
784
+ this.live.set(key, value);
785
+ while (this.live.size > this.limit) {
786
+ const oldest = this.live.keys().next();
787
+ if (oldest.done)
788
+ break;
789
+ this.drop(oldest.value);
790
+ }
791
+ }
792
+ drop(key) {
793
+ const value = this.live.get(key);
794
+ if (value === undefined)
795
+ return;
796
+ this.live.delete(key);
797
+ this.safeClose(value);
798
+ }
799
+ clear() {
800
+ for (const key of [...this.live.keys()])
801
+ this.drop(key);
802
+ }
803
+ get size() {
804
+ return this.live.size;
805
+ }
806
+ safeClose(value) {
807
+ try {
808
+ this.close(value);
809
+ } catch {}
810
+ }
811
+ }
812
+
489
813
  // src/tools/snapshot.ts
490
814
  var INTERACTIVE_ROLES = new Set([
491
815
  "button",
@@ -518,6 +842,91 @@ async function resolveUid(conn, uid) {
518
842
  return { objectId: object.objectId };
519
843
  }
520
844
 
845
+ // src/cdp/workers.ts
846
+ var INVENTORY_FIRST_EVENT_MS = 1500;
847
+ var INVENTORY_SETTLE_MS = 250;
848
+ var WAKE_POLL_MS = 300;
849
+ var WAKE_POLL_ATTEMPTS = 30;
850
+ var NO_HOST_PAGE_MESSAGE = "cannot start a service worker: the ServiceWorker CDP domain is only reachable over a page session, " + "and this browser has no page target open. Open any tab (new_page) and retry — the tab does not have " + "to belong to the extension.";
851
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
852
+ async function requestWorkerStart(needle) {
853
+ const conn = await openBrowser();
854
+ try {
855
+ const host = (await listTargets()).find((t) => t.type === "page");
856
+ if (!host)
857
+ throw new CdpError(NO_HOST_PAGE_MESSAGE);
858
+ const { sessionId } = await conn.send("Target.attachToTarget", {
859
+ targetId: host.id,
860
+ flatten: true
861
+ });
862
+ if (!sessionId)
863
+ throw new CdpError("could not attach a session to host the ServiceWorker domain");
864
+ const scopeById = new Map;
865
+ const versions = [];
866
+ conn.on("ServiceWorker.workerRegistrationUpdated", (params) => {
867
+ for (const r of params.registrations ?? []) {
868
+ if (r.isDeleted === true)
869
+ continue;
870
+ scopeById.set(String(r.registrationId), String(r.scopeURL ?? ""));
871
+ }
872
+ });
873
+ conn.on("ServiceWorker.workerVersionUpdated", (params) => {
874
+ for (const v of params.versions ?? []) {
875
+ versions.push({ registrationId: String(v.registrationId), scriptURL: String(v.scriptURL ?? "") });
876
+ }
877
+ });
878
+ await conn.send("ServiceWorker.enable", {}, { sessionId });
879
+ await conn.waitFor("ServiceWorker.workerVersionUpdated", undefined, INVENTORY_FIRST_EVENT_MS).catch(() => {
880
+ return;
881
+ });
882
+ await sleep(INVENTORY_SETTLE_MS);
883
+ const scopes = [...new Set(scopeById.values())].filter((s) => s.length > 0).sort();
884
+ const matched = new Set;
885
+ for (const v of versions) {
886
+ const scope = scopeById.get(v.registrationId);
887
+ if (scope === undefined)
888
+ continue;
889
+ if (v.scriptURL.includes(needle) || scope.includes(needle))
890
+ matched.add(scope);
891
+ }
892
+ if (matched.size === 0)
893
+ return { startRequested: false, scopes };
894
+ if (matched.size > 1) {
895
+ throw new CdpError(`'worker:${needle}' matches more than one registered service-worker scope: ${[...matched].sort().join(", ")}. ` + "Narrow the substring.");
896
+ }
897
+ await conn.send("ServiceWorker.startWorker", { scopeURL: [...matched][0] }, { sessionId });
898
+ return { startRequested: true, scopes };
899
+ } finally {
900
+ conn.close();
901
+ }
902
+ }
903
+ async function resolveWorkerSelector(selector, opts) {
904
+ const needle = workerNeedle(selector);
905
+ if (needle === "")
906
+ throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
907
+ const first = resolveWorkerTargets(await listTargets(), needle);
908
+ if (first.matches.length > 1)
909
+ throw new CdpError(workerAmbiguityMessage(needle, first.matches));
910
+ if (first.matches.length === 1)
911
+ return first.matches[0];
912
+ if (!opts.wake) {
913
+ throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers: first.liveWorkers }));
914
+ }
915
+ const { startRequested, scopes } = await requestWorkerStart(needle);
916
+ if (startRequested) {
917
+ for (let i = 0;i < WAKE_POLL_ATTEMPTS; i++) {
918
+ await sleep(WAKE_POLL_MS);
919
+ const again = resolveWorkerTargets(await listTargets(), needle);
920
+ if (again.matches.length > 1)
921
+ throw new CdpError(workerAmbiguityMessage(needle, again.matches));
922
+ if (again.matches.length === 1)
923
+ return again.matches[0];
924
+ }
925
+ }
926
+ const final = resolveWorkerTargets(await listTargets(), needle);
927
+ throw new CdpError(workerMissMessage(needle, { wakeAttempted: true, liveWorkers: final.liveWorkers, registeredScopes: scopes }));
928
+ }
929
+
521
930
  // src/tools/network_mock.ts
522
931
  function escapeRegExpChar(ch) {
523
932
  return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch;
@@ -709,11 +1118,22 @@ var REQUIRED_CAPABILITIES = {
709
1118
  stop_screen_recording: ["capture.screencast"],
710
1119
  mock_request: ["network.intercept"],
711
1120
  list_mocks: ["network.intercept"],
712
- clear_mocks: ["network.intercept"]
1121
+ clear_mocks: ["network.intercept"],
1122
+ dispatch_mouse: ["input.raw"],
1123
+ wait_for_download: ["browser.downloads"],
1124
+ grant_permissions: ["browser.permissions"]
713
1125
  };
714
1126
  function isDriverError(e) {
715
1127
  return e instanceof Error && typeof e.code === "string";
716
1128
  }
1129
+ function interpolatePoints(from, to, steps) {
1130
+ const out = [];
1131
+ for (let i = 1;i < steps; i++) {
1132
+ out.push({ x: from.x + (to.x - from.x) * i / steps, y: from.y + (to.y - from.y) * i / steps });
1133
+ }
1134
+ out.push({ x: to.x, y: to.y });
1135
+ return out;
1136
+ }
717
1137
 
718
1138
  // src/cdp/driver.ts
719
1139
  function driverError(code, message, data) {
@@ -769,12 +1189,21 @@ async function centerOf(conn, objectId) {
769
1189
  async function focusElement(conn, objectId) {
770
1190
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: "function(){this.focus&&this.focus();}", returnByValue: true });
771
1191
  }
772
- async function setValueOnObject(conn, objectId, value) {
1192
+ 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);});})()";
1193
+ async function armScrollSettleWatch(conn) {
1194
+ await conn.send("Runtime.evaluate", { expression: ARM_SCROLL_SETTLE_WATCH, returnByValue: true });
1195
+ }
1196
+ async function awaitScrollSettle(conn) {
1197
+ await conn.send("Runtime.evaluate", { expression: "window.__cdpScrollSettle", awaitPromise: true, returnByValue: true }).catch(() => {
1198
+ return;
1199
+ });
1200
+ }
1201
+ async function setValueOnObject(conn, objectId, value, sendInput2) {
773
1202
  await focusElement(conn, objectId);
774
1203
  const fn = "function(){if('value'in this){this.value='';this.dispatchEvent(new Event('input',{bubbles:true}));}else if(this.isContentEditable){this.textContent='';}}";
775
1204
  await conn.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fn, returnByValue: true });
776
1205
  if (value.length)
777
- await conn.send("Input.insertText", { text: value });
1206
+ await sendInput2("Input.insertText", { text: value });
778
1207
  }
779
1208
  var NAMED_KEYS = {
780
1209
  enter: { key: "Enter", code: "Enter", keyCode: 13, text: "\r" },
@@ -799,6 +1228,18 @@ var NAMED_KEYS = {
799
1228
  pagedown: { key: "PageDown", code: "PageDown", keyCode: 34 }
800
1229
  };
801
1230
  var MODIFIER_BITS = { alt: 1, control: 2, ctrl: 2, meta: 4, cmd: 4, command: 4, shift: 8 };
1231
+ var CLICK_MODIFIER_BITS = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
1232
+ function inputModifierBits(mods) {
1233
+ let bits = 0;
1234
+ for (const m of mods ?? []) {
1235
+ const bit = CLICK_MODIFIER_BITS[m];
1236
+ if (bit === undefined)
1237
+ throw driverError("page-error", `unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
1238
+ bits |= bit;
1239
+ }
1240
+ return bits;
1241
+ }
1242
+ var HTML5_DRAG_INTERCEPT_TIMEOUT_MS = 5000;
802
1243
  function resolveKey(key) {
803
1244
  const named = NAMED_KEYS[key.toLowerCase()];
804
1245
  if (named)
@@ -963,7 +1404,12 @@ var CDP_CAPABILITIES = new Set([
963
1404
  "input.insertTextAtomic",
964
1405
  "locate.text",
965
1406
  "locate.xpath",
966
- "capture.screencast"
1407
+ "capture.screencast",
1408
+ "input.raw",
1409
+ "input.html5Drag",
1410
+ "browser.downloads",
1411
+ "browser.permissions",
1412
+ "worker.targets"
967
1413
  ]);
968
1414
 
969
1415
  class CdpPageDriver {
@@ -983,6 +1429,9 @@ class CdpPageDriver {
983
1429
  this.enabledDomains.add(name);
984
1430
  await this.conn.send(`${name}.enable`);
985
1431
  }
1432
+ async dispatchInput(method, params = {}) {
1433
+ return sendInput(this.conn, "chrome", this.info.id, method, params);
1434
+ }
986
1435
  async ensureDomainSoft(name) {
987
1436
  if (this.enabledDomains.has(name))
988
1437
  return;
@@ -991,11 +1440,22 @@ class CdpPageDriver {
991
1440
  return;
992
1441
  });
993
1442
  }
1443
+ async historyEntry(direction) {
1444
+ const h = await this.conn.send("Page.getNavigationHistory");
1445
+ const entries = h.entries ?? [];
1446
+ const entry = entries[h.currentIndex + (direction === "back" ? -1 : 1)];
1447
+ if (!entry) {
1448
+ throw driverError("page-error", `navigate: no history entry to go ${direction} to (at entry ${h.currentIndex + 1} of ${entries.length})`);
1449
+ }
1450
+ return entry;
1451
+ }
994
1452
  async navigate(opts) {
995
1453
  await this.ensureDomain("Page");
996
1454
  const reload = opts.reload === true;
997
- if (!reload && !opts.url)
1455
+ const history = opts.history;
1456
+ if (!reload && !history && !opts.url)
998
1457
  throw driverError("page-error", "navigate: 'url' is required unless reload:true");
1458
+ const entry = history ? await this.historyEntry(history) : undefined;
999
1459
  const waitUntil = opts.waitUntil ?? "load";
1000
1460
  const milestoneMethod = waitUntil === "load" ? "Page.loadEventFired" : "Page.domContentEventFired";
1001
1461
  const milestone = this.conn.waitFor(milestoneMethod, undefined, opts.timeoutMs).then(() => waitUntil).catch(() => {
@@ -1013,6 +1473,10 @@ class CdpPageDriver {
1013
1473
  contextId = tree?.frameTree?.frame?.id ?? "";
1014
1474
  url = tree?.frameTree?.frame?.url ?? opts.url ?? "";
1015
1475
  reloaded = true;
1476
+ } else if (entry) {
1477
+ await this.conn.send("Page.navigateToHistoryEntry", { entryId: entry.id }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
1478
+ contextId = "";
1479
+ url = entry.url;
1016
1480
  } else {
1017
1481
  const nav = await this.conn.send("Page.navigate", { url: opts.url }, opts.timeoutMs ? { timeoutMs: opts.timeoutMs } : {});
1018
1482
  if (nav.errorText)
@@ -1021,7 +1485,20 @@ class CdpPageDriver {
1021
1485
  url = opts.url;
1022
1486
  }
1023
1487
  const settled = await Promise.race([milestone, stopped]);
1024
- return { url, contextId, ...reloaded ? { reloaded } : {}, waitedFor: settled ?? "timeout" };
1488
+ if (entry) {
1489
+ const tree = await this.conn.send("Page.getFrameTree").catch(() => {
1490
+ return;
1491
+ });
1492
+ contextId = tree?.frameTree?.frame?.id ?? contextId;
1493
+ url = tree?.frameTree?.frame?.url ?? url;
1494
+ }
1495
+ return {
1496
+ url,
1497
+ contextId,
1498
+ ...reloaded ? { reloaded } : {},
1499
+ ...history ? { traversed: history } : {},
1500
+ waitedFor: settled ?? "timeout"
1501
+ };
1025
1502
  }
1026
1503
  async waitForText(text, timeoutMs = 15000, pollMs = 250) {
1027
1504
  await this.ensureDomain("Runtime");
@@ -1101,36 +1578,96 @@ class CdpPageDriver {
1101
1578
  async hover(loc) {
1102
1579
  const { objectId } = await resolveElementLocator(this.conn, loc);
1103
1580
  const { x, y } = await centerOf(this.conn, objectId);
1104
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1581
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x, y });
1105
1582
  return { x, y };
1106
1583
  }
1107
1584
  async click(loc, opts) {
1108
1585
  const button = opts?.button ?? "left";
1109
1586
  const clickCount = opts?.clickCount ?? 1;
1587
+ const modifiers = inputModifierBits(opts?.modifiers);
1110
1588
  const { x, y } = await this.hover(loc);
1111
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount });
1112
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount });
1589
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x, y, button, clickCount, modifiers });
1590
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x, y, button, clickCount, modifiers });
1113
1591
  return { x, y };
1114
1592
  }
1115
- async drag(from, to) {
1593
+ async scroll(anchor, opts) {
1594
+ await this.ensureDomain("Runtime");
1595
+ const { x, y } = await this.resolveScrollPoint(anchor);
1596
+ await armScrollSettleWatch(this.conn);
1597
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseWheel", x, y, deltaX: opts.deltaX ?? 0, deltaY: opts.deltaY ?? 0 });
1598
+ await awaitScrollSettle(this.conn);
1599
+ return { x, y };
1600
+ }
1601
+ async resolveScrollPoint(anchor) {
1602
+ if (anchor === undefined) {
1603
+ await this.ensureDomain("Runtime");
1604
+ const { result, exceptionDetails } = await this.conn.send("Runtime.evaluate", { expression: "({x: window.innerWidth / 2, y: window.innerHeight / 2})", returnByValue: true });
1605
+ if (exceptionDetails || !result.value)
1606
+ throw driverError("page-error", `could not measure viewport for scroll center: ${exceptionDetails?.text ?? "no value"}`);
1607
+ return result.value;
1608
+ }
1609
+ if ("x" in anchor)
1610
+ return anchor;
1611
+ const { objectId } = await resolveElementLocator(this.conn, anchor);
1612
+ return centerOf(this.conn, objectId);
1613
+ }
1614
+ async drag(from, to, opts) {
1116
1615
  const fromPt = await this.hover(from);
1117
- const { objectId: dstId } = await resolveElementLocator(this.conn, to);
1118
- const toPt = await centerOf(this.conn, dstId);
1119
- await this.conn.send("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1120
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, button: "left" });
1121
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: toPt.x, y: toPt.y, button: "left" });
1122
- await this.conn.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1616
+ const toPt = await this.resolveDragDestination(to, fromPt);
1617
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
1618
+ if ((opts?.mode ?? "mouse") === "html5") {
1619
+ await this.html5Drag(fromPt, toPt, steps);
1620
+ return { from: fromPt, to: toPt };
1621
+ }
1622
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1623
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1624
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1625
+ }
1626
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1123
1627
  return { from: fromPt, to: toPt };
1124
1628
  }
1629
+ async resolveDragDestination(to, fromPt) {
1630
+ if ("dx" in to)
1631
+ return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
1632
+ if ("x" in to)
1633
+ return to;
1634
+ const { objectId } = await resolveElementLocator(this.conn, to);
1635
+ return centerOf(this.conn, objectId);
1636
+ }
1637
+ async html5Drag(fromPt, toPt, steps) {
1638
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: true });
1639
+ try {
1640
+ const intercepted = this.conn.waitFor("Input.dragIntercepted", undefined, HTML5_DRAG_INTERCEPT_TIMEOUT_MS).catch(() => {
1641
+ return;
1642
+ });
1643
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mousePressed", x: fromPt.x, y: fromPt.y, button: "left", clickCount: 1 });
1644
+ for (const pt of interpolatePoints(fromPt, toPt, steps)) {
1645
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseMoved", x: pt.x, y: pt.y, button: "left" });
1646
+ }
1647
+ const event = await intercepted;
1648
+ if (!event?.data) {
1649
+ 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.');
1650
+ }
1651
+ const data = event.data;
1652
+ for (const type of ["dragEnter", "dragOver", "drop"]) {
1653
+ await this.dispatchInput("Input.dispatchDragEvent", { type, x: toPt.x, y: toPt.y, data });
1654
+ }
1655
+ await this.dispatchInput("Input.dispatchMouseEvent", { type: "mouseReleased", x: toPt.x, y: toPt.y, button: "left", clickCount: 1 });
1656
+ } finally {
1657
+ await this.dispatchInput("Input.setInterceptDrags", { enabled: false }).catch(() => {
1658
+ return;
1659
+ });
1660
+ }
1661
+ }
1125
1662
  async setValue(loc, value) {
1126
1663
  const { objectId } = await resolveElementLocator(this.conn, loc);
1127
- await setValueOnObject(this.conn, objectId, value);
1664
+ await setValueOnObject(this.conn, objectId, value, (method, params) => this.dispatchInput(method, params));
1128
1665
  }
1129
1666
  async typeText(loc, text) {
1130
1667
  const { objectId } = await resolveElementLocator(this.conn, loc);
1131
1668
  await focusElement(this.conn, objectId);
1132
1669
  if (text.length)
1133
- await this.conn.send("Input.insertText", { text });
1670
+ await this.dispatchInput("Input.insertText", { text });
1134
1671
  }
1135
1672
  async pressKey(press) {
1136
1673
  const spec = resolveKey(press.key);
@@ -1145,8 +1682,8 @@ class CdpPageDriver {
1145
1682
  const down = { type: "keyDown", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits };
1146
1683
  if (spec.text && !suppressText)
1147
1684
  down.text = spec.text;
1148
- await this.conn.send("Input.dispatchKeyEvent", down);
1149
- await this.conn.send("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1685
+ await this.dispatchInput("Input.dispatchKeyEvent", down);
1686
+ await this.dispatchInput("Input.dispatchKeyEvent", { type: "keyUp", key: spec.key, code: spec.code, windowsVirtualKeyCode: spec.keyCode, nativeVirtualKeyCode: spec.keyCode, modifiers: modBits });
1150
1687
  }
1151
1688
  async setFiles(loc, files) {
1152
1689
  const { objectId } = await resolveElementLocator(this.conn, loc);
@@ -1362,6 +1899,36 @@ class CdpPageDriver {
1362
1899
  this.conn.close();
1363
1900
  }
1364
1901
  }
1902
+ var beaconSessions = new BeaconSessions((conn) => conn.close());
1903
+ async function targetWsUrl(targetId) {
1904
+ const hit = (await listTargets()).find((t) => t.id === targetId);
1905
+ return hit?.webSocketDebuggerUrl;
1906
+ }
1907
+ async function readBeaconOver(conn) {
1908
+ try {
1909
+ const res = await conn.send("Runtime.evaluate", {
1910
+ expression: BEACON_READ_EXPRESSION,
1911
+ returnByValue: true
1912
+ });
1913
+ const value = res.result?.value;
1914
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
1915
+ } catch {
1916
+ return;
1917
+ }
1918
+ }
1919
+ async function probeOver(conn, timeoutMs) {
1920
+ try {
1921
+ const res = await conn.send("Runtime.evaluate", { expression: RENDERER_PROBE_EXPRESSION, returnByValue: true }, { timeoutMs });
1922
+ const value = res.result?.value;
1923
+ if (Array.isArray(value) && value[0] === 1) {
1924
+ const ts = value[1];
1925
+ return { responsive: true, beaconTs: typeof ts === "number" && Number.isFinite(ts) ? ts : null };
1926
+ }
1927
+ return { responsive: false, beaconTs: null };
1928
+ } catch {
1929
+ return { responsive: false, beaconTs: null };
1930
+ }
1931
+ }
1365
1932
  async function withBrowserConn(fn) {
1366
1933
  const conn = await openBrowser();
1367
1934
  try {
@@ -1400,6 +1967,8 @@ class CdpBrowserDriver {
1400
1967
  async closePage(id) {
1401
1968
  return withBrowserConn(async (conn) => {
1402
1969
  const res = await conn.send("Target.closeTarget", { targetId: id });
1970
+ if (res.success !== false)
1971
+ beaconSessions.drop(id);
1403
1972
  return { success: res.success ?? true };
1404
1973
  });
1405
1974
  }
@@ -1408,119 +1977,94 @@ class CdpBrowserDriver {
1408
1977
  await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
1409
1978
  return { id: target.id, url: target.url, title: target.title };
1410
1979
  }
1980
+ async resolveWorkerTarget(selector, opts) {
1981
+ const target = await resolveWorkerSelector(selector, opts).catch(noSuchTarget);
1982
+ return { id: target.id, url: target.url, title: target.title, type: target.type };
1983
+ }
1411
1984
  async page(selector) {
1412
1985
  const { conn, target } = await openPage(selector).catch(noSuchTarget);
1413
1986
  return new CdpPageDriver(conn, target, this);
1414
1987
  }
1415
- async dispose() {}
1416
- }
1417
- function createCdpDriver() {
1418
- return new CdpBrowserDriver;
1419
- }
1420
-
1421
- // src/origins.ts
1422
- import { mkdir as mkdir2, readFile as readFile2, readdir as readdir2, unlink as unlink2, writeFile as writeFile2 } from "node:fs/promises";
1423
- import { join as join2 } from "node:path";
1424
- function safeId2(id) {
1425
- return id.replace(/[^A-Za-z0-9._-]/g, "_");
1426
- }
1427
- function originFile(backend, targetId) {
1428
- return join2(leaseDir(), `origin-${backend}-${safeId2(targetId)}.json`);
1429
- }
1430
- var ORIGIN_FILE_RE = /^origin-(chrome|firefox)-(.+)\.json$/;
1431
- async function recordOrigin(backend, targetId, opts = {}) {
1432
- const record = {
1433
- backend,
1434
- targetId,
1435
- label: typeof opts.label === "string" && opts.label.length ? opts.label : defaultLabel(),
1436
- pid: process.pid,
1437
- createdAt: opts.now ?? Date.now()
1438
- };
1439
- const file = originFile(backend, targetId);
1440
- try {
1441
- await mkdir2(leaseDir(), { recursive: true });
1442
- try {
1443
- await writeFile2(file, JSON.stringify(record), { flag: "wx" });
1444
- return;
1445
- } catch (err) {
1446
- if (err.code !== "EEXIST")
1447
- throw err;
1988
+ async installActivityBeacon(targetId) {
1989
+ const held = beaconSessions.get(targetId);
1990
+ if (held) {
1991
+ const rearmed = await held.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true }).then(() => true).catch(() => false);
1992
+ if (rearmed)
1993
+ return true;
1994
+ beaconSessions.drop(targetId);
1448
1995
  }
1449
- await unlink2(file).catch(() => {
1996
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
1450
1997
  return;
1451
1998
  });
1452
- await writeFile2(file, JSON.stringify(record), { flag: "wx" });
1453
- } catch {}
1454
- }
1455
- async function readOrigin(backend, targetId) {
1456
- let raw;
1457
- try {
1458
- raw = await readFile2(originFile(backend, targetId), "utf8");
1459
- } catch {
1460
- return;
1461
- }
1462
- try {
1463
- const rec = JSON.parse(raw);
1464
- if (typeof rec?.targetId === "string" && typeof rec?.label === "string" && typeof rec?.createdAt === "number")
1465
- return rec;
1466
- } catch {}
1467
- return;
1468
- }
1469
- async function listOrigins(opts = {}) {
1470
- let names = [];
1471
- try {
1472
- names = await readdir2(leaseDir());
1473
- } catch {
1474
- return [];
1999
+ if (!wsUrl)
2000
+ return false;
2001
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
2002
+ return;
2003
+ });
2004
+ if (!conn)
2005
+ return false;
2006
+ try {
2007
+ await conn.send("Page.enable");
2008
+ await conn.send("Page.addScriptToEvaluateOnNewDocument", { source: BEACON_SOURCE });
2009
+ await conn.send("Runtime.evaluate", { expression: BEACON_SOURCE, returnByValue: true });
2010
+ } catch {
2011
+ conn.close();
2012
+ return false;
2013
+ }
2014
+ beaconSessions.set(targetId, conn);
2015
+ return true;
1475
2016
  }
1476
- const live = opts.liveIds === undefined ? undefined : new Set(opts.liveIds.map((id) => safeId2(id)));
1477
- const out = [];
1478
- for (const name of names) {
1479
- const parts = ORIGIN_FILE_RE.exec(name);
1480
- if (!parts)
1481
- continue;
1482
- const backend = parts[1];
1483
- const nameId = parts[2];
1484
- const scoped = live !== undefined && (opts.liveBackend === undefined || opts.liveBackend === backend);
1485
- if (scoped && !live.has(nameId)) {
1486
- await unlink2(join2(leaseDir(), name)).catch(() => {
1487
- return;
1488
- });
1489
- continue;
2017
+ async readActivityBeacon(targetId) {
2018
+ const held = beaconSessions.get(targetId);
2019
+ if (held) {
2020
+ const value = await readBeaconOver(held);
2021
+ if (value !== undefined)
2022
+ return value;
2023
+ beaconSessions.drop(targetId);
1490
2024
  }
1491
- const fromName = { backend, targetId: nameId, label: "", pid: 0, createdAt: 0 };
1492
- let rec;
2025
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
2026
+ return;
2027
+ });
2028
+ if (!wsUrl)
2029
+ return null;
2030
+ const conn = await new CdpConnection(wsUrl).connect().catch(() => {
2031
+ return;
2032
+ });
2033
+ if (!conn)
2034
+ return null;
1493
2035
  try {
1494
- rec = JSON.parse(await readFile2(join2(leaseDir(), name), "utf8"));
1495
- } catch (err) {
1496
- const code = err.code;
1497
- if (code === "ENOENT")
1498
- continue;
1499
- out.push({ ...fromName, unreadable: code ?? "unparseable" });
1500
- continue;
2036
+ return await readBeaconOver(conn) ?? null;
2037
+ } finally {
2038
+ conn.close();
1501
2039
  }
1502
- if (typeof rec?.targetId !== "string" || typeof rec?.label !== "string" || typeof rec?.createdAt !== "number") {
1503
- out.push({ ...fromName, unreadable: "unparseable" });
1504
- continue;
2040
+ }
2041
+ async probeRenderer(targetId, timeoutMs = RENDERER_PROBE_TIMEOUT_MS) {
2042
+ const held = beaconSessions.get(targetId);
2043
+ if (held)
2044
+ return probeOver(held, timeoutMs);
2045
+ const wsUrl = await targetWsUrl(targetId).catch(() => {
2046
+ return;
2047
+ });
2048
+ if (!wsUrl)
2049
+ return { responsive: false, beaconTs: null };
2050
+ const conn = new CdpConnection(wsUrl, { timeoutMs });
2051
+ try {
2052
+ await conn.connect();
2053
+ } catch {
2054
+ return { responsive: false, beaconTs: null };
2055
+ }
2056
+ try {
2057
+ return await probeOver(conn, timeoutMs);
2058
+ } finally {
2059
+ conn.close();
1505
2060
  }
1506
- out.push({ backend: rec.backend, targetId: rec.targetId, label: rec.label, pid: rec.pid, createdAt: rec.createdAt });
1507
2061
  }
1508
- return out;
1509
- }
1510
- async function originIndex(backend, liveIds) {
1511
- const rows = await listOrigins({ liveIds, liveBackend: backend }).catch(() => []);
1512
- const index = new Map;
1513
- for (const row of rows) {
1514
- if (row.backend !== backend)
1515
- continue;
1516
- index.set(row.targetId, row);
2062
+ async dispose() {
2063
+ beaconSessions.clear();
1517
2064
  }
1518
- return index;
1519
2065
  }
1520
- async function newTrackedPage(driver, backend, opts = {}) {
1521
- const page = await driver.newPage(opts.url);
1522
- await recordOrigin(backend, page.id, { label: opts.label });
1523
- return page;
2066
+ function createCdpDriver() {
2067
+ return new CdpBrowserDriver;
1524
2068
  }
1525
2069
 
1526
2070
  // src/reap.ts
@@ -1534,6 +2078,8 @@ function staleAgentTabs(input) {
1534
2078
  continue;
1535
2079
  if (row.stale !== "dead-pid" && row.stale !== "expired")
1536
2080
  continue;
2081
+ if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs)
2082
+ continue;
1537
2083
  if (!live.has(row.targetId))
1538
2084
  continue;
1539
2085
  const origin = input.origins.get(row.targetId);
@@ -1543,13 +2089,19 @@ function staleAgentTabs(input) {
1543
2089
  }
1544
2090
  return out;
1545
2091
  }
2092
+ var DEFAULT_REAP_GRACE_MS = 2700000;
2093
+ function reapGraceMs() {
2094
+ const raw = Number(process.env.CDP_REAP_GRACE_MS);
2095
+ return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
2096
+ }
1546
2097
  async function reapStaleAgentTabs(driver, backend) {
2098
+ const now = Date.now();
1547
2099
  const livePageIds = (await driver.listPages()).map((p) => p.id);
1548
2100
  const [origins, leases] = await Promise.all([
1549
2101
  originIndex(backend, undefined),
1550
- listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [])
2102
+ listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [])
1551
2103
  ]);
1552
- const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
2104
+ const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
1553
2105
  const closed = [];
1554
2106
  for (const c of candidates) {
1555
2107
  const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
@@ -1615,6 +2167,11 @@ async function pickPage(driver, pages, selector) {
1615
2167
  throw new SharedToolError(`no page title containing '${needle}'`);
1616
2168
  return p;
1617
2169
  }
2170
+ if (selector.startsWith("label:"))
2171
+ return pickByLabel(driver, pages, selector.slice(6));
2172
+ if (isWorkerSelector(selector)) {
2173
+ throw new SharedToolError(backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE);
2174
+ }
1618
2175
  const exactPage = pages.find((x) => x.id === selector);
1619
2176
  if (exactPage)
1620
2177
  return exactPage;
@@ -1624,6 +2181,18 @@ async function pickPage(driver, pages, selector) {
1624
2181
  return exact;
1625
2182
  throw new SharedToolError(`no target with id '${selector}'`);
1626
2183
  }
2184
+ async function pickByLabel(driver, pages, label) {
2185
+ const { matchIds, knownLabels } = await resolveLiveLabel(backendOf(driver), label, pages.map((x) => x.id));
2186
+ if (matchIds.length > 1) {
2187
+ throw new SharedToolError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
2188
+ }
2189
+ if (matchIds.length === 1) {
2190
+ const p = pages.find((x) => x.id === matchIds[0]);
2191
+ if (p)
2192
+ return p;
2193
+ }
2194
+ throw new SharedToolError(knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
2195
+ }
1627
2196
  function locatorOf(args) {
1628
2197
  const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
1629
2198
  const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
@@ -1641,18 +2210,29 @@ async function reapSet(driver, pages, all) {
1641
2210
  }
1642
2211
  }
1643
2212
  async function listPages(driver, args = {}) {
1644
- const reaped = requireLease() ? await reapStaleAgentTabs(driver, backendOf(driver)) : [];
2213
+ const backend = backendOf(driver);
2214
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
1645
2215
  const reapedIds = new Set(reaped.map((r) => r.targetId));
1646
2216
  const pages = (await driver.listPages({ all: args.all })).filter((p) => !reapedIds.has(p.id));
1647
- const ledger = await originIndex(backendOf(driver), await reapSet(driver, pages, args.all));
1648
- const annotated = pages.map((p) => {
2217
+ const ledger = await originIndex(backend, await reapSet(driver, pages, args.all));
2218
+ const now = Date.now();
2219
+ 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]));
2220
+ const doProbe = args.probe === true && rendererProbeSupported(driver);
2221
+ const annotated = await Promise.all(pages.map(async (p) => {
1649
2222
  const rec = ledger.get(p.id);
1650
- if (!rec)
1651
- return { ...p, origin: "unknown" };
1652
- if (rec.unreadable !== undefined)
1653
- return { ...p, origin: "unknown", originUnreadable: rec.unreadable };
1654
- return { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
1655
- });
2223
+ const base = !rec ? { ...p, origin: "unknown" } : rec.unreadable !== undefined ? { ...p, origin: "unknown", originUnreadable: rec.unreadable } : { ...p, origin: "agent", label: rec.label, createdAt: rec.createdAt };
2224
+ const leaseRow = leaseByTarget.get(p.id);
2225
+ if (leaseRow) {
2226
+ base.lease = { label: leaseRow.label, pid: leaseRow.pid, idleMs: now - leaseRow.lastUsedAt, expiresAt: leaseRow.lastUsedAt + leaseRow.ttlMs, stale: leaseRow.stale };
2227
+ }
2228
+ if (doProbe && (p.type === undefined || p.type === "page")) {
2229
+ const probe = await probeRendererActivity(driver, backend, p.id, now);
2230
+ base.responsive = probe.responsive;
2231
+ if (probe.humanActiveMs !== undefined)
2232
+ base.humanActiveMs = probe.humanActiveMs;
2233
+ }
2234
+ return base;
2235
+ }));
1656
2236
  return { pages: annotated, count: annotated.length, ...reaped.length ? { reaped } : {} };
1657
2237
  }
1658
2238
  async function newPage(driver, args = {}) {
@@ -1666,6 +2246,7 @@ async function newPage(driver, args = {}) {
1666
2246
  ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
1667
2247
  auto: !explicit
1668
2248
  });
2249
+ await installBeacon(driver, p.id);
1669
2250
  return { targetId: p.id, url: p.url, lease: token, label: record.label, expiresAt: record.lastUsedAt + record.ttlMs };
1670
2251
  }
1671
2252
  async function closePage(driver, args) {
@@ -1697,17 +2278,36 @@ function toLegacyWaitedFor(w) {
1697
2278
  return "navigate-only";
1698
2279
  return w;
1699
2280
  }
1700
- async function navigatePage(driver, args) {
2281
+ function resolveNavigateMode(args) {
1701
2282
  const reload = args.reload === true;
1702
- if (!reload && (!args.url || typeof args.url !== "string")) {
1703
- throw new SharedToolError("navigate_page: 'url' is required (or pass reload:true to reload the current page)");
2283
+ const hasUrl = typeof args.url === "string" && args.url.length > 0;
2284
+ const hasHistory = args.history !== undefined && args.history !== null;
2285
+ const given = [hasUrl ? "url" : "", reload ? "reload" : "", hasHistory ? "history" : ""].filter(Boolean);
2286
+ if (given.length > 1) {
2287
+ throw new SharedToolError(`navigate_page: 'url', 'reload' and 'history' are mutually exclusive (got ${given.join(" + ")})`);
1704
2288
  }
2289
+ if (hasHistory) {
2290
+ if (args.history !== "back" && args.history !== "forward") {
2291
+ throw new SharedToolError(`navigate_page: 'history' must be 'back' or 'forward' (got ${JSON.stringify(args.history)})`);
2292
+ }
2293
+ return { mode: "history", history: args.history };
2294
+ }
2295
+ if (reload)
2296
+ return { mode: "reload" };
2297
+ if (!hasUrl) {
2298
+ 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)");
2299
+ }
2300
+ return { mode: "url", url: args.url };
2301
+ }
2302
+ async function navigatePage(driver, args) {
2303
+ const mode = resolveNavigateMode(args);
1705
2304
  return withPage2(driver, args.target, async (page) => {
1706
- const r = await page.navigate(args);
2305
+ const r = await page.navigate(mode.mode === "history" ? { ...args, history: mode.history } : args);
1707
2306
  return {
1708
2307
  url: r.url,
1709
2308
  frameId: r.contextId,
1710
2309
  ...r.reloaded ? { reloaded: r.reloaded } : {},
2310
+ ...r.traversed ? { traversed: r.traversed } : {},
1711
2311
  waitedFor: toLegacyWaitedFor(r.waitedFor)
1712
2312
  };
1713
2313
  });
@@ -1734,11 +2334,29 @@ async function writeJsonSink(savePath, value) {
1734
2334
  function sinkRequested(savePath) {
1735
2335
  return savePath !== undefined && savePath !== "";
1736
2336
  }
2337
+ var EXPRESSION_ALIAS_KEYS = ["function", "code", "js", "script", "fn", "body"];
2338
+ async function resolveWorkerSelectorFor(driver, selector, wake) {
2339
+ if (!driver.capabilities.has("worker.targets") || typeof driver.resolveWorkerTarget !== "function") {
2340
+ throw new SharedToolError(`evaluateScript: ${WORKER_SELECTOR_UNSUPPORTED_MESSAGE}`);
2341
+ }
2342
+ return driver.resolveWorkerTarget(selector, { wake });
2343
+ }
1737
2344
  async function evaluateScript(driver, args) {
1738
2345
  if (typeof args.expression !== "string" || args.expression.length === 0) {
2346
+ const wrongKey = EXPRESSION_ALIAS_KEYS.find((k) => args[k] !== undefined);
2347
+ if (wrongKey !== undefined) {
2348
+ throw new SharedToolError(`evaluateScript: you passed '${wrongKey}'; the key is 'expression' — function literals go in 'expression', invoked with 'args'.`);
2349
+ }
1739
2350
  throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
1740
2351
  }
1741
- return withPage2(driver, args.target, async (page) => {
2352
+ if (args.wake !== undefined && typeof args.wake !== "boolean") {
2353
+ throw new SharedToolError("evaluateScript: 'wake' must be a boolean");
2354
+ }
2355
+ if (args.wake !== undefined && !isWorkerSelector(args.target)) {
2356
+ throw new SharedToolError("evaluateScript: 'wake' only applies to a target of the form 'worker:<substring>'; a page target is never asleep.");
2357
+ }
2358
+ const target = isWorkerSelector(args.target) ? (await resolveWorkerSelectorFor(driver, args.target, args.wake ?? true)).id : args.target;
2359
+ return withPage2(driver, target, async (page) => {
1742
2360
  const value = await page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true });
1743
2361
  if (!sinkRequested(args.savePath))
1744
2362
  return value;
@@ -1853,22 +2471,113 @@ async function takeSnapshot(driver, args = {}) {
1853
2471
  }
1854
2472
  async function click(driver, args) {
1855
2473
  return withPage2(driver, args.target, async (page) => {
1856
- const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1 });
2474
+ const { x, y } = await page.click(locatorOf(args), { button: args.button ?? "left", clickCount: args.clickCount ?? 1, modifiers: args.modifiers });
1857
2475
  return { clicked: true, x, y };
1858
2476
  });
1859
2477
  }
2478
+ function resolveScrollAnchor(args) {
2479
+ const hasUid = args.uid !== undefined && args.uid !== null && args.uid !== "";
2480
+ const hasSelector = typeof args.selector === "string" && args.selector.length > 0;
2481
+ const hasX = args.x !== undefined;
2482
+ const hasY = args.y !== undefined;
2483
+ if (hasX !== hasY)
2484
+ throw new SharedToolError("scroll: 'x' and 'y' must be provided together");
2485
+ const hasXY = hasX && hasY;
2486
+ const anchorCount = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2487
+ if (anchorCount > 1)
2488
+ throw new SharedToolError("scroll: provide at most one of { uid }, { selector }, or { x, y } (omit all three to scroll at the viewport center)");
2489
+ if (hasUid || hasSelector)
2490
+ return locatorOf({ uid: args.uid, selector: args.selector });
2491
+ if (hasXY)
2492
+ return { x: args.x, y: args.y };
2493
+ return;
2494
+ }
2495
+ function resolveScrollDelta(args) {
2496
+ const deltaX = args.deltaX ?? 0;
2497
+ const deltaY = args.deltaY ?? 0;
2498
+ if (deltaX === 0 && deltaY === 0)
2499
+ throw new SharedToolError("scroll requires at least one of { deltaX } or { deltaY }");
2500
+ return { deltaX, deltaY };
2501
+ }
2502
+ async function scroll(driver, args) {
2503
+ const anchor = resolveScrollAnchor(args);
2504
+ const { deltaX, deltaY } = resolveScrollDelta(args);
2505
+ return withPage2(driver, args.target, async (page) => {
2506
+ const { x, y } = await page.scroll(anchor, { deltaX, deltaY });
2507
+ return { x, y, deltaX, deltaY, target: target3(page.info) };
2508
+ });
2509
+ }
1860
2510
  async function hover(driver, args) {
1861
2511
  return withPage2(driver, args.target, async (page) => {
1862
2512
  const { x, y } = await page.hover(locatorOf(args));
1863
2513
  return { hovered: true, x, y };
1864
2514
  });
1865
2515
  }
2516
+ function resolveDragTo(to) {
2517
+ const hasUid = to.uid !== undefined && to.uid !== null && to.uid !== "";
2518
+ const hasSelector = typeof to.selector === "string" && to.selector.length > 0;
2519
+ const hasX = to.x !== undefined;
2520
+ const hasY = to.y !== undefined;
2521
+ if (hasX !== hasY)
2522
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be provided together");
2523
+ const hasXY = hasX && hasY;
2524
+ const count = [hasUid, hasSelector, hasXY].filter(Boolean).length;
2525
+ if (count !== 1)
2526
+ throw new SharedToolError("drag: 'to' takes exactly one of { uid }, { selector }, or { x, y }");
2527
+ if (hasXY) {
2528
+ if (!Number.isFinite(to.x) || !Number.isFinite(to.y))
2529
+ throw new SharedToolError("drag: 'to.x' and 'to.y' must be finite numbers");
2530
+ return { x: to.x, y: to.y };
2531
+ }
2532
+ return locatorOf({ uid: to.uid, selector: to.selector });
2533
+ }
2534
+ function resolveDragDestination(args) {
2535
+ const hasTo = args.to !== undefined && args.to !== null;
2536
+ const hasBy = args.by !== undefined && args.by !== null;
2537
+ if (hasTo === hasBy)
2538
+ throw new SharedToolError("drag requires exactly one of { to } or { by }");
2539
+ if (hasBy) {
2540
+ const by = args.by;
2541
+ const hasDx = by.dx !== undefined;
2542
+ const hasDy = by.dy !== undefined;
2543
+ if (!hasDx && !hasDy)
2544
+ throw new SharedToolError("drag: 'by' requires at least one of { dx } or { dy }");
2545
+ const dx = by.dx ?? 0;
2546
+ const dy = by.dy ?? 0;
2547
+ if (!Number.isFinite(dx) || !Number.isFinite(dy))
2548
+ throw new SharedToolError("drag: 'by.dx' and 'by.dy' must be finite numbers");
2549
+ return { dx, dy };
2550
+ }
2551
+ return resolveDragTo(args.to);
2552
+ }
2553
+ var MAX_DRAG_STEPS = 500;
2554
+ function resolveDragSteps(steps) {
2555
+ if (steps === undefined)
2556
+ return 2;
2557
+ if (typeof steps !== "number" || !Number.isInteger(steps) || steps < 1 || steps > MAX_DRAG_STEPS) {
2558
+ throw new SharedToolError(`drag: 'steps' must be an integer between 1 and ${MAX_DRAG_STEPS} (default 2)`);
2559
+ }
2560
+ return steps;
2561
+ }
2562
+ function resolveDragMode(mode, capabilities) {
2563
+ if (mode === undefined || mode === "mouse")
2564
+ return "mouse";
2565
+ if (mode !== "html5")
2566
+ throw new SharedToolError(`drag: unknown mode '${mode}' (use 'mouse' or 'html5')`);
2567
+ if (!capabilities.has("input.html5Drag")) {
2568
+ 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.");
2569
+ }
2570
+ return "html5";
2571
+ }
1866
2572
  async function drag(driver, args) {
1867
- if (!args.from || !args.to)
1868
- throw new SharedToolError("drag requires { from } and { to }");
2573
+ if (!args.from)
2574
+ throw new SharedToolError("drag requires { from }");
2575
+ const destination = resolveDragDestination(args);
2576
+ const steps = resolveDragSteps(args.steps);
2577
+ const mode = resolveDragMode(args.mode, driver.capabilities);
1869
2578
  return withPage2(driver, args.target, async (page) => {
1870
- const { from, to } = await page.drag(locatorOf(args.from), locatorOf(args.to));
1871
- return { dragged: true, from, to };
2579
+ const { from, to } = await page.drag(locatorOf(args.from), destination, { mode, steps });
2580
+ return { dragged: true, mode, steps, from, to };
1872
2581
  });
1873
2582
  }
1874
2583
  async function fill(driver, args) {
@@ -1989,6 +2698,7 @@ var SHARED_TOOLS = {
1989
2698
  click,
1990
2699
  hover,
1991
2700
  drag,
2701
+ scroll,
1992
2702
  fill,
1993
2703
  fill_form: fillForm,
1994
2704
  type_text: typeText,
@@ -2010,7 +2720,7 @@ async function claimPage(driver, args = {}) {
2010
2720
  const hasTarget = typeof args.target === "string" && args.target.length > 0;
2011
2721
  const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
2012
2722
  if (hasTarget && hasTargetId) {
2013
- 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.");
2723
+ throw new LeaseToolError("claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | label:<name> | <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.");
2014
2724
  }
2015
2725
  const backend = backendOf2(driver);
2016
2726
  const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
@@ -2038,12 +2748,15 @@ async function claimPage(driver, args = {}) {
2038
2748
  url = page.url;
2039
2749
  opened = true;
2040
2750
  }
2751
+ const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
2041
2752
  const liveIds = [...pages.map((p) => p.id), targetId];
2042
2753
  const { record, token } = await claimLease(backend, targetId, {
2043
2754
  label,
2044
2755
  ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
2045
2756
  liveIds
2046
2757
  });
2758
+ await installBeacon(driver, targetId);
2759
+ const contention = contentionWarning(humanMs, { takeover: !opened });
2047
2760
  return {
2048
2761
  lease: token,
2049
2762
  targetId,
@@ -2051,7 +2764,9 @@ async function claimPage(driver, args = {}) {
2051
2764
  label: record.label,
2052
2765
  ttlMs: record.ttlMs,
2053
2766
  expiresAt: record.lastUsedAt + record.ttlMs,
2054
- opened
2767
+ opened,
2768
+ ...beaconSupported(driver) ? { humanActiveMs: humanMs } : {},
2769
+ ...contention !== undefined ? { contention } : {}
2055
2770
  };
2056
2771
  }
2057
2772
  async function releasePage(driver, args = {}) {
@@ -2086,9 +2801,22 @@ async function listLeasesTool(driver, _args = {}) {
2086
2801
  const backend = backendOf2(driver);
2087
2802
  const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
2088
2803
  const reapedIds = new Set(reaped.map((r) => r.targetId));
2089
- const liveIds = (await driver.listPages()).map((p) => p.id);
2804
+ const livePages = await driver.listPages();
2805
+ const liveIds = livePages.map((p) => p.id);
2090
2806
  const all = await listLeases({ liveIds, liveBackend: backend });
2091
- const leases = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
2807
+ const kept = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
2808
+ const liveSet = new Set(liveIds);
2809
+ const now = Date.now();
2810
+ const leases = [];
2811
+ for (const row of kept) {
2812
+ const computed = row.unreadable !== undefined ? row : { ...row, idleMs: now - row.lastUsedAt, expiresAt: row.lastUsedAt + row.ttlMs };
2813
+ if (row.backend !== backend || row.unreadable !== undefined || !liveSet.has(row.targetId)) {
2814
+ leases.push(computed);
2815
+ continue;
2816
+ }
2817
+ const humanMs = await readHumanActiveMs(driver, backend, row.targetId, now).catch(() => null);
2818
+ leases.push(humanMs === null ? computed : { ...computed, humanActiveMs: humanMs });
2819
+ }
2092
2820
  return { leases, count: leases.length, ...reaped.length ? { reaped } : {} };
2093
2821
  }
2094
2822
  var LEASE_TOOLS = {
@@ -3190,58 +3918,325 @@ async function lighthouseAudit(args) {
3190
3918
  if (Array.isArray(args.categories) && args.categories.length > 0) {
3191
3919
  lhArgs.push(`--only-categories=${args.categories.join(",")}`);
3192
3920
  }
3193
- const res = await runProcess("npx", lhArgs, { timeoutMs });
3194
- if (res.spawnError) {
3195
- const code = res.spawnError.code;
3196
- if (code === "ENOENT") {
3197
- throw new CdpError("lighthouse_audit requires 'npx' (Node.js) on PATH to fetch and run the Lighthouse CLI; 'npx' was not found.");
3921
+ const res = await runProcess("npx", lhArgs, { timeoutMs });
3922
+ if (res.spawnError) {
3923
+ const code = res.spawnError.code;
3924
+ if (code === "ENOENT") {
3925
+ throw new CdpError("lighthouse_audit requires 'npx' (Node.js) on PATH to fetch and run the Lighthouse CLI; 'npx' was not found.");
3926
+ }
3927
+ throw new CdpError(`lighthouse_audit failed to spawn npx/lighthouse: ${res.spawnError.message}`);
3928
+ }
3929
+ let raw;
3930
+ try {
3931
+ raw = await readFile6(outPath, "utf8");
3932
+ } catch {
3933
+ raw = undefined;
3934
+ }
3935
+ if (raw === undefined) {
3936
+ if (res.signal === "SIGKILL") {
3937
+ throw new CdpError(`lighthouse_audit timed out after ${timeoutMs}ms (no report written). stderr: ${truncate(res.stderr)}`);
3938
+ }
3939
+ const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx; ensure network access and that 'lighthouse' is installable." : "";
3940
+ throw new CdpError(`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`);
3941
+ }
3942
+ let report;
3943
+ try {
3944
+ report = JSON.parse(raw);
3945
+ } catch (e) {
3946
+ throw new CdpError(`lighthouse_audit wrote a report that is not valid JSON (${e.message})`);
3947
+ }
3948
+ if (report.runtimeError?.code && report.runtimeError.code !== "NO_ERROR") {
3949
+ throw new CdpError(`lighthouse_audit runtime error [${report.runtimeError.code}]: ${report.runtimeError.message ?? "unknown"}`);
3950
+ }
3951
+ const categories = {};
3952
+ for (const [key, cat] of Object.entries(report.categories ?? {})) {
3953
+ categories[key] = cat?.score ?? null;
3954
+ }
3955
+ if (Object.keys(categories).length === 0) {
3956
+ throw new CdpError(`lighthouse_audit report contained no category scores (exit code ${res.code}). stderr: ${truncate(res.stderr)}`);
3957
+ }
3958
+ const { size } = await stat3(outPath);
3959
+ return {
3960
+ path: outPath,
3961
+ bytes: size,
3962
+ url,
3963
+ finalUrl: report.finalDisplayedUrl ?? report.finalUrl,
3964
+ lighthouseVersion: report.lighthouseVersion,
3965
+ fetchTime: report.fetchTime,
3966
+ categories,
3967
+ warnings: Array.isArray(report.runWarnings) && report.runWarnings.length > 0 ? report.runWarnings : undefined
3968
+ };
3969
+ }
3970
+ function truncate(s, max = 600) {
3971
+ const t = s.trim();
3972
+ return t.length > max ? `${t.slice(0, max)}…` : t;
3973
+ }
3974
+
3975
+ // src/tools/dispatch-mouse.ts
3976
+ var CLICK_MODIFIER_BITS2 = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
3977
+ var BUTTONS = new Set(["left", "right", "middle"]);
3978
+ var CDP_EVENT_TYPE = {
3979
+ move: "mouseMoved",
3980
+ down: "mousePressed",
3981
+ up: "mouseReleased"
3982
+ };
3983
+ function modifierBits(mods) {
3984
+ let bits = 0;
3985
+ for (const m of mods ?? []) {
3986
+ const bit = CLICK_MODIFIER_BITS2[m];
3987
+ if (bit === undefined)
3988
+ throw new Error(`dispatch_mouse: unknown modifier '${m}' (use Alt, Control, Meta, or Shift)`);
3989
+ bits |= bit;
3990
+ }
3991
+ return bits;
3992
+ }
3993
+ function validateDispatchMouseArgs(args) {
3994
+ if (args.action !== "move" && args.action !== "down" && args.action !== "up") {
3995
+ throw new Error("dispatch_mouse requires { action } of 'move', 'down', or 'up'");
3996
+ }
3997
+ if (typeof args.x !== "number" || typeof args.y !== "number" || !Number.isFinite(args.x) || !Number.isFinite(args.y)) {
3998
+ throw new Error("dispatch_mouse requires numeric { x } and { y } on every call");
3999
+ }
4000
+ const button = args.button ?? "left";
4001
+ if (!BUTTONS.has(button))
4002
+ throw new Error(`dispatch_mouse: unknown button '${button}' (use left, right, or middle)`);
4003
+ }
4004
+ async function dispatchMouse(args) {
4005
+ validateDispatchMouseArgs(args);
4006
+ const button = args.button ?? "left";
4007
+ const modifiers = modifierBits(args.modifiers);
4008
+ return withPage(args.target, async (conn, target) => {
4009
+ const params = { type: CDP_EVENT_TYPE[args.action], x: args.x, y: args.y, button, modifiers };
4010
+ if (args.action !== "move")
4011
+ params.clickCount = args.clickCount ?? 1;
4012
+ await sendInput(conn, "chrome", target.id, "Input.dispatchMouseEvent", params);
4013
+ return { dispatched: args.action, x: args.x, y: args.y };
4014
+ });
4015
+ }
4016
+
4017
+ // src/tools/downloads.ts
4018
+ import { readdir as readdir3, rename, stat as stat4 } from "node:fs/promises";
4019
+ import { basename, extname, join as join8 } from "node:path";
4020
+
4021
+ // src/tools/browser-session.ts
4022
+ import { mkdir as mkdir9 } from "node:fs/promises";
4023
+ import { join as join7 } from "node:path";
4024
+ var ARTIFACT_DIR7 = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
4025
+ var DOWNLOADS_DIR = join7(ARTIFACT_DIR7, "downloads");
4026
+ var DOWNLOAD_BUFFER_LIMIT = 50;
4027
+ var session;
4028
+ function isDisconnect(e) {
4029
+ const m = e instanceof Error ? e.message : String(e);
4030
+ return /connection (not open|closed)|closed by caller|websocket|ECONNREFUSED|socket/i.test(m);
4031
+ }
4032
+ function record(s, guid) {
4033
+ let r = s.downloads.find((d) => d.guid === guid);
4034
+ if (!r) {
4035
+ r = { guid, state: "inProgress", consumed: false };
4036
+ s.downloads.push(r);
4037
+ while (s.downloads.length > DOWNLOAD_BUFFER_LIMIT) {
4038
+ const i = s.downloads.findIndex((d) => d.consumed);
4039
+ s.downloads.splice(i >= 0 ? i : 0, 1);
4040
+ }
4041
+ }
4042
+ return r;
4043
+ }
4044
+ function subscribe(s) {
4045
+ s.conn.on("Browser.downloadWillBegin", (p) => {
4046
+ const r = record(s, String(p.guid));
4047
+ if (typeof p.url === "string")
4048
+ r.url = p.url;
4049
+ if (typeof p.suggestedFilename === "string")
4050
+ r.suggestedFilename = p.suggestedFilename;
4051
+ });
4052
+ s.conn.on("Browser.downloadProgress", (p) => {
4053
+ const r = record(s, String(p.guid));
4054
+ if (typeof p.receivedBytes === "number")
4055
+ r.bytes = p.receivedBytes;
4056
+ if (typeof p.filePath === "string")
4057
+ r.filePath = p.filePath;
4058
+ const state = String(p.state);
4059
+ if (state === "completed" || state === "canceled") {
4060
+ r.state = state;
4061
+ if (state === "completed") {
4062
+ const waiter = s.waiters.shift();
4063
+ if (waiter) {
4064
+ r.consumed = true;
4065
+ waiter(r);
4066
+ }
4067
+ }
4068
+ }
4069
+ });
4070
+ }
4071
+ async function getBrowserSession() {
4072
+ if (session && !session.dead)
4073
+ return session;
4074
+ const previousPath = session?.armedPath;
4075
+ const conn = await openBrowser();
4076
+ const s = { conn, downloads: [], waiters: [], dead: false };
4077
+ subscribe(s);
4078
+ session = s;
4079
+ if (previousPath)
4080
+ await armDownloadsAt(previousPath);
4081
+ return s;
4082
+ }
4083
+ async function browserSend(method, params = {}) {
4084
+ const s = await getBrowserSession();
4085
+ try {
4086
+ return await s.conn.send(method, params);
4087
+ } catch (e) {
4088
+ if (isDisconnect(e)) {
4089
+ s.dead = true;
4090
+ const fresh = await getBrowserSession();
4091
+ return await fresh.conn.send(method, params);
4092
+ }
4093
+ throw e;
4094
+ }
4095
+ }
4096
+ async function armDownloadsAt(path) {
4097
+ await mkdir9(path, { recursive: true });
4098
+ const s = await getBrowserSession();
4099
+ await s.conn.send("Browser.setDownloadBehavior", { behavior: "allowAndName", downloadPath: path, eventsEnabled: true });
4100
+ s.armedPath = path;
4101
+ }
4102
+ async function armDownloads() {
4103
+ const s = await getBrowserSession();
4104
+ if (s.armedPath !== DOWNLOADS_DIR)
4105
+ await armDownloadsAt(DOWNLOADS_DIR);
4106
+ return { downloadPath: DOWNLOADS_DIR };
4107
+ }
4108
+ function pendingDownloads() {
4109
+ return (session?.downloads ?? []).filter((d) => d.state === "completed" && !d.consumed);
4110
+ }
4111
+ async function nextCompletedDownload(timeoutMs) {
4112
+ const s = await getBrowserSession();
4113
+ const buffered = s.downloads.find((d) => d.state === "completed" && !d.consumed);
4114
+ if (buffered) {
4115
+ buffered.consumed = true;
4116
+ return buffered;
4117
+ }
4118
+ return new Promise((resolve, reject) => {
4119
+ const waiter = (r) => {
4120
+ clearTimeout(timer);
4121
+ resolve(r);
4122
+ };
4123
+ const timer = setTimeout(() => {
4124
+ const i = s.waiters.indexOf(waiter);
4125
+ if (i >= 0)
4126
+ s.waiters.splice(i, 1);
4127
+ 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).`));
4128
+ }, timeoutMs);
4129
+ s.waiters.push(waiter);
4130
+ });
4131
+ }
4132
+ async function disposeBrowserSession() {
4133
+ const s = session;
4134
+ session = undefined;
4135
+ if (!s)
4136
+ return;
4137
+ s.dead = true;
4138
+ try {
4139
+ s.conn.close();
4140
+ } catch {}
4141
+ }
4142
+
4143
+ // src/tools/downloads.ts
4144
+ function safeFilename(suggested) {
4145
+ const raw = (suggested ?? "").replace(/[\u0000-\u001f]/g, "");
4146
+ const name = basename(raw.split("\\").pop() ?? "").trim();
4147
+ if (!name || name === "." || name === "..")
4148
+ return "download";
4149
+ return name;
4150
+ }
4151
+ function nextFreeName(name, taken) {
4152
+ if (!taken.has(name))
4153
+ return name;
4154
+ const ext = extname(name);
4155
+ const stem = ext ? name.slice(0, -ext.length) : name;
4156
+ for (let i = 1;; i++) {
4157
+ const candidate = `${stem}-${i}${ext}`;
4158
+ if (!taken.has(candidate))
4159
+ return candidate;
4160
+ }
4161
+ }
4162
+ function validateWaitForDownloadArgs(args) {
4163
+ if (args.timeoutMs !== undefined) {
4164
+ if (typeof args.timeoutMs !== "number" || !Number.isFinite(args.timeoutMs) || args.timeoutMs <= 0) {
4165
+ throw new Error(`wait_for_download: 'timeoutMs' must be a positive number (got ${JSON.stringify(args.timeoutMs)})`);
3198
4166
  }
3199
- throw new CdpError(`lighthouse_audit failed to spawn npx/lighthouse: ${res.spawnError.message}`);
3200
4167
  }
3201
- let raw;
4168
+ if (args.arm !== undefined && typeof args.arm !== "boolean") {
4169
+ throw new Error(`wait_for_download: 'arm' must be a boolean (got ${JSON.stringify(args.arm)})`);
4170
+ }
4171
+ }
4172
+ async function waitForDownload(args = {}) {
4173
+ validateWaitForDownloadArgs(args);
4174
+ const t = await resolveTarget(args.target);
4175
+ const target = { id: t.id, url: t.url, title: t.title };
4176
+ const { downloadPath } = await armDownloads();
4177
+ if (args.arm === true)
4178
+ return { armed: true, downloadPath, pending: pendingDownloads().length, target };
4179
+ const rec = await nextCompletedDownload(args.timeoutMs ?? 30000);
4180
+ if (!rec.filePath) {
4181
+ throw new Error(`wait_for_download: download ${rec.guid} completed without a file path (nothing to return)`);
4182
+ }
4183
+ const suggestedFilename = safeFilename(rec.suggestedFilename);
4184
+ const taken = new Set(await readdir3(DOWNLOADS_DIR).catch(() => []));
4185
+ taken.delete(basename(rec.filePath));
4186
+ const finalName = nextFreeName(suggestedFilename, taken);
4187
+ const path = join8(DOWNLOADS_DIR, finalName);
4188
+ await rename(rec.filePath, path);
4189
+ const bytes = (await stat4(path)).size;
4190
+ return { path, suggestedFilename, bytes, ...rec.url ? { url: rec.url } : {}, target };
4191
+ }
4192
+
4193
+ // src/tools/permissions.ts
4194
+ function originOf(url) {
3202
4195
  try {
3203
- raw = await readFile6(outPath, "utf8");
4196
+ const o = new URL(url).origin;
4197
+ return o && o !== "null" ? o : undefined;
3204
4198
  } catch {
3205
- raw = undefined;
4199
+ return;
3206
4200
  }
3207
- if (raw === undefined) {
3208
- if (res.signal === "SIGKILL") {
3209
- throw new CdpError(`lighthouse_audit timed out after ${timeoutMs}ms (no report written). stderr: ${truncate(res.stderr)}`);
3210
- }
3211
- const hint = /not found|could not determine executable|npm ERR|E404|ERR_MODULE/i.test(res.stderr) ? " The Lighthouse CLI could not be fetched/installed via npx; ensure network access and that 'lighthouse' is installable." : "";
3212
- throw new CdpError(`lighthouse_audit produced no report (exit code ${res.code}).${hint} stderr: ${truncate(res.stderr)}`);
4201
+ }
4202
+ function validateGrantPermissionsArgs(args) {
4203
+ const reset = args.reset === true;
4204
+ if (args.reset !== undefined && typeof args.reset !== "boolean") {
4205
+ throw new Error(`grant_permissions: 'reset' must be a boolean (got ${JSON.stringify(args.reset)})`);
3213
4206
  }
3214
- let report;
3215
- try {
3216
- report = JSON.parse(raw);
3217
- } catch (e) {
3218
- throw new CdpError(`lighthouse_audit wrote a report that is not valid JSON (${e.message})`);
4207
+ if (args.permissions === undefined) {
4208
+ if (!reset)
4209
+ throw new Error("grant_permissions: 'permissions' is required (or pass reset:true to clear previous grants)");
4210
+ return;
3219
4211
  }
3220
- if (report.runtimeError?.code && report.runtimeError.code !== "NO_ERROR") {
3221
- throw new CdpError(`lighthouse_audit runtime error [${report.runtimeError.code}]: ${report.runtimeError.message ?? "unknown"}`);
4212
+ if (!Array.isArray(args.permissions)) {
4213
+ throw new Error(`grant_permissions: 'permissions' must be an array of CDP PermissionType strings (got ${JSON.stringify(args.permissions)})`);
3222
4214
  }
3223
- const categories = {};
3224
- for (const [key, cat] of Object.entries(report.categories ?? {})) {
3225
- categories[key] = cat?.score ?? null;
4215
+ if (args.permissions.length === 0 && !reset) {
4216
+ throw new Error("grant_permissions: 'permissions' must not be empty (pass reset:true if the intent is to clear grants)");
3226
4217
  }
3227
- if (Object.keys(categories).length === 0) {
3228
- throw new CdpError(`lighthouse_audit report contained no category scores (exit code ${res.code}). stderr: ${truncate(res.stderr)}`);
4218
+ for (const p of args.permissions) {
4219
+ if (typeof p !== "string" || p.length === 0) {
4220
+ throw new Error(`grant_permissions: every entry of 'permissions' must be a non-empty string (got ${JSON.stringify(p)})`);
4221
+ }
3229
4222
  }
3230
- const { size } = await stat3(outPath);
3231
- return {
3232
- path: outPath,
3233
- bytes: size,
3234
- url,
3235
- finalUrl: report.finalDisplayedUrl ?? report.finalUrl,
3236
- lighthouseVersion: report.lighthouseVersion,
3237
- fetchTime: report.fetchTime,
3238
- categories,
3239
- warnings: Array.isArray(report.runWarnings) && report.runWarnings.length > 0 ? report.runWarnings : undefined
3240
- };
3241
4223
  }
3242
- function truncate(s, max = 600) {
3243
- const t = s.trim();
3244
- return t.length > max ? `${t.slice(0, max)}…` : t;
4224
+ async function grantPermissions(args = {}) {
4225
+ validateGrantPermissionsArgs(args);
4226
+ const t = await resolveTarget(args.target);
4227
+ const target = { id: t.id, url: t.url, title: t.title };
4228
+ const reset = args.reset === true;
4229
+ const permissions = args.permissions ?? [];
4230
+ if (reset)
4231
+ await browserSend("Browser.resetPermissions", {});
4232
+ if (permissions.length === 0)
4233
+ return { reset: true, target };
4234
+ const origin = args.origin ?? originOf(t.url);
4235
+ if (!origin) {
4236
+ 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"`);
4237
+ }
4238
+ await browserSend("Browser.grantPermissions", { origin, permissions });
4239
+ return { granted: [...permissions], origin, ...reset ? { reset: true } : {}, target };
3245
4240
  }
3246
4241
 
3247
4242
  // src/index.ts
@@ -3264,6 +4259,7 @@ var TOOLS = {
3264
4259
  click: onCdp(SHARED_TOOLS.click),
3265
4260
  hover: onCdp(SHARED_TOOLS.hover),
3266
4261
  drag: onCdp(SHARED_TOOLS.drag),
4262
+ scroll: onCdp(SHARED_TOOLS.scroll),
3267
4263
  fill: onCdp(SHARED_TOOLS.fill),
3268
4264
  fill_form: onCdp(SHARED_TOOLS.fill_form),
3269
4265
  type_text: onCdp(SHARED_TOOLS.type_text),
@@ -3288,6 +4284,9 @@ var TOOLS = {
3288
4284
  mock_request: mockRequest,
3289
4285
  list_mocks: listMocks,
3290
4286
  clear_mocks: clearMocks,
4287
+ dispatch_mouse: dispatchMouse,
4288
+ wait_for_download: waitForDownload,
4289
+ grant_permissions: grantPermissions,
3291
4290
  claim_page: onCdp(LEASE_TOOLS.claim_page),
3292
4291
  release_page: onCdp(LEASE_TOOLS.release_page),
3293
4292
  list_leases: onCdp(LEASE_TOOLS.list_leases)
@@ -3303,7 +4302,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
3303
4302
  var MANIFEST = [
3304
4303
  {
3305
4304
  name: "list_pages",
3306
- description: "Enumerate browser page targets via the CDP browser endpoint (GET /json/list). By default returns only page-type tabs; set 'all' to also include workers and background pages. Each entry carries the targetId used as a target selector elsewhere, plus an 'origin' field recording where the tab came from: 'agent' means this toolkit created it (the entry then also carries the creating 'label' and 'createdAt'), and this stays true after the creating agent releases its lease or dies, which is what makes a stray agent tab findable later. 'unknown' means there is no creation record. It never says 'human': the toolkit cannot prove a person opened a tab, so 'unknown' is the honest word for a tab it did not create, one opened before the toolkit ran, or one whose record could not be written. A tab whose record exists but could not be read reports origin 'unknown' plus 'originUnreadable', so a broken record is never mistaken for no record. Under CDP_REQUIRE_LEASE this call also reaps: a tab this toolkit created whose lease is stale because the owning process died or the TTL elapsed is closed, and the closed tabs are reported in an additive 'reaped' array ({targetId,label,reason}) that is present only when something was actually closed. A tab with no lease is never reaped, so tabs opened by a caller that never uses leases are untouched.",
4305
+ description: "Enumerate browser page targets via the CDP browser endpoint (GET /json/list). By default returns only page-type tabs; set 'all' to also include workers and background pages. Each entry carries the targetId used as a target selector elsewhere, plus an 'origin' field recording where the tab came from: 'agent' means this toolkit created it (the entry then also carries the creating 'label' and 'createdAt'), and this stays true after the creating agent releases its lease or dies, which is what makes a stray agent tab findable later. 'unknown' means there is no creation record. It never says 'human': the toolkit cannot prove a person opened a tab, so 'unknown' is the honest word for a tab it did not create, one opened before the toolkit ran, or one whose record could not be written. A tab whose record exists but could not be read reports origin 'unknown' plus 'originUnreadable', so a broken record is never mistaken for no record. Under CDP_REQUIRE_LEASE this call also reaps abandoned agent tabs, but reap-CLOSE and lease-RECLAIMABLE (list_leases' 'stale') now fire at different times: a lease reads reclaimable the moment its TTL elapses, cheap and reversible since another agent may simply take it, while this call only CLOSES the tab, destructively, after an ADDITIONAL grace period past that TTL (CDP_REAP_GRACE_MS, default 2700000ms/45 minutes, so 60 minutes total after last use) — a tab an agent is mid-build between calls on is not destroyed out from under it. A dead-pid lease is reaped immediately regardless of grace: that process is never coming back. Closed tabs are reported in an additive 'reaped' array ({targetId,label,reason}) present only when something was actually closed; a tab with no lease is never reaped. Every entry under an active, readable lease of this backend also carries 'lease': {label, pid, idleMs, expiresAt, stale} — idleMs and expiresAt are computed fresh on every call (now-lastUsedAt and lastUsedAt+ttlMs), never read off disk, and this is unconditional on 'probe'. Set 'probe' to true to also ping each page-type target's renderer: one bounded (500ms) check per target, never more, so a single wedged tab cannot stall the listing beyond its own budget. This adds 'responsive' (true iff the ping answered in time; false on a timeout, a page-side exception, or an unreachable target — never an error for the whole call) and, where that same round trip found human-attributed input, 'humanActiveMs' (see claim_page and list_leases for what that field means and cannot prove). 'responsive' and 'humanActiveMs' are both ABSENT — never false/null — when 'probe' is not set, and 'responsive' is likewise absent on a backend that cannot answer this ping at all.",
3307
4306
  inputSchema: {
3308
4307
  type: "object",
3309
4308
  properties: {
@@ -3314,6 +4313,10 @@ var MANIFEST = [
3314
4313
  all: {
3315
4314
  type: "boolean",
3316
4315
  description: "Include non-page targets (service/shared workers, background pages) when true; otherwise only page-type tabs are listed."
4316
+ },
4317
+ probe: {
4318
+ type: "boolean",
4319
+ description: "Ping each page-type target's renderer before returning: one bounded (500ms) Runtime.evaluate per target, never more. Adds 'responsive' to every page-type entry and, where the same round trip finds human-attributed input, 'humanActiveMs'. A wedged or unreachable tab reports responsive:false rather than failing the call. Defaults to false, which is byte-identical to the pre-1.8.0 shape."
3317
4320
  }
3318
4321
  },
3319
4322
  required: [],
@@ -3363,7 +4366,7 @@ var MANIFEST = [
3363
4366
  },
3364
4367
  target: {
3365
4368
  type: "string",
3366
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4369
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3367
4370
  }
3368
4371
  },
3369
4372
  required: [
@@ -3384,7 +4387,7 @@ var MANIFEST = [
3384
4387
  },
3385
4388
  target: {
3386
4389
  type: "string",
3387
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4390
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3388
4391
  }
3389
4392
  },
3390
4393
  required: [
@@ -3395,7 +4398,7 @@ var MANIFEST = [
3395
4398
  },
3396
4399
  {
3397
4400
  name: "navigate_page",
3398
- description: "Navigate a target page to a URL (Page.navigate) OR reload it (reload:true) over raw CDP, then wait for the load milestone with a bounded timeout so a wedged renderer can't hang. Returns {url,frameId,waitedFor} (plus reloaded:true on a reload); waitUntil supports 'load'|'domcontentloaded' only (no 'networkidle'), and there is no auto-snapshot of the new page. Pass reload:true with ignoreCache:true for a hard reload that refetches every subresource (e.g. to pick up a freshly-deployed, non-content-hashed bundle the HTTP cache would serve stale).",
4401
+ description: "Navigate a target page to a URL (Page.navigate), reload it (reload:true), OR go back/forward in its session history (history:'back'|'forward'), then wait for the load milestone with a bounded timeout so a wedged renderer can't hang. Exactly one of url / reload / history per call. Returns {url,frameId,waitedFor} (plus reloaded:true on a reload, traversed:'back'|'forward' on a history move); waitUntil supports 'load'|'domcontentloaded' only (no 'networkidle'), and there is no auto-snapshot of the new page. Pass reload:true with ignoreCache:true for a hard reload that refetches every subresource (e.g. to pick up a freshly-deployed, non-content-hashed bundle the HTTP cache would serve stale).",
3399
4402
  inputSchema: {
3400
4403
  type: "object",
3401
4404
  properties: {
@@ -3405,7 +4408,7 @@ var MANIFEST = [
3405
4408
  },
3406
4409
  target: {
3407
4410
  type: "string",
3408
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4411
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3409
4412
  },
3410
4413
  url: {
3411
4414
  type: "string",
@@ -3415,6 +4418,14 @@ var MANIFEST = [
3415
4418
  type: "boolean",
3416
4419
  description: "Reload the current page (Page.reload) instead of navigating to url. Default false."
3417
4420
  },
4421
+ history: {
4422
+ type: "string",
4423
+ enum: [
4424
+ "back",
4425
+ "forward"
4426
+ ],
4427
+ 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."
4428
+ },
3418
4429
  ignoreCache: {
3419
4430
  type: "boolean",
3420
4431
  description: "On reload, bypass the HTTP cache (hard reload) so subresources are refetched. Ignored unless reload:true. Default false."
@@ -3447,7 +4458,7 @@ var MANIFEST = [
3447
4458
  },
3448
4459
  target: {
3449
4460
  type: "string",
3450
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4461
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3451
4462
  },
3452
4463
  text: {
3453
4464
  type: "string",
@@ -3470,7 +4481,7 @@ var MANIFEST = [
3470
4481
  },
3471
4482
  {
3472
4483
  name: "evaluate_script",
3473
- description: "Run arbitrary JavaScript in the target page's main-world context over raw CDP and return the evaluated value (returnByValue). With no 'args' the 'expression' is evaluated as a raw expression; when 'args' is provided 'expression' must be a function literal (arrow or classic) invoked on globalThis with the args passed positionally. A thrown exception surfaces as an error; non-serializable returns (DOM nodes, functions) come back as their CDP description string. Pass 'savePath' to write the evaluated value to a JSON file instead: the response then carries only {path,bytes,type,target} and the value itself never appears in it, in any form. That is the way to read a credential (a JWT or session token out of localStorage, for example) without putting the secret into the caller's transcript.",
4484
+ description: "Run arbitrary JavaScript in the target page's main-world context over raw CDP and return the evaluated value (returnByValue). With no 'args' the 'expression' is evaluated as a raw expression; when 'args' is provided 'expression' must be a function literal (arrow or classic) invoked on globalThis with the args passed positionally. A thrown exception surfaces as an error; non-serializable returns (DOM nodes, functions) come back as their CDP description string. Pass 'savePath' to write the evaluated value to a JSON file instead: the response then carries only {path,bytes,type,target} and the value itself never appears in it, in any form. That is the way to read a credential (a JWT or session token out of localStorage, for example) without putting the secret into the caller's transcript. This is also the one tool that can evaluate inside an MV3 extension's background SERVICE WORKER rather than a page: pass target 'worker:<extension-id>' (Chrome only) and read chrome.storage.local, chrome.runtime and the worker's own globals directly, with an idle-evicted worker started for you first (see 'wake').",
3474
4485
  inputSchema: {
3475
4486
  type: "object",
3476
4487
  properties: {
@@ -3480,11 +4491,15 @@ var MANIFEST = [
3480
4491
  },
3481
4492
  target: {
3482
4493
  type: "string",
3483
- description: "Page selector: 'active' (or omit) -> first page-type target | '<32-hex targetId>' -> exact target by id | 'index:N' -> Nth page-type target (0-based) | 'url:<substring>' -> first page whose url contains substring | 'title:<substring>' -> first page whose title contains substring."
4494
+ description: "Page selector: 'active' (or omit) -> first page-type target | '<32-hex targetId>' -> exact target by id | 'index:N' -> Nth page-type target (0-based) | 'url:<substring>' -> first page whose url contains substring | 'title:<substring>' -> first page whose title contains substring | 'label:<name>' -> the page-type target with exactly this label (checked against the origin ledger and live leases) | 'worker:<substring>' -> a service/shared worker whose url contains the substring, which is how you reach an MV3 extension's background service worker (worker:<extension-id> or worker:background.js) and read chrome.storage.local from it. The worker: arm is CHROME-ONLY (Capability 'worker.targets') and is accepted by this tool only; an MV3 worker that has been idle-evicted is started automatically first, see 'wake'."
4495
+ },
4496
+ wake: {
4497
+ type: "boolean",
4498
+ description: "Only valid with a 'worker:<substring>' target, and rejected on a page target rather than ignored. Default true: an MV3 extension service worker is idle-evicted after seconds of inactivity and then exists in NO target listing, so a wake asks Chrome to start it (ServiceWorker.startWorker over a page session) and then re-reads the target list to confirm it actually came up. Pass false to fail fast instead of starting anything; the error then says the worker may simply be asleep."
3484
4499
  },
3485
4500
  expression: {
3486
4501
  type: "string",
3487
- description: "JavaScript to run. Evaluated as an expression when 'args' is omitted; must be a function literal (arrow or classic) whose parameters receive 'args' when 'args' is provided."
4502
+ description: "JavaScript to run. Evaluated as an expression when 'args' is omitted; must be a function literal (arrow or classic) whose parameters receive 'args' when 'args' is provided. REQUIRED — the code always goes in this key, never in 'function', 'code', 'js', 'script', 'fn' or 'body'; a call using one of those instead is rejected with an error naming the wrong key."
3488
4503
  },
3489
4504
  awaitPromise: {
3490
4505
  type: "boolean",
@@ -3517,7 +4532,7 @@ var MANIFEST = [
3517
4532
  },
3518
4533
  target: {
3519
4534
  type: "string",
3520
- description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
4535
+ description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
3521
4536
  },
3522
4537
  domain: {
3523
4538
  type: "string",
@@ -3548,7 +4563,7 @@ var MANIFEST = [
3548
4563
  },
3549
4564
  target: {
3550
4565
  type: "string",
3551
- description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
4566
+ description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
3552
4567
  },
3553
4568
  name: {
3554
4569
  type: "string",
@@ -3604,7 +4619,7 @@ var MANIFEST = [
3604
4619
  },
3605
4620
  target: {
3606
4621
  type: "string",
3607
- description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
4622
+ description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
3608
4623
  },
3609
4624
  name: {
3610
4625
  type: "string",
@@ -3639,7 +4654,7 @@ var MANIFEST = [
3639
4654
  },
3640
4655
  target: {
3641
4656
  type: "string",
3642
- description: "Target page selector: 'active' (default, = first page-type target) | 'index:N' (Nth page-type target, 0-based) | 'url:<substring>' | 'title:<substring>' | '<32-hex targetId>' (exact target by id)."
4657
+ description: "Target page selector: 'active' (default, = first page-type target) | 'index:N' (Nth page-type target, 0-based) | 'url:<substring>' | 'title:<substring>' | 'label:<name>' (exact label match, origin ledger or live lease) | '<32-hex targetId>' (exact target by id)."
3643
4658
  },
3644
4659
  interactiveOnly: {
3645
4660
  type: "boolean",
@@ -3652,7 +4667,7 @@ var MANIFEST = [
3652
4667
  },
3653
4668
  {
3654
4669
  name: "click",
3655
- description: "Click an element via a synthetic mouse press/release at the element's scrolled-into-view bounding-rect center. Target the element with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector).",
4670
+ description: "Click an element via a synthetic mouse press/release at the element's scrolled-into-view bounding-rect center. Target the element with exactly one of 'uid' (a CDP backendDOMNodeId from take_snapshot) or 'selector' (a CSS selector). clickCount:3 triple-clicks (selects a paragraph/line in most editors). 'modifiers' holds Alt/Control/Meta/Shift for the press and release, like a real modifier-click; on the Firefox backend a non-empty 'modifiers' throws (not yet supported over BiDi), so a modifier click there needs --browser chrome.",
3656
4671
  inputSchema: {
3657
4672
  type: "object",
3658
4673
  properties: {
@@ -3662,7 +4677,7 @@ var MANIFEST = [
3662
4677
  },
3663
4678
  target: {
3664
4679
  type: "string",
3665
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4680
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3666
4681
  },
3667
4682
  uid: {
3668
4683
  type: "number",
@@ -3683,7 +4698,20 @@ var MANIFEST = [
3683
4698
  },
3684
4699
  clickCount: {
3685
4700
  type: "number",
3686
- description: "Number of clicks: 1 = single (default), 2 = double-click."
4701
+ description: "Number of clicks: 1 = single (default), 2 = double-click, 3 = triple-click."
4702
+ },
4703
+ modifiers: {
4704
+ type: "array",
4705
+ items: {
4706
+ type: "string",
4707
+ enum: [
4708
+ "Alt",
4709
+ "Control",
4710
+ "Meta",
4711
+ "Shift"
4712
+ ]
4713
+ },
4714
+ description: "Modifier keys held for the click's press and release, e.g. ['Shift'] for a shift-click. Not supported on the Firefox backend: passing a non-empty array there throws."
3687
4715
  }
3688
4716
  },
3689
4717
  required: [],
@@ -3702,7 +4730,7 @@ var MANIFEST = [
3702
4730
  },
3703
4731
  target: {
3704
4732
  type: "string",
3705
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4733
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3706
4734
  },
3707
4735
  uid: {
3708
4736
  type: "number",
@@ -3719,7 +4747,7 @@ var MANIFEST = [
3719
4747
  },
3720
4748
  {
3721
4749
  name: "drag",
3722
- description: "Drag from a source element to a destination element via synthetic mouse press/move/release (approximates HTML5 drag-and-drop; not guaranteed for every custom DnD library). 'from' and 'to' each take exactly one of uid (a CDP backendDOMNodeId from take_snapshot) or selector.",
4750
+ description: `Drag from a source element to a destination. 'from' takes exactly one of uid (a CDP backendDOMNodeId from take_snapshot) or selector. The destination is exactly one of 'to' (an element via uid/selector, or an absolute viewport point via x+y) or 'by' ({dx,dy} offset from the source point — sliders, map panning, resize handles). mode:'mouse' (default) dispatches synthetic mouse press/move/release: right for widgets built on raw pointer events, and Chrome does turn it into a real drag, but WHICH drag events reach the page depends on where the interpolated pointer path happens to land — measured on Chrome 151, the default steps:2 delivers ZERO dragover events, so an HTML5 drop zone written the standard way (preventDefault inside dragover) refuses the drop entirely. mode:'html5' performs the drag deterministically instead: Chrome's drag interception hands back the DragData the page's own dragstart built, and the toolkit replays it as dragEnter/dragOver/drop exactly at the destination, so a draggable="true" / dataTransfer drop zone works regardless of the pointer path. mode:'html5' is CHROME-ONLY: it requires capability 'input.html5Drag' and is rejected with a clear error under the Firefox backend, where the tool itself remains available for mouse-mode drags. 'steps' (default 2) sets how many interpolated mouse-move events are dispatched between source and destination; raise it for DnD libraries with a movement threshold or per-frame sampling.`,
3723
4751
  inputSchema: {
3724
4752
  type: "object",
3725
4753
  properties: {
@@ -3729,7 +4757,7 @@ var MANIFEST = [
3729
4757
  },
3730
4758
  target: {
3731
4759
  type: "string",
3732
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
4760
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3733
4761
  },
3734
4762
  from: {
3735
4763
  type: "object",
@@ -3748,7 +4776,7 @@ var MANIFEST = [
3748
4776
  },
3749
4777
  to: {
3750
4778
  type: "object",
3751
- description: "Destination element to drag to. Provide exactly one of uid or selector.",
4779
+ description: "Where the drag ends: exactly one of uid, selector, or x+y. Mutually exclusive with 'by'; exactly one of to/by is required.",
3752
4780
  properties: {
3753
4781
  uid: {
3754
4782
  type: "number",
@@ -3757,18 +4785,222 @@ var MANIFEST = [
3757
4785
  selector: {
3758
4786
  type: "string",
3759
4787
  description: "CSS selector for the destination element (resolved via document.querySelector)."
4788
+ },
4789
+ x: {
4790
+ type: "number",
4791
+ 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)."
4792
+ },
4793
+ y: {
4794
+ type: "number",
4795
+ description: "Absolute viewport y-coordinate to drop at. Must be given together with 'x'."
4796
+ }
4797
+ },
4798
+ additionalProperties: false
4799
+ },
4800
+ by: {
4801
+ type: "object",
4802
+ 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.",
4803
+ properties: {
4804
+ dx: {
4805
+ type: "number",
4806
+ description: "Horizontal offset in CSS pixels; positive drags RIGHT."
4807
+ },
4808
+ dy: {
4809
+ type: "number",
4810
+ description: "Vertical offset in CSS pixels; positive drags DOWN."
3760
4811
  }
3761
4812
  },
3762
4813
  additionalProperties: false
4814
+ },
4815
+ mode: {
4816
+ type: "string",
4817
+ enum: [
4818
+ "mouse",
4819
+ "html5"
4820
+ ],
4821
+ 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."
4822
+ },
4823
+ steps: {
4824
+ type: "number",
4825
+ 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."
4826
+ }
4827
+ },
4828
+ required: [
4829
+ "from"
4830
+ ],
4831
+ additionalProperties: false
4832
+ }
4833
+ },
4834
+ {
4835
+ name: "scroll",
4836
+ 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.",
4837
+ inputSchema: {
4838
+ type: "object",
4839
+ properties: {
4840
+ lease: {
4841
+ type: "string",
4842
+ 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."
4843
+ },
4844
+ target: {
4845
+ type: "string",
4846
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
4847
+ },
4848
+ uid: {
4849
+ type: "number",
4850
+ 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."
4851
+ },
4852
+ selector: {
4853
+ type: "string",
4854
+ 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."
4855
+ },
4856
+ x: {
4857
+ type: "number",
4858
+ 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."
4859
+ },
4860
+ y: {
4861
+ type: "number",
4862
+ 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."
4863
+ },
4864
+ deltaX: {
4865
+ type: "number",
4866
+ description: "Horizontal scroll delta; positive scrolls RIGHT. At least one of deltaX/deltaY is required."
4867
+ },
4868
+ deltaY: {
4869
+ type: "number",
4870
+ description: "Vertical scroll delta; positive scrolls DOWN. At least one of deltaX/deltaY is required."
4871
+ }
4872
+ },
4873
+ required: [],
4874
+ additionalProperties: false
4875
+ }
4876
+ },
4877
+ {
4878
+ name: "dispatch_mouse",
4879
+ 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.",
4880
+ inputSchema: {
4881
+ type: "object",
4882
+ properties: {
4883
+ lease: {
4884
+ type: "string",
4885
+ 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."
4886
+ },
4887
+ target: {
4888
+ type: "string",
4889
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
4890
+ },
4891
+ action: {
4892
+ type: "string",
4893
+ enum: [
4894
+ "move",
4895
+ "down",
4896
+ "up"
4897
+ ],
4898
+ description: "Which raw event to dispatch: 'move' (mouseMoved), 'down' (mousePressed), or 'up' (mouseReleased)."
4899
+ },
4900
+ x: {
4901
+ type: "number",
4902
+ description: "Viewport x-coordinate. Required on every call: CDP has no notion of a 'current pointer position' to default from."
4903
+ },
4904
+ y: {
4905
+ type: "number",
4906
+ description: "Viewport y-coordinate. Required on every call."
4907
+ },
4908
+ button: {
4909
+ type: "string",
4910
+ enum: [
4911
+ "left",
4912
+ "right",
4913
+ "middle"
4914
+ ],
4915
+ description: "Mouse button: 'left' (default), 'right', or 'middle'."
4916
+ },
4917
+ clickCount: {
4918
+ type: "number",
4919
+ 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."
4920
+ },
4921
+ modifiers: {
4922
+ type: "array",
4923
+ items: {
4924
+ type: "string",
4925
+ enum: [
4926
+ "Alt",
4927
+ "Control",
4928
+ "Meta",
4929
+ "Shift"
4930
+ ]
4931
+ },
4932
+ description: "Modifier keys held for this one event, e.g. ['Shift']."
3763
4933
  }
3764
4934
  },
3765
4935
  required: [
3766
- "from",
3767
- "to"
4936
+ "action",
4937
+ "x",
4938
+ "y"
3768
4939
  ],
3769
4940
  additionalProperties: false
3770
4941
  }
3771
4942
  },
4943
+ {
4944
+ name: "wait_for_download",
4945
+ 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.",
4946
+ inputSchema: {
4947
+ type: "object",
4948
+ properties: {
4949
+ lease: {
4950
+ type: "string",
4951
+ 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."
4952
+ },
4953
+ target: {
4954
+ type: "string",
4955
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<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."
4956
+ },
4957
+ arm: {
4958
+ type: "boolean",
4959
+ 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."
4960
+ },
4961
+ timeoutMs: {
4962
+ type: "number",
4963
+ 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."
4964
+ }
4965
+ },
4966
+ required: [],
4967
+ additionalProperties: false
4968
+ }
4969
+ },
4970
+ {
4971
+ name: "grant_permissions",
4972
+ 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.",
4973
+ inputSchema: {
4974
+ type: "object",
4975
+ properties: {
4976
+ lease: {
4977
+ type: "string",
4978
+ 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."
4979
+ },
4980
+ target: {
4981
+ type: "string",
4982
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'. Used for the lease check and, when 'origin' is omitted, as the source of the origin to grant for."
4983
+ },
4984
+ permissions: {
4985
+ type: "array",
4986
+ items: {
4987
+ type: "string"
4988
+ },
4989
+ 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."
4990
+ },
4991
+ origin: {
4992
+ type: "string",
4993
+ 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."
4994
+ },
4995
+ reset: {
4996
+ type: "boolean",
4997
+ 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."
4998
+ }
4999
+ },
5000
+ required: [],
5001
+ additionalProperties: false
5002
+ }
5003
+ },
3772
5004
  {
3773
5005
  name: "fill",
3774
5006
  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).",
@@ -3781,7 +5013,7 @@ var MANIFEST = [
3781
5013
  },
3782
5014
  target: {
3783
5015
  type: "string",
3784
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5016
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3785
5017
  },
3786
5018
  uid: {
3787
5019
  type: "number",
@@ -3814,7 +5046,7 @@ var MANIFEST = [
3814
5046
  },
3815
5047
  target: {
3816
5048
  type: "string",
3817
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5049
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3818
5050
  },
3819
5051
  fields: {
3820
5052
  type: "array",
@@ -3860,7 +5092,7 @@ var MANIFEST = [
3860
5092
  },
3861
5093
  target: {
3862
5094
  type: "string",
3863
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5095
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3864
5096
  },
3865
5097
  uid: {
3866
5098
  type: "number",
@@ -3893,7 +5125,7 @@ var MANIFEST = [
3893
5125
  },
3894
5126
  target: {
3895
5127
  type: "string",
3896
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5128
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3897
5129
  },
3898
5130
  key: {
3899
5131
  type: "string",
@@ -3925,7 +5157,7 @@ var MANIFEST = [
3925
5157
  },
3926
5158
  target: {
3927
5159
  type: "string",
3928
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5160
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
3929
5161
  },
3930
5162
  uid: {
3931
5163
  type: "number",
@@ -3964,7 +5196,7 @@ var MANIFEST = [
3964
5196
  },
3965
5197
  target: {
3966
5198
  type: "string",
3967
- description: "Target page selector. undefined or 'active' -> first page target; '<32-hex targetId>' -> exact target by id; 'index:N' -> Nth page target (0-based); 'url:<substring>' -> first page whose url contains substring; 'title:<substring>' -> first page whose title contains substring."
5199
+ description: "Target page selector. undefined or 'active' -> first page target; '<32-hex targetId>' -> exact target by id; 'index:N' -> Nth page target (0-based); 'url:<substring>' -> first page whose url contains substring; 'title:<substring>' -> first page whose title contains substring; 'label:<name>' -> the page with exactly this label (origin ledger or live lease)."
3968
5200
  },
3969
5201
  format: {
3970
5202
  type: "string",
@@ -4015,7 +5247,7 @@ var MANIFEST = [
4015
5247
  },
4016
5248
  target: {
4017
5249
  type: "string",
4018
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5250
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
4019
5251
  },
4020
5252
  width: {
4021
5253
  type: "number",
@@ -4115,7 +5347,7 @@ var MANIFEST = [
4115
5347
  },
4116
5348
  target: {
4117
5349
  type: "string",
4118
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5350
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
4119
5351
  },
4120
5352
  width: {
4121
5353
  type: "number",
@@ -4153,7 +5385,7 @@ var MANIFEST = [
4153
5385
  },
4154
5386
  target: {
4155
5387
  type: "string",
4156
- description: "Target page selector: 'active' (or omitted) for the first page-type target | 'index:N' (0-based) | 'url:<substring>' | 'title:<substring>' | a 32-hex <targetId>."
5388
+ description: "Target page selector: 'active' (or omitted) for the first page-type target | 'index:N' (0-based) | 'url:<substring>' | 'title:<substring>' | 'label:<name>' (exact label match, origin ledger or live lease) | a 32-hex <targetId>."
4157
5389
  },
4158
5390
  accept: {
4159
5391
  type: "boolean",
@@ -4190,7 +5422,7 @@ var MANIFEST = [
4190
5422
  },
4191
5423
  target: {
4192
5424
  type: "string",
4193
- description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
5425
+ description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
4194
5426
  },
4195
5427
  reload: {
4196
5428
  type: "boolean",
@@ -4217,7 +5449,7 @@ var MANIFEST = [
4217
5449
  },
4218
5450
  target: {
4219
5451
  type: "string",
4220
- description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
5452
+ description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
4221
5453
  },
4222
5454
  index: {
4223
5455
  type: "number",
@@ -4240,7 +5472,7 @@ var MANIFEST = [
4240
5472
  },
4241
5473
  target: {
4242
5474
  type: "string",
4243
- description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
5475
+ description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
4244
5476
  },
4245
5477
  reload: {
4246
5478
  type: "boolean",
@@ -4271,7 +5503,7 @@ var MANIFEST = [
4271
5503
  },
4272
5504
  target: {
4273
5505
  type: "string",
4274
- description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>'."
5506
+ description: "Target page selector: 'active' (default) | '<targetId>' | 'index:N' | 'url:<substring>' | 'title:<substring>' | 'label:<name>'."
4275
5507
  },
4276
5508
  requestId: {
4277
5509
  type: "string",
@@ -4306,7 +5538,7 @@ var MANIFEST = [
4306
5538
  },
4307
5539
  target: {
4308
5540
  type: "string",
4309
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5541
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
4310
5542
  },
4311
5543
  categories: {
4312
5544
  type: "array",
@@ -4332,7 +5564,7 @@ var MANIFEST = [
4332
5564
  },
4333
5565
  target: {
4334
5566
  type: "string",
4335
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5567
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
4336
5568
  }
4337
5569
  },
4338
5570
  required: [],
@@ -4370,7 +5602,7 @@ var MANIFEST = [
4370
5602
  },
4371
5603
  target: {
4372
5604
  type: "string",
4373
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5605
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
4374
5606
  },
4375
5607
  durationMs: {
4376
5608
  type: "number",
@@ -4408,7 +5640,7 @@ var MANIFEST = [
4408
5640
  },
4409
5641
  target: {
4410
5642
  type: "string",
4411
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5643
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
4412
5644
  },
4413
5645
  format: {
4414
5646
  type: "string",
@@ -4455,7 +5687,7 @@ var MANIFEST = [
4455
5687
  },
4456
5688
  target: {
4457
5689
  type: "string",
4458
- description: "Which recording to stop: 'active' (default, valid only when exactly one recording is in flight) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5690
+ description: "Which recording to stop: 'active' (default, valid only when exactly one recording is in flight) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
4459
5691
  },
4460
5692
  savePath: {
4461
5693
  type: "string",
@@ -4478,7 +5710,7 @@ var MANIFEST = [
4478
5710
  },
4479
5711
  target: {
4480
5712
  type: "string",
4481
- description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | '<targetId>'."
5713
+ description: "Target page selector: 'active' (default) | 'index:N' | 'url:<substr>' | 'title:<substr>' | 'label:<name>' | '<targetId>'."
4482
5714
  },
4483
5715
  savePath: {
4484
5716
  type: "string",
@@ -4545,7 +5777,7 @@ var MANIFEST = [
4545
5777
  },
4546
5778
  target: {
4547
5779
  type: "string",
4548
- description: "Target selector: active | index:N | url:<substr> | title:<substr> | <targetId>. Defaults to the active page."
5780
+ description: "Target selector: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>. Defaults to the active page."
4549
5781
  },
4550
5782
  urlPattern: {
4551
5783
  type: "string",
@@ -4647,13 +5879,13 @@ var MANIFEST = [
4647
5879
  },
4648
5880
  {
4649
5881
  name: "claim_page",
4650
- description: "Take exclusive ownership of a browser tab and return an opaque lease token. Two modes. FRESH TAB: with no target and no targetId, opens a new tab (optionally at url) and claims it, so 'give me my own tab' is one call. TAKEOVER: with target, claims a tab that is already open — this is how you work in a tab the human already has open when they ask you to, instead of opening your own — resolving any target selector against the live page list and never creating anything. The answer's 'opened' flag says which happened (true only when this call created the tab), and that same creation record is what release_page consults, so a tab you took over is left open when you release it. Any later tool call against a leased tab must carry the matching token in its 'lease' argument or it is refused, naming the holder. The lease is reclaimable once its owning process dies, its ttlMs elapses without use, or its tab is closed. Refused from the CLI: a CLI invocation is one process per call, so its lease would be reclaimable immediately.",
5882
+ description: "Take exclusive ownership of a browser tab and return an opaque lease token. Two modes. FRESH TAB: with no target and no targetId, opens a new tab (optionally at url) and claims it, so 'give me my own tab' is one call. TAKEOVER: with target, claims a tab that is already open — this is how you work in a tab the human already has open when they ask you to, instead of opening your own — resolving any target selector against the live page list and never creating anything. The answer's 'opened' flag says which happened (true only when this call created the tab), and that same creation record is what release_page consults, so a tab you took over is left open when you release it. Any later tool call against a leased tab must carry the matching token in its 'lease' argument or it is refused, naming the holder. The lease is reclaimable once its owning process dies, its ttlMs elapses without use, or its tab is closed. Refused from the CLI: a CLI invocation is one process per call, so its lease would be reclaimable immediately. IS SOMEBODY ELSE IN THIS TAB: the answer also carries 'humanActiveMs', milliseconds since input that this server did not dispatch — i.e. since a person last clicked, typed, or scrolled here. null means NO DATA (the tab carries no activity beacon, which is normal the first time anyone claims a person's tab, or every input on it was this server's own); it never means 'no human'. The field is absent entirely on a backend that cannot answer. On a takeover of a tab a human used within the last 30 seconds the answer additionally carries 'contention', a warning string. THE CLAIM IS NEVER REFUSED FOR THIS: taking over a person's tab is what takeover mode is for, so by the time you read the warning you already hold the lease and the tab is yours to drive. It tells you that driving it now means fighting a live person for the keyboard and mouse, so you can open your own tab instead or ask first. Known limits: input inside a cross-origin iframe is invisible to it, and a second MCP server's clicks read as human to this one.",
4651
5883
  inputSchema: {
4652
5884
  type: "object",
4653
5885
  properties: {
4654
5886
  target: {
4655
5887
  type: "string",
4656
- 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."
5888
+ 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> | label:<name> | <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."
4657
5889
  },
4658
5890
  targetId: {
4659
5891
  type: "string",
@@ -4688,7 +5920,7 @@ var MANIFEST = [
4688
5920
  },
4689
5921
  target: {
4690
5922
  type: "string",
4691
- 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."
5923
+ description: "Target selector for a tab this process holds: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>. Mutually exclusive with 'lease'. Refused if another process holds the tab."
4692
5924
  },
4693
5925
  close: {
4694
5926
  type: "boolean",
@@ -4701,7 +5933,7 @@ var MANIFEST = [
4701
5933
  },
4702
5934
  {
4703
5935
  name: "list_leases",
4704
- description: "Enumerate every active lease for diagnosis: backend, target id, agent label, pid, createdAt, lastUsedAt, ttlMs, whether the owning process is still alive, and whether the lease is reclaimable and why. Requires no token. Never returns the lease nonce, so this cannot be used to forge a token. A row for a lease file that could not be read or parsed instead carries `unreadable` (the errno, or \"unparseable\"), with label, pid, and the timestamp fields as zero placeholders and stale always false, since stale is what marks a lease free to take and an unreadable one must never read that way. Under CDP_REQUIRE_LEASE this call reaps abandoned agent tabs exactly as list_pages does, reporting them in an additive 'reaped' array and omitting their rows from the listing.",
5936
+ description: "Enumerate every active lease for diagnosis: backend, target id, agent label, pid, createdAt, lastUsedAt, ttlMs, whether the owning process is still alive, and whether the lease is reclaimable and why. Requires no token. Never returns the lease nonce, so this cannot be used to forge a token. A row for a lease file that could not be read or parsed instead carries `unreadable` (the errno, or \"unparseable\"), with label, pid, and the timestamp fields as zero placeholders and stale always false, since stale is what marks a lease free to take and an unreadable one must never read that way. Under CDP_REQUIRE_LEASE this call reaps abandoned agent tabs exactly as list_pages does (see that tool's description for the grace period between a lease reading reclaimable and its tab actually being closed), reporting them in an additive 'reaped' array and omitting their rows from the listing. A row also carries 'humanActiveMs' — milliseconds since input this server did not dispatch, i.e. since a person last used that tab — WHERE THE TAB CAN ANSWER. The field is ABSENT rather than null when it cannot: no activity beacon on that tab, every input on it accounted for by this server, a row belonging to the other backend, a tab no longer open, or a page that would not evaluate. Absence therefore means 'no answer', never 'nobody is there'. Note that lastUsedAt measures TOOLKIT calls only, so a tab a person has been typing in for ten minutes looks perfectly idle by that field and is exactly the case humanActiveMs exists to expose. Two further fields are computed fresh on every call, never read off disk: 'idleMs' (now minus lastUsedAt) and 'expiresAt' (lastUsedAt plus ttlMs — the same value claim_page already returns under that name). Both are omitted on an 'unreadable' row, whose lastUsedAt and ttlMs are the zeroed placeholders described above.",
4705
5937
  inputSchema: {
4706
5938
  type: "object",
4707
5939
  properties: {},
@@ -5465,6 +6697,8 @@ var KEY_VALUES = {
5465
6697
  };
5466
6698
  var MODIFIER_VALUES = { shift: "", control: "", ctrl: "", alt: "", meta: "", cmd: "", command: "" };
5467
6699
  var BUTTON_CODES = { left: 0, middle: 1, right: 2 };
6700
+ 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);});}";
6701
+ var AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}";
5468
6702
  var BIDI_CAPABILITIES = new Set([
5469
6703
  "emulate.deviceMetrics",
5470
6704
  "screenshot.fullPage",
@@ -5486,6 +6720,7 @@ async function getConnection(port, timeoutMs) {
5486
6720
  connections.set(port, conn);
5487
6721
  return conn;
5488
6722
  }
6723
+ var bidiBeaconScripts = new Map;
5489
6724
  async function safeTitle(conn, contextId) {
5490
6725
  try {
5491
6726
  const res = await conn.send("script.evaluate", { expression: "document.title", target: { context: contextId }, awaitPromise: false });
@@ -5530,6 +6765,22 @@ async function pickContext(conn, contexts, selector) {
5530
6765
  return c;
5531
6766
  throw driverError2("no-such-target", `no context with title containing '${needle}'`);
5532
6767
  }
6768
+ if (selector.startsWith("label:")) {
6769
+ const label = selector.slice(6);
6770
+ const { matchIds, knownLabels } = await resolveLiveLabel("firefox", label, contexts.map((c) => c.context));
6771
+ if (matchIds.length > 1) {
6772
+ throw driverError2("no-such-target", `label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
6773
+ }
6774
+ if (matchIds.length === 1) {
6775
+ const hit = contexts.find((c) => c.context === matchIds[0]);
6776
+ if (hit)
6777
+ return hit;
6778
+ }
6779
+ throw driverError2("no-such-target", knownLabels.length ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})` : `no live target with label '${label}' (no labels are currently assigned to any open target)`);
6780
+ }
6781
+ if (isWorkerSelector(selector)) {
6782
+ throw driverError2("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
6783
+ }
5533
6784
  const exact = contexts.find((c) => c.context === selector);
5534
6785
  if (exact)
5535
6786
  return exact;
@@ -5549,9 +6800,31 @@ class BidiPageDriver {
5549
6800
  this.info = info;
5550
6801
  this.browser = browser;
5551
6802
  }
6803
+ async urlAndReadyState() {
6804
+ const raw = await this.conn.send("script.evaluate", {
6805
+ expression: "location.href + '\\u0000' + document.readyState",
6806
+ target: { context: this.contextId },
6807
+ awaitPromise: false
6808
+ }).then((r) => r.type === "success" ? String(deserializeRemote(r.result) ?? "") : "").catch(() => "");
6809
+ const [url = "", ready = ""] = raw.split("\x00");
6810
+ return [url, ready];
6811
+ }
6812
+ async settleAfterTraversal(beforeUrl, waitUntil, timeoutMs) {
6813
+ const ready = waitUntil === "domcontentloaded" ? ["interactive", "complete"] : ["complete"];
6814
+ const deadline = Date.now() + timeoutMs;
6815
+ for (;; ) {
6816
+ const [url, state] = await this.urlAndReadyState();
6817
+ if (url && url !== beforeUrl && ready.includes(state))
6818
+ return { waitedFor: waitUntil, url };
6819
+ if (Date.now() >= deadline)
6820
+ return { waitedFor: "timeout", url: url || beforeUrl };
6821
+ await new Promise((r) => setTimeout(r, 50));
6822
+ }
6823
+ }
5552
6824
  async navigate(opts) {
5553
6825
  const reload = opts.reload === true;
5554
- if (!reload && !opts.url)
6826
+ const history = opts.history;
6827
+ if (!reload && !history && !opts.url)
5555
6828
  throw driverError2("page-error", "navigate: 'url' is required unless reload:true");
5556
6829
  const waitUntil = opts.waitUntil ?? "load";
5557
6830
  const wait = waitUntil === "domcontentloaded" ? "interactive" : "complete";
@@ -5561,6 +6834,17 @@ class BidiPageDriver {
5561
6834
  const r2 = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
5562
6835
  return { url: r2.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
5563
6836
  }
6837
+ if (history) {
6838
+ const [beforeUrl] = await this.urlAndReadyState();
6839
+ await this.conn.send("browsingContext.traverseHistory", { context: this.contextId, delta: history === "back" ? -1 : 1 }, sendOpts).catch((e) => {
6840
+ const msg = e instanceof Error ? e.message : String(e);
6841
+ if (/no such history entry|history/i.test(msg))
6842
+ throw driverError2("page-error", `navigate: no history entry to go ${history} to (${msg})`);
6843
+ throw e;
6844
+ });
6845
+ const settled = await this.settleAfterTraversal(beforeUrl, waitUntil, opts.timeoutMs ?? 15000);
6846
+ return { url: settled.url, contextId: this.contextId, traversed: history, waitedFor: settled.waitedFor };
6847
+ }
5564
6848
  const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url, wait }, sendOpts);
5565
6849
  return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
5566
6850
  } catch (e) {
@@ -5643,6 +6927,7 @@ class BidiPageDriver {
5643
6927
  throw driverError2("stale-uid", `uid does not resolve: ${uid}`);
5644
6928
  }
5645
6929
  async performActions(actions) {
6930
+ recordDispatch("firefox", this.contextId);
5646
6931
  try {
5647
6932
  await this.conn.send("input.performActions", { context: this.contextId, actions });
5648
6933
  } catch (e) {
@@ -5663,6 +6948,9 @@ class BidiPageDriver {
5663
6948
  return { x, y };
5664
6949
  }
5665
6950
  async click(loc, opts) {
6951
+ if (opts?.modifiers && opts.modifiers.length > 0) {
6952
+ throw driverError2("unsupported", "click: 'modifiers' is not supported by the Firefox/BiDi driver; omit 'modifiers' or use --browser chrome for a modifier click.");
6953
+ }
5666
6954
  const ref = await resolveElementLocator2(this.conn, this.contextId, loc);
5667
6955
  const { x, y } = await this.centerOf(ref);
5668
6956
  const button = BUTTON_CODES[opts?.button ?? "left"];
@@ -5672,19 +6960,53 @@ class BidiPageDriver {
5672
6960
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
5673
6961
  return { x, y };
5674
6962
  }
5675
- async drag(from, to) {
6963
+ async scroll(anchor, opts) {
6964
+ const { x, y } = await this.resolveScrollPoint(anchor);
6965
+ const deltaX = Math.round(opts.deltaX ?? 0);
6966
+ const deltaY = Math.round(opts.deltaY ?? 0);
6967
+ await this.callFunctionValue(ARM_SCROLL_SETTLE_WATCH_SOURCE, []);
6968
+ const actions = [{ type: "scroll", x: Math.round(x), y: Math.round(y), deltaX, deltaY }];
6969
+ await this.performActions([{ type: "wheel", id: "cdp-wheel", actions }]);
6970
+ await this.callFunctionValue(AWAIT_SCROLL_SETTLE_SOURCE, []).catch(() => {
6971
+ return;
6972
+ });
6973
+ return { x, y };
6974
+ }
6975
+ async resolveScrollPoint(anchor) {
6976
+ if (anchor === undefined) {
6977
+ const v = await this.callFunctionValue("function(){return {x: window.innerWidth / 2, y: window.innerHeight / 2};}", []);
6978
+ if (!v || typeof v !== "object")
6979
+ throw driverError2("page-error", "could not measure viewport for scroll center");
6980
+ return v;
6981
+ }
6982
+ if ("x" in anchor)
6983
+ return anchor;
6984
+ return this.centerOf(await resolveElementLocator2(this.conn, this.contextId, anchor));
6985
+ }
6986
+ async drag(from, to, opts) {
6987
+ if (opts?.mode === "html5") {
6988
+ 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.");
6989
+ }
5676
6990
  const fromPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, from));
5677
- const toPt = await this.centerOf(await resolveElementLocator2(this.conn, this.contextId, to));
6991
+ const toPt = await this.resolveDragDestination(to, fromPt);
6992
+ const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
5678
6993
  const actions = [
5679
6994
  { type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 },
5680
- { type: "pointerDown", button: 0 },
5681
- { type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
5682
- { type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 },
5683
- { type: "pointerUp", button: 0 }
6995
+ { type: "pointerDown", button: 0 }
5684
6996
  ];
6997
+ for (const pt of interpolatePoints(fromPt, toPt, steps))
6998
+ actions.push({ type: "pointerMove", x: pt.x, y: pt.y, duration: 50 });
6999
+ actions.push({ type: "pointerUp", button: 0 });
5685
7000
  await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
5686
7001
  return { from: fromPt, to: toPt };
5687
7002
  }
7003
+ async resolveDragDestination(to, fromPt) {
7004
+ if ("dx" in to)
7005
+ return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
7006
+ if ("x" in to)
7007
+ return to;
7008
+ return this.centerOf(await resolveElementLocator2(this.conn, this.contextId, to));
7009
+ }
5688
7010
  async sendKeys(text) {
5689
7011
  if (!text.length)
5690
7012
  return;
@@ -6054,7 +7376,60 @@ class BidiBrowserDriver {
6054
7376
  const info = await pageInfoOf(conn, ctx);
6055
7377
  return new BidiPageDriver(conn, ctx.context, info, this);
6056
7378
  }
7379
+ async installActivityBeacon(contextId) {
7380
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => {
7381
+ return;
7382
+ });
7383
+ if (!conn)
7384
+ return false;
7385
+ try {
7386
+ const key = `${this.port}:${contextId}`;
7387
+ if (!bidiBeaconScripts.has(key)) {
7388
+ const registered = await conn.send("script.addPreloadScript", {
7389
+ functionDeclaration: BEACON_FUNCTION_DECLARATION,
7390
+ contexts: [contextId]
7391
+ });
7392
+ bidiBeaconScripts.set(key, registered.script);
7393
+ }
7394
+ await conn.send("script.evaluate", {
7395
+ expression: BEACON_SOURCE,
7396
+ target: { context: contextId },
7397
+ awaitPromise: false
7398
+ });
7399
+ return true;
7400
+ } catch {
7401
+ return false;
7402
+ } finally {
7403
+ conn.release();
7404
+ }
7405
+ }
7406
+ async readActivityBeacon(contextId) {
7407
+ const conn = await getConnection(this.port, this.timeoutMs).catch(() => {
7408
+ return;
7409
+ });
7410
+ if (!conn)
7411
+ return null;
7412
+ try {
7413
+ const res = await conn.send("script.evaluate", {
7414
+ expression: BEACON_READ_EXPRESSION,
7415
+ target: { context: contextId },
7416
+ awaitPromise: false
7417
+ });
7418
+ if (res.type !== "success")
7419
+ return null;
7420
+ const value = deserializeRemote(res.result);
7421
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
7422
+ } catch {
7423
+ return null;
7424
+ } finally {
7425
+ conn.release();
7426
+ }
7427
+ }
6057
7428
  async dispose() {
7429
+ for (const key of [...bidiBeaconScripts.keys()]) {
7430
+ if (key.startsWith(`${this.port}:`))
7431
+ bidiBeaconScripts.delete(key);
7432
+ }
6058
7433
  const conn = connections.get(this.port);
6059
7434
  if (!conn)
6060
7435
  return;
@@ -6071,7 +7446,7 @@ import { spawn as spawn3 } from "node:child_process";
6071
7446
  import { mkdtempSync, rmSync } from "node:fs";
6072
7447
  import { createConnection, createServer } from "node:net";
6073
7448
  import { tmpdir } from "node:os";
6074
- import { join as join7 } from "node:path";
7449
+ import { join as join9 } from "node:path";
6075
7450
  function resolveBinary() {
6076
7451
  const candidates = [
6077
7452
  process.env.FIREFOX_BIN,
@@ -6145,7 +7520,7 @@ async function launchFirefox(opts = {}) {
6145
7520
  const binary = resolveBinary();
6146
7521
  const port = await pickPort(opts.port);
6147
7522
  const isThrowaway = opts.profilePath === undefined;
6148
- const profilePath = opts.profilePath ?? mkdtempSync(join7(tmpdir(), "cdp-toolkit-ff-profile-"));
7523
+ const profilePath = opts.profilePath ?? mkdtempSync(join9(tmpdir(), "cdp-toolkit-ff-profile-"));
6149
7524
  const args = ["--profile", profilePath, "--remote-debugging-port", String(port), "--no-remote"];
6150
7525
  if (opts.headless)
6151
7526
  args.push("--headless");
@@ -6415,7 +7790,7 @@ var FIREFOX_TOOLS = {
6415
7790
  };
6416
7791
 
6417
7792
  // src/mcp.ts
6418
- var VERSION = "1.7.0";
7793
+ var VERSION = "1.9.0";
6419
7794
  var BROWSER = resolveBrowserKind(stripBrowserFlag(process.argv.slice(2)));
6420
7795
  var AVAILABILITY = toolAvailability(BROWSER);
6421
7796
  var AVAILABLE_NAMES = new Set(AVAILABILITY.available);
@@ -6462,8 +7837,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
6462
7837
  if (!neutralFn)
6463
7838
  return { content: [{ type: "text", text: `unknown tool: ${name}` }], isError: true };
6464
7839
  try {
6465
- const session = await getOrCreateFirefoxSession();
6466
- const result = await neutralFn(session.driver, args);
7840
+ const session2 = await getOrCreateFirefoxSession();
7841
+ const result = await neutralFn(session2.driver, args);
6467
7842
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
6468
7843
  } catch (err) {
6469
7844
  const message = err instanceof Error ? err.message : String(err);
@@ -6481,6 +7856,7 @@ async function shutdown() {
6481
7856
  return;
6482
7857
  shuttingDown = true;
6483
7858
  await disposeFirefoxSession();
7859
+ await disposeBrowserSession();
6484
7860
  process.exit(0);
6485
7861
  }
6486
7862
  process.on("SIGINT", () => void shutdown());