cdp-toolkit 1.6.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/driver.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * ADR-001: one browser-neutral Driver interface behind the 41 tools.
2
+ * ADR-001: one browser-neutral Driver interface behind the 45 tools.
3
3
  *
4
4
  * Context. Every tool in src/tools/ is a stateless one-shot on withPage():
5
5
  * open a socket, act, always close. That is only correct because a CDP `Uid`
@@ -138,7 +138,11 @@ export type Capability =
138
138
  | "snapshot.accessibilityTree" // native a11y dump, not a DOM-walk approximation
139
139
  | "input.insertTextAtomic" // atomic value commit, not synthesized keystrokes
140
140
  | "locate.text" // find a node by visible text substring
141
- | "locate.xpath"; // find a node by xpath
141
+ | "locate.xpath" // find a node by xpath
142
+ | "input.raw" // raw single mouse-event dispatch (dispatch_mouse's move/down/up primitive); Chrome only
143
+ | "input.html5Drag" // real HTML5 drag events (drag's mode:"html5"); Chrome only, see PageDriver.drag
144
+ | "browser.downloads" // capture a file download to a known path (wait_for_download); Chrome only
145
+ | "browser.permissions"; // grant/reset browser permissions for an origin (grant_permissions); Chrome only
142
146
 
143
147
  /**
144
148
  * Tools whose availability depends on a capability. A tool absent from this
@@ -157,6 +161,9 @@ export const REQUIRED_CAPABILITIES: Partial<Record<ToolName, readonly Capability
157
161
  mock_request: ["network.intercept"],
158
162
  list_mocks: ["network.intercept"],
159
163
  clear_mocks: ["network.intercept"],
164
+ dispatch_mouse: ["input.raw"],
165
+ wait_for_download: ["browser.downloads"],
166
+ grant_permissions: ["browser.permissions"],
160
167
  } as const;
161
168
 
162
169
  /* --------------------------------- errors --------------------------------- */
@@ -213,6 +220,19 @@ export interface NavigateOptions {
213
220
  url?: string;
214
221
  reload?: boolean;
215
222
  ignoreCache?: boolean;
223
+ /**
224
+ * Traverse the tab's session history instead of loading a URL: the browser Back / Forward
225
+ * buttons. Mutually exclusive with `url` and `reload` (the tool layer enforces that; a driver
226
+ * trusts its caller). Chrome: Page.getNavigationHistory + Page.navigateToHistoryEntry. Firefox:
227
+ * browsingContext.traverseHistory{delta:-1|+1}. Universal — both drivers implement it, so this is
228
+ * deliberately NOT a Capability.
229
+ *
230
+ * NO SILENT NO-OP. Asking to go back from the first history entry (or forward from the last) is a
231
+ * caller mistake, not a legal outcome: both drivers MUST throw an error naming the direction
232
+ * rather than return a success that navigated nowhere, because a no-op success reads as "you are
233
+ * on the previous page" and the caller acts on that.
234
+ */
235
+ history?: "back" | "forward";
216
236
  waitUntil?: "load" | "domcontentloaded";
217
237
  timeoutMs?: number;
218
238
  }
@@ -222,12 +242,93 @@ export interface NavigateResult {
222
242
  /** CDP frameId or BiDi context id. Opaque. */
223
243
  contextId: string;
224
244
  reloaded?: boolean;
245
+ /** Which direction a `history` traversal actually moved. Present only for a history navigation,
246
+ * the same way `reloaded` is present only for a reload. */
247
+ traversed?: "back" | "forward";
225
248
  waitedFor: "load" | "domcontentloaded" | "committed" | "timeout";
226
249
  }
227
250
 
