cdp-toolkit 1.7.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +403 -0
- package/CONTRACT.md +9 -1
- package/README.md +58 -17
- package/dist/cli.js +1302 -158
- package/dist/index.js +1144 -145
- package/dist/mcp.js +1620 -244
- package/package.json +6 -3
- package/src/activity.ts +533 -0
- package/src/bidi/driver.ts +259 -8
- package/src/capabilities.ts +1 -1
- package/src/cdp/driver.ts +487 -25
- package/src/cdp/workers.ts +177 -0
- package/src/cli.ts +3 -1
- package/src/client.ts +52 -2
- package/src/driver.ts +187 -3
- package/src/index.ts +26 -4
- package/src/leases-tools.ts +127 -6
- package/src/leases.ts +1 -1
- package/src/manifest.ts +279 -46
- package/src/mcp.ts +6 -1
- package/src/origins.ts +41 -1
- package/src/reap.ts +55 -4
- package/src/shared-tools.ts +372 -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/types.ts +20 -0
- package/src/workers.ts +159 -0
package/src/mcp.ts
CHANGED
|
@@ -19,11 +19,12 @@ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprot
|
|
|
19
19
|
import { TOOLS, TOOL_NAMES, BASE } from "./index.ts";
|
|
20
20
|
import { MANIFEST } from "./manifest.ts";
|
|
21
21
|
import { resolveBrowserKind, stripBrowserFlag, getOrCreateFirefoxSession, disposeFirefoxSession } from "./backend.ts";
|
|
22
|
+
import { disposeBrowserSession } from "./tools/browser-session.ts";
|
|
22
23
|
import { toolAvailability } from "./capabilities.ts";
|
|
23
24
|
import { FIREFOX_TOOLS } from "./firefox-tools.ts";
|
|
24
25
|
import { leaseFromArgs, markLongLivedProcess, withLeaseScope } from "./leases.ts";
|
|
25
26
|
|
|
26
|
-
const VERSION = "1.
|
|
27
|
+
const VERSION = "1.9.0";
|
|
27
28
|
|
|
28
29
|
// --browser is read once at startup (MCP has no per-call notion of backend): explicit flag,
|
|
29
30
|
// else CDP_BROWSER env var, else "chrome" (zero behavior change for existing users/configs).
|
|
@@ -115,6 +116,10 @@ async function shutdown(): Promise<void> {
|
|
|
115
116
|
if (shuttingDown) return;
|
|
116
117
|
shuttingDown = true;
|
|
117
118
|
await disposeFirefoxSession();
|
|
119
|
+
// The standing browser-endpoint connection behind wait_for_download / grant_permissions (1.8.0
|
|
120
|
+
// Track P3). Unlike Firefox it owns no OS process, so process.exit would collect it anyway; it is
|
|
121
|
+
// closed explicitly so shutdown does not depend on that, and is a no-op when neither tool ran.
|
|
122
|
+
await disposeBrowserSession();
|
|
118
123
|
process.exit(0);
|
|
119
124
|
}
|
|
120
125
|
process.on("SIGINT", () => void shutdown());
|
package/src/origins.ts
CHANGED
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
import { mkdir, readFile, readdir, unlink, writeFile } from "node:fs/promises";
|
|
33
33
|
import { join } from "node:path";
|
|
34
34
|
import type { BrowserDriver, PageInfo } from "./driver.ts";
|
|
35
|
-
import { defaultLabel, leaseDir, type LeaseBackend } from "./leases.ts";
|
|
35
|
+
import { defaultLabel, leaseDir, listLeases, type LeaseBackend } from "./leases.ts";
|
|
36
36
|
|
|
37
37
|
/** What list_pages can say about where a tab came from. There is deliberately
|
|
38
38
|
* no "human": see the file header. */
|
|
@@ -264,3 +264,43 @@ export async function newTrackedPage(
|
|
|
264
264
|
await recordOrigin(backend, page.id, { label: opts.label });
|
|
265
265
|
return page;
|
|
266
266
|
}
|
|
267
|
+
|
|
268
|
+
/** What "label:<name>" resolution found, common to all three selector-grammar
|
|
269
|
+
* copies (shared-tools.ts's pickPage, client.ts's pickTarget, bidi/driver.ts's
|
|
270
|
+
* pickContext): 0, 1 or >1 live targetIds carrying the queried label, plus
|
|
271
|
+
* every label actually in use, for a miss error that teaches something. */
|
|
272
|
+
export interface LabelResolution {
|
|
273
|
+
matchIds: string[];
|
|
274
|
+
/** Deduped, sorted. Empty when no live target has any recorded label. */
|
|
275
|
+
knownLabels: string[];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* The shared core of "label:<name>" resolution: exact match against BOTH the
|
|
280
|
+
* origin ledger (agent-created tabs) and live lease records (covers a
|
|
281
|
+
* taken-over tab claimed with a label, which has no origin record — see
|
|
282
|
+
* leases-tools.ts's claimPage), scoped to `liveIds` so a label whose tab is
|
|
283
|
+
* gone neither matches nor pollutes the miss list.
|
|
284
|
+
*
|
|
285
|
+
* PURE OVER ITS INPUTS BEYOND THE TWO READS: no reap, no write, no lease gate.
|
|
286
|
+
* Each of the three callers formats its own error in its own idiom
|
|
287
|
+
* (SharedToolError, CdpError, DriverError), so this returns data, never
|
|
288
|
+
* throws — a caller decides what "matches.length !== 1" means to it.
|
|
289
|
+
*/
|
|
290
|
+
export async function resolveLiveLabel(backend: LeaseBackend, label: string, liveIds: readonly string[]): Promise<LabelResolution> {
|
|
291
|
+
const live = new Set(liveIds);
|
|
292
|
+
const [origins, leases] = await Promise.all([originIndex(backend), listLeases({ liveBackend: backend })]);
|
|
293
|
+
const knownLabels = new Set<string>();
|
|
294
|
+
const matchIds = new Set<string>();
|
|
295
|
+
for (const rec of origins.values()) {
|
|
296
|
+
if (rec.unreadable !== undefined || !live.has(rec.targetId)) continue;
|
|
297
|
+
knownLabels.add(rec.label);
|
|
298
|
+
if (rec.label === label) matchIds.add(rec.targetId);
|
|
299
|
+
}
|
|
300
|
+
for (const rec of leases) {
|
|
301
|
+
if (rec.backend !== backend || rec.unreadable !== undefined || !live.has(rec.targetId)) continue;
|
|
302
|
+
knownLabels.add(rec.label);
|
|
303
|
+
if (rec.label === label) matchIds.add(rec.targetId);
|
|
304
|
+
}
|
|
305
|
+
return { matchIds: [...matchIds], knownLabels: [...knownLabels].sort() };
|
|
306
|
+
}
|
package/src/reap.ts
CHANGED
|
@@ -18,6 +18,16 @@
|
|
|
18
18
|
* whole matrix of {provenance} x {lease state} x {liveness} with no browser and
|
|
19
19
|
* no filesystem, which is the only way to be confident about a destructive
|
|
20
20
|
* operation. The impure wrapper below does nothing but call it and act.
|
|
21
|
+
*
|
|
22
|
+
* WHY REAPING SPLIT FROM RECLAIMABILITY (1.8.0). Before this version `expired`
|
|
23
|
+
* meant one thing everywhere: at ttlMs, both "another agent may take this
|
|
24
|
+
* lease" AND "this tab gets closed" fired at once. That is wrong for the
|
|
25
|
+
* second question and right for the first: a lease going reclaimable at ttlMs
|
|
26
|
+
* is cheap and reversible, but closing someone's tab is not, and an agent 20
|
|
27
|
+
* minutes into a build between browser calls looks identical to one that died.
|
|
28
|
+
* leases.ts's staleReason keeps deciding reclaimability at ttlMs, unchanged;
|
|
29
|
+
* this module alone additionally waits reapGraceMs past that before treating
|
|
30
|
+
* `expired` as grounds to close anything. See staleAgentTabs' condition 4.
|
|
21
31
|
*/
|
|
22
32
|
import type { BrowserDriver } from "./driver.ts";
|
|
23
33
|
import { listLeases, releaseLeaseFor, type LeaseBackend, type LeaseSummary } from "./leases.ts";
|
|
@@ -44,12 +54,19 @@ export interface ReapInput {
|
|
|
44
54
|
livePageIds: readonly string[];
|
|
45
55
|
origins: ReadonlyMap<string, OriginSummary>;
|
|
46
56
|
leases: readonly LeaseSummary[];
|
|
57
|
+
/** Clock for the grace check below. Threaded in rather than read internally
|
|
58
|
+
* (Date.now()) so this function stays pure and the whole matrix is testable
|
|
59
|
+
* with no wall clock. */
|
|
60
|
+
now: number;
|
|
61
|
+
/** How much longer an `expired` lease survives before its tab is destroyed,
|
|
62
|
+
* on top of the ttlMs that already made it reclaimable. See reapGraceMs(). */
|
|
63
|
+
reapGraceMs: number;
|
|
47
64
|
}
|
|
48
65
|
|
|
49
66
|
/**
|
|
50
67
|
* Select the tabs that may be closed. PURE: no I/O, no driver, no clock.
|
|
51
68
|
*
|
|
52
|
-
* All
|
|
69
|
+
* All conditions must hold, and condition 4 (the grace check) is new in 1.8.0:
|
|
53
70
|
*
|
|
54
71
|
* 1. the lease row is for this backend and READABLE. An `unreadable` row is
|
|
55
72
|
* never reaped for the same reason listLeases reports it as stale:false -
|
|
@@ -59,7 +76,17 @@ export interface ReapInput {
|
|
|
59
76
|
* the tab is already closed and there is nothing to do, and obviously not
|
|
60
77
|
* `false`.
|
|
61
78
|
* 3. the target is actually still open, per livePageIds.
|
|
62
|
-
* 4.
|
|
79
|
+
* 4. THE GRACE WINDOW. `stale === "expired"` alone is the RECLAIMABILITY
|
|
80
|
+
* signal (see leases.ts's staleReason): it fires at ttlMs and is cheap and
|
|
81
|
+
* reversible — another agent may simply take the lease. Closing the tab is
|
|
82
|
+
* a different, destructive question, and it does not fire until an
|
|
83
|
+
* ADDITIONAL reapGraceMs has elapsed past that same ttlMs, so an agent that
|
|
84
|
+
* is 20 minutes into a build between browser calls gets a window to come
|
|
85
|
+
* back before its tab is destroyed. `dead-pid` skips this check entirely:
|
|
86
|
+
* a process that no longer exists is never coming back to reclaim
|
|
87
|
+
* anything, so there is nothing to wait for, and that is why dead-pid stays
|
|
88
|
+
* immediate while expired does not.
|
|
89
|
+
* 5. the toolkit OPENED it. An agent-created tab with NO lease is deliberately
|
|
63
90
|
* never reaped: that is exactly what new_page produces for a user who never
|
|
64
91
|
* touches leases, and closing those would be a data-loss bug rather than a
|
|
65
92
|
* cleanup. Only a tab that was claimed and then abandoned qualifies.
|
|
@@ -71,6 +98,7 @@ export function staleAgentTabs(input: ReapInput): ReapedTab[] {
|
|
|
71
98
|
if (row.backend !== input.backend) continue;
|
|
72
99
|
if (row.unreadable !== undefined) continue;
|
|
73
100
|
if (row.stale !== "dead-pid" && row.stale !== "expired") continue;
|
|
101
|
+
if (row.stale === "expired" && input.now - row.lastUsedAt <= row.ttlMs + input.reapGraceMs) continue;
|
|
74
102
|
if (!live.has(row.targetId)) continue;
|
|
75
103
|
const origin = input.origins.get(row.targetId);
|
|
76
104
|
if (origin === undefined || origin.unreadable !== undefined) continue;
|
|
@@ -79,6 +107,24 @@ export function staleAgentTabs(input: ReapInput): ReapedTab[] {
|
|
|
79
107
|
return out;
|
|
80
108
|
}
|
|
81
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 45 minutes. Combined with the default 15-minute lease TTL (leases.ts), the
|
|
112
|
+
* total fuse from an agent's last touch to its tab being destroyed is 60
|
|
113
|
+
* minutes — generous, because the cost of guessing wrong here is a tab an
|
|
114
|
+
* agent was still using getting closed out from under it, not merely a lease
|
|
115
|
+
* another agent could have taken.
|
|
116
|
+
*/
|
|
117
|
+
export const DEFAULT_REAP_GRACE_MS = 2_700_000;
|
|
118
|
+
|
|
119
|
+
/** Read per call, not at module load, so a test can redirect the value — same
|
|
120
|
+
* rule as leaseTtlMs() in leases.ts. Unlike a TTL, zero is a legitimate
|
|
121
|
+
* override (disables the grace period, reverting to at-TTL reaping), so the
|
|
122
|
+
* validity check is `>= 0` rather than `> 0`. */
|
|
123
|
+
export function reapGraceMs(): number {
|
|
124
|
+
const raw = Number(process.env.CDP_REAP_GRACE_MS);
|
|
125
|
+
return Number.isFinite(raw) && raw >= 0 ? raw : DEFAULT_REAP_GRACE_MS;
|
|
126
|
+
}
|
|
127
|
+
|
|
82
128
|
/**
|
|
83
129
|
* Select, close, and drop the leases. Returns ONLY the tabs actually closed.
|
|
84
130
|
*
|
|
@@ -92,6 +138,11 @@ export function staleAgentTabs(input: ReapInput): ReapedTab[] {
|
|
|
92
138
|
* on the next listing. One lifetime rule for origin records, not three.
|
|
93
139
|
*/
|
|
94
140
|
export async function reapStaleAgentTabs(driver: BrowserDriver, backend: LeaseBackend): Promise<ReapedTab[]> {
|
|
141
|
+
// ONE clock reading, shared with listLeases' own staleReason classification
|
|
142
|
+
// below: the "expired" verdict a lease reads at and the grace check applied
|
|
143
|
+
// to it here must agree on what "now" was, or the two could disagree at the
|
|
144
|
+
// margin.
|
|
145
|
+
const now = Date.now();
|
|
95
146
|
// The PAGE-ONLY listing: see ReapInput.livePageIds.
|
|
96
147
|
const livePageIds = (await driver.listPages()).map((p) => p.id);
|
|
97
148
|
const [origins, leases] = await Promise.all([
|
|
@@ -100,9 +151,9 @@ export async function reapStaleAgentTabs(driver: BrowserDriver, backend: LeaseBa
|
|
|
100
151
|
// listing); doing it here too would delete records for targets that are
|
|
101
152
|
// live but filtered out of the page-only view.
|
|
102
153
|
originIndex(backend, undefined),
|
|
103
|
-
listLeases({ liveIds: livePageIds, liveBackend: backend }).catch(() => [] as LeaseSummary[]),
|
|
154
|
+
listLeases({ now, liveIds: livePageIds, liveBackend: backend }).catch(() => [] as LeaseSummary[]),
|
|
104
155
|
]);
|
|
105
|
-
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases });
|
|
156
|
+
const candidates = staleAgentTabs({ backend, livePageIds, origins, leases, now, reapGraceMs: reapGraceMs() });
|
|
106
157
|
const closed: ReapedTab[] = [];
|
|
107
158
|
for (const c of candidates) {
|
|
108
159
|
const res = await driver.closePage(c.targetId).catch(() => ({ success: false }));
|