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/CHANGELOG.md +439 -0
- package/CONTRACT.md +11 -2
- package/README.md +89 -23
- package/dist/cli.js +1150 -111
- package/dist/index.js +1011 -99
- package/dist/mcp.js +1447 -167
- package/package.json +5 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +231 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +472 -25
- package/src/driver.ts +161 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +269 -27
- package/src/leases.ts +84 -4
- package/src/manifest.ts +295 -56
- package/src/mcp.ts +10 -2
- package/src/reap.ts +165 -0
- package/src/shared-tools.ts +303 -30
- package/src/tools/browser-session.ts +246 -0
- package/src/tools/dispatch-mouse.ts +108 -0
- package/src/tools/downloads.ts +143 -0
- package/src/tools/permissions.ts +130 -0
package/src/bidi/driver.ts
CHANGED
|
@@ -24,14 +24,16 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import type { TargetSelector } from "../types.ts";
|
|
26
26
|
import {
|
|
27
|
-
UID_STAMP_ATTR, isDriverError,
|
|
27
|
+
UID_STAMP_ATTR, isDriverError, interpolatePoints,
|
|
28
28
|
type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
|
|
29
|
+
type DragDestination, type DragOptions,
|
|
29
30
|
type SetCookieParams,
|
|
30
31
|
type DriverUid, type ElementLocator, type EmulationOptions, type InterceptRule, type KeyPress, type LifetimeModel,
|
|
31
32
|
type MouseButtonOptions, type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo,
|
|
32
|
-
type ScreenshotOptions, type SnapshotNode, type UidStability,
|
|
33
|
+
type ScreenshotOptions, type ScrollOptions, type SnapshotNode, type UidStability,
|
|
33
34
|
} from "../driver.ts";
|
|
34
35
|
import { assertLeaseOk } from "../leases.ts";
|
|
36
|
+
import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
|
|
35
37
|
import { BidiConnection, BidiError, connectBidiSession } from "./client.ts";
|
|
36
38
|
import { takeStampedSnapshot } from "./snapshot.ts";
|
|
37
39
|
import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
|
|
@@ -39,7 +41,7 @@ import type {
|
|
|
39
41
|
BrowsingContextId, BrowsingContextInfo, BrowsingContextLocator, BrowsingContextReadinessState,
|
|
40
42
|
BrowsingContextCaptureScreenshotParameters, ScriptSharedReference, ScriptRemoteValue, ScriptLocalValue,
|
|
41
43
|
ScriptNodeRemoteValue, ScriptExceptionDetails, InputSourceActions, InputPointerSourceAction, InputKeySourceAction,
|
|
42
|
-
NetworkCookie,
|
|
44
|
+
InputWheelSourceAction, NetworkCookie,
|
|
43
45
|
} from "./protocol.ts";
|
|
44
46
|
|
|
45
47
|
/* ---------------------------------- cookies ---------------------------------- */
|
|
@@ -256,6 +258,14 @@ const KEY_VALUES: Record<string, string> = {
|
|
|
256
258
|
const MODIFIER_VALUES: Record<string, string> = { shift: "\uE008", control: "\uE009", ctrl: "\uE009", alt: "\uE00A", meta: "\uE03D", cmd: "\uE03D", command: "\uE03D" };
|
|
257
259
|
const BUTTON_CODES: Record<"left" | "right" | "middle", number> = { left: 0, middle: 1, right: 2 };
|
|
258
260
|
|
|
261
|
+
/** Same scroll-settle technique as cdp/driver.ts's ARM_SCROLL_SETTLE_WATCH / awaitScrollSettle,
|
|
262
|
+
* see that file's doc comment for the full rationale: a capture-phase 'scroll' listener on
|
|
263
|
+
* window, debounced 60ms with a 500ms cap, so scroll()'s caller observes the settled position. */
|
|
264
|
+
const ARM_SCROLL_SETTLE_WATCH_SOURCE =
|
|
265
|
+
"function(){window.__cdpScrollSettle=new Promise((resolve)=>{let t;const done=()=>{window.removeEventListener('scroll',on,true);resolve(true);};" +
|
|
266
|
+
"const on=()=>{clearTimeout(t);t=setTimeout(done,60);};window.addEventListener('scroll',on,true);t=setTimeout(done,60);setTimeout(done,500);});}";
|
|
267
|
+
const AWAIT_SCROLL_SETTLE_SOURCE = "function(){return window.__cdpScrollSettle;}";
|
|
268
|
+
|
|
259
269
|
/**
|
|
260
270
|
* Firefox 153 capabilities. Only what was empirically verified is declared: emulate.deviceMetrics
|
|
261
271
|
* (browsingContext.setViewport) and the userAgent path (universal, no capability token) both work.
|
|
@@ -301,6 +311,22 @@ async function getConnection(port: number, timeoutMs?: number): Promise<BidiConn
|
|
|
301
311
|
return conn;
|
|
302
312
|
}
|
|
303
313
|
|
|
314
|
+
/**
|
|
315
|
+
* Contexts that already carry a beacon preload script, keyed port:context ->
|
|
316
|
+
* the script id BiDi handed back.
|
|
317
|
+
*
|
|
318
|
+
* WHY FIREFOX NEEDS NO HELD CONNECTION where Chrome does. This driver's
|
|
319
|
+
* LifetimeModel is "session": ONE BidiConnection is memoized per port above and
|
|
320
|
+
* lives until dispose(), so a preload script registered on it stays registered
|
|
321
|
+
* across navigations for free. Chrome's equivalent registration is cleared when
|
|
322
|
+
* the registering client disconnects, and this toolkit's CDP lifetime is
|
|
323
|
+
* "per-call", which is why cdp/driver.ts has to hold a socket per beaconed tab
|
|
324
|
+
* and this file does not. The map exists only to avoid stacking a second
|
|
325
|
+
* registration on a context that already has one; the in-page script is
|
|
326
|
+
* idempotent, so a duplicate would be harmless but pointless.
|
|
327
|
+
*/
|
|
328
|
+
const bidiBeaconScripts = new Map<string, string>();
|
|
329
|
+
|
|
304
330
|
/* ----------------------------------- context/page lookup ----------------------------------- */
|
|
305
331
|
async function safeTitle(conn: BidiConnection, contextId: BrowsingContextId): Promise<string> {
|
|
306
332
|
try {
|
|
@@ -369,9 +395,51 @@ class BidiPageDriver implements PageDriver {
|
|
|
369
395
|
private bodyCollector?: string;
|
|
370
396
|
constructor(private readonly conn: BidiConnection, private readonly contextId: BrowsingContextId, readonly info: PageInfo, readonly browser: BrowserDriver) {}
|
|
371
397
|
|
|
398
|
+
/** `location.href` and `document.readyState` in one round trip, NUL-joined so the result comes
|
|
399
|
+
* back as one plain string (deserializeRemote's simplest shape). Empty strings on any failure —
|
|
400
|
+
* a transient evaluate error during a navigation is normal and must not abort a poll. */
|
|
401
|
+
private async urlAndReadyState(): Promise<[string, string]> {
|
|
402
|
+
const raw = await this.conn.send("script.evaluate", {
|
|
403
|
+
expression: "location.href + '\\u0000' + document.readyState",
|
|
404
|
+
target: { context: this.contextId }, awaitPromise: false,
|
|
405
|
+
}).then((r) => (r.type === "success" ? String(deserializeRemote(r.result) ?? "") : "")).catch(() => "");
|
|
406
|
+
const [url = "", ready = ""] = raw.split("\u0000");
|
|
407
|
+
return [url, ready];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Bounded readiness wait after a history traversal.
|
|
412
|
+
*
|
|
413
|
+
* WHY THIS EXISTS AT ALL. browsingContext.navigate and .reload both take a `wait` readiness state
|
|
414
|
+
* and block server-side until it is reached, which is why the two branches below simply return
|
|
415
|
+
* `waitedFor: waitUntil`. browsingContext.traverseHistory takes NO `wait` parameter and returns
|
|
416
|
+
* as soon as the traversal is dispatched, so this driver owns the wait for that one path — the
|
|
417
|
+
* same position cdp/driver.ts is in for every navigation.
|
|
418
|
+
*
|
|
419
|
+
* Polling rather than a `browsingContext.load` subscription, matching waitForText's in-file
|
|
420
|
+
* pattern: a bfcache restore does not necessarily re-fire a load event, so an event wait could
|
|
421
|
+
* hang on a perfectly successful traversal while a poll observes the settled document directly.
|
|
422
|
+
*
|
|
423
|
+
* KNOWN LIMIT, stated rather than hidden: "we moved" is inferred from the url changing. A
|
|
424
|
+
* traversal between two history entries with the IDENTICAL url therefore cannot be confirmed and
|
|
425
|
+
* burns the whole budget before returning "timeout" — with the correct url, and after the
|
|
426
|
+
* traversal really did happen. That is the honest reading of the only signal available here.
|
|
427
|
+
*/
|
|
428
|
+
private async settleAfterTraversal(beforeUrl: string, waitUntil: "load" | "domcontentloaded", timeoutMs: number): Promise<{ waitedFor: NavigateResult["waitedFor"]; url: string }> {
|
|
429
|
+
const ready = waitUntil === "domcontentloaded" ? ["interactive", "complete"] : ["complete"];
|
|
430
|
+
const deadline = Date.now() + timeoutMs;
|
|
431
|
+
for (;;) {
|
|
432
|
+
const [url, state] = await this.urlAndReadyState();
|
|
433
|
+
if (url && url !== beforeUrl && ready.includes(state)) return { waitedFor: waitUntil, url };
|
|
434
|
+
if (Date.now() >= deadline) return { waitedFor: "timeout", url: url || beforeUrl };
|
|
435
|
+
await new Promise((r) => setTimeout(r, 50));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
372
439
|
async navigate(opts: NavigateOptions): Promise<NavigateResult> {
|
|
373
440
|
const reload = opts.reload === true;
|
|
374
|
-
|
|
441
|
+
const history = opts.history;
|
|
442
|
+
if (!reload && !history && !opts.url) throw driverError("page-error", "navigate: 'url' is required unless reload:true");
|
|
375
443
|
const waitUntil = opts.waitUntil ?? "load";
|
|
376
444
|
const wait: BrowsingContextReadinessState = waitUntil === "domcontentloaded" ? "interactive" : "complete";
|
|
377
445
|
const sendOpts = opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {};
|
|
@@ -380,6 +448,21 @@ class BidiPageDriver implements PageDriver {
|
|
|
380
448
|
const r = await this.conn.send("browsingContext.reload", { context: this.contextId, ignoreCache: opts.ignoreCache === true, wait }, sendOpts);
|
|
381
449
|
return { url: r.url, contextId: this.contextId, reloaded: true, waitedFor: waitUntil };
|
|
382
450
|
}
|
|
451
|
+
if (history) {
|
|
452
|
+
const [beforeUrl] = await this.urlAndReadyState();
|
|
453
|
+
// NavigateOptions.history forbids a silent no-op. Firefox answers a traversal past either
|
|
454
|
+
// end of the session history with a "no such history entry" error, which mapBidiError would
|
|
455
|
+
// relay verbatim — a message that never names the direction the caller asked for. Rewrapped
|
|
456
|
+
// here so back-at-the-start and forward-at-the-end read the same on both backends.
|
|
457
|
+
await this.conn.send("browsingContext.traverseHistory", { context: this.contextId, delta: history === "back" ? -1 : 1 }, sendOpts)
|
|
458
|
+
.catch((e) => {
|
|
459
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
460
|
+
if (/no such history entry|history/i.test(msg)) throw driverError("page-error", `navigate: no history entry to go ${history} to (${msg})`);
|
|
461
|
+
throw e;
|
|
462
|
+
});
|
|
463
|
+
const settled = await this.settleAfterTraversal(beforeUrl, waitUntil, opts.timeoutMs ?? 15_000);
|
|
464
|
+
return { url: settled.url, contextId: this.contextId, traversed: history, waitedFor: settled.waitedFor };
|
|
465
|
+
}
|
|
383
466
|
const r = await this.conn.send("browsingContext.navigate", { context: this.contextId, url: opts.url as string, wait }, sendOpts);
|
|
384
467
|
return { url: r.url, contextId: this.contextId, waitedFor: waitUntil };
|
|
385
468
|
} catch (e) {
|
|
@@ -454,7 +537,25 @@ class BidiPageDriver implements PageDriver {
|
|
|
454
537
|
if (!nodes.length) throw driverError("stale-uid", `uid does not resolve: ${uid}`);
|
|
455
538
|
}
|
|
456
539
|
|
|
540
|
+
/**
|
|
541
|
+
* THE INPUT DISPATCH CHOKE POINT for Firefox, and the direct counterpart of
|
|
542
|
+
* cdp/driver.ts's dispatchInput. Every synthesized input this driver produces
|
|
543
|
+
* is already funnelled through this one method (hover, click, scroll, drag,
|
|
544
|
+
* setValue, typeText, pressKey all build actions and call it), so the
|
|
545
|
+
* dispatch-log write that keeps the toolkit's own input from reading as a
|
|
546
|
+
* human's needs exactly one line, here. See ../activity.ts.
|
|
547
|
+
*
|
|
548
|
+
* scroll and the generalised drag arrived on the OTHER 1.8.0 branch (Track P)
|
|
549
|
+
* and inherited the log for free by building on this method — which is the
|
|
550
|
+
* whole argument for a choke point, and the reason this list is worth keeping
|
|
551
|
+
* accurate rather than deleting.
|
|
552
|
+
*
|
|
553
|
+
* NOT routed through here: input.setFiles (upload_file). Same reasoning as the
|
|
554
|
+
* CDP side — it fires none of the beacon's events, so logging it could only
|
|
555
|
+
* suppress a real human input, never prevent a false one.
|
|
556
|
+
*/
|
|
457
557
|
private async performActions(actions: InputSourceActions[]): Promise<void> {
|
|
558
|
+
recordDispatch("firefox", this.contextId);
|
|
458
559
|
try {
|
|
459
560
|
await this.conn.send("input.performActions", { context: this.contextId, actions });
|
|
460
561
|
} catch (e) {
|
|
@@ -474,7 +575,18 @@ class BidiPageDriver implements PageDriver {
|
|
|
474
575
|
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions: [{ type: "pointerMove", x, y, duration: 0 }] }]);
|
|
475
576
|
return { x, y };
|
|
476
577
|
}
|
|
578
|
+
// modifiers is new in 1.8.0 (Track P1). Chrome expresses it as a bitmask on the same
|
|
579
|
+
// mousePressed/mouseReleased events (cdp/driver.ts's inputModifierBits); WebDriver BiDi has no
|
|
580
|
+
// equivalent field on a pointerDown/pointerUp action; a modifier chord instead requires a SECOND
|
|
581
|
+
// "key" input source ticked in lockstep with the pointer source across the same performActions
|
|
582
|
+
// call, which the spec explicitly permits skipping ("throw a clear error when modifiers are
|
|
583
|
+
// passed on firefox" — see cdp-toolkit's 1.8.0 spec, Track P1 §3) and this driver was not
|
|
584
|
+
// verified against real Firefox for this pass, so it throws "unsupported" rather than guess at
|
|
585
|
+
// untested tick-synchronization semantics.
|
|
477
586
|
async click(loc: ElementLocator, opts?: MouseButtonOptions): Promise<{ x: number; y: number }> {
|
|
587
|
+
if (opts?.modifiers && opts.modifiers.length > 0) {
|
|
588
|
+
throw driverError("unsupported", "click: 'modifiers' is not supported by the Firefox/BiDi driver; omit 'modifiers' or use --browser chrome for a modifier click.");
|
|
589
|
+
}
|
|
478
590
|
const ref = await resolveElementLocator(this.conn, this.contextId, loc);
|
|
479
591
|
const { x, y } = await this.centerOf(ref);
|
|
480
592
|
const button = BUTTON_CODES[opts?.button ?? "left"];
|
|
@@ -483,17 +595,63 @@ class BidiPageDriver implements PageDriver {
|
|
|
483
595
|
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
484
596
|
return { x, y };
|
|
485
597
|
}
|
|
486
|
-
|
|
598
|
+
// 1.8.0 Track P1: BiDi's "wheel" input source (a distinct source type from "pointer"/"key")
|
|
599
|
+
// carries x/y/deltaX/deltaY directly on its one "scroll" action — no tick synchronization with
|
|
600
|
+
// another source needed, unlike click's modifiers above, which is what makes this tractable.
|
|
601
|
+
// Same settle-wait rationale as cdp/driver.ts's armScrollSettleWatch: a wheel-triggered scroll
|
|
602
|
+
// is a browser-animated effect, not a synchronous DOM mutation, so a caller reading scroll
|
|
603
|
+
// position immediately after performActions resolves risks the pre-scroll value. Not empirically
|
|
604
|
+
// verified against real Firefox this pass (no Firefox available in this environment), but applied
|
|
605
|
+
// for consistency with the verified Chrome behavior rather than left as a known, unfixed gap.
|
|
606
|
+
async scroll(anchor: ElementLocator | { x: number; y: number } | undefined, opts: ScrollOptions): Promise<{ x: number; y: number }> {
|
|
607
|
+
const { x, y } = await this.resolveScrollPoint(anchor);
|
|
608
|
+
const deltaX = Math.round(opts.deltaX ?? 0);
|
|
609
|
+
const deltaY = Math.round(opts.deltaY ?? 0);
|
|
610
|
+
await this.callFunctionValue(ARM_SCROLL_SETTLE_WATCH_SOURCE, []);
|
|
611
|
+
const actions: InputWheelSourceAction[] = [{ type: "scroll", x: Math.round(x), y: Math.round(y), deltaX, deltaY }];
|
|
612
|
+
await this.performActions([{ type: "wheel", id: "cdp-wheel", actions }]);
|
|
613
|
+
await this.callFunctionValue(AWAIT_SCROLL_SETTLE_SOURCE, []).catch(() => undefined);
|
|
614
|
+
return { x, y };
|
|
615
|
+
}
|
|
616
|
+
private async resolveScrollPoint(anchor: ElementLocator | { x: number; y: number } | undefined): Promise<{ x: number; y: number }> {
|
|
617
|
+
if (anchor === undefined) {
|
|
618
|
+
const v = await this.callFunctionValue("function(){return {x: window.innerWidth / 2, y: window.innerHeight / 2};}", []);
|
|
619
|
+
if (!v || typeof v !== "object") throw driverError("page-error", "could not measure viewport for scroll center");
|
|
620
|
+
return v as { x: number; y: number };
|
|
621
|
+
}
|
|
622
|
+
if ("x" in anchor) return anchor;
|
|
623
|
+
return this.centerOf(await resolveElementLocator(this.conn, this.contextId, anchor));
|
|
624
|
+
}
|
|
625
|
+
// 1.8.0 Track P2. mode:"html5" is REFUSED here, never silently downgraded to mouse mode: WebDriver
|
|
626
|
+
// BiDi has no drag-interception primitive at all (no setInterceptDrags equivalent, no drag event
|
|
627
|
+
// to subscribe to, no dispatchDragEvent), so there is nothing to port. The param-level refusal
|
|
628
|
+
// mirrors click's modifiers-on-Firefox above — `drag` itself stays in tools/list because mouse
|
|
629
|
+
// mode is fully supported here. `to` accepting a point or an offset, and `steps`, are both
|
|
630
|
+
// backend-neutral and DO work on this driver.
|
|
631
|
+
async drag(from: ElementLocator, to: DragDestination, opts?: DragOptions): Promise<{ from: { x: number; y: number }; to: { x: number; y: number } }> {
|
|
632
|
+
if (opts?.mode === "html5") {
|
|
633
|
+
throw driverError(
|
|
634
|
+
"unsupported",
|
|
635
|
+
"drag: mode:'html5' is not supported by the Firefox/BiDi driver (WebDriver BiDi has no drag-interception primitive); " +
|
|
636
|
+
"omit 'mode' for the synthetic-mouse drag, or use --browser chrome for real HTML5 drag events.",
|
|
637
|
+
);
|
|
638
|
+
}
|
|
487
639
|
const fromPt = await this.centerOf(await resolveElementLocator(this.conn, this.contextId, from));
|
|
488
|
-
const toPt = await this.
|
|
640
|
+
const toPt = await this.resolveDragDestination(to, fromPt);
|
|
641
|
+
const steps = Math.max(1, Math.trunc(opts?.steps ?? 2));
|
|
489
642
|
const actions: InputPointerSourceAction[] = [
|
|
490
643
|
{ type: "pointerMove", x: fromPt.x, y: fromPt.y, duration: 0 }, { type: "pointerDown", button: 0 },
|
|
491
|
-
{ type: "pointerMove", x: (fromPt.x + toPt.x) / 2, y: (fromPt.y + toPt.y) / 2, duration: 50 },
|
|
492
|
-
{ type: "pointerMove", x: toPt.x, y: toPt.y, duration: 50 }, { type: "pointerUp", button: 0 },
|
|
493
644
|
];
|
|
645
|
+
for (const pt of interpolatePoints(fromPt, toPt, steps)) actions.push({ type: "pointerMove", x: pt.x, y: pt.y, duration: 50 });
|
|
646
|
+
actions.push({ type: "pointerUp", button: 0 });
|
|
494
647
|
await this.performActions([{ type: "pointer", id: "cdp-mouse", actions }]);
|
|
495
648
|
return { from: fromPt, to: toPt };
|
|
496
649
|
}
|
|
650
|
+
private async resolveDragDestination(to: DragDestination, fromPt: { x: number; y: number }): Promise<{ x: number; y: number }> {
|
|
651
|
+
if ("dx" in to) return { x: fromPt.x + to.dx, y: fromPt.y + to.dy };
|
|
652
|
+
if ("x" in to) return to;
|
|
653
|
+
return this.centerOf(await resolveElementLocator(this.conn, this.contextId, to));
|
|
654
|
+
}
|
|
497
655
|
// No atomic value-commit primitive over BiDi (established fact): every key goes through
|
|
498
656
|
// input.performActions, batched into ONE call regardless of string length, so setValue/typeText
|
|
499
657
|
// cost exactly 2 round trips (one evaluate to focus/clear, one performActions for the keys).
|
|
@@ -921,7 +1079,72 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
921
1079
|
const info = await pageInfoOf(conn, ctx);
|
|
922
1080
|
return new BidiPageDriver(conn, ctx.context, info, this);
|
|
923
1081
|
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Install the activity beacon (../activity.ts) into a context and every
|
|
1085
|
+
* document it navigates to afterwards.
|
|
1086
|
+
*
|
|
1087
|
+
* Both legs matter: script.addPreloadScript arms every FUTURE document (and
|
|
1088
|
+
* survives navigation for free here, see bidiBeaconScripts above), while the
|
|
1089
|
+
* script.evaluate arms the document already loaded, which a preload script
|
|
1090
|
+
* never retroactively reaches.
|
|
1091
|
+
*
|
|
1092
|
+
* DELIBERATELY GATE-FREE (no resolveContext), like its CDP twin and for the
|
|
1093
|
+
* same reason: the claim_page{target} path reads and installs around a claim
|
|
1094
|
+
* that the lease gate would otherwise take on its own behalf. Returning false
|
|
1095
|
+
* rather than throwing is the contract — installBeacon's caller treats this as
|
|
1096
|
+
* an annotation that must never fail real work.
|
|
1097
|
+
*/
|
|
1098
|
+
async installActivityBeacon(contextId: string): Promise<boolean> {
|
|
1099
|
+
const conn = await getConnection(this.port, this.timeoutMs).catch(() => undefined);
|
|
1100
|
+
if (!conn) return false;
|
|
1101
|
+
try {
|
|
1102
|
+
const key = `${this.port}:${contextId}`;
|
|
1103
|
+
if (!bidiBeaconScripts.has(key)) {
|
|
1104
|
+
const registered = await conn.send("script.addPreloadScript", {
|
|
1105
|
+
functionDeclaration: BEACON_FUNCTION_DECLARATION,
|
|
1106
|
+
contexts: [contextId],
|
|
1107
|
+
});
|
|
1108
|
+
bidiBeaconScripts.set(key, registered.script);
|
|
1109
|
+
}
|
|
1110
|
+
await conn.send("script.evaluate", {
|
|
1111
|
+
expression: BEACON_SOURCE,
|
|
1112
|
+
target: { context: contextId },
|
|
1113
|
+
awaitPromise: false,
|
|
1114
|
+
});
|
|
1115
|
+
return true;
|
|
1116
|
+
} catch {
|
|
1117
|
+
return false;
|
|
1118
|
+
} finally {
|
|
1119
|
+
conn.release();
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
/** Read the beacon timestamp for a context, or null when it has none. Gate-free;
|
|
1124
|
+
* see the declaration in ../driver.ts for why that is required here. */
|
|
1125
|
+
async readActivityBeacon(contextId: string): Promise<number | null> {
|
|
1126
|
+
const conn = await getConnection(this.port, this.timeoutMs).catch(() => undefined);
|
|
1127
|
+
if (!conn) return null;
|
|
1128
|
+
try {
|
|
1129
|
+
const res = await conn.send("script.evaluate", {
|
|
1130
|
+
expression: BEACON_READ_EXPRESSION,
|
|
1131
|
+
target: { context: contextId },
|
|
1132
|
+
awaitPromise: false,
|
|
1133
|
+
});
|
|
1134
|
+
if (res.type !== "success") return null;
|
|
1135
|
+
const value = deserializeRemote(res.result);
|
|
1136
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
1137
|
+
} catch {
|
|
1138
|
+
return null;
|
|
1139
|
+
} finally {
|
|
1140
|
+
conn.release();
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
|
|
924
1144
|
async dispose(): Promise<void> {
|
|
1145
|
+
for (const key of [...bidiBeaconScripts.keys()]) {
|
|
1146
|
+
if (key.startsWith(`${this.port}:`)) bidiBeaconScripts.delete(key);
|
|
1147
|
+
}
|
|
925
1148
|
const conn = connections.get(this.port);
|
|
926
1149
|
if (!conn) return;
|
|
927
1150
|
connections.delete(this.port);
|
package/src/capabilities.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Per-backend tool availability: filters the
|
|
2
|
+
* Per-backend tool availability: filters the 45-tool registry down to what a
|
|
3
3
|
* given DriverKind can actually run, per ADR-001's rule that tools/list
|
|
4
4
|
* discovers gaps, not runtime "unsupported" throws. Pure and I/O-free: driver
|
|
5
5
|
* construction reads a static readonly `capabilities` set, it does not dial a
|