228
251
  export interface MouseButtonOptions {
229
252
  button?: "left" | "right" | "middle";
230
253
  clickCount?: number;
254
+ /** "Alt" | "Control" | "Meta" | "Shift", held for the press/release. CDP bits: Alt=1,
255
+ * Control=2, Meta=4, Shift=8 (cdp/driver.ts's inputModifierBits). A non-empty array is
256
+ * "unsupported" on the Firefox/BiDi driver today: see click()'s implementation note. */
257
+ modifiers?: readonly string[];
258
+ }
259
+
260
+ /**
261
+ * Where a drag ends. 1.8.0 Track P2 widened this past "an element": an absolute viewport point
262
+ * ({x,y}) and an offset from the source point ({dx,dy}) are both real destinations for widgets
263
+ * that have no droppable element to name — a range slider's track, a map canvas, a resize gutter.
264
+ * The three branches are discriminated structurally ("dx" in / "x" in), the same way
265
+ * PageDriver.scroll's anchor already discriminates an ElementLocator from a viewport point; no
266
+ * ElementLocator variant carries an `x` or `dx` field, so the check is exact and not a heuristic.
267
+ */
268
+ export type DragDestination = ElementLocator | { x: number; y: number } | { dx: number; dy: number };
269
+
270
+ export interface DragOptions {
271
+ /**
272
+ * "mouse" (default): synthetic mousePressed/mouseMoved…/mouseReleased. Fine for widgets built on
273
+ * raw pointer events, and it is what `drag` has always done.
274
+ *
275
+ * "html5": a REAL HTML5 drag — Input.setInterceptDrags(true), the same press/move sequence, then
276
+ * the intercepted DragData replayed as Input.dispatchDragEvent dragEnter/dragOver/drop at the
277
+ * destination.
278
+ *
279
+ * WHAT THE DIFFERENCE ACTUALLY IS (measured on Chrome 151.0.7922.109, macOS, both --headless=new
280
+ * and headed — NOT assumed). Synthetic mouse events DO start a real drag in modern Chrome, so
281
+ * mouse mode is not inert on HTML5 drop zones the way the folklore says. What mouse mode does
282
+ * not control is WHICH drag events the page receives: that follows the interpolated pointer
283
+ * path, and at the default steps:2 a source-to-zone drag delivered dragstart and drop but ZERO
284
+ * dragover. A drop zone written the standard way — preventDefault inside dragover, which is how
285
+ * the HTML spec says you mark a valid drop target — therefore refused the drop outright, while
286
+ * steps:1 and steps:8 on the very same page happened to work. html5 mode removes the luck: the
287
+ * dragEnter/dragOver/drop triple is dispatched explicitly at the destination every time.
288
+ *
289
+ * Requires Capability "input.html5Drag" — Chrome only. A driver without that capability MUST
290
+ * throw "unsupported"; it must never silently downgrade to mouse mode, because a caller who
291
+ * asked for html5 asked precisely because mouse mode was not delivering the drop.
292
+ */
293
+ mode?: "mouse" | "html5";
294
+ /** Interpolated mouseMoved events dispatched between the source and destination points, evenly
295
+ * spaced, the last one landing exactly on the destination. Default 2 (the pre-1.8.0 hardcoded
296
+ * behavior: midpoint, then destination). DnD libraries with a movement threshold or per-frame
297
+ * sampling need more. The tool layer validates the range; a driver trusts its caller. */
298
+ steps?: number;
299
+ }
300
+
301
+ /**
302
+ * The `steps` evenly-spaced points between `from` (exclusive) and `to` (inclusive), for a drag's
303
+ * interpolated move sequence. The last point is EXACTLY `to`, emitted as `to` itself rather than
304
+ * computed, so float error can never leave the pointer a fraction of a pixel short of the
305
+ * destination element. steps:1 is a single jump to the destination; steps:2 is
306
+ * midpoint-then-destination, the pre-1.8.0 hardcoded behavior.
307
+ *
308
+ * Lives here, in the backend-neutral module, because BOTH drivers need the identical geometry:
309
+ * the CDP driver turns each point into an Input.dispatchMouseEvent{mouseMoved} and the BiDi driver
310
+ * into a performActions pointerMove, but "where the pointer goes" must not differ by backend.
311
+ * Callers pass an already-validated integer >= 1 (the tool layer validates; a driver trusts it).
312
+ */
313
+ export function interpolatePoints(
314
+ from: { x: number; y: number },
315
+ to: { x: number; y: number },
316
+ steps: number,
317
+ ): Array<{ x: number; y: number }> {
318
+ const out: Array<{ x: number; y: number }> = [];
319
+ for (let i = 1; i < steps; i++) {
320
+ out.push({ x: from.x + ((to.x - from.x) * i) / steps, y: from.y + ((to.y - from.y) * i) / steps });
321
+ }
322
+ out.push({ x: to.x, y: to.y });
323
+ return out;
324
+ }
325
+
326
+ export interface ScrollOptions {
327
+ /** Positive scrolls DOWN, positive scrolls RIGHT: wheel-event convention. The tool layer
328
+ * (shared-tools.ts) requires at least one of deltaX/deltaY; a driver trusts its caller and
329
+ * does not re-validate. */
330
+ deltaX?: number;
331
+ deltaY?: number;
231
332
  }
232
333
 
233
334
  export interface KeyPress {
@@ -414,6 +515,54 @@ export interface BrowserDriver {
414
515
  /** Acquire a page handle. The caller MUST release() it. Prefer withDriver. */
415
516
  page(selector: TargetSelector): Promise<PageDriver>;
416
517
 
518
+ /**
519
+ * Install the activity beacon into a target and, where the backend can, into
520
+ * every document it navigates to afterwards. Resolves true when the beacon is
521
+ * in place. See src/activity.ts.
522
+ *
523
+ * OPTIONAL, per ADR-001's "absent rather than present-and-throwing" rule, but
524
+ * NOT a `Capability`: that union gates whole tools out of tools/list, and no
525
+ * tool is gated here. The beacon is an additive FIELD on claim_page and
526
+ * list_leases, both of which work unchanged without it, so the honest
527
+ * expression of a backend that cannot do this is an absent method producing an
528
+ * absent field — not a missing tool and not a throw.
529
+ */
530
+ installActivityBeacon?(targetId: string): Promise<boolean>;
531
+
532
+ /**
533
+ * Read the beacon's timestamp for a target, or null when the target has no
534
+ * beacon. WITHOUT THE LEASE GATE, which is required rather than convenient:
535
+ * claim_page{target} must read this BEFORE claiming, and under
536
+ * CDP_REQUIRE_LEASE a gated read would auto-acquire the lease and then collide
537
+ * with the explicit claim behind it (the same reason pickPage is gate-free;
538
+ * see leases-tools.ts's header).
539
+ *
540
+ * This is not a general gate bypass and must never become one: it returns one
541
+ * number and takes no expression, so it cannot be made to read or change
542
+ * anything else about the page.
543
+ */
544
+ readActivityBeacon?(targetId: string): Promise<number | null>;
545
+
546
+ /**
547
+ * Bounded liveness probe behind list_pages{probe:true}: run ONE Runtime.evaluate
548
+ * (or backend equivalent) capped at `timeoutMs`, and report whether it
549
+ * answered in time plus whatever the activity beacon said in that same round
550
+ * trip. See src/activity.ts's RENDERER_PROBE_EXPRESSION for exactly what is
551
+ * evaluated and why.
552
+ *
553
+ * MUST NEVER REJECT. A timeout, a thrown page-side exception, a dead
554
+ * connection, or a target that no longer exists are all
555
+ * `{responsive:false, beaconTs:null}` — indistinguishable from each other and
556
+ * from a genuinely wedged renderer, which is the honest answer: none of them
557
+ * let the caller drive that tab any faster than this call already told it.
558
+ *
559
+ * OPTIONAL and NOT a `Capability`, for the same reason installActivityBeacon
560
+ * and readActivityBeacon are not: no whole tool is gated, only an additive
561
+ * field on list_pages, so the honest expression of a backend that cannot do
562
+ * this is an absent method producing an absent field.
563
+ */
564
+ probeRenderer?(targetId: string, timeoutMs?: number): Promise<{ responsive: boolean; beaconTs: number | null }>;
565
+
417
566
  /** Tear down the transport: process exit (CLI) or shutdown (MCP server).
418
567
  * Idempotent. MUST NOT be called by a tool. */
419
568
  dispose(): Promise<void>;
@@ -446,7 +595,16 @@ export interface PageDriver {
446
595
  /* input */
447
596
  click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }>;
448
597
  hover(loc: ElementLocator): Promise<{ x: number; y: number }>;
449
- drag(from: ElementLocator, to: ElementLocator): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }>;
598
+ /** Press at `from`, move to `to`, release. `to` is an element, an absolute viewport point, or an
599
+ * offset from the resolved source point (DragDestination). opts.mode picks synthetic-mouse vs.
600
+ * real HTML5 drag events; opts.steps sets how many interpolated moves are dispatched. */
601
+ drag(from: ElementLocator, to: DragDestination, opts?: DragOptions): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }>;
602
+ /** Dispatch a wheel/scroll event at an anchor point. `anchor` is an ElementLocator (scrolled
603
+ * into view first, exactly like click/hover), an absolute viewport point, or `undefined` for
604
+ * the viewport center. Chrome: Input.dispatchMouseEvent{type:"mouseWheel"}. Firefox: BiDi's
605
+ * "wheel" input source via input.performActions. Universal: both drivers must implement it
606
+ * (absent from driver.ts's REQUIRED_CAPABILITIES, unlike dispatch_mouse). */
607
+ scroll(anchor: ElementLocator | { x: number; y: number } | undefined, opts: ScrollOptions): Promise<{ x: number; y: number }>;
450
608
  /** Overwrite an element's value. One atomic commit under
451
609
  * "input.insertTextAtomic", else synthesized keystrokes, so per-key handlers
452
610
  * on the page WILL fire. Callers must not depend on either behavior. */
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Two exports:
5
5
  * 1. `TOOLS` : the registry mapping every chrome-devtools-mcp tool name
6
6
  * (snake_case string, all 29 of them) plus the toolkit's own superset
7
- * tools, 41 entries in all, to its raw-CDP implementation function. The
7
+ * tools, 45 entries in all, to its raw-CDP implementation function. The
8
8
  * CLI and any embedder dispatch through this single table.
9
9
  * 2. The client primitives, re-exported so consumers can build their own
10
10
  * flows on the same connection/timeout machinery the tools use.
@@ -41,6 +41,11 @@ import { takeHeapsnapshot } from "./tools/heap.ts";
41
41
  import { lighthouseAudit } from "./tools/lighthouse.ts";
