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/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.7.0";
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 four conditions must hold, and condition 4 is the load-bearing one:
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. the toolkit OPENED it. An agent-created tab with NO lease is deliberately
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 }));