cdp-toolkit 1.7.0 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "1.7.0",
4
- "description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 41 tools including built-in network mocking, a cookie group, tab leases, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs. No Puppeteer, no all-target fan-out.",
3
+ "version": "1.8.0",
4
+ "description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 45 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": {
@@ -78,7 +78,9 @@
78
78
  "screencast:smoke": "bun run ./test/screencast-smoke.ts",
79
79
  "mcp:smoke": "bun run ./test/mcp-smoke.ts",
80
80
  "firefox:smoke": "bun run ./test/firefox-smoke.ts",
81
- "lease:smoke": "bun run ./test/lease-smoke.ts"
81
+ "lease:smoke": "bun run ./test/lease-smoke.ts",
82
+ "staleness:smoke": "bun run ./test/staleness-smoke.ts",
83
+ "input:smoke": "bun run ./test/input-smoke.ts"
82
84
  },
83
85
  "devDependencies": {
84
86
  "@types/node": "22.10.10",
@@ -0,0 +1,533 @@
1
+ /**
2
+ * The activity beacon: what tells an agent that a HUMAN is still using the tab
3
+ * it is about to take over.
4
+ *
5
+ * WHY THIS EXISTS. Since 1.5.0 claim_page{target} can take over a tab that is
6
+ * already open, and the tab that feature exists for is a human's. Nothing until
7
+ * now could detect the human still using it: a lease's lastUsedAt measures
8
+ * TOOLKIT calls, so a tab a person has been typing in for ten minutes and no
9
+ * agent has touched looks perfectly idle. This module measures the other side.
10
+ *
11
+ * THE HONEST SIGNAL IS A CORRELATION, NOT A FLAG. The obvious approach —
12
+ * listen for input events and trust `isTrusted` — does not work, and it fails
13
+ * in the direction that matters: CDP-dispatched input (Input.dispatchMouseEvent,
14
+ * Input.dispatchKeyEvent) arrives at the page as `isTrusted: true`, byte for
15
+ * byte indistinguishable from a real finger. So an in-page listener alone can
16
+ * only say "input happened", never "a person did it". What makes the answer
17
+ * honest is correlating the in-page timestamp with THIS PROCESS'S OWN log of
18
+ * every input it dispatched: input this process cannot account for is input it
19
+ * did not cause. See isHumanAttributed.
20
+ *
21
+ * THE CAVEAT THAT CANNOT BE ENGINEERED AWAY, stated plainly because a reader
22
+ * will otherwise assume it was overlooked: the dispatch log is IN-PROCESS. Two
23
+ * MCP server processes drive one Chrome (see backend.ts and leases.ts), so a
24
+ * SECOND server's clicks are not in this one's log and read as human here. That
25
+ * is the wrong answer in the safe direction — it reports contention that is
26
+ * really another agent, rather than reporting a human as idle — and the tool
27
+ * surface never refuses a claim on it, so the cost is a spurious warning rather
28
+ * than a blocked call. Do not "fix" it by writing the dispatch log to disk
29
+ * beside the lease files without also solving clock skew and write amplification
30
+ * on every single click; that trade was considered and declined.
31
+ *
32
+ * A SECOND, NARROWER GAP: the beacon listens on the TOP-LEVEL window only.
33
+ * Events do not cross document boundaries, so a person clicking inside a
34
+ * cross-origin iframe moves no timestamp this module can see. Same direction of
35
+ * error (reads as idle), same non-fix (a per-frame beacon would need a
36
+ * frame-tree walk on every install and still miss an OOPIF that navigates).
37
+ *
38
+ * AND A THIRD: the beacon only answers for a tab something INSTALLED it into.
39
+ * A human's tab that no agent has ever claimed carries no beacon at all, so the
40
+ * first claim_page{target} against it reports humanActiveMs null — "no data",
41
+ * never "no human". Every consumer must read null as unknown. The value shows
42
+ * up on the second claim, on any tab an agent claimed and released, and on any
43
+ * tab listed by list_leases, which is exactly the population where two parties
44
+ * are most likely to be fighting over one tab.
45
+ */
46
+ import type { BrowserDriver } from "./driver.ts";
47
+ import type { LeaseBackend } from "./leases.ts";
48
+
49
+ /* ------------------------------- the in-page beacon ------------------------------- */
50
+
51
+ /** Where the beacon parks its timestamp. Read by this module, by list_leases,
52
+ * and (from 1.8.0's renderer ping) by list_pages. Never change this string:
53
+ * a page carrying a beacon installed by an older server must stay readable. */
54
+ export const BEACON_DATA_GLOBAL = "__cdpToolkitLastInput";
55
+
56
+ /**
57
+ * The idempotence guard, deliberately a DIFFERENT global from the data one.
58
+ *
59
+ * Guarding on BEACON_DATA_GLOBAL itself would be the obvious one-global design
60
+ * and it is broken: that global does not exist until the first input arrives,
61
+ * so on a tab nobody has touched yet every re-install would see `undefined`,
62
+ * conclude "not installed", and add another full set of listeners. The beacon is
63
+ * re-installed on every claim, so that leak is not hypothetical.
64
+ */
65
+ export const BEACON_INSTALLED_GLOBAL = "__cdpToolkitBeacon";
66
+
67
+ /**
68
+ * The events that count as input.
69
+ *
70
+ * `pointerdown` and `mousedown` are both listed on purpose rather than as a
71
+ * belt-and-braces reflex: a page that calls preventDefault() on pointerdown
72
+ * suppresses the compatibility mousedown, and a page using a pointer-events
73
+ * polyfill can fire mousedown with no pointerdown at all. `wheel` is what makes
74
+ * plain reading register — scrolling an article is the most common thing a
75
+ * person does in a tab without ever clicking. Deliberately NOT here: mousemove
76
+ * (fires continuously and would report "active" for a cursor resting over the
77
+ * window), focus/blur (fire on tab switching, i.e. on someone leaving), and
78
+ * keyup (redundant with keydown and doubles the event volume).
79
+ */
80
+ export const BEACON_EVENTS = ["pointerdown", "mousedown", "keydown", "wheel", "touchstart"] as const;
81
+
82
+ /**
83
+ * Capture phase and passive, both load-bearing.
84
+ *
85
+ * CAPTURE: a page that stops propagation on a bubbling handler (drag surfaces,
86
+ * canvas editors and modal overlays all do) would hide the input from a bubble-
87
+ * phase listener entirely. Capture runs before the page's own handlers, so no
88
+ * page can suppress the beacon.
89
+ *
90
+ * PASSIVE: promises the browser this listener will never call preventDefault(),
91
+ * which is what keeps a `wheel` listener off the scrolling critical path. A
92
+ * non-passive wheel listener on window measurably janks scrolling, and a
93
+ * diagnostic that makes the page it is diagnosing feel slower is not acceptable.
94
+ */
95
+ const BEACON_BODY = `
96
+ if (!window.${BEACON_INSTALLED_GLOBAL}) {
97
+ window.${BEACON_INSTALLED_GLOBAL} = true;
98
+ var mark = function () { window.${BEACON_DATA_GLOBAL} = Date.now(); };
99
+ var events = ${JSON.stringify([...BEACON_EVENTS])};
100
+ for (var i = 0; i < events.length; i++) {
101
+ window.addEventListener(events[i], mark, { capture: true, passive: true });
102
+ }
103
+ }`;
104
+
105
+ /** The beacon as a PROGRAM, for CDP (Page.addScriptToEvaluateOnNewDocument and
106
+ * Runtime.evaluate both take a source string). */
107
+ export const BEACON_SOURCE = `(function () {${BEACON_BODY}\n})();`;
108
+
109
+ /** The same beacon as a FUNCTION DECLARATION, for BiDi, whose
110
+ * script.addPreloadScript takes a functionDeclaration rather than a program. */
111
+ export const BEACON_FUNCTION_DECLARATION = `function () {${BEACON_BODY}\n}`;
112
+
113
+ /** Reads the beacon timestamp, or null on a document that has no beacon. Never
114
+ * throws in-page: an unknown global is `undefined`, not a ReferenceError, when
115
+ * reached through `window`. */
116
+ export const BEACON_READ_EXPRESSION = `(typeof window.${BEACON_DATA_GLOBAL} === "number" ? window.${BEACON_DATA_GLOBAL} : null)`;
117
+
118
+ /* -------------------------------- the dispatch log -------------------------------- */
119
+
120
+ /**
121
+ * How long after one of OUR dispatches a beacon timestamp is still attributed
122
+ * to us rather than to a human.
123
+ *
124
+ * A single toolkit click produces a burst: pointerdown, mousedown, and on a
125
+ * page with its own synthesized follow-ups possibly more, all after the CDP
126
+ * command was sent and all landing on the beacon. The window has to cover that
127
+ * burst plus the round trip. 1500ms is generous for the burst and short enough
128
+ * that a person who reaches for the mouse right after an agent acted is still
129
+ * seen: the failure it trades away (a human input inside 1.5s of our own click
130
+ * read as ours) is a false NEGATIVE on contention, which is the direction that
131
+ * merely under-warns rather than the direction that cries wolf.
132
+ */
133
+ export const DISPATCH_ATTRIBUTION_WINDOW_MS = 1500;
134
+
135
+ /**
136
+ * Keyed by backend AND target id, exactly as leases.ts keys its records, and
137
+ * for the same stated reason: a CDP targetId and a BiDi context id are not
138
+ * guaranteed disjoint, so a bare id could let a Chrome tab's dispatch suppress
139
+ * a Firefox context's beacon.
140
+ */
141
+ function dispatchKey(backend: LeaseBackend, targetId: string): string {
142
+ return `${backend}:${targetId}`;
143
+ }
144
+
145
+ /**
146
+ * targetId -> when this process last dispatched input into it.
147
+ *
148
+ * MODULE STATE, WHICH leases.ts's HEADER OTHERWISE FORBIDS, and the exception is
149
+ * the same one leases.ts carves out for `longLived`: that prohibition is about
150
+ * state describing OTHER processes, which must live on disk because two MCP
151
+ * servers drive one browser. This describes what THIS process did, which no
152
+ * other process can observe and none should — the whole discrimination rule is
153
+ * "input this process cannot account for". Putting it on disk would not make it
154
+ * more correct, it would make it a different and worse signal (see the header's
155
+ * caveat).
156
+ */
157
+ const dispatchLog = new Map<string, number>();
158
+
159
+ /**
160
+ * Bound on the log, because an MCP server can outlive thousands of tabs.
161
+ * Eviction is oldest-write-first, and evicting is SAFE in the only direction
162
+ * that matters: a forgotten dispatch makes a later beacon read as human, which
163
+ * over-warns rather than under-warns.
164
+ */
165
+ const DISPATCH_LOG_LIMIT = 512;
166
+
167
+ /**
168
+ * THE CHOKE POINT. Every driver method that dispatches synthesized input calls
169
+ * this, and it is called from exactly one place per driver (cdp/driver.ts's
170
+ * dispatchInput, bidi/driver.ts's performActions) so a NEW input tool inherits
171
+ * the bookkeeping by construction instead of by its author remembering.
172
+ *
173
+ * If you are adding an input tool: do not call this directly. Route the protocol
174
+ * command through the driver's own input choke point and this comes for free.
175
+ */
176
+ export function recordDispatch(backend: LeaseBackend, targetId: string, now: number = Date.now()): void {
177
+ const key = dispatchKey(backend, targetId);
178
+ // Delete-then-set so the Map's insertion order is genuinely last-write order,
179
+ // which is what makes the eviction below oldest-first rather than arbitrary.
180
+ dispatchLog.delete(key);
181
+ dispatchLog.set(key, now);
182
+ while (dispatchLog.size > DISPATCH_LOG_LIMIT) {
183
+ const oldest = dispatchLog.keys().next();
184
+ if (oldest.done) break;
185
+ dispatchLog.delete(oldest.value);
186
+ }
187
+ }
188
+
189
+ /** The one thing `sendInput` needs from a CDP connection. Structural on purpose: this module is
190
+ * backend-neutral and must not import chrome's CdpConnection, and tools/dispatch-mouse.ts must be
191
+ * able to use it without depending on the Driver-abstraction layer (see that file's header). */
192
+ export interface InputDispatchConnection {
193
+ send<T = Record<string, unknown>>(method: string, params?: Record<string, unknown>): Promise<T>;
194
+ }
195
+
196
+ /**
197
+ * `Input.*` commands that change a MODE and synthesize no event in the page. Recording one would
198
+ * write a dispatch timestamp with no input behind it, and a dispatch we did not actually make
199
+ * suppresses a real human input landing inside the attribution window — the under-warning
200
+ * direction, which is the one this module's comments consistently refuse to trade toward.
201
+ *
202
+ * A DENYLIST rather than an allowlist of the dispatching methods, deliberately: an `Input.*` command
203
+ * nobody thought to classify then defaults to being LOGGED, which over-warns. A missing allowlist
204
+ * entry would default the other way and silently reopen the exact hole the choke point closes.
205
+ */
206
+ const NON_SYNTHESIZING_INPUT_METHODS = new Set(["Input.setInterceptDrags", "Input.setIgnoreInputEvents"]);
207
+
208
+ /**
209
+ * THE ONLY PLACE AN `Input.*` COMMAND IS WRITTEN TO A CHROME CONNECTION.
210
+ *
211
+ * cdp/driver.ts's `dispatchInput` is the driver-side choke point and delegates here; the chrome-only
212
+ * raw tools that live OUTSIDE the Driver abstraction (tools/dispatch-mouse.ts) call this directly,
213
+ * because they have a connection and a target id but no PageDriver to route through. Both paths
214
+ * therefore log, and the post-merge invariant is mechanically checkable: a literal
215
+ * `conn.send("Input.` appears NOWHERE in src/ (this comment aside) — the only send of an `Input.*`
216
+ * command is the parameterised one below. Firefox's twin is bidi/driver.ts's performActions.
217
+ *
218
+ * Keeping the log complete is the whole point — a synthesized input this process forgets to record
219
+ * reads back as a HUMAN's, which is precisely the false contention warning the beacon exists to
220
+ * avoid. See recordDispatch's contract above.
221
+ */
222
+ export async function sendInput<T = Record<string, unknown>>(
223
+ conn: InputDispatchConnection,
224
+ backend: LeaseBackend,
225
+ targetId: string,
226
+ method: string,
227
+ params: Record<string, unknown> = {},
228
+ ): Promise<T> {
229
+ if (!NON_SYNTHESIZING_INPUT_METHODS.has(method)) recordDispatch(backend, targetId);
230
+ return conn.send<T>(method, params);
231
+ }
232
+
233
+ /** When this process last dispatched input into a target, or undefined if never. */
234
+ export function lastDispatchAt(backend: LeaseBackend, targetId: string): number | undefined {
235
+ return dispatchLog.get(dispatchKey(backend, targetId));
236
+ }
237
+
238
+ /** Drop one target's dispatch record. For a tab that has been closed, and for
239
+ * tests that need a clean slate for one target. */
240
+ export function forgetDispatch(backend: LeaseBackend, targetId: string): void {
241
+ dispatchLog.delete(dispatchKey(backend, targetId));
242
+ }
243
+
244
+ /** Empty the log. Tests only: no product path wants to forget what it did. */
245
+ export function clearDispatchLog(): void {
246
+ dispatchLog.clear();
247
+ }
248
+
249
+ /* ------------------------------- discrimination ------------------------------- */
250
+
251
+ /**
252
+ * THE RULE: a beacon timestamp is a HUMAN's iff it is newer than this process's
253
+ * last dispatch into that tab by more than the attribution window.
254
+ *
255
+ * `dispatchAt` undefined means this process has never driven the tab, so every
256
+ * real timestamp is unaccounted-for and therefore human. That falls out of the
257
+ * `?? 0` rather than needing its own branch, and it is the correct reading: a
258
+ * tab we have never touched cannot have been moved by us.
259
+ *
260
+ * A non-finite or absent beacon value is NOT human — it is NO DATA. The two are
261
+ * different answers and every caller has to keep them apart, which is why the
262
+ * surfaced field is `number | null` and never a boolean.
263
+ */
264
+ export function isHumanAttributed(
265
+ beaconTs: number | null | undefined,
266
+ dispatchAt: number | undefined,
267
+ windowMs: number = DISPATCH_ATTRIBUTION_WINDOW_MS,
268
+ ): boolean {
269
+ if (typeof beaconTs !== "number" || !Number.isFinite(beaconTs)) return false;
270
+ return beaconTs > (dispatchAt ?? 0) + windowMs;
271
+ }
272
+
273
+ /**
274
+ * Milliseconds since the last HUMAN-attributed input, or null when there is no
275
+ * human-attributed input to measure (no beacon, no data, or every timestamp
276
+ * accounted for by our own dispatches).
277
+ *
278
+ * Clamped at zero. The beacon's clock is the page's `Date.now()` and this
279
+ * function's is the server's; they are the same machine's wall clock, but a
280
+ * beacon read that races a millisecond forward would otherwise produce a
281
+ * negative "ms ago", which reads as a corrupt field rather than as the rounding
282
+ * artifact it is.
283
+ */
284
+ export function humanActiveMs(
285
+ beaconTs: number | null | undefined,
286
+ dispatchAt: number | undefined,
287
+ now: number = Date.now(),
288
+ windowMs: number = DISPATCH_ATTRIBUTION_WINDOW_MS,
289
+ ): number | null {
290
+ if (!isHumanAttributed(beaconTs, dispatchAt, windowMs)) return null;
291
+ return Math.max(0, now - (beaconTs as number));
292
+ }
293
+
294
+ /* --------------------------------- contention --------------------------------- */
295
+
296
+ /**
297
+ * How recent a human input has to be for a takeover to be worth warning about.
298
+ * 30s is "they are still in this tab", not "they used it this session".
299
+ */
300
+ export const CONTENTION_WINDOW_MS = 30_000;
301
+
302
+ /**
303
+ * The warning claim_page attaches to a takeover of a tab a human is still using.
304
+ *
305
+ * IT IS A WARNING AND NEVER A REFUSAL, and that is a product decision, not an
306
+ * oversight: taking over a human's tab IS the feature (see leases-tools.ts's
307
+ * header), a caller asked for this specific tab, and a tool that refused on
308
+ * detected activity would break the exact workflow the takeover mode exists to
309
+ * serve. The claim has already succeeded by the time this string is built. The
310
+ * warning informs; the caller decides.
311
+ *
312
+ * Returns undefined — the field is then omitted entirely — for a fresh tab this
313
+ * call opened (nobody can be contending for a tab that did not exist a moment
314
+ * ago), for no data, and for a human who has been idle past the window.
315
+ */
316
+ export function contentionWarning(ms: number | null, opts: { takeover: boolean }): string | undefined {
317
+ if (!opts.takeover) return undefined;
318
+ if (ms === null || ms >= CONTENTION_WINDOW_MS) return undefined;
319
+ return (
320
+ `a person appears to be using this tab right now: input not dispatched by this server landed ${ms}ms ago. ` +
321
+ `The claim SUCCEEDED and you hold the lease — this is a warning, not a refusal. ` +
322
+ `Driving the tab now will fight them for the keyboard and mouse. ` +
323
+ `Consider claim_page with no target to work in your own tab instead, or ask before continuing.`
324
+ );
325
+ }
326
+
327
+ /* ---------------------------- driver-facing helpers ---------------------------- */
328
+
329
+ /**
330
+ * Whether this backend can answer beacon questions at all.
331
+ *
332
+ * The two members are optional on BrowserDriver rather than universal because
333
+ * ADR-001's rule for a capability one backend lacks is "absent, never present
334
+ * and throwing". They are NOT in the `Capability` union, deliberately: that
335
+ * union gates whole TOOLS out of tools/list, and no tool here is gated — the
336
+ * beacon is an additive FIELD on two tools that both work fine without it. So
337
+ * the honest expression of the gap is an absent optional method and an absent
338
+ * field, which is what a caller sees on a backend with no beacon.
339
+ */
340
+ export function beaconSupported(driver: BrowserDriver): boolean {
341
+ return typeof driver.installActivityBeacon === "function" && typeof driver.readActivityBeacon === "function";
342
+ }
343
+
344
+ /**
345
+ * Install the beacon into a target, best effort.
346
+ *
347
+ * NEVER THROWS, and never fails the caller's real work. This is an annotation,
348
+ * exactly like origins.ts's ledger write: claim_page's job is to hand back a
349
+ * lease, and a claim that failed because a diagnostic script could not be
350
+ * injected would be a worse tool than one with a missing field. The cost, stated
351
+ * plainly: a tab whose injection silently failed reports humanActiveMs null
352
+ * forever, indistinguishable from a tab nobody has touched.
353
+ */
354
+ export async function installBeacon(driver: BrowserDriver, targetId: string): Promise<boolean> {
355
+ if (typeof driver.installActivityBeacon !== "function") return false;
356
+ return driver.installActivityBeacon(targetId).catch(() => false);
357
+ }
358
+
359
+ /**
360
+ * Read a target's human-activity age, gate-free, best effort.
361
+ *
362
+ * GATE-FREE IS REQUIRED, NOT CONVENIENT, on the claim_page{target} path, and
363
+ * for exactly the reason pickPage is gate-free (see leases-tools.ts's header):
364
+ * under CDP_REQUIRE_LEASE the lease gate AUTO-ACQUIRES a lease on an unleased
365
+ * tab, so reading through a gated path before claiming would claim the tab
366
+ * once implicitly and then collide with itself on the explicit claim. The
367
+ * driver members this calls therefore bypass the gate — and the bypass is safe
368
+ * to hand out because it is not general: `readActivityBeacon` can return one
369
+ * number and cannot drive, mutate, or even read anything else from the page.
370
+ *
371
+ * Returns null both for "no beacon" and for "every timestamp was ours". A
372
+ * caller that must distinguish "this backend has no beacon at all" checks
373
+ * beaconSupported first, and omits the field entirely in that case.
374
+ */
375
+ export async function readHumanActiveMs(
376
+ driver: BrowserDriver,
377
+ backend: LeaseBackend,
378
+ targetId: string,
379
+ now: number = Date.now(),
380
+ ): Promise<number | null> {
381
+ if (typeof driver.readActivityBeacon !== "function") return null;
382
+ const beaconTs = await driver.readActivityBeacon(targetId).catch(() => null);
383
+ return humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
384
+ }
385
+
386
+ /* ------------------------------- the renderer ping ------------------------------- */
387
+
388
+ /**
389
+ * How long list_pages{probe:true} waits for one target's renderer to answer
390
+ * before calling it unresponsive. Bounded so ONE wedged tab cannot stall the
391
+ * whole listing beyond its own budget; see BrowserDriver.probeRenderer.
392
+ */
393
+ export const RENDERER_PROBE_TIMEOUT_MS = 500;
394
+
395
+ /**
396
+ * Evaluated in-page by the bounded probe. The leading `1` is a liveness
397
+ * marker, not a beacon reading: it is what lets a caller distinguish "the
398
+ * renderer executed this and came back with genuinely no beacon data"
399
+ * (`[1, null]`) from a probe that never got a value back at all, which the
400
+ * driver reports as a rejection — see probeRenderer — rather than as this
401
+ * expression evaluating to something falsy.
402
+ *
403
+ * The second element is BEACON_READ_EXPRESSION verbatim, not a re-derived
404
+ * `window.X ?? null` — composed, not duplicated, so list_pages{probe:true}
405
+ * and claim_page/list_leases's own beacon reads can never drift apart on what
406
+ * counts as a valid timestamp. That guard matters: `?? null` alone would let
407
+ * a non-number value some other script wrote into the beacon global (a
408
+ * string, an object — anything but null/undefined) pass straight through,
409
+ * where BEACON_READ_EXPRESSION's `typeof ... === "number"` check does not.
410
+ */
411
+ export const RENDERER_PROBE_EXPRESSION = `[1, ${BEACON_READ_EXPRESSION}]`;
412
+
413
+ /**
414
+ * Whether this backend can answer the renderer ping at all.
415
+ *
416
+ * Same rule as beaconSupported and the same reason: not a `Capability`,
417
+ * because no whole TOOL is gated here, only an additive field on list_pages
418
+ * that works fine (simply absent) without it.
419
+ */
420
+ export function rendererProbeSupported(driver: BrowserDriver): boolean {
421
+ return typeof driver.probeRenderer === "function";
422
+ }
423
+
424
+ export interface RendererProbeResult {
425
+ responsive: boolean;
426
+ /** Same rule as everywhere else in this module: present only where the
427
+ * same round trip found beacon data AND that data is human-attributed. */
428
+ humanActiveMs?: number;
429
+ }
430
+
431
+ /**
432
+ * Probe one target and fold the answer through the SAME discrimination rule as
433
+ * every other beacon read (isHumanAttributed via humanActiveMs, keyed by this
434
+ * process's own dispatch log), so probe:true can never disagree with
435
+ * claim_page or list_leases about what counts as human.
436
+ *
437
+ * NEVER THROWS. driver.probeRenderer is documented to never reject, but this
438
+ * defends anyway (matching readHumanActiveMs's own defensive `.catch`) rather
439
+ * than trust every implementer to honor that. A driver with no probeRenderer
440
+ * at all degrades to `{responsive:false}` with no humanActiveMs — the same
441
+ * shape as a backend that answered and found nothing, which is deliberate:
442
+ * the caller that needs to tell "cannot answer" from "answered, no data" is
443
+ * list_pages itself, via rendererProbeSupported, not this function.
444
+ */
445
+ export async function probeRendererActivity(
446
+ driver: BrowserDriver,
447
+ backend: LeaseBackend,
448
+ targetId: string,
449
+ now: number = Date.now(),
450
+ ): Promise<RendererProbeResult> {
451
+ if (typeof driver.probeRenderer !== "function") return { responsive: false };
452
+ const { responsive, beaconTs } = await driver
453
+ .probeRenderer(targetId, RENDERER_PROBE_TIMEOUT_MS)
454
+ .catch(() => ({ responsive: false, beaconTs: null as number | null }));
455
+ const ms = humanActiveMs(beaconTs, lastDispatchAt(backend, targetId), now);
456
+ return ms === null ? { responsive } : { responsive, humanActiveMs: ms };
457
+ }
458
+
459
+ /* ------------------------- keep-alive session bookkeeping ------------------------- */
460
+
461
+ /**
462
+ * A bounded registry of the live connections that keep a beacon re-arming
463
+ * across navigation. Both drivers own one; the eviction policy lives here once
464
+ * rather than twice.
465
+ *
466
+ * WHY A HELD CONNECTION IS NECESSARY AT ALL, since it is the one expensive thing
467
+ * in this module and a reader will reasonably ask. Chrome's
468
+ * Page.addScriptToEvaluateOnNewDocument is SESSION-SCOPED: the registration is
469
+ * cleared when the client that made it disconnects. Verified against Chrome
470
+ * 151.0.7922.109 — install, disconnect, reconnect, navigate, and the script does
471
+ * not run; install and navigate on ONE connection and it runs on every
472
+ * navigation. This toolkit's CDP lifetime is "per-call" (driver.ts), so the
473
+ * naive install would cover the current document only and would be silently
474
+ * lost the moment the person navigated, which is the single most common thing
475
+ * a person does in a tab. Holding one socket per beaconed tab is what makes
476
+ * "survives navigation" a true statement instead of an aspiration.
477
+ *
478
+ * EVICTION DEGRADES, IT DOES NOT BREAK. Past the limit the oldest socket is
479
+ * closed; the listeners already installed in that tab's CURRENT document keep
480
+ * working and keep answering reads, so the tab loses only the re-arm on its
481
+ * NEXT navigation. That is the same state as a tab beaconed by a CLI process,
482
+ * which cannot hold anything at all.
483
+ */
484
+ export class BeaconSessions<T> {
485
+ private readonly live = new Map<string, T>();
486
+ constructor(
487
+ private readonly close: (value: T) => void,
488
+ private readonly limit = 32,
489
+ ) {}
490
+
491
+ get(key: string): T | undefined {
492
+ return this.live.get(key);
493
+ }
494
+
495
+ /** Adopt a handle for `key`, closing any handle it displaces and evicting the
496
+ * oldest once over the limit. */
497
+ set(key: string, value: T): void {
498
+ const previous = this.live.get(key);
499
+ if (previous !== undefined) {
500
+ this.live.delete(key);
501
+ this.safeClose(previous);
502
+ }
503
+ this.live.set(key, value);
504
+ while (this.live.size > this.limit) {
505
+ const oldest = this.live.keys().next();
506
+ if (oldest.done) break;
507
+ this.drop(oldest.value);
508
+ }
509
+ }
510
+
511
+ drop(key: string): void {
512
+ const value = this.live.get(key);
513
+ if (value === undefined) return;
514
+ this.live.delete(key);
515
+ this.safeClose(value);
516
+ }
517
+
518
+ clear(): void {
519
+ for (const key of [...this.live.keys()]) this.drop(key);
520
+ }
521
+
522
+ get size(): number {
523
+ return this.live.size;
524
+ }
525
+
526
+ private safeClose(value: T): void {
527
+ try {
528
+ this.close(value);
529
+ } catch {
530
+ /* a socket that is already gone is exactly the state we wanted */
531
+ }
532
+ }
533
+ }