42
42
  // --- network mocking (Fetch domain) : toolkit addition beyond the 29 parity tools ---
43
43
  import { mockRequest, listMocks, clearMocks } from "./tools/network_mock.ts";
44
+ // --- raw mouse dispatch (Input.dispatchMouseEvent, one event per call) : chrome-only, capability "input.raw" ---
45
+ import { dispatchMouse } from "./tools/dispatch-mouse.ts";
46
+ // --- downloads + permissions (browser-endpoint state on a standing connection) : chrome-only, 1.8.0 Track P3 ---
47
+ import { waitForDownload } from "./tools/downloads.ts";
48
+ import { grantPermissions } from "./tools/permissions.ts";
44
49
 
45
50
  /** A toolkit tool: a single typed-args function returning JSON-serializable data. */
46
51
  export type ToolFn = (args: never) => Promise<unknown>;
@@ -67,8 +72,16 @@ function onCdp<A>(fn: (driver: ReturnType<typeof createCdpDriver>, args: A) => P
67
72
  * opt-in tab ownership so many agents can share one browser), plus a 2-tool
68
73
  * screen-recording pair (start_screen_recording/stop_screen_recording : a tab
69
74
  * captured to an H.265 MP4 with per-frame durations, so a variable-rate
70
- * repaint stream plays back at wallclock speed).
71
- * 29 + 1 + 3 + 3 + 3 + 2 = 41 entries total. Listed explicitly so the mapping is auditable at a glance and
75
+ * repaint stream plays back at wallclock speed), plus a 2-tool input-parity
76
+ * addition (1.8.0 Track P1: `scroll`, a wheel-event dispatch at an anchor
77
+ * point sharing click/hover's element-locator and lease-gate contract; and
78
+ * `dispatch_mouse`, the raw move/down/up mouse-event primitive composable
79
+ * into anything a physical mouse can do, chrome-only per capability "input.raw"), plus a 2-tool
80
+ * browser-state addition (1.8.0 Track P3: `wait_for_download`, which captures a file download to a
81
+ * named path, and `grant_permissions`, which answers permission prompts for an origin; both
82
+ * chrome-only and both driven from a standing browser-endpoint connection, see
83
+ * tools/browser-session.ts).
84
+ * 29 + 1 + 3 + 3 + 3 + 2 + 2 + 2 = 45 entries total. Listed explicitly so the mapping is auditable at a glance and
72
85
  * the CLI can `--list` it.
73
86
  *
