cdp-toolkit 1.1.0 → 1.2.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 +76 -9
- package/CONTRACT.md +7 -5
- package/README.md +67 -30
- package/dist/cli.js +410 -61
- package/dist/index.js +370 -39
- package/dist/mcp.js +609 -72
- package/package.json +2 -2
- package/src/bidi/driver.ts +31 -3
- package/src/capabilities.ts +1 -1
- package/src/cli.ts +47 -22
- package/src/client.ts +25 -7
- package/src/driver.ts +1 -1
- package/src/firefox-tools.ts +6 -3
- package/src/index.ts +6 -1
- package/src/leases-tools.ts +101 -0
- package/src/leases.ts +533 -0
- package/src/manifest.ts +211 -13
- package/src/mcp.ts +27 -19
- package/src/shared-tools.ts +52 -6
- package/src/tools/console.ts +3 -3
- package/src/tools/evaluate.ts +2 -2
- package/src/tools/heap.ts +3 -3
- package/src/tools/lighthouse.ts +4 -4
- package/src/tools/navigation.ts +1 -1
- package/src/tools/network.ts +7 -7
- package/src/tools/network_mock.ts +8 -8
- package/src/tools/performance.ts +11 -11
- package/src/tools/recorder.ts +5 -5
- package/src/tools/snapshot.ts +2 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdp-toolkit",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "A lightweight chrome-devtools-mcp alternative: a single-target MCP server + CLI that drives one tab over a 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.
|
|
3
|
+
"version": "1.2.0",
|
|
4
|
+
"description": "A lightweight chrome-devtools-mcp alternative: a single-target MCP server + CLI that drives one tab over a 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. 36 tools including built-in network mocking. No Puppeteer, no all-target fan-out.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": {
|
package/src/bidi/driver.ts
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
type MouseButtonOptions, type NavigateOptions, type NavigateResult, type PageDriver, type PageInfo,
|
|
31
31
|
type ScreenshotOptions, type SnapshotNode, type UidStability,
|
|
32
32
|
} from "../driver.ts";
|
|
33
|
+
import { assertLeaseOk } from "../leases.ts";
|
|
33
34
|
import { BidiConnection, BidiError, connectBidiSession } from "./client.ts";
|
|
34
35
|
import { takeStampedSnapshot } from "./snapshot.ts";
|
|
35
36
|
import { selectRule, buildFulfillParams, effectiveAction } from "../tools/network_mock.ts";
|
|
@@ -256,10 +257,33 @@ async function pageInfoOf(conn: BidiConnection, ctx: BrowsingContextInfo): Promi
|
|
|
256
257
|
return { id: ctx.context, url: ctx.url, title: await safeTitle(conn, ctx.context) };
|
|
257
258
|
}
|
|
258
259
|
/** Same TargetSelector grammar as client.ts's resolveTarget, but resolved entirely over BiDi
|
|
259
|
-
* (browsingContext.getTree): there is no HTTP discovery endpoint here, per client.ts's own footer.
|
|
260
|
-
|
|
260
|
+
* (browsingContext.getTree): there is no HTTP discovery endpoint here, per client.ts's own footer.
|
|
261
|
+
*
|
|
262
|
+
* THE Firefox choke point for tab leases. Keyed "firefox", never a bare id: a CDP targetId and a
|
|
263
|
+
* BiDi context id are not disjoint by construction, so one bare key space could produce a false
|
|
264
|
+
* collision between an unrelated Chrome tab and a Firefox context. Note this backend needs the
|
|
265
|
+
* lease far less than Chrome does (backend.ts: every MCP server process launches its OWN Firefox,
|
|
266
|
+
* so two sessions cannot race over one instance the way they do over Chrome on port 9222). It is
|
|
267
|
+
* here for consistency and for a future shared-instance setup, not to close an observed gap. */
|
|
268
|
+
async function resolveContext(
|
|
269
|
+
conn: BidiConnection,
|
|
270
|
+
selector: TargetSelector,
|
|
271
|
+
opts: { lease?: string } = {},
|
|
272
|
+
): Promise<BrowsingContextInfo> {
|
|
261
273
|
const { contexts } = await conn.send("browsingContext.getTree", {}).catch((e) => { throw mapBidiError(e); });
|
|
262
274
|
if (!contexts.length) throw driverError("no-such-target", "no browsing contexts available");
|
|
275
|
+
const hit = await pickContext(conn, contexts, selector);
|
|
276
|
+
// liveIds deliberately not passed: hit is by construction a member of contexts, so target-gone
|
|
277
|
+
// could never fire for the context just resolved (see leases.ts's assertLeaseOk callers).
|
|
278
|
+
await assertLeaseOk("firefox", hit.context, { lease: opts.lease, url: hit.url });
|
|
279
|
+
return hit;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function pickContext(
|
|
283
|
+
conn: BidiConnection,
|
|
284
|
+
contexts: BrowsingContextInfo[],
|
|
285
|
+
selector: TargetSelector,
|
|
286
|
+
): Promise<BrowsingContextInfo> {
|
|
263
287
|
if (selector === undefined || selector === "active") return contexts[0]!;
|
|
264
288
|
if (selector.startsWith("index:")) {
|
|
265
289
|
const t = contexts[Number(selector.slice(6))];
|
|
@@ -771,7 +795,11 @@ class BidiBrowserDriver implements BrowserDriver {
|
|
|
771
795
|
export function createFirefoxDriver(port: number, opts?: { timeoutMs?: number }): BrowserDriver {
|
|
772
796
|
return new BidiBrowserDriver(port, opts?.timeoutMs);
|
|
773
797
|
}
|
|
774
|
-
|
|
798
|
+
// resolveContext is exported for ONE reason: it is the Firefox lease choke
|
|
799
|
+
// point, and a choke point nothing can call directly is a choke point nothing
|
|
800
|
+
// can test. Its gate was deletable with a green suite until test/leases.test.ts
|
|
801
|
+
// could drive it against a stub connection. Not part of the public surface.
|
|
802
|
+
export { BidiBrowserDriver, BidiPageDriver, resolveContext };
|
|
775
803
|
|
|
776
804
|
/* ------------------------------------------------------------------------------
|
|
777
805
|
* BiDi modules/commands used: session (new, subscribe/unsubscribe, via client.ts); browsingContext
|
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 36-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
|
package/src/cli.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { TOOLS, TOOL_NAMES, type ToolName } from "./index.ts";
|
|
|
26
26
|
import { resolveBrowserKind, stripBrowserFlag, startFirefoxSession } from "./backend.ts";
|
|
27
27
|
import { toolAvailability } from "./capabilities.ts";
|
|
28
28
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
29
|
+
import { leaseFromArgs, withLeaseScope } from "./leases.ts";
|
|
29
30
|
|
|
30
31
|
const USAGE = `cdp-toolkit: raw single-target CDP, 29-tool chrome-devtools-mcp parity, plus a Firefox backend over WebDriver BiDi.
|
|
31
32
|
|
|
@@ -48,6 +49,8 @@ zero behavior change for existing users). Firefox is LAUNCHED per invocation (it
|
|
|
48
49
|
to), used for exactly one tool call, then disposed before the process exits.
|
|
49
50
|
|
|
50
51
|
Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
|
|
52
|
+
Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
|
|
53
|
+
Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
|
|
51
54
|
Run with --list to print every tool name available under the selected backend.
|
|
52
55
|
Run with --capabilities to see, per tool, whether it is available and (if not) which capability
|
|
53
56
|
the selected backend is missing.`;
|
|
@@ -168,30 +171,52 @@ async function main(): Promise<number> {
|
|
|
168
171
|
return 1;
|
|
169
172
|
}
|
|
170
173
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
process.stderr.write(`${JSON.stringify({ error: `tool '${parsed.tool}' has no Firefox implementation wired` })}\n`);
|
|
174
|
+
// Decision: the CLI cannot claim a lease. Per backend.ts, a CLI invocation is
|
|
175
|
+
// one process per call, so the claiming process is already dead by the next
|
|
176
|
+
// call and the dead-pid rule would make the lease reclaimable at once,
|
|
177
|
+
// regardless of ttlMs. A lease that is reclaimable on arrival is worse than
|
|
178
|
+
// no lease, because it reads as protection that is not there. Every OTHER
|
|
179
|
+
// CLI call still goes through assertLeaseOk and may present --lease.
|
|
180
|
+
if (parsed.tool === "claim_page") {
|
|
181
|
+
process.stderr.write(
|
|
182
|
+
`${JSON.stringify({
|
|
183
|
+
error:
|
|
184
|
+
"claim_page is not available from the CLI: a CLI invocation is one process per call, so the lease it claimed would be reclaimable immediately by the dead-pid rule. Claim from the cdp-toolkit MCP server, which is long lived, then pass the token to CLI calls with --lease <token>.",
|
|
185
|
+
})}\n`,
|
|
186
|
+
);
|
|
185
187
|
return 1;
|
|
186
188
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
189
|
+
|
|
190
|
+
// ONE lease scope per invocation, wrapping BOTH backend branches, exactly as
|
|
191
|
+
// mcp.ts does. `--lease <token>` lands in parsed.args like any other flag, so
|
|
192
|
+
// no tool signature changes and a tool added later is covered for free.
|
|
193
|
+
const tool = parsed.tool;
|
|
194
|
+
return withLeaseScope(leaseFromArgs(parsed.args), async () => {
|
|
195
|
+
if (browser === "chrome") {
|
|
196
|
+
const fn = TOOLS[tool];
|
|
197
|
+
// The registry's value type is the union of all tool signatures; each tool
|
|
198
|
+
// validates its own args at runtime, so we hand the parsed object through.
|
|
199
|
+
const result = await (fn as (args: unknown) => Promise<unknown>)(parsed.args);
|
|
200
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Firefox: one process per invocation. Launch, run exactly one tool call, always dispose,
|
|
205
|
+
// even if the tool call itself throws, so the spawned Firefox process never leaks.
|
|
206
|
+
const neutralFn = FIREFOX_TOOLS[tool];
|
|
207
|
+
if (!neutralFn) {
|
|
208
|
+
process.stderr.write(`${JSON.stringify({ error: `tool '${tool}' has no Firefox implementation wired` })}\n`);
|
|
209
|
+
return 1;
|
|
210
|
+
}
|
|
211
|
+
const session = await startFirefoxSession();
|
|
212
|
+
try {
|
|
213
|
+
const result = await neutralFn(session.driver, parsed.args as never);
|
|
214
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
215
|
+
return 0;
|
|
216
|
+
} finally {
|
|
217
|
+
await session.dispose();
|
|
218
|
+
}
|
|
219
|
+
});
|
|
195
220
|
}
|
|
196
221
|
|
|
197
222
|
main()
|
package/src/client.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Core raw-CDP client. Zero runtime dependencies
|
|
2
|
+
* Core raw-CDP client. Zero runtime dependencies: uses Node's global
|
|
3
3
|
* WebSocket (Node >= 22; verified on Node 25.9). Every tool module is built on
|
|
4
4
|
* the primitives exported here. See CONTRACT.md.
|
|
5
5
|
*/
|
|
6
6
|
import type { CdpResponse, Target, TargetSelector } from "./types.ts";
|
|
7
|
+
import { assertLeaseOk } from "./leases.ts";
|
|
7
8
|
|
|
8
9
|
/** Base HTTP origin of the DevTools endpoint. Override with CDP_BASE. */
|
|
9
10
|
export const BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
|
|
@@ -185,15 +186,32 @@ export function listTargets(): Promise<Target[]> {
|
|
|
185
186
|
return httpJson<Target[]>("/json/list");
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
/** Browser-level WebSocket URL (GET /json/version)
|
|
189
|
+
/** Browser-level WebSocket URL (GET /json/version), for Target.* / Browser.*. */
|
|
189
190
|
export async function browserWsUrl(): Promise<string> {
|
|
190
191
|
const v = await httpJson<{ webSocketDebuggerUrl: string }>("/json/version");
|
|
191
192
|
return v.webSocketDebuggerUrl;
|
|
192
193
|
}
|
|
193
194
|
|
|
194
|
-
/** Resolve a TargetSelector to a concrete page target. See types.ts for grammar.
|
|
195
|
-
|
|
195
|
+
/** Resolve a TargetSelector to a concrete page target. See types.ts for grammar.
|
|
196
|
+
* opts.lease is for library callers; the MCP/CLI path supplies the token through
|
|
197
|
+
* the ambient lease scope instead (see leases.ts). */
|
|
198
|
+
export async function resolveTarget(selector: TargetSelector, opts: { lease?: string } = {}): Promise<Target> {
|
|
196
199
|
const targets = await listTargets();
|
|
200
|
+
const hit = pickTarget(targets, selector);
|
|
201
|
+
// THE Chrome choke point. Every tool reaches a page through here, so a tool
|
|
202
|
+
// added after this shipped is protected with no action from its author.
|
|
203
|
+
//
|
|
204
|
+
// Deliberately NOT passing liveIds. It would be inert here: pickTarget
|
|
205
|
+
// returns an element of `targets`, so hit.id is always a member of that list
|
|
206
|
+
// and the "target-gone" staleness condition could never fire for the very
|
|
207
|
+
// target we just resolved. Passing it would only suggest a check is happening
|
|
208
|
+
// that is not. target-gone earns its keep on claim, and on a list_leases
|
|
209
|
+
// reap, where a record's id may genuinely be absent from the browser.
|
|
210
|
+
await assertLeaseOk("chrome", hit.id, { lease: opts.lease, url: hit.url, title: hit.title });
|
|
211
|
+
return hit;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pickTarget(targets: Target[], selector: TargetSelector): Target {
|
|
197
215
|
const pages = targets.filter((t) => t.type === "page");
|
|
198
216
|
if (!selector || selector === "active") {
|
|
199
217
|
if (!pages.length) throw new CdpError("no page targets open");
|
|
@@ -230,9 +248,9 @@ export async function openBrowser(opts: { timeoutMs?: number } = {}): Promise<Cd
|
|
|
230
248
|
/** Open a connection to a page target. Returns the connection and the target. */
|
|
231
249
|
export async function openPage(
|
|
232
250
|
selector: TargetSelector,
|
|
233
|
-
opts: { timeoutMs?: number } = {},
|
|
251
|
+
opts: { timeoutMs?: number; lease?: string } = {},
|
|
234
252
|
): Promise<{ conn: CdpConnection; target: Target }> {
|
|
235
|
-
const target = await resolveTarget(selector);
|
|
253
|
+
const target = await resolveTarget(selector, { lease: opts.lease });
|
|
236
254
|
const conn = await new CdpConnection(target.webSocketDebuggerUrl, opts).connect();
|
|
237
255
|
return { conn, target };
|
|
238
256
|
}
|
|
@@ -244,7 +262,7 @@ export async function openPage(
|
|
|
244
262
|
export async function withPage<T>(
|
|
245
263
|
selector: TargetSelector,
|
|
246
264
|
fn: (conn: CdpConnection, target: Target) => Promise<T>,
|
|
247
|
-
opts: { timeoutMs?: number } = {},
|
|
265
|
+
opts: { timeoutMs?: number; lease?: string } = {},
|
|
248
266
|
): Promise<T> {
|
|
249
267
|
const { conn, target } = await openPage(selector, opts);
|
|
250
268
|
try {
|
package/src/driver.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* ADR-001: one browser-neutral Driver interface behind the
|
|
2
|
+
* ADR-001: one browser-neutral Driver interface behind the 36 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`
|
package/src/firefox-tools.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The complete Firefox tool registry: the 20 tools shared with Chrome (src/shared-tools.ts)
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* The complete Firefox tool registry: the 20 tools shared with Chrome (src/shared-tools.ts), the
|
|
3
|
+
* 7 Firefox-only console/network/mock tools (src/bidi-tools.ts), and the 3 lease tools
|
|
4
|
+
* (src/leases-tools.ts), which are backend-neutral by construction. This is what src/neutral.ts
|
|
5
|
+
* used to be; it now composes three files instead of reimplementing everything in one.
|
|
5
6
|
*/
|
|
6
7
|
import type { BrowserDriver } from "./driver.ts";
|
|
7
8
|
import type { ToolName } from "./index.ts";
|
|
8
9
|
import { SHARED_TOOLS } from "./shared-tools.ts";
|
|
9
10
|
import { BIDI_ONLY_TOOLS } from "./bidi-tools.ts";
|
|
11
|
+
import { LEASE_TOOLS } from "./leases-tools.ts";
|
|
10
12
|
|
|
11
13
|
export const FIREFOX_TOOLS: Partial<Record<ToolName, (driver: BrowserDriver, args: never) => Promise<unknown>>> = {
|
|
12
14
|
...SHARED_TOOLS,
|
|
13
15
|
...BIDI_ONLY_TOOLS,
|
|
16
|
+
...LEASE_TOOLS,
|
|
14
17
|
};
|
package/src/index.ts
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// the byte-identical-Chrome-behavior contract this bind honors, and for the 7 tools that stay
|
|
20
20
|
// two implementations (console/network/mock, immediately below, unchanged from before).
|
|
21
21
|
import { createCdpDriver } from "./cdp/driver.ts";
|
|
22
|
+
import { LEASE_TOOLS } from "./leases-tools.ts";
|
|
22
23
|
import { SHARED_TOOLS } from "./shared-tools.ts";
|
|
23
24
|
// --- console + network (read the recorder buffer) ---
|
|
24
25
|
import { listConsoleMessages, getConsoleMessage } from "./tools/console.ts";
|
|
@@ -56,7 +57,7 @@ function onCdp<A>(fn: (driver: ReturnType<typeof createCdpDriver>, args: A) => P
|
|
|
56
57
|
* (`performance_trace`, a robust single-call trace : the start/stop pair cannot
|
|
57
58
|
* span two stateless CLI processes), plus a 3-tool network-mocking group
|
|
58
59
|
* (mock_request/list_mocks/clear_mocks : a persistent per-target fake backend).
|
|
59
|
-
*
|
|
60
|
+
* 36 entries total. Listed explicitly so the mapping is auditable at a glance and
|
|
60
61
|
* the CLI can `--list` it.
|
|
61
62
|
*
|
|
62
63
|
* 20 of the 29 MCP-parity tools (pages/navigation/evaluate/snapshot/interaction/
|
|
@@ -110,6 +111,10 @@ export const TOOLS = {
|
|
|
110
111
|
mock_request: mockRequest,
|
|
111
112
|
list_mocks: listMocks,
|
|
112
113
|
clear_mocks: clearMocks,
|
|
114
|
+
// tab leases (3) : opt-in ownership so many agents can share one browser
|
|
115
|
+
claim_page: onCdp(LEASE_TOOLS.claim_page),
|
|
116
|
+
release_page: onCdp(LEASE_TOOLS.release_page),
|
|
117
|
+
list_leases: onCdp(LEASE_TOOLS.list_leases),
|
|
113
118
|
} satisfies Record<string, (args: never) => Promise<unknown>>;
|
|
114
119
|
|
|
115
120
|
/** Canonical MCP tool name accepted by the registry. */
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three lease tools, shaped like src/shared-tools.ts entries
|
|
3
|
+
* ((driver, args) => Promise<result>) so ONE implementation serves Chrome and
|
|
4
|
+
* Firefox, exactly as the other 20 unified tools do.
|
|
5
|
+
*/
|
|
6
|
+
import type { BrowserDriver } from "./driver.ts";
|
|
7
|
+
import {
|
|
8
|
+
claimLease,
|
|
9
|
+
leaseTtlMs,
|
|
10
|
+
listLeases,
|
|
11
|
+
releaseLease,
|
|
12
|
+
type LeaseBackend,
|
|
13
|
+
type LeaseSummary,
|
|
14
|
+
type LeaseToken,
|
|
15
|
+
} from "./leases.ts";
|
|
16
|
+
|
|
17
|
+
class LeaseToolError extends Error {}
|
|
18
|
+
|
|
19
|
+
/** The driver's uid scheme is the one stable per-backend discriminator on the
|
|
20
|
+
* BrowserDriver surface ("cdp" for Chrome, "bidi" for Firefox). */
|
|
21
|
+
function backendOf(driver: BrowserDriver): LeaseBackend {
|
|
22
|
+
return driver.scheme === "bidi" ? "firefox" : "chrome";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ClaimPageResult {
|
|
26
|
+
lease: LeaseToken;
|
|
27
|
+
targetId: string;
|
|
28
|
+
url: string;
|
|
29
|
+
label: string;
|
|
30
|
+
ttlMs: number;
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Claim a tab. With no targetId, opens a fresh tab first (optionally at `url`)
|
|
36
|
+
* and claims that, so "give me my own tab" is one call. With a targetId, claims
|
|
37
|
+
* an already-open tab.
|
|
38
|
+
*/
|
|
39
|
+
export async function claimPage(
|
|
40
|
+
driver: BrowserDriver,
|
|
41
|
+
args: { targetId?: string; url?: string; label?: string; ttlMs?: number } = {},
|
|
42
|
+
): Promise<ClaimPageResult> {
|
|
43
|
+
const backend = backendOf(driver);
|
|
44
|
+
const label = typeof args.label === "string" && args.label.length ? args.label : `pid-${process.pid}`;
|
|
45
|
+
const pages = await driver.listPages();
|
|
46
|
+
let targetId = args.targetId;
|
|
47
|
+
let url = args.url ?? "about:blank";
|
|
48
|
+
if (targetId === undefined || targetId === "") {
|
|
49
|
+
const page = await driver.newPage(args.url);
|
|
50
|
+
targetId = page.id;
|
|
51
|
+
url = page.url;
|
|
52
|
+
} else {
|
|
53
|
+
const existing = pages.find((p) => p.id === targetId);
|
|
54
|
+
if (!existing) throw new LeaseToolError(`claim_page: no page target with id '${targetId}'`);
|
|
55
|
+
url = existing.url;
|
|
56
|
+
}
|
|
57
|
+
const liveIds = [...pages.map((p) => p.id), targetId];
|
|
58
|
+
const { record, token } = await claimLease(backend, targetId, {
|
|
59
|
+
label,
|
|
60
|
+
ttlMs: typeof args.ttlMs === "number" && args.ttlMs > 0 ? args.ttlMs : leaseTtlMs(),
|
|
61
|
+
liveIds,
|
|
62
|
+
});
|
|
63
|
+
return {
|
|
64
|
+
lease: token,
|
|
65
|
+
targetId,
|
|
66
|
+
url,
|
|
67
|
+
label: record.label,
|
|
68
|
+
ttlMs: record.ttlMs,
|
|
69
|
+
expiresAt: record.lastUsedAt + record.ttlMs,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Release a held lease. Idempotent: an already-released or reclaimed lease
|
|
74
|
+
* reports released:false rather than throwing. */
|
|
75
|
+
export async function releasePage(
|
|
76
|
+
_driver: BrowserDriver,
|
|
77
|
+
args: { lease?: LeaseToken },
|
|
78
|
+
): Promise<{ released: boolean }> {
|
|
79
|
+
if (typeof args.lease !== "string" || !args.lease.length) {
|
|
80
|
+
throw new LeaseToolError("release_page requires the 'lease' token returned by claim_page");
|
|
81
|
+
}
|
|
82
|
+
return releaseLease(args.lease);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Enumerate active leases for diagnosis. Requires no token by design. */
|
|
86
|
+
export async function listLeasesTool(
|
|
87
|
+
driver: BrowserDriver,
|
|
88
|
+
_args: Record<string, never> = {} as Record<string, never>,
|
|
89
|
+
): Promise<{ leases: LeaseSummary[]; count: number }> {
|
|
90
|
+
const liveIds = (await driver.listPages()).map((p) => p.id);
|
|
91
|
+
// liveBackend scopes the target-gone test to the browser these ids came from,
|
|
92
|
+
// so leases held on the OTHER backend are not mislabeled as reclaimable.
|
|
93
|
+
const leases = await listLeases({ liveIds, liveBackend: backendOf(driver) });
|
|
94
|
+
return { leases, count: leases.length };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export const LEASE_TOOLS = {
|
|
98
|
+
claim_page: claimPage,
|
|
99
|
+
release_page: releasePage,
|
|
100
|
+
list_leases: listLeasesTool,
|
|
101
|
+
} satisfies Record<string, (driver: BrowserDriver, args: never) => Promise<unknown>>;
|