74
87
  * 20 of the 29 MCP-parity tools (pages/navigation/evaluate/snapshot/interaction/
@@ -93,10 +106,11 @@ export const TOOLS = {
93
106
  delete_cookies: onCdp(SHARED_TOOLS.delete_cookies),
94
107
  // snapshot (1)
95
108
  take_snapshot: onCdp(SHARED_TOOLS.take_snapshot),
96
- // interaction (8)
109
+ // interaction (9) : scroll is 1.8.0 Track P1, a toolkit addition beyond the 29 parity tools
97
110
  click: onCdp(SHARED_TOOLS.click),
98
111
  hover: onCdp(SHARED_TOOLS.hover),
99
112
  drag: onCdp(SHARED_TOOLS.drag),
113
+ scroll: onCdp(SHARED_TOOLS.scroll),
100
114
  fill: onCdp(SHARED_TOOLS.fill),
101
115
  fill_form: onCdp(SHARED_TOOLS.fill_form),
102
116
  type_text: onCdp(SHARED_TOOLS.type_text),
@@ -129,6 +143,14 @@ export const TOOLS = {
129
143
  mock_request: mockRequest,
130
144
  list_mocks: listMocks,
131
145
  clear_mocks: clearMocks,
146
+ // raw mouse dispatch (1) : 1.8.0 Track P1 toolkit addition, chrome-only (capability "input.raw")
147
+ dispatch_mouse: dispatchMouse,
148
+ // downloads + permissions (2) : 1.8.0 Track P3 toolkit additions, chrome-only (capabilities
149
+ // "browser.downloads" / "browser.permissions"). Both drive browser-endpoint state that Chrome
150
+ // reverts when the issuing client disconnects, so both run on tools/browser-session.ts's standing
151
+ // connection rather than a per-call socket : see that module's header for the measurements.
152
+ wait_for_download: waitForDownload,
153
+ grant_permissions: grantPermissions,
132
154
  // tab leases (3) : opt-in ownership so many agents can share one browser
133
155
  claim_page: onCdp(LEASE_TOOLS.claim_page),
134
156
  release_page: onCdp(LEASE_TOOLS.release_page),
@@ -3,14 +3,21 @@
3
3
  * ((driver, args) => Promise<result>) so ONE implementation serves Chrome and
4
4
  * Firefox, exactly as the 23 unified tools in that file do.
5
5
  */
6
+ import { beaconSupported, contentionWarning, installBeacon, readHumanActiveMs } from "./activity.ts";
6
7
  import type { BrowserDriver } from "./driver.ts";
7
- import { newTrackedPage } from "./origins.ts";
8
+ import { newTrackedPage, readOrigin } from "./origins.ts";
9
+ import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
10
+ import { pickPage, resolvePage } from "./shared-tools.ts";
11
+ import type { TargetSelector } from "./types.ts";
8
12
  import {
9
13
  claimLease,
10
14
  defaultLabel,
11
15
  leaseTtlMs,
12
16
  listLeases,
13
17
  releaseLease,
18
+ releaseLeaseFor,
19
+ requireLease,
20
+ tokenParts,
14
21
  type LeaseBackend,
15
22
  type LeaseSummary,
16
23
  type LeaseToken,
@@ -31,41 +38,142 @@ export interface ClaimPageResult {
31
38
  label: string;
32
39
  ttlMs: number;
33
40
  expiresAt: number;
41
+ /** true when this call CREATED the tab, false when it claimed one that was
42
+ * already open. The same distinction the creation ledger records, surfaced
43
+ * in the answer so a caller knows without a second lookup whether
44
+ * release_page will close the tab. */
45
+ opened: boolean;
46
+ /**
47
+ * Milliseconds since the last input this server cannot account for — i.e.
48
+ * since a HUMAN last touched this tab. See src/activity.ts for how that
49
+ * attribution is made and what it cannot prove.
50
+ *
51
+ * `null` means NO DATA, and never "no human": the tab carries no beacon (the
52
+ * usual case the first time anyone claims a person's tab), or every timestamp
53
+ * on it is explained by this server's own dispatches. ABSENT ENTIRELY on a
54
+ * backend with no beacon support, so a caller can tell "this browser cannot
55
+ * answer" from "this browser answered, and the answer is no data".
56
+ */
57
+ humanActiveMs?: number | null;
58
+ /**
59
+ * Present only on a TAKEOVER of a tab a human used within the last 30s. A
60
+ * warning, never a refusal — the claim in this same result already succeeded.
61
+ * See contentionWarning in src/activity.ts for why refusing would be wrong.
62
+ */
63
+ contention?: string;
34
64
  }
35
65
 
36
66
  /**
37
- * Claim a tab. With no targetId, opens a fresh tab first (optionally at `url`)
38
- * and claims that, so "give me my own tab" is one call. With a targetId, claims
39
- * an already-open tab.
67
+ * Claim a tab, in one of two modes.
68
+ *
69
+ * OPEN: with neither `target` nor `targetId`, opens a fresh tab (optionally at
70
+ * `url`) and claims that, so "give me my own tab" is one call.
71
+ *
72
+ * TAKE OVER: with `target`, claims a tab that is ALREADY OPEN — the first-class
73
+ * path for "work in the tab I have open", which previously had none, because
74
+ * `targetId` demands an exact id a human never has to hand and no id at all
75
+ * meant a brand new tab. `target` takes the toolkit's whole selector grammar
76
+ * (active | index:N | url:<substr> | title:<substr> | <targetId>).
77
+ *
78
+ * `targetId` is unchanged and still accepted; `target` is the same thing with a
79
+ * grammar, so exactly one of the two may be given.
80
+ *
81
+ * WHY RESOLUTION IS GATE-FREE HERE. `target` resolves through pickPage, NOT
82
+ * resolvePage. resolvePage runs assertLeaseOk, which under CDP_REQUIRE_LEASE
83
+ * auto-acquires a lease on an unleased tab: routing a claim through it would
84
+ * claim the tab twice and collide with itself. The protection is not lost, only
85
+ * moved — claimLease below still refuses a tab a live process holds.
86
+ *
87
+ * TAKEOVER IS OF UNLEASED TABS ONLY. There is deliberately no force/steal
88
+ * option: a tab another live agent holds stays refused with the same conflict
89
+ * error as before. The tab this feature exists to take over is a human's, and a
90
+ * human's tab carries no lease.
91
+ *
92
+ * RESOLUTION NEVER CREATES A TAB. With `target` given, the open branch is
93
+ * unreachable: an unmatched selector is an error, never a silently substituted
94
+ * new tab, because "the tab I asked for is not there" and "here is an empty one
95
+ * instead" are answers a caller must be able to tell apart.
96
+ *
97
+ * THE ACTIVITY BEACON IS READ BEFORE THE CLAIM AND INSTALLED AFTER IT, and the
98
+ * order is not arbitrary in either half.
99
+ *
100
+ * Read first, because the read has to be GATE-FREE for exactly the reason the
101
+ * resolution above is: under CDP_REQUIRE_LEASE, anything that touches the tab
102
+ * through the lease gate auto-acquires a lease on it, and a claim that ran after
103
+ * that would collide with the lease taken on its own behalf. The driver members
104
+ * behind readHumanActiveMs bypass the gate and can do nothing but return one
105
+ * number (see driver.ts).
106
+ *
107
+ * Install after, because installing is a WRITE into the page, and this is the
108
+ * first moment we are entitled to make one: the claim has succeeded, so the tab
109
+ * is ours. It is also the only ordering that keeps the read honest, since
110
+ * installing first cannot invent a timestamp but re-arming a beacon on a tab
111
+ * whose claim then FAILED would leave our script running in somebody else's tab
112
+ * for nothing.
113
+ *
114
+ * A CLAIM IS NEVER REFUSED ON HUMAN ACTIVITY. Taking over a person's tab is the
115
+ * feature; a caller that asked for this tab gets this tab. Contention is
116
+ * reported in the result and the caller decides. See contentionWarning.
40
117
  */
41
118
  export async function claimPage(
42
119
  driver: BrowserDriver,
43
- args: { targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
120
+ args: { target?: TargetSelector; targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
44
121
  ): Promise<ClaimPageResult> {
122
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
123
+ const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
124
+ if (hasTarget && hasTargetId) {
125
+ throw new LeaseToolError(
126
+ "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.",
127
+ );
128
+ }
45
129
  const backend = backendOf(driver);
46
130
  const label = typeof args.label === "string" && args.label.length ? args.label : defaultLabel();
47
131
  const pages = await driver.listPages();
48
- let targetId = args.targetId;
49
- let url = args.url ?? "about:blank";
50
- if (targetId === undefined || targetId === "") {
132
+ let targetId: string;
133
+ let url: string;
134
+ let opened: boolean;
135
+ if (hasTarget) {
136
+ // Gate-free on purpose: see the header. No tab is created on this path,
137
+ // including when nothing matches.
138
+ const hit = await pickPage(driver, pages, args.target).catch((err: unknown) => {
139
+ throw new LeaseToolError(
140
+ `claim_page: no open tab matches target '${args.target}' (${(err as Error).message}). No tab was opened: claim_page never creates one when 'target' is given.`,
141
+ );
142
+ });
143
+ targetId = hit.id;
144
+ url = hit.url;
145
+ opened = false;
146
+ } else if (hasTargetId) {
147
+ const existing = pages.find((p) => p.id === args.targetId);
148
+ if (!existing) throw new LeaseToolError(`claim_page: no page target with id '${args.targetId}'`);
149
+ targetId = existing.id;
150
+ url = existing.url;
151
+ opened = false;
152
+ } else {
51
153
  // Creation ledger, not the lease: this record survives release_page and
52
154
  // expiry, so the tab stays attributable to `label` after the lease is gone.
53
- // Claiming an EXISTING tab (the else branch) records nothing, because this
54
- // toolkit did not create it and provenance is not ownership.
155
+ // Claiming an EXISTING tab (the branches above) records nothing, because
156
+ // this toolkit did not create it and provenance is not ownership.
55
157
  const page = await newTrackedPage(driver, backend, { url: args.url, label });
56
158
  targetId = page.id;
57
159
  url = page.url;
58
- } else {
59
- const existing = pages.find((p) => p.id === targetId);
60
- if (!existing) throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
61
- url = existing.url;
160
+ opened = true;
62
161
  }
162
+ // Gate-free, before the claim, and only for a takeover: a tab this call is
163
+ // about to CREATE cannot have a human in it, so asking would be theatre.
164
+ const humanMs = opened ? null : await readHumanActiveMs(driver, backend, targetId);
63
165
  const liveIds = [...pages.map((p) => p.id), targetId];
64
166
  const { record, token } = await claimLease(backend, targetId, {
65
167
  label,
66
168
  ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
67
169
  liveIds,
68
170
  });
171
+ // Best effort by construction (installBeacon never throws): a claim that
172
+ // succeeded must not be reported as a failure because a diagnostic script
173
+ // could not be injected. The cost is a tab that reports humanActiveMs null
174
+ // forever, which reads the same as a tab nobody has touched.
175
+ await installBeacon(driver, targetId);
176
+ const contention = contentionWarning(humanMs, { takeover: !opened });
69
177
  return {
70
178
  lease: token,
71
179
  targetId,
@@ -73,31 +181,165 @@ export async function claimPage(
73
181
  label: record.label,
74
182
  ttlMs: record.ttlMs,
75
183
  expiresAt: record.lastUsedAt + record.ttlMs,
184
+ opened,
185
+ // Absent, not null, when the backend has no beacon at all: "cannot answer"
186
+ // and "answered, no data" are different facts (ADR-001's absent-over-
187
+ // throwing rule applied to a field rather than to a whole tool).
188
+ ...(beaconSupported(driver) ? { humanActiveMs: humanMs } : {}),
189
+ ...(contention !== undefined ? { contention } : {}),
76
190
  };
77
191
  }
78
192
 
79
- /** Release a held lease. Idempotent: an already-released or reclaimed lease
80
- * reports released:false rather than throwing. */
193
+ /**
194
+ * Give a lease back, and close the tab if this toolkit opened it.
195
+ *
196
+ * TWO WAYS IN, because an auto-acquired lease never handed the caller a token:
197
+ * `lease` is the 1.4.0 path, `target` resolves through shared-tools' resolvePage
198
+ * and is authorized by the same gate as any other call. Exactly one is required;
199
+ * accepting both would leave it ambiguous which one authorizes the close.
200
+ *
201
+ * WHY PROVENANCE DECIDES THE CLOSE. A lease says who is driving a tab, never who
202
+ * opened it. Closing on release is right for a tab this toolkit opened for an
203
+ * agent and wrong for a tab the human already had open and an agent merely
204
+ * claimed. origins.ts records exactly that distinction and its records outlive
205
+ * the lease, so the answer is still there at release time.
206
+ *
207
+ * A RELEASE THAT DID NOT HAPPEN NEVER CLOSES. An already-released or reclaimed
208
+ * lease reports released:false, and the tab may well belong to another agent by
209
+ * then; closing it would destroy a tab someone else is driving. This is the one
210
+ * rule in this function that is a safety property rather than a convenience.
211
+ */
81
212
  export async function releasePage(
82
- _driver: BrowserDriver,
83
- args: { lease?: LeaseToken },
84
- ): Promise<{ released: boolean }> {
85
- if (typeof args.lease !== "string" || !args.lease.length) {
86
- throw new LeaseToolError("release_page requires the 'lease' token returned by claim_page");
213
+ driver: BrowserDriver,
214
+ args: { lease?: LeaseToken; target?: TargetSelector; close?: boolean } = {},
215
+ ): Promise<{ released: boolean; closed: boolean; targetId?: string }> {
216
+ const hasLease = typeof args.lease === "string" && args.lease.length > 0;
217
+ const hasTarget = typeof args.target === "string" && args.target.length > 0;
218
+ if (hasLease === hasTarget) {
219
+ throw new LeaseToolError(
220
+ "release_page takes exactly one of 'lease' (the token claim_page returned) or 'target' (a selector for a tab this process holds)",
221
+ );
87
222
  }
88
- return releaseLease(args.lease);
223
+
224
+ const backend = backendOf(driver);
225
+ let targetId: string;
226
+ let released: boolean;
227
+
228
+ if (hasLease) {
229
+ const parts = tokenParts(args.lease as LeaseToken);
230
+ // A malformed token names no tab, so there is nothing to release and
231
+ // nothing to close. Idempotent rather than throwing, as in 1.4.0.
232
+ if (!parts) return { released: false, closed: false };
233
+ targetId = parts.targetId;
234
+ released = (await releaseLease(args.lease as LeaseToken)).released;
235
+ } else {
236
+ // resolvePage runs assertLeaseOk, which IS the authorization: a tab held by
237
+ // another process throws here, and under strict mode an unleased tab is
238
+ // acquired first, so the caller holds it by the time we release it.
239
+ const page = await resolvePage(driver, args.target);
240
+ targetId = page.id;
241
+ released = (await releaseLeaseFor(backend, targetId)).released;
242
+ }
243
+
244
+ if (!released) return { released: false, closed: false, ...(hasTarget ? { targetId } : {}) };
245
+
246
+ const shouldClose =
247
+ args.close === false ? false
248
+ : args.close === true ? true
249
+ : (await readOrigin(backend, targetId)) !== undefined;
250
+ if (!shouldClose) return { released: true, closed: false, targetId };
251
+
252
+ // The release already succeeded and is not undone by a failed close: the
253
+ // caller has genuinely given the lease back either way, and throwing here
254
+ // would report a release that did happen as an error.
255
+ const res = await driver.closePage(targetId).catch(() => ({ success: false }));
256
+ return { released: true, closed: res.success === true, targetId };
257
+ }
258
+
259
+ /**
260
+ * One list_leases row: everything the lease store knows, plus what the browser
261
+ * can add.
262
+ *
263
+ * `humanActiveMs` lives HERE and not on LeaseSummary because leases.ts is
264
+ * deliberately free of any CDP or BiDi import ("it takes browser-derived input
265
+ * as data", see its staleReason) and answering this needs a live driver. Keeping
266
+ * the annotation in the tool layer is what preserves that, and it is the same
267
+ * split shared-tools.ts uses for ListedPage's provenance fields.
268
+ *
269
+ * `idleMs` and `expiresAt` need no driver at all — they are arithmetic on a
270
+ * record leases.ts already has in hand — but they live here for the same
271
+ * reason: this is the DISPLAY layer, and the rule "compute presentation
272
+ * fields where they are shown, not in the store that owns the data" should
273
+ * not have two different homes depending on whether a given field happens to
274
+ * need a browser.
275
+ */
276
+ export interface LeaseRow extends LeaseSummary {
277
+ /** Ms since a human last touched this tab (src/activity.ts). ABSENT — never
278
+ * null — when the tab has no beacon, when every timestamp on it was this
279
+ * server's own, when the row belongs to the other backend, or when the read
280
+ * failed. A diagnosis tool must not fail because one page would not answer. */
281
+ humanActiveMs?: number;
282
+ /** now - lastUsedAt, computed fresh on every call and never stored: storing
283
+ * it would mean every row goes stale the instant it is written. ABSENT on
284
+ * an `unreadable` row, whose lastUsedAt is a zeroed placeholder (see
285
+ * LeaseSummary) that would otherwise print as "idle since the epoch". */
286
+ idleMs?: number;
287
+ /** lastUsedAt + ttlMs — the same value claim_page already returns under this
288
+ * name. Same omission rule as idleMs. */
289
+ expiresAt?: number;
89
290
  }
90
291
 
91
- /** Enumerate active leases for diagnosis. Requires no token by design. */
292
+ /**
293
+ * Enumerate active leases for diagnosis. Requires no token by design.
294
+ *
295
+ * The beacon annotation is per-row, best effort, and NEVER fatal: an evaluate
296
+ * that throws, a tab that closed mid-listing, or a page with no beacon all leave
297
+ * the field off that row and change nothing else. list_leases is what an
298
+ * operator runs when tabs already look wrong, so it failing on a wrong-looking
299
+ * tab would be exactly backwards.
300
+ *
301
+ * Only rows of THIS driver's backend are annotated, mirroring the liveBackend
302
+ * scoping just below: a Chrome driver cannot read a Firefox context, and asking
303
+ * it to would either error or, worse, resolve some unrelated id.
304
+ */
92
305
  export async function listLeasesTool(
93
306
  driver: BrowserDriver,
94
307
  _args: Record<string, never> = {} as Record<string, never>,
95
- ): Promise<{ leases: LeaseSummary[]; count: number }> {
96
- const liveIds = (await driver.listPages()).map((p) => p.id);
308
+ ): Promise<{ leases: LeaseRow[]; count: number; reaped?: ReapedTab[] }> {
309
+ const backend = backendOf(driver);
310
+ // Same reap as list_pages, and for the same reason: this is the other tool an
311
+ // operator runs when tabs look wrong, so it must not report a lease it is
312
+ // about to delete. Strict mode only.
313
+ const reaped = requireLease() ? await reapStaleAgentTabs(driver, backend) : [];
314
+ const reapedIds = new Set(reaped.map((r) => r.targetId));
315
+ const livePages = await driver.listPages();
316
+ const liveIds = livePages.map((p) => p.id);
97
317
  // liveBackend scopes the target-gone test to the browser these ids came from,
98
318
  // so leases held on the OTHER backend are not mislabeled as reclaimable.
99
- const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
100
- return { leases, count: leases.length };
319
+ const all = await listLeases({ liveIds, liveBackend: backend });
320
+ const kept = all.filter((l) => !(l.backend === backend && reapedIds.has(l.targetId)));
321
+ const liveSet = new Set(liveIds);
322
+ const now = Date.now();
323
+ const leases: LeaseRow[] = [];
324
+ for (const row of kept) {
325
+ // idleMs/expiresAt are pure arithmetic on the record itself, so they apply
326
+ // to BOTH backends and to leases on tabs that are not currently open — the
327
+ // browser has nothing to do with this computation. An unreadable row is
328
+ // the one exception: its lastUsedAt/ttlMs are zeroed placeholders (see
329
+ // LeaseSummary), and computing off zeros would print a meaningless number
330
+ // rather than the honest "we cannot say" an unreadable row already means.
331
+ const computed: LeaseRow = row.unreadable !== undefined ? row : { ...row, idleMs: now - row.lastUsedAt, expiresAt: row.lastUsedAt + row.ttlMs };
332
+ // A row this driver cannot speak to at all: other backend, unreadable
333
+ // record (its targetId came from a FILENAME, not from the browser), or a
334
+ // tab that is simply not open any more.
335
+ if (row.backend !== backend || row.unreadable !== undefined || !liveSet.has(row.targetId)) {
336
+ leases.push(computed);
337
+ continue;
338
+ }
339
+ const humanMs = await readHumanActiveMs(driver, backend, row.targetId, now).catch(() => null);
340
+ leases.push(humanMs === null ? computed : { ...computed, humanActiveMs: humanMs });
341
+ }
342
+ return { leases, count: leases.length, ...(reaped.length ? { reaped } : {}) };
101
343
  }
102
344
 
103
345
  export const LEASE_TOOLS = {