cdp-toolkit 1.4.0 → 1.7.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 CHANGED
@@ -5,6 +5,255 @@ All notable changes to cdp-toolkit are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.7.0] - 2026-08-09
9
+
10
+ Four things land on top of 1.2's opt-in lease model, all under one new switch
11
+ plus one default that moves without it. `CDP_REQUIRE_LEASE` turns leasing from
12
+ optional into mandatory for a long-lived MCP server: every call auto-acquires
13
+ the tab it touches instead of driving it lease-free. `release_page` now closes
14
+ a tab this toolkit opened, the one behavior a caller sees without opting into
15
+ anything. Reap closes tabs an agent abandoned instead of leaking them forever.
16
+ And `claim_page` gained a `target` mode so an agent can take over a tab a human
17
+ already has open, not just claim one it created itself.
18
+
19
+ ### Added
20
+
21
+ - **`CDP_REQUIRE_LEASE`, a switch that makes holding a lease mandatory instead
22
+ of optional, and the `auto` tier that makes it livable.** Off by default, so
23
+ a 1.6.0 consumer is unaffected. On, in a long-lived MCP server process only:
24
+ `assertLeaseOk` now *acquires* a lease on an unheld tab instead of waving the
25
+ call through, which is what makes "you cannot drive a tab you do not hold"
26
+ actually true rather than aspirational. That acquired lease is marked
27
+ `auto: true` on the `LeaseRecord` (absent, and therefore `false`, on every
28
+ record a pre-1.7.0 toolkit ever wrote, so an upgrade never downgrades a held
29
+ lease's protection), and an auto lease is owned by a *process*: any later
30
+ call from the same pid passes with no token, while a lease taken explicitly
31
+ via `claim_page` or `new_page{claim:true}` still demands its token even from
32
+ that same process. That two-tier split is what lets an ordinary tool call
33
+ keep working with zero protocol changes while still giving one subagent a
34
+ way to fence off a tab from its siblings, which share its pid. `new_page`
35
+ auto-claims the tab it just created under strict mode, even without
36
+ `claim:true`, because a tab nobody holds the instant it exists is a tab the
37
+ very next call would have to auto-acquire anyway; `claim:true` still yields
38
+ the stronger, explicit tier. **Strict mode is MCP-only, deliberately and
39
+ unconditionally**, not just by convention: `requireLease()` reads a
40
+ per-process flag that only `mcp.ts` sets at startup, so `CDP_REQUIRE_LEASE=1`
41
+ in a CLI invocation's environment is inert no matter what. A CLI call is one
42
+ process per invocation, so a lease it auto-acquired would be reclaimable by
43
+ the dead-pid rule the instant that process exited, and "a lease that is
44
+ reclaimable on arrival" is worse protection than none; the sharper danger is
45
+ reap, which would then read that instantly-dead-pid record as an abandoned
46
+ agent tab and could close it on the very next `list_pages` call.
47
+
48
+ - **`release_page{target}`, the counterpart an auto-acquired lease needs**,
49
+ because the gate mints those without ever handing the caller a token to give
50
+ back with `lease`. `target` takes the same selector grammar as everywhere
51
+ else and resolves through `resolvePage`, which is itself the authorization:
52
+ it runs `assertLeaseOk`, so a tab another live process holds is refused
53
+ there and, under strict mode, an unheld tab is acquired on the way in, which
54
+ means the caller genuinely holds it by the time it is released. `lease` and
55
+ `target` are mutually exclusive and exactly one is required.
56
+
57
+ - **Reap: `list_pages` and `list_leases` close tabs an agent abandoned,
58
+ instead of leaking them forever.** `release_page` only closes a tab an agent
59
+ gives back, and an agent that crashes, is killed, or just stops calling
60
+ never gives anything back; a timeout is a more likely end for an agent than
61
+ a clean shutdown. Rather than a background sweeper (a second lifetime to
62
+ reason about, and a process that has to be running), reap runs on read: the
63
+ two list tools already hold the browser's live target list and the lease
64
+ directory in hand at that moment, so checking costs one extra pass over data
65
+ already fetched. The selection itself, `staleAgentTabs` in the new
66
+ `src/reap.ts`, is a pure function so the four conditions that gate a close
67
+ can be tested exhaustively with no browser and no filesystem, which matters
68
+ because every mistake in this code closes a tab someone wanted: (1) the
69
+ lease row is for this backend and actually readable — an `unreadable` row is
70
+ never a candidate, because a guess in the destructive direction is the one
71
+ guess that cannot be undone; (2) it is stale for `dead-pid` or `expired`,
72
+ never `target-gone` (nothing to close) or a healthy lease; (3) the target is
73
+ still genuinely open, per the live page listing; and (4), the load-bearing
74
+ one, **the toolkit both opened the tab AND leased it** — an origin record by
75
+ itself is not enough. An agent-created tab that never went through
76
+ `claim_page`/`new_page{claim:true}` and was never auto-leased has no lease
77
+ row at all, and that is exactly what `new_page` produces for a caller who
78
+ never touches leases; reaping those would be a data-loss bug wearing a
79
+ cleanup's clothes, not a cleanup. Closed tabs are reported in an additive
80
+ `reaped` array (`{targetId, label, reason}`) present only when something was
81
+ actually closed, so the common-case response shape is byte-identical to
82
+ 1.6.0; a close that fails is not reported and the lease is left in place, so
83
+ the next read tries again rather than lying about what is still open.
84
+ Strict-mode gated, for the same MCP-only reason as auto-acquire: a CLI user
85
+ running `list_pages` twice would otherwise watch the second run close the
86
+ tabs the first run's now-dead process just leased.
87
+
88
+ - **`claim_page{target}`, a takeover path for a tab that is already open.**
89
+ With neither `target` nor `targetId`, `claim_page` still opens a fresh tab,
90
+ which was its only mode before; `targetId` still claims an exact existing
91
+ target id, kept for back-compat. Neither of those covers "work in the tab I
92
+ already have open" when the caller does not have that tab's id to hand,
93
+ which is the ordinary shape of a human asking an agent to continue in a tab
94
+ that is sitting right in front of them. `target` takes the toolkit's whole
95
+ selector grammar (`active | index:N | url:<substr> | title:<substr> |
96
+ <targetId>`) and resolves it against the live page list only: an unmatched
97
+ selector is an error, never a silently substituted new tab, because "the tab
98
+ I asked for is not there" and "here is an empty one instead" are answers a
99
+ caller has to be able to tell apart. The result gained `opened: boolean`,
100
+ true only when this call actually created the tab, which is the same
101
+ distinction the creation ledger records and is what keeps close-on-release
102
+ safe through this new path: a tab taken over this way has no creation
103
+ record, so `release_page` releases it and leaves it open. **Resolution here
104
+ is deliberately gate-free** — it goes through `pickPage`, not `resolvePage`
105
+ — because under strict mode `resolvePage` would auto-acquire a lease on an
106
+ unheld tab on the way in, and the explicit claim right behind it would then
107
+ collide with the lease it had just minted for itself. The protection this
108
+ skips is not lost, only moved one layer down: `claimLease`'s own conflict
109
+ check still refuses a tab any live process holds, auto-acquired or explicit.
110
+ **There is deliberately no force or steal option.** A tab another live agent
111
+ holds is refused with the same conflict error as ever; the tab this feature
112
+ exists to take over is a human's, and a human's tab carries no lease at all.
113
+
114
+ ### Changed (behavior, no flag)
115
+
116
+ - **`release_page` now closes a tab this toolkit opened, by default.** This is
117
+ the one thing a 1.6.0 caller sees without setting `CDP_REQUIRE_LEASE`:
118
+ previously `release_page` only ever gave the lease back and never touched
119
+ the tab. The close is scoped by the creation ledger from `origins.ts`, which
120
+ answers "did this toolkit open it" independently of the lease, and only
121
+ independently of the lease could it answer that at release time: a tab this
122
+ toolkit opened for an agent is closed, a tab a human already had open and an
123
+ agent merely claimed is released and left exactly alone, and `close: false`
124
+ opts out of a close per call while `close: true` forces one either way. A
125
+ release that did not actually happen — already released, reclaimed, or
126
+ expired — never closes anything, because by then the tab may belong to
127
+ another agent entirely; that is the one rule here that is a safety property
128
+ rather than a convenience, not a default that can be overridden.
129
+
130
+ ### Residuals
131
+
132
+ Stated plainly rather than designed away, most inherited from strict mode's
133
+ design doc, one new to the takeover path:
134
+
135
+ - **Auto leases do not isolate subagents sharing one MCP server process.** All
136
+ of a session's subagents share one pid, so they pass each other's auto
137
+ leases freely. The escape hatch is an explicit `claim_page`, which demands
138
+ its token even same-process; that is the accepted cost of keeping the
139
+ ordinary path ergonomic.
140
+ - **Reap closes an idle-but-alive agent's tab after `ttlMs` elapses.**
141
+ `expired` means reclaimable, so another agent could already have taken the
142
+ tab by the time reap runs; closing it is consistent with that, but it is
143
+ still a close the original agent did not ask for. Tunable via
144
+ `CDP_LEASE_TTL_MS` (default 15 minutes).
145
+ - **A read can have a side effect.** `list_pages` and `list_leases` can close
146
+ tabs under strict mode. Mitigated only by reporting every close in `reaped`.
147
+ - **`release_page{target}` under strict mode, on an unleased agent tab,**
148
+ auto-acquires it and then closes it in the same call, closing a tab the
149
+ caller itself never drove a single command against. Accepted because under
150
+ strict mode an unleased agent tab means nobody is actually driving it.
151
+ - **Auto-acquire can mint a lease on a non-page target.** `pickPage`'s bare-id
152
+ branch searches the full `all:true` listing, so an exact id for a worker or
153
+ an iframe still resolves and gets leased. Harmless, since a lease is just a
154
+ file, but it will show up as a row in `list_leases`.
155
+
156
+ ### Notes
157
+
158
+ - Tool count is unchanged at 41 (29 parity + 12 superset); 33 under Firefox.
159
+ `claim_page`, `release_page`, and `list_leases` gain arguments and result
160
+ fields, none of them removed or renamed.
161
+
162
+ ## [1.6.0] - 2026-08-09
163
+
164
+ This release skips 1.5.0 (reserved for in-flight work on another branch).
165
+
166
+ ### Added
167
+
168
+ - **`start_screen_recording` and `stop_screen_recording`, a tab-to-video capture
169
+ pair.** `take_screenshot` answers "what did the page look like"; nothing in
170
+ the toolkit answered "what did it DO", so proving a flow worked meant
171
+ stitching stills together by hand or shelling out to a desktop recorder that
172
+ captures the whole screen instead of the tab it was told to drive. The pair
173
+ records one named target over `Page.startScreencast`, keyed by targetId like
174
+ network mocking, so several agents can record different tabs in the same
175
+ browser at once. `start_screen_recording` opens a persistent page connection,
176
+ probes ffmpeg, and returns `{target, format, spoolDir, encoder, codec,
177
+ startedAt, note}`; `stop_screen_recording` tears the stream down, encodes the
178
+ spooled frames with ffmpeg, and returns `{path, bytes, durationMs,
179
+ frameCount, encodedFrames, codec, encoder, width, height, droppedFrames,
180
+ target}`. Like `performance_start_trace`/`performance_stop_trace`, the pair
181
+ must run WITHIN THE SAME PROCESS: the frames are events on the connection
182
+ that started the screencast and cannot be re-attached from a fresh process,
183
+ so this needs the MCP server rather than the one-process-per-call CLI.
184
+
185
+ - **Chrome only, and absent rather than throwing on Firefox.** WebDriver BiDi
186
+ has no streamed-frame primitive at all (the spec offers only the one-shot
187
+ `browsingContext.captureScreenshot`), so both tools declare a new capability,
188
+ `capture.screencast`, that the BiDi driver never offers. Per the toolkit's
189
+ ADR-001 pattern, a capability a backend cannot run means the tool is missing
190
+ from `tools/list` under `--browser firefox`, not present and failing mid-call.
191
+
192
+ - **Frames are acked before the disk write, not after.** Chrome will not send
193
+ screencast frame N+1 until frame N is acknowledged with
194
+ `Page.screencastFrameAck`, so the ack fires from the event handler before
195
+ the frame's bytes are written to the spool; anything awaited before the ack
196
+ becomes the frame rate. An unacked frame stalls the whole stream.
197
+
198
+ - **Variable frame rate handled with a timestamped ledger, not a fixed
199
+ encoding rate.** Chrome emits a screencast frame on repaint, not on a clock:
200
+ a still page emits nothing and an animating one emits at whatever rate it
201
+ paints. Every captured frame is timestamped into an in-memory ledger, and on
202
+ stop that ledger is rendered as an ffconcat manifest with an explicit
203
+ per-frame duration, so a still page holds one long frame and the total video
204
+ duration tracks wallclock rather than racing or freezing.
205
+
206
+ - **Frames are coalesced onto the encoder's 40ms PTS grid, deliberately and
207
+ visibly.** ffmpeg's concat demuxer takes its stream time_base from the first
208
+ file's sub-demuxer, and image files arrive through `image2` at its default
209
+ 25fps, so every frame's presentation timestamp is silently snapped to a
210
+ 1/25s grid no matter what the manifest's `duration` lines say — `-vsync vfr`
211
+ then drops every frame that collides with an earlier one on that grid, with
212
+ no warning on stderr. A first pass caught this the hard way: a 373-frame
213
+ capture encoded to 102 frames with every check green. Three other fixes were
214
+ measured and rejected: `-r` before `-i` overrides the durations outright (a
215
+ 1.000s clip became 0.101s), `-framerate` is not an option the concat demuxer
216
+ accepts, and `settb`/`-video_track_timescale` change only the output
217
+ timebase. The toolkit now snaps the ledger onto the 40ms grid itself before
218
+ handing it to ffmpeg, keeping the first frame per slot, and reports both
219
+ numbers instead of letting them silently differ: `frameCount` is what was
220
+ captured off the wire, `encodedFrames` is what actually reached the video.
221
+
222
+ - **Encoder ladder, probed once per process from `ffmpeg -hide_banner
223
+ -encoders`, matched on the name column only:** `hevc_videotoolbox` →
224
+ `h264_videotoolbox` → `libx265` → `libx264`. Both HEVC rungs get `-tag:v
225
+ hvc1`, without which QuickTime refuses to play the MP4 it just wrote.
226
+ Matching only the encoder-name column (not the raw text) matters because
227
+ ffmpeg repeats a family name inside a sibling encoder's description — a
228
+ substring match on `libx264` also hits `libx264rgb`, an RGB-only encoder
229
+ that cannot write the `yuv420p` file the tool just promised.
230
+
231
+ - **ffmpeg is probed at START, with an actionable error, not at stop.** A
232
+ missing ffmpeg now fails before a recording is captured and thrown away,
233
+ rather than after. The error names the two install commands
234
+ (`brew install ffmpeg` / `apt install ffmpeg`) and `CDP_FFMPEG`, the env var
235
+ that points the toolkit at a binary not on `PATH`.
236
+
237
+ - **A failed encode keeps the spool and hands back the exact re-run
238
+ command.** If ffmpeg exits non-zero, the spooled frames and the generated
239
+ `frames.ffconcat` manifest are kept on disk (named in the error) instead of
240
+ being deleted with the failed attempt, along with the literal ffmpeg
241
+ command line to re-run by hand.
242
+
243
+ - **`start_screen_recording` takes the same capture knobs as the underlying
244
+ CDP call:** `format` (`jpeg` default, or `png`), `quality` (JPEG 0-100),
245
+ `maxWidth`/`maxHeight` (also pins frame size against a mid-recording
246
+ viewport change), and `everyNthFrame` to skip repaints on a high-framerate
247
+ page before they're coalesced away. `stop_screen_recording` takes
248
+ `savePath` to override the default `<artifact dir>/screen-recording-
249
+ <targetIdShort>-<stamp>.mp4` output path.
250
+
251
+ ### Notes
252
+ - Tool count is now 41 (29 parity + 12 superset); 33 under Firefox, which
253
+ lacks the eight capability-gated tools (`start_screen_recording` /
254
+ `stop_screen_recording` join the tracing, heap-snapshot, and Lighthouse
255
+ groups already absent there).
256
+
8
257
  ## [1.4.0] - 2026-08-06
9
258
 
10
259
  This is the first npm release since 1.2.0. Both `v1.2.1` and `v1.3.0` were
@@ -220,4 +469,6 @@ First public release.
220
469
  - Zero runtime dependencies in the CDP/CLI layer (Node's global `WebSocket` + `fetch`). The MCP server adds only `@modelcontextprotocol/sdk`; `lighthouse_audit` is the sole non-CDP tool and shells out to `npx lighthouse`.
221
470
  - Runtime: Bun ≥ 1.1 (recommended) or Node ≥ 22 (for the global `WebSocket`). Requires Chrome/Chromium started with `--remote-debugging-port=9222`.
222
471
 
472
+ [1.7.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.7.0
473
+ [1.6.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.6.0
223
474
  [1.0.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.0.0
package/CONTRACT.md CHANGED
@@ -12,7 +12,11 @@ writing a module.
12
12
  3. **TypeScript strict.** `tsc --noEmit` must pass with `strict`, `noUncheckedIndexedAccess`, `verbatimModuleSyntax`. Import types with `import type`. Use `.ts` extensions in imports (e.g. `import { withPage } from "../client.ts"`).
13
13
  4. **One module per assigned bundle.** Write only the files you are assigned. Never edit `client.ts`, `types.ts`, or another agent's file.
14
14
  5. **Tool fn naming = camelCase of the MCP tool name.** `take_snapshot` → `export async function takeSnapshot(...)`. `list_network_requests` → `listNetworkRequests`. The integration step maps mechanically, so the names must be exact.
15
- 6. **Never re-resolve a target outside a choke point.** There are exactly three functions that turn a `TargetSelector` into a concrete tab, and all three call `assertLeaseOk`: `resolveTarget` (`src/client.ts`, Chrome), `resolveContext` (`src/bidi/driver.ts`, Firefox), and `resolvePage` (`src/shared-tools.ts`, used by `close_page` and `select_page`). A new tool must go through one of them. If you add a fourth resolution path, it must call `assertLeaseOk` too, or every tool on that path silently loses lease protection. Do NOT add a lease check to an individual tool: that puts the burden on every future contributor to remember it, and one forgotten tool defeats the feature for that tool. A lease conflict throws `LeaseConflictError` rather than being modelled as a capability gap under ADR-001. That departure is deliberate and reviewed: a runtime ownership collision between two agents is not a missing backend capability, so it cannot be discovered at `tools/list` time and must surface at call time. If you touch any of those three choke points, or the error handling above them, re-run `bun run lease:smoke` (`test/lease-smoke.ts`): `bun test` cannot reach this, because a lease is keyed on the claiming pid and only a second live OS process can collide with the first. That harness spawns one, drives a real browser, and asserts the refusal keeps its type, its `targetId` and its `holder` and actually blocks the side effect. It is safe against a browser with real tabs open: one throwaway tab, addressed and closed by its own id, with a runtime-captured baseline diffed at the end.
15
+ 6. **Never re-resolve a target outside a choke point.** There are exactly three functions that turn a `TargetSelector` into a concrete tab, and all three call `assertLeaseOk`: `resolveTarget` (`src/client.ts`, Chrome), `resolveContext` (`src/bidi/driver.ts`, Firefox), and `resolvePage` (`src/shared-tools.ts`, used by `close_page`, `select_page`, and `release_page`'s `target` argument). A new tool must go through one of them. If you add a fourth resolution path, it must call `assertLeaseOk` too, or every tool on that path silently loses lease protection. Do NOT add a lease check to an individual tool: that puts the burden on every future contributor to remember it, and one forgotten tool defeats the feature for that tool. A lease conflict throws `LeaseConflictError` rather than being modelled as a capability gap under ADR-001. That departure is deliberate and reviewed: a runtime ownership collision between two agents is not a missing backend capability, so it cannot be discovered at `tools/list` time and must surface at call time. If you touch any of those three choke points, or the error handling above them, re-run `bun run lease:smoke` (`test/lease-smoke.ts`): `bun test` cannot reach this, because a lease is keyed on the claiming pid and only a second live OS process can collide with the first. That harness spawns one, drives a real browser, and asserts the refusal keeps its type, its `targetId` and its `holder` and actually blocks the side effect. It is safe against a browser with real tabs open: one throwaway tab, addressed and closed by its own id, with a runtime-captured baseline diffed at the end.
16
+
17
+ **Under `CDP_REQUIRE_LEASE` the gate does not only check, it can ACQUIRE.** `assertLeaseOk` calls `claimLease` itself when the resolved tab is unheld and strict mode is on (see `requireLease()` and the `auto` tier in `src/leases.ts`). A fourth resolution path that skipped the choke point used to lose only enforcement; under strict mode it also loses acquisition, so a tab reached that way is silently never protected even after the flag is on. `lease:smoke`'s strict-mode phase (`CDP_REQUIRE_LEASE=1`, run as a second live process) is what actually exercises this; `bun test` cannot, since acquisition is keyed on the calling pid the same way enforcement is.
18
+
19
+ **`claim_page{target}` is a deliberate, reviewed exception to this rule, not a violation of it.** Its target resolution goes through `pickPage` (`src/shared-tools.ts`), which is exported *specifically* for this one caller and does **not** call `assertLeaseOk`. Routing a claim through `resolvePage` instead would, under strict mode, auto-acquire a lease on the way in and then collide with the explicit claim right behind it, one call claiming the same tab against itself twice. The protection is not dropped, only relocated one layer down: `claimLease`'s own conflict check still refuses a tab any live process holds, auto-acquired or explicit, so a stranger's tab is still refused, only later and by a different function. `pickPage` must never gain a second caller without re-deriving this argument for that caller too; if it does not need gate-free resolution, it needs `resolvePage`, not `pickPage`.
16
20
 
17
21
  ## Core client API (from `src/client.ts`)
18
22
 
@@ -61,7 +65,9 @@ export async function click(args: ClickArgs): Promise<unknown>;
61
65
  - Artifacts (PNG, trace, heapsnapshot, lighthouse report): write under `ARTIFACT_DIR` = `process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit"`. `mkdir -p` it. Filenames: `<tool>-<targetIdShort>-<isoish>.<ext>` where you derive a stamp from `Date.now()` at call time (allowed in normal runtime; only the Workflow *script* sandbox forbids it, modules run normally).
62
66
  - Selected-target state file (for `select_page`): `process.env.CDP_STATE_DIR ?? "/tmp/cdp-toolkit"` + `/selected`. Contains a bare targetId. `resolveTarget` does NOT read it; tools may read it as a fallback default if you choose, but the simplest correct behavior is fine.
63
67
  - Recorder buffers: `${ARTIFACT_DIR}/rec-<targetId>.jsonl`.
64
- - Lease files: `${ARTIFACT_DIR}/lease-<backend>-<targetId>.json`, one per leased tab, backend is `chrome` or `firefox`. Written with the `wx` exclusive-create flag so two simultaneous claims cannot both win. Keyed by backend plus id because a CDP targetId and a BiDi context id are not disjoint by construction.
68
+ - Screencast spool: `${ARTIFACT_DIR}/screencast-<targetIdShort>-<isoish>/` holding `frame-NNNNNN.jpg|png` plus the generated `frames.ffconcat`. Deleted on a successful encode; **kept, and named in the error, when ffmpeg fails**, so the frames can be re-encoded by hand instead of lost. Output video: `${ARTIFACT_DIR}/screen-recording-<targetIdShort>-<isoish>.mp4`.
69
+ - Lease files: `${ARTIFACT_DIR}/lease-<backend>-<targetId>.json`, one per leased tab, backend is `chrome` or `firefox`. Written with the `wx` exclusive-create flag so two simultaneous claims cannot both win. Keyed by backend plus id because a CDP targetId and a BiDi context id are not disjoint by construction. Carries an `auto?: boolean` field (absent, and read as `false`, on any record older than 1.7.0): `true` means the gate itself acquired it under `CDP_REQUIRE_LEASE` and it is usable by any call from the owning pid with no token; `false` means it was claimed explicitly (`claim_page` or `new_page{claim:true}`) and demands its token even from that same pid.
70
+ - Origin files (`src/origins.ts`): `${ARTIFACT_DIR}/origin-<backend>-<targetId>.json`, one per tab this toolkit itself created (never written for a tab merely claimed). Same exclusive-create discipline as lease files, same directory, and it deliberately OUTLIVES the lease: `release_page`, expiry, and reclamation never touch it, only the tab closing does (reaped on the next `list_pages`/`list_origins` read). This is what `release_page` and reap both consult to decide whether closing a given tab is this toolkit's call to make; see `src/reap.ts`'s header for the four conditions that gate a reap close.
65
71
 
66
72
  ## The element-reference scheme (READ if you touch snapshot or input)
67
73
 
@@ -105,6 +111,7 @@ A `Uid` **is** a CDP `backendDOMNodeId` (a number). This makes refs stateless:
105
111
  | `src/tools/dialogs.ts` | `handle_dialog`→`handleDialog` | `Page.enable`, subscribe `Page.javascriptDialogOpening`, respond with `Page.handleJavaScriptDialog({ accept, promptText })`. Support a "wait for next dialog then handle" mode and an "auto-handle for N ms" mode. |
106
112
  | `src/tools/recorder.ts` + `src/tools/console.ts` + `src/tools/network.ts` | console: `list_console_messages`→`listConsoleMessages`, `get_console_message`→`getConsoleMessage`; network: `list_network_requests`→`listNetworkRequests`, `get_network_request`→`getNetworkRequest` | **Owned by one agent.** `recorder.ts` exports `startRecorder(target, {network,console}): Promise<{stop()}>` that opens a persistent `openPage` conn, enables `Network`+`Runtime`(+`Log`), and appends events to `rec-<targetId>.jsonl`. console/network `list_*` read that file (and support a one-shot `{ reload:true, durationMs }` mode that records fresh by reloading the page and capturing for a window, then returns). `get_*` filter by id/url. Document the recorder model at the top of `recorder.ts`. |
107
113
  | `src/tools/performance.ts` | `performance_start_trace`→`performanceStartTrace`, `performance_stop_trace`→`performanceStopTrace`, `performance_analyze_insight`→`performanceAnalyzeInsight` | `Tracing.start`(categories incl. `devtools.timeline`,`blink.user_timing`,`loading`,`-*`), collect `Tracing.dataCollected`, `Tracing.end`→`Tracing.tracingComplete`, write trace JSON to ARTIFACT_DIR. start/stop need a persistent conn, so use a state file to hold the in-flight trace path/connection target. `performance_analyze_insight` parses a trace file for LCP, FCP/navigation timing, long tasks (>50ms), layout shifts (CLS), total blocking time; return structured metrics. **Mark in a top comment that this is a CDP-native approximation of the MCP's insight analyzer.** |
114
+ | `src/tools/screencast.ts` | `start_screen_recording`→`startScreenRecording`, `stop_screen_recording`→`stopScreenRecording` | **Toolkit addition (not MCP parity): a tab captured to an H.265 MP4.** Persistent `openPage` conn parked in a module-level `Map` keyed by targetId (recorder.ts's statefulness, performance.ts's registry), `Page.startScreencast` → `Page.screencastFrame` → **ack from the handler, before the disk write** (an unacked frame stalls the stream) → spool file + ledger row `{file,timestamp,width,height}`. Frames arrive on repaint only, so playback timing MUST come from per-frame ffconcat `duration`s off the ledger, never a fixed rate. Encoder ladder probed once from `ffmpeg -encoders`: `hevc_videotoolbox`→`h264_videotoolbox`→`libx265`→`libx264`, `-tag:v hvc1` on HEVC, always `+faststart`/`yuv420p`/even dims. ffmpeg is probed at START (fail before capturing, not after). Pure parts (`frameDurationsMs`/`buildConcatManifest`/`selectEncoder`/`buildFfmpegArgs`/`imageSize`) are unit-tested; live capture via `test/screencast-smoke.ts`. |
108
115
  | `src/tools/heap.ts` | `take_heapsnapshot`→`takeHeapsnapshot` | `HeapProfiler.enable` → `HeapProfiler.takeHeapSnapshot`, assemble `HeapProfiler.addHeapSnapshotChunk` events into a `.heapsnapshot` file under ARTIFACT_DIR; return `{ path, bytes }`. |
109
116
  | `src/tools/lighthouse.ts` | `lighthouse_audit`→`lighthouseAudit` | **Not pure CDP.** Spawn `npx --yes lighthouse <url> --port=9222 --output=json --output-path=<artifact> --chrome-flags=...` via `node:child_process`. If lighthouse/npx unavailable, throw a clear `CdpError` explaining the dependency. Return `{ path, categories: {performance, accessibility, ...scores} }` parsed from the JSON. Top comment must state this is the one non-CDP tool. |
110
117
  | `src/tools/network_mock.ts` | `mock_request`→`mockRequest`, `list_mocks`→`listMocks`, `clear_mocks`→`clearMocks` | **Toolkit addition (not MCP parity): a persistent fake backend.** Holds a per-target session (module-level `Map`, like `recorder.ts`) with a persistent `openPage` conn and `Fetch.enable` (patterns). `Fetch.requestPaused` → `selectRule` → `Fetch.fulfillRequest`/`failRequest`/`continueRequest` (default-continue unmatched, or page hangs). Pure logic (`urlMatches`/`selectRule`/`buildFulfillParams`/`effectiveAction`) is unit-tested; I/O via `test/mock-smoke.ts`. Persistence is MCP-server-process-scoped (CLI is one-shot). Request-stage only. |
@@ -115,4 +122,4 @@ End every module with a brief block comment listing the exact CDP methods/domain
115
122
 
116
123
  ## Integration (built after all modules exist)
117
124
 
118
- `src/index.ts` exports a `TOOLS` registry: `Record<mcpToolName, (args) => Promise<unknown>>` importing every fn. `src/cli.ts`: `bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<k> <v> ...]` → parse args, call `TOOLS[tool]`, print JSON result or `{ error }`, exit non-zero on throw. `README.md`: the full 29-tool mapping table + usage + the "why direct CDP beats MCP for known targets" rationale.
125
+ `src/index.ts` exports a `TOOLS` registry: `Record<mcpToolName, (args) => Promise<unknown>>` importing every fn. `src/cli.ts`: `bun run src/cli.ts <tool> [--target <sel>] [--json '<obj>'] [--<k> <v> ...]` → parse args, call `TOOLS[tool]`, print JSON result or `{ error }`, exit non-zero on throw. `README.md`: the full tool mapping table + usage + the "why direct CDP beats MCP for known targets" rationale.
package/README.md CHANGED
@@ -1,8 +1,8 @@
1
1
  # cdp-toolkit
2
2
 
3
- **A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the *one* Chrome tab you point it at over the raw DevTools Protocol, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, one tab, no fan-out, plus a built-in network-mocking fake backend. **39 tools.**
3
+ **A lightweight, drop-in alternative to [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) that won't wedge your agent.** It drives the Chrome tabs you point it at over the raw DevTools Protocol: any number of tabs, one explicitly named target per call over one direct socket, with a bounded timeout on every call, so a stuck page returns a clean error instead of hanging your agent and forcing a `/mcp` restart. Same idea, no all-target fan-out, plus tab leases so several agents can work one browser, plus a built-in network-mocking fake backend. **41 tools.**
4
4
 
5
- > For AI-agent developers and Claude Code / Cursor users who need **one known tab driven reliably**, not a Puppeteer-managed browser.
5
+ > For AI-agent developers and Claude Code / Cursor users who need **the tabs they name driven reliably**, not a Puppeteer-managed browser.
6
6
 
7
7
  [![CI](https://github.com/sblattj/cdp-toolkit/actions/workflows/ci.yml/badge.svg)](https://github.com/sblattj/cdp-toolkit/actions/workflows/ci.yml)
8
8
  [![npm](https://img.shields.io/npm/v/cdp-toolkit?color=cb3837&logo=npm)](https://www.npmjs.com/package/cdp-toolkit)
@@ -16,7 +16,7 @@
16
16
 
17
17
  You let an AI agent (Claude Code, Cursor, any MCP host) control a Chrome tab: click, type, read the page, take screenshots, even fake API responses to test a UI before its backend exists. [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) does this too, but it runs a whole Puppeteer browser and talks to *all* your open tabs at once, which is why a single busy tab can freeze it and leave you typing `/mcp` to restart the server mid-task.
18
18
 
19
- `cdp-toolkit` keeps it simple: **one connection to the one tab you point it at, and a time limit on every action.** When something stalls, you get an error back, not a frozen agent. Same things you could do before, minus the wedging and the restarts.
19
+ `cdp-toolkit` keeps it simple: **one connection to the one tab each call names, and a time limit on every action.** Drive as many tabs as you like, one named target at a time, and point several agents at the same Chrome without them stealing each other's tabs. When something stalls, you get an error back, not a frozen agent. Same things you could do before, minus the wedging and the restarts.
20
20
 
21
21
  That's the whole pitch. The technical *why* (fan-out, lazy domain enabling, the `Network.enable` hang) is below.
22
22
 
@@ -24,13 +24,13 @@ That's the whole pitch. The technical *why* (fan-out, lazy domain enabling, the
24
24
 
25
25
  If [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp) ever **wedged your agent on a busy tab**, you've met its design: it manages a Puppeteer browser, fans every operation out across *all* attached targets, and enables the `Network` domain on connect so it can passively buffer everything. That generality is exactly what makes it fragile once you already know which tab you want to drive.
26
26
 
27
- `cdp-toolkit` makes the opposite bet. Every tool attaches **one** direct WebSocket to **one** resolved target, enables only the CDP domains it needs, and enforces a **per-command timeout** so a stuck renderer can never hang the caller. For driving a tab you already have in hand, the common automation and evidence-gathering case, it's materially more robust.
27
+ `cdp-toolkit` makes the opposite bet. Every call attaches **one** direct WebSocket to the **one** target it resolved, enables only the CDP domains it needs, and enforces a **per-command timeout** so a stuck renderer can never hang the caller. The connection lives for that call and closes after it, so nothing bleeds between tabs. For driving tabs you already have in hand, the common automation and evidence-gathering case, it's materially more robust.
28
28
 
29
29
  ## cdp-toolkit vs chrome-devtools-mcp
30
30
 
31
31
  | | **cdp-toolkit** | **chrome-devtools-mcp** |
32
32
  |---|---|---|
33
- | **Target scope** | one resolved tab (`active` / `index:N` / `url:` / `title:`) | all attached targets; fan-out can stall on an unrelated tab |
33
+ | **Target scope** | one resolved tab per call (`active` / `index:N` / `url:` / `title:`), any number of tabs across calls | all attached targets; fan-out can stall on an unrelated tab |
34
34
  | **`Network.enable`** | lazy, only when a tool needs it | eager on connect, a known hang on busy renderers |
35
35
  | **Per-command timeout** | ✅ bounded (`CDP_TIMEOUT_MS`, 15s default), rejects, never hangs | ❌ none; a stuck renderer blocks indefinitely |
36
36
  | **Element refs** | stateless `backendDOMNodeId` (resolved on demand) | server-side handle table (can drift / expire) |
@@ -38,12 +38,12 @@ If [`chrome-devtools-mcp`](https://github.com/ChromeDevTools/chrome-devtools-mcp
38
38
  | **Runtime deps** | CDP/CLI layer: native `WebSocket` + `fetch` (the MCP server adds only the MCP SDK) | Puppeteer stack |
39
39
  | **Auto-wait / retry** | ❌ single-shot; re-snapshot between steps | ✅ Puppeteer's auto-wait envelope |
40
40
 
41
- **Use `chrome-devtools-mcp`** if you need multi-target autonomy or Puppeteer's auto-wait/retry on an *unknown* page. **Use `cdp-toolkit`** when you have one tab in hand and need it not to hang. They coexist: `cdp-toolkit`'s tools are namespaced `mcp__cdp-toolkit__*`, distinct from `mcp__chrome-devtools__*`.
41
+ **Use `chrome-devtools-mcp`** if you need multi-target autonomy or Puppeteer's auto-wait/retry on an *unknown* page. **Use `cdp-toolkit`** when you know which tabs you want driven, however many that is, and need them not to hang. They coexist: `cdp-toolkit`'s tools are namespaced `mcp__cdp-toolkit__*`, distinct from `mcp__chrome-devtools__*`.
42
42
 
43
43
  ## Is this for you?
44
44
 
45
45
  **Yes, if you:**
46
- - drive one known tab from Claude Code / Cursor / any MCP host and want it to never wedge;
46
+ - drive tabs you name from Claude Code / Cursor / any MCP host and want them to never wedge;
47
47
  - need to **mock a backend** to build or test a UI before the real API exists;
48
48
  - have been bitten by the eager-`Network.enable` hang on a busy renderer;
49
49
  - run **multiple agents against one Chrome** and need them to stop stealing each other's tabs.
@@ -116,22 +116,23 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
116
116
 
117
117
  ## Key capabilities
118
118
 
119
- - **Single-target reliability.** One WebSocket to one resolved target, a bounded timeout on every CDP command, lazy domain enabling, and stateless element refs. There is no broadcast step that can stall on a wedged tab.
119
+ - **One target per call, never a broadcast.** Each call opens one WebSocket to the one target it named, with a bounded timeout on every CDP command, lazy domain enabling, and stateless element refs. Drive as many tabs as you like across calls; there is still no broadcast step that can stall on a wedged tab.
120
120
  - **Network mocking: build the UI before the backend exists.** `mock_request` arms a persistent per-target fake backend: return canned responses, force errors, or inject latency/fault rates. Mocks survive reloads and navigations until `clear_mocks`.
121
- - **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, heap snapshots, and a cookie group that reads, writes, and deletes httpOnly cookies. 39 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
121
+ - **Full chrome-devtools-mcp parity + extras.** All 29 upstream tools, plus `performance_trace` (a robust single-call trace), Lighthouse audits, heap snapshots, a cookie group that reads, writes, and deletes httpOnly cookies, and tab-to-video screen recording. 41 single-purpose tools, no discovery overhead, and they coexist with `chrome-devtools-mcp` in a separate namespace.
122
122
  - **Many agents, one browser, no stolen tabs.** `claim_page` hands out an opaque lease token for one tab; every other tool checks it at target resolution, so an unqualified call against a leased tab is refused by name rather than silently retargeted to whatever tab a different agent is driving.
123
+ - **Out-of-model secret handling.** A read that would return a credential, a JWT in `localStorage` via `evaluate_script` or an httpOnly session cookie via `list_cookies`, takes a `savePath` that writes the value to a file and keeps it out of the tool response entirely, so the secret never lands in the agent transcript. That same per-call, in-process design makes cdp-toolkit a clean substrate for credential-injection tools: a vaulted password can be typed straight into the DOM while only a status crosses back to the model, never the secret.
123
124
  - **Provenance that outlives the lease.** `list_pages` reports `origin: "agent"` (with the creating `label`) for every tab this toolkit opened, and `"unknown"` otherwise. It never claims `"human"`: the absence of a creation record cannot prove a person opened the tab, so `"unknown"` is the honest word once an agent releases, expires, or dies and its tab is left behind.
124
125
 
125
126
  ## Why raw CDP beats the MCP for known targets
126
127
 
127
128
  Each point below leads with the symptom you've probably hit, then the cause, then the fix.
128
129
 
129
- - **Your agent stalls on a call when a busy background tab is open** → that's the **all-target fan-out**: every operation broadcasts to all attached targets. cdp-toolkit resolves *one* target (`active | index:N | url:<substr> | title:<substr> | <targetId>`) and attaches a single WebSocket to just that page.
130
+ - **Your agent stalls on a call when a busy background tab is open** → that's the **all-target fan-out**: every operation broadcasts to all attached targets. every cdp-toolkit call resolves *one* target (`active | index:N | url:<substr> | title:<substr> | <targetId>`) and attaches a single WebSocket to just that page, so a busy tab you did not name is never touched.
130
131
  - **A tool hangs forever and never returns** → the MCP's eager `Network.enable` on a busy or hung renderer is a known wedge. cdp-toolkit enables domains **lazily**, only where a tool needs them (the recorder enables `Network`/`Runtime`/`Log`; most tools touch only `Page`/`Runtime`/`DOM`).
131
132
  - **No way to bound a slow call** → `CdpConnection.send()` enforces `CDP_TIMEOUT_MS` (15s default) on *every* command and **rejects rather than hangs**, so a stuck renderer can never block a caller indefinitely.
132
133
  - **Element handles drift across calls** → a `uid` **is** a CDP `backendDOMNodeId`, resolved on demand via `DOM.resolveNode`. There is no server-side handle table to drift or expire.
133
134
 
134
- The trade-off is generality: this toolkit targets one known page at a time and does **not** replicate Puppeteer's auto-wait/retry envelope. Re-`take_snapshot` between steps rather than expecting an implicit wait.
135
+ The trade-off is generality: this toolkit acts on one named page per call and does **not** replicate Puppeteer's auto-wait/retry envelope. Re-`take_snapshot` between steps rather than expecting an implicit wait.
135
136
 
136
137
  ## Network mocking: a fake backend for building/testing UIs
137
138
 
@@ -197,10 +198,11 @@ await TOOLS.navigate_page({ target: "index:0", url: "https://example.com" });
197
198
  | `CDP_ARTIFACT_DIR` | `/tmp/cdp-toolkit` | Screenshots, traces, heap snapshots, lighthouse reports, recorder buffers. |
198
199
  | `CDP_STATE_DIR` | `/tmp/cdp-toolkit` | `select_page` selected-target file, in-flight trace state. |
199
200
  | `CDP_LEASE_TTL_MS` | `900000` | How long a tab lease survives without use before another agent can reclaim it. Refreshed on every checked call. |
201
+ | `CDP_REQUIRE_LEASE` | off | Strict mode, MCP server only (inert under the CLI regardless of value). Turns leasing from optional into mandatory: a call against an unheld tab acquires a lease instead of driving it lease-free, and `list_pages`/`list_leases` close tabs an abandoned agent left behind. See "Parallel tabs" below. |
200
202
 
201
203
  ## Firefox (WebDriver BiDi)
202
204
 
203
- cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 39-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
205
+ cdp-toolkit ships a second backend, Firefox over [WebDriver BiDi](https://w3c.github.io/webdriver-bidi/), behind the same 41-tool surface. Chrome stays the default and its behavior is unchanged, opt in explicitly to reach Firefox:
204
206
 
205
207
  ```bash
206
208
  cdp --browser firefox take_snapshot # CLI: explicit flag
@@ -220,8 +222,9 @@ Backend selection precedence: `--browser chrome|firefox` flag, then `CDP_BROWSER
220
222
  - `performance_start_trace`, `performance_stop_trace`, `performance_analyze_insight`, `performance_trace` (needs `trace.performance`)
221
223
  - `take_heapsnapshot` (needs `heap.snapshot`)
222
224
  - `lighthouse_audit` (needs `audit.lighthouse`)
225
+ - `start_screen_recording`, `stop_screen_recording` (needs `capture.screencast`): BiDi has no streamed-frame primitive at all, only the one-shot `browsingContext.captureScreenshot`.
223
226
 
224
- Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain) and the `claim_page`/`release_page`/`list_leases` lease group, is available under both backends: 33 of the 39 tools.
227
+ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's `network.addIntercept` covers the same fake-backend use case as Chrome's `Fetch` domain) and the `claim_page`/`release_page`/`list_leases` lease group, is available under both backends: 33 of the 41 tools.
225
228
 
226
229
  **Honest capability gaps, not oversold parity:**
227
230
 
@@ -232,14 +235,14 @@ Everything else, including `mock_request`/`list_mocks`/`clear_mocks` (Firefox's
232
235
  - **`locate.text` is not available** (Firefox 153's `browsingContext.locateNodes` rejects the `innerText` locator type as unsupported), unlike Chrome, which has it via `DOM.performSearch`.
233
236
  - **No `--browser firefox` attach mode.** By design, per the launch model above: every invocation gets its own throwaway Firefox process and profile.
234
237
 
235
- ## The tools (29 parity + 10 superset = 39)
238
+ ## The tools (29 parity + 12 superset = 41)
236
239
 
237
- The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 10 superset tools (`performance_trace`, the `list_cookies`/`set_cookie`/`delete_cookies` cookie group, the `mock_request`/`list_mocks`/`clear_mocks` group, and the `claim_page`/`release_page`/`list_leases` lease group) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
240
+ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 12 superset tools (`performance_trace`, the `list_cookies`/`set_cookie`/`delete_cookies` cookie group, the `mock_request`/`list_mocks`/`clear_mocks` group, the `claim_page`/`release_page`/`list_leases` lease group, and the `start_screen_recording`/`stop_screen_recording` screen-recording pair) are toolkit additions. Each row notes the underlying CDP method(s) and the precise parity gaps.
238
241
 
239
242
  | MCP name | CDP method(s) | Parity notes / gaps |
240
243
  |---|---|---|
241
- | `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. Each row additionally carries `origin` (`agent` or `unknown`, never `human`) plus `label`/`createdAt` for tabs this toolkit created. |
242
- | `new_page` | `Target.createTarget` (+ lease file) | Returns `{targetId,url}`; does not await navigation (use `navigate_page`). `claim:true` also claims the new tab and returns a `lease` token (`label`/`ttlMs` optional). |
244
+ | `list_pages` | `GET /json/list` | `all` flag also exposes worker/background targets; MCP lists only page tabs. Each row additionally carries `origin` (`agent` or `unknown`, never `human`) plus `label`/`createdAt` for tabs this toolkit created. Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs first, reporting closures in an additive `reaped` array. |
245
+ | `new_page` | `Target.createTarget` (+ lease file) | Returns `{targetId,url}`; does not await navigation (use `navigate_page`). `claim:true` also claims the new tab and returns a `lease` token (`label`/`ttlMs` optional). Under `CDP_REQUIRE_LEASE` the tab is claimed and a `lease` returned even without `claim:true`. |
243
246
  | `close_page` | `Target.closeTarget` (+ lease file) | Reports `success:true` on the empty result newer Chromium returns. A successful close also releases that tab's lease; a failed close leaves it in place. |
244
247
  | `select_page` | `Target.activateTarget` + selected-state file | Writes a flat-file selected target; `resolveTarget` does not read it, so `active` still means `index:0` unless a tool opts in. |
245
248
  | `navigate_page` | `Page.navigate` / `Page.reload` + load events | Returns `{url,frameId,waitedFor}` (no auto-snapshot). `waitUntil` supports `load`/`domcontentloaded`. `reload:true` (+ `ignoreCache:true` for a hard reload). |
@@ -258,6 +261,8 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 10 superset tools (`
258
261
  | `press_key` | `Input.dispatchKeyEvent` | Curated named-key table + single chars; not the full Puppeteer KeyInput enum. |
259
262
  | `upload_file` | `DOM.setFileInputFiles` | Requires a resolvable `<input type=file>` (uid or selector). |
260
263
  | `take_screenshot` | `Page.captureScreenshot` (+ layout metrics) | Clip scale fixed at 1. Full-page uses `captureBeyondViewport` + layout-metrics clip. |
264
+ | `start_screen_recording` *(superset)* | `Page.startScreencast` / `Page.screencastFrame` / `Page.screencastFrameAck` | **Toolkit addition; chrome-devtools-mcp has no screen-recording tool.** Opens a persistent per-target connection and spools frames to a ledger on disk; pairs with `stop_screen_recording` in the SAME process, for the same cross-process reason as `performance_start_trace`. ffmpeg is probed here so a missing encoder fails before a recording is captured. Chrome only: absent from `tools/list` under `--browser firefox` (needs `capture.screencast`, which BiDi has no primitive for). |
265
+ | `stop_screen_recording` *(superset)* | `Page.stopScreencast` (+ ffmpeg encode) | Assembles the spooled frames into an H.265 (`hevc_videotoolbox`, falling back to `h264_videotoolbox` → `libx265` → `libx264`) MP4 using per-frame durations from the capture ledger, coalesced onto ffmpeg's 40ms concat-demuxer grid. Returns `{path,bytes,durationMs,frameCount,encodedFrames,codec,encoder,width,height,droppedFrames,target}` (`frameCount` captured vs. `encodedFrames` in the video). On an ffmpeg failure the spool is kept and the exact re-run command is named in the error. |
261
266
  | `emulate` | `Emulation.*` / `Network.emulateNetworkConditions` | Stateless: UA/CPU/media/network overrides reset when the per-call connection closes. No named device presets. |
262
267
  | `resize_page` | `Emulation.setDeviceMetricsOverride` | Verifies via `window.innerWidth/innerHeight`. Override persists on the target. |
263
268
  | `handle_dialog` | `Page.javascriptDialogOpening` / `handleJavaScriptDialog` | Caller arms first and triggers out-of-band (or `handleDialogForExpression` to trigger-and-handle atomically). Supports wait-for-next and auto-handle-for-N-ms. |
@@ -274,9 +279,9 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 10 superset tools (`
274
279
  | `mock_request` *(superset)* | `Fetch.*` (+ `Page.reload`) | **A fake backend.** Registers a rule on a target's persistent session: fulfill with a canned response, fail, or continue (with optional `delayMs`/`failRate`). Persists across reloads until `clear_mocks`. Request-stage only. Cached requests aren't intercepted (use `reload:true`). |
275
280
  | `list_mocks` *(superset)* | `Runtime.evaluate` (liveness probe) | Lists active mock sessions with rules + hit counts; prunes sessions whose tab closed. |
276
281
  | `clear_mocks` *(superset)* | `Fetch.disable` | Tears down the resolved target's mock session (or all with `all:true`). |
277
- | `claim_page` *(superset)* | `Target.createTarget` + lease file | Opens or claims a tab and returns an opaque lease token. MCP only; the CLI refuses it. |
278
- | `release_page` *(superset)* | lease file | Gives a lease back. Idempotent, and does not close the tab. |
279
- | `list_leases` *(superset)* | lease file | Who holds what, with pid liveness and reclaimability. Needs no token; never returns the nonce. A lease file that could not be read or parsed is reported as an `unreadable` row instead of being skipped. |
282
+ | `claim_page` *(superset)* | `Target.createTarget` + lease file | Opens a fresh tab and claims it (no `target`/`targetId`), or takes over a tab that is already open (`target`, any selector, or the exact-id `targetId`) and returns an opaque lease token plus `opened` (true only when this call created the tab). Never steals a tab another live process holds. MCP only; the CLI refuses it. |
283
+ | `release_page` *(superset)* | lease file | Gives a lease back via `lease` (the token) or `target` (a selector, for a lease the gate auto-acquired and so never handed out a token for). Idempotent. Closes the tab by default when this toolkit's creation ledger says it opened it; leaves a merely-claimed tab open. Override either way with `close`. |
284
+ | `list_leases` *(superset)* | lease file | Who holds what, with pid liveness and reclaimability. Needs no token; never returns the nonce. A lease file that could not be read or parsed is reported as an `unreadable` row instead of being skipped. Under `CDP_REQUIRE_LEASE` also reaps abandoned agent tabs, reporting closures in an additive `reaped` array. |
280
285
 
281
286
  ## Parallel tabs: many agents, one browser
282
287
 
@@ -292,17 +297,59 @@ Two agents pointed at the same Chrome both resolve `target: active` to whatever
292
297
  {"lease": "chrome:1A2B...:0f3c...", "targetId": "1A2B...", "url": "https://example.com", "label": "checkout-agent", "ttlMs": 900000, "expiresAt": 1754400000000}
293
298
  ```
294
299
 
295
- Pass that token as `lease` on every later call against the tab. `new_page` with `claim: true` does the open-and-claim in one call. `release_page` gives it back, `list_leases` shows who holds what.
300
+ Pass that token as `lease` on every later call against the tab. `new_page` with `claim: true` does the open-and-claim in one call. `release_page` gives it back and, by default, closes the tab if this toolkit opened it (see "Close on release" below). `list_leases` shows who holds what.
296
301
 
297
- **Opt in, and only ever a refusal.** A tab nobody claimed behaves exactly as it did before. Omitting `lease` is identical to pre-1.2 behavior in every respect: no default changed, no return shape changed, no previously legal call now needs a new argument. This is why it is 1.2 and not 2.0.
302
+ **Opt in, and only ever a refusal — this is the default, with `CDP_REQUIRE_LEASE` unset.** A tab nobody claimed behaves exactly as it did before. Omitting `lease` is identical to pre-1.2 behavior in every respect: no default changed, no return shape changed, no previously legal call now needs a new argument. This is why it was 1.2 and not 2.0.
303
+
304
+ ### Strict mode: `CDP_REQUIRE_LEASE`
305
+
306
+ The default above is opt-in on purpose, but opt-in means an agent that never learns the protocol never gets the protection either. `CDP_REQUIRE_LEASE=1` (also `true`/`yes`/`on`, case-insensitively), read only by a long-lived MCP server process (never the CLI, see below), turns holding a lease from optional into mandatory: any call that resolves to a tab nobody holds now **acquires** a lease on it instead of driving it lease-free, so "you cannot drive a tab you do not hold" becomes literally true rather than a convention an agent can forget. `new_page` claims the tab it just created even without `claim:true`, for the same reason: a tab nobody holds the instant it exists is a tab the very next call would have to auto-acquire anyway.
307
+
308
+ That auto-acquired lease is a weaker tier than an explicit one, and the difference is what keeps strict mode ergonomic instead of a second protocol to learn:
309
+
310
+ - **Auto** (acquired by the gate, or by `new_page` without `claim:true`): owned by a *process*. Any later call from the same pid passes with no `lease` argument at all; only another process is refused.
311
+ - **Explicit** (`claim_page`, or `new_page{claim:true}`): still demands its token on every call, even from the process that claimed it. This is how one subagent fences a tab off from sibling subagents that share its pid, since a Claude Code session's subagents all run inside one MCP server process.
312
+
313
+ An unleased tab under strict mode is still refused to a different process exactly as under the default: the tab's whole selector surface (`active`, no target, `index:N`, `url:`, `title:`, a bare targetId) all resolve through the same gate, so there is no selector form that quietly bypasses acquisition. Turning `CDP_REQUIRE_LEASE` off never locks a process out of leases it already holds: the pid-only check on an auto lease is not itself gated on the flag, only the *acquiring* is.
314
+
315
+ **Strict mode is MCP-only, unconditionally, not by convention.** The CLI never reads `CDP_REQUIRE_LEASE` as true no matter how it is set, because a CLI invocation is one process per call: a lease it auto-acquired would be reclaimable by the dead-pid rule the instant that process exited, which is a worse position than no lease at all, and reap (below) would then read that instantly-dead-pid record as an abandoned agent tab and could close it the next time anything lists pages.
298
316
 
299
317
  **What can newly fail.** Any call that resolves to a leased tab without presenting that tab's token is refused, naming the tab, its url, and the label of whoever holds it. That is every selector form, not one of them: `active` or no target at all, `index:N`, `url:<substring>`, `title:<substring>`, and a bare targetId all reach the same check. The case worth singling out is `active` (or no target) against a leased tab index 0, because there you did not name the tab and before 1.2 the call silently succeeded against whatever tab happened to be first. That refusal is the point of the feature, and it is called out here rather than left to be discovered.
300
318
 
301
- **Reclamation.** A lease is reclaimable when its owning process is gone, or when `lastUsedAt` is older than `ttlMs` (default 15 minutes, `CDP_LEASE_TTL_MS`). Those two are the whole list. A lease whose tab is no longer open is *reported* by `list_leases` as `target-gone`, which is useful to see, but that report is not what frees it: such a record is freed by the same two rules as any other, whichever lands first. Every checked call refreshes `lastUsedAt`, so an agent that is working never expires and no heartbeat is needed. Reclaiming mints a fresh nonce, which invalidates the previous owner's token: a stalled agent that comes back cannot keep driving a tab someone else now owns.
319
+ **Reclamation.** A lease is reclaimable when its owning process is gone, or when `lastUsedAt` is older than `ttlMs` (default 15 minutes, `CDP_LEASE_TTL_MS`). Those two are the whole list. A lease whose tab is no longer open is *reported* by `list_leases` as `target-gone`, which is useful to see, but that report is not what frees it: such a record is freed by the same two rules as any other, whichever lands first. Every checked call refreshes `lastUsedAt`, so an agent that is working never expires and no heartbeat is needed. Reclaiming mints a fresh nonce, which invalidates the previous owner's token: a stalled agent that comes back cannot keep driving a tab someone else now owns. This applies equally to an auto lease and an explicit one; the tier only changes who may use the lease without a token, never how or when it goes stale.
320
+
321
+ ### Taking over a tab that is already open
322
+
323
+ `claim_page` had one mode before: open a fresh tab (or claim an exact `targetId`) and return a token for it. Neither covers "work in the tab I already have open", the ordinary shape of a human handing an agent a task in a tab that is already sitting in front of them, since a human does not have a targetId to hand over. `target` is that path — the toolkit's whole selector grammar, resolved against the tab that is actually there:
324
+
325
+ ```json
326
+ {"tool": "claim_page", "arguments": {"target": "active", "label": "continuing-agent"}}
327
+ ```
328
+
329
+ ```json
330
+ {"lease": "chrome:9F1A...:7b2e...", "targetId": "9F1A...", "url": "https://app.example.com/inbox", "label": "continuing-agent", "ttlMs": 900000, "expiresAt": 1754400000000, "opened": false}
331
+ ```
332
+
333
+ `opened: false` says this call took over a tab rather than creating one; it is `true` only from the fresh-tab mode. That flag matters because `release_page` reads the same distinction: a tab `claim_page{target}` took over has no creation-ledger record (this toolkit did not open it), so releasing it always leaves it open, regardless of `close`. Two safety properties hold without exception: **a selector that matches nothing is an error, never a silently substituted new tab** (`target` given means the fresh-tab branch is unreachable), and **there is no force or steal** — a tab another live process holds, auto-acquired or explicit, is refused with the same conflict error `claim_page` has always thrown, naming the holder. The tab this exists to take over is a human's, and a human's tab carries no lease to begin with.
334
+
335
+ ### Close on release
336
+
337
+ `release_page` used to only ever give the lease back. It now also closes the tab, **by default and without any flag**, when the creation ledger says this toolkit opened it — the one thing a caller sees change without setting `CDP_REQUIRE_LEASE` at all:
338
+
339
+ - A tab `new_page` or `claim_page` created is closed on release.
340
+ - A tab that was already open, and merely claimed (`claim_page{targetId}` or `claim_page{target}`), is released and left exactly alone.
341
+ - `close: false` opts a call out of closing; `close: true` forces a close either way.
342
+ - A release that did not actually happen — already released, reclaimed, or expired — never closes anything, because the tab may belong to a different agent by then. That is the one rule here that is a safety property, not a convenience, and it holds regardless of `close`.
343
+
344
+ Under `CDP_REQUIRE_LEASE`, `release_page{target}` is how you give back a lease the gate acquired for you automatically, since that path never hands out a token to pass to `lease`.
345
+
346
+ ### Reap: abandoned agent tabs get closed on read
347
+
348
+ `release_page` closes a tab an agent gives back; it does nothing for a tab whose agent crashed, was killed, or simply stopped calling; a timeout ending an agent is more common than a clean shutdown. Under `CDP_REQUIRE_LEASE`, `list_pages` and `list_leases` reap first: a tab is closed only when **all four** hold — its lease is for the browser being listed and is actually readable (an `unreadable` row is never touched), the lease is stale for `dead-pid` or `expired` (never `target-gone`, already closed, or a healthy lease), the target is still genuinely open, and **the toolkit both opened the tab and leased it**. That last condition is the one that matters most: an agent-created tab with no lease at all is never reaped, because that is exactly what `new_page` produces for a caller who never touches leases, and closing those would be data loss dressed as cleanup. Closed tabs are reported in an additive `reaped` array (`{targetId, label, reason}`), present only when something actually closed, so the common-case response shape is unchanged; a close that fails is silently left for the next read to retry rather than reported as done. Reap is strict-mode only, for the same reason auto-acquire is: outside a long-lived process, a dead-pid record proves nothing about abandonment.
302
349
 
303
350
  **Unreadable lease files.** A row in `list_leases` can carry `unreadable`, holding the errno (for example `EACCES`) or `"unparseable"`, when the lease file exists but could not be read or parsed. On such a row `label`, `pid`, `createdAt`, `lastUsedAt`, and `ttlMs` are zero placeholders, not real values, since they live inside the file that could not be read. `stale` is always `false` on that row, deliberately: `stale` is what a reader treats as free to take, and an unreadable lease must never read that way.
304
351
 
305
- **Verifying it against a real browser.** `bun run lease:smoke` (`test/lease-smoke.ts`) is the end-to-end harness for this feature. It drives a REAL browser on `CDP_BASE` (default `http://127.0.0.1:9222`) and spawns a genuine second OS process for the stranger role, because a lease records the claiming pid and a sequence of CLI calls can never collide: the first process is dead before the second starts. It asserts that the owner can act, that a tokenless stranger is refused with the `LeaseConflictError` type, `targetId` and `holder` intact and NOT coded `no-such-target`, that the refusal actually prevented the side effect (proved by reading the marker back, not by the throw), that a genuinely missing target still reports `no-such-target`, that the stranger is admitted after `release_page`, and that a lease orphaned by a real child process which exited is reclaimable with a fresh nonce. It is safe to point at a browser with real tabs open: it opens exactly one throwaway `about:blank` tab, addresses it only by its own target id, closes exactly that tab, and never closes the browser. It captures its own baseline tab listing at runtime and fails only if a tab that existed before the run went missing; tabs you open mid-run are ignored. Lease files go to a private temp dir that is removed on exit.
352
+ **Verifying it against a real browser.** `bun run lease:smoke` (`test/lease-smoke.ts`) is the end-to-end harness for this feature, and `bun test` cannot reach any of it: a lease is keyed on the claiming pid, so only a second genuinely separate OS process can collide with the first, and reap only ever fires from a long-lived process. It drives a REAL browser on `CDP_BASE` (default `http://127.0.0.1:9222`) and spawns real child OS processes to play a tokenless stranger, a doomed child that claims a tab and exits, and a corpse that exists only to donate a genuinely dead pid. Under the default (flag off): it asserts the owner can act, a tokenless stranger is refused with the `LeaseConflictError` type, `targetId` and `holder` intact and NOT coded `no-such-target`, the refusal actually prevented the side effect (proved by reading a marker back, not by the throw), a genuinely missing target still reports `no-such-target`, the stranger is admitted after `release_page`, and an orphaned lease from a real dead child is reclaimable with a fresh nonce. It then turns `CDP_REQUIRE_LEASE` on for the rest of the run and asserts: a second strict process with no token is refused the same way; an ordinary ungated call auto-acquires a lease, marked `auto` and owned by that pid; `release_page{target}` closes a tab this toolkit opened and leaves alone one with no creation record, even when explicitly claimed; and reap closes a tab with a genuinely dead-pid lease, names it in `reaped` with `reason:"dead-pid"`, and leaves every other tab the run opened untouched. A final scenario takes over a stand-in for a human's tab (open, unleased, no creation record) via `claim_page{target}`, drives it with the returned token, releases it, and confirms it is still open, then confirms a target that matches nothing is refused and opens no tab. It is safe to point at a browser with real tabs open: every tab it touches is one it opened itself, addressed only by its own target id, and it never calls `Browser.close`. It captures its own baseline tab listing at runtime and fails only if a tab that existed before the run went missing; tabs you open mid-run are ignored. Lease and origin files go to a private temp dir that is removed on exit, and reap — the single most destructive path exercised here — only ever considers a tab with both a lease record and an origin record in that private directory, which no pre-existing tab has.
306
353
 
307
354
  ### Which tab did an agent open? (`origin`)
308
355
 
@@ -318,9 +365,9 @@ So every tab this toolkit creates is written to a creation ledger, and `list_pag
318
365
  - `origin: "agent"` means cdp-toolkit created the tab, via `new_page` or via `claim_page` with no `targetId`. The row then also carries the creating `label` (the same value a lease would show, defaulting to `pid-<pid>`) and `createdAt`.
319
366
  - `origin: "unknown"` means there is no creation record. **It never says `"human"`.** The toolkit cannot prove a person opened a tab: no record is equally consistent with a tab opened before the server started, a tab restored from a previous session, another tool driving the same Chrome, or a record that could not be written. `unknown` is the honest word, and reading it as "safe, a human opened this" is the one mistake this field exists to prevent.
320
367
 
321
- **It outlives the lease, deliberately.** `release_page`, expiry, and reclamation all leave the record alone. Only the tab going away removes it: the ledger is reaped when it is read, dropping records for targets the browser no longer has, so it stays self-cleaning with nothing scheduled.
368
+ **It outlives the lease, deliberately.** `release_page`, expiry, and reclamation all leave the record alone. Only the tab going away removes it: the ledger is reaped when it is read, dropping records for targets the browser no longer has, so it stays self-cleaning with nothing scheduled. Under `CDP_REQUIRE_LEASE`, the record is also what authorizes a *close*: `list_pages`/`list_leases` reap an abandoned agent's tab only when it has both this origin record and a stale (`dead-pid`/`expired`) lease, never on the origin record alone, and `release_page` (or reap) consults it to decide whether closing this specific tab is this toolkit's call to make. See "Reap" above.
322
369
 
323
- **Claiming a tab you did not open records nothing.** `claim_page` with an explicit `targetId` takes ownership of an existing tab; that tab keeps reporting `origin: "unknown"`, because provenance is not ownership.
370
+ **Claiming a tab you did not open records nothing.** `claim_page` with an explicit `targetId`, or with `target` (the takeover path), takes ownership of an existing tab without creating one; that tab keeps reporting `origin: "unknown"`, because provenance is not ownership. That is also why `release_page` leaves such a tab open no matter how it was claimed.
324
371
 
325
372
  **Unreadable records.** If a target has a creation record that could not be read or parsed, its row reports `origin: "unknown"` plus `originUnreadable` (the errno, for example `EACCES`, or `"unparseable"`). Same principle as `list_leases`' `unreadable` rows: a broken record must never be indistinguishable from no record at all. A missing or unreadable ledger never fails `list_pages`; every page simply reports `unknown`.
326
373
 
@@ -328,10 +375,10 @@ So every tab this toolkit creates is written to a creation ledger, and `list_pag
328
375
 
329
376
  **What this does not do.**
330
377
 
331
- - No fan-out. The shape is many agents with one tab each. One agent driving several tabs at once is a different feature and is not addressed.
378
+ - No fan-out. Every call still names exactly one tab. An agent that wants several drives them a call at a time and holds a separate lease per tab; a single call acting on many tabs is a different feature and is not addressed.
332
379
  - No isolation. Two leased tabs on one Chrome still share cookies, `localStorage`, and every other origin-scoped store. A lease is ownership of a tab, not isolation of the browser under it.
333
380
  - No protection from humans. The lease is enforced against cdp-toolkit's own tool calls. It cannot stop someone clicking into the tab.
334
- - Not for the CLI. A CLI invocation is one process per call, so a lease it claimed would be reclaimable immediately by the dead-pid rule. `claim_page` is refused there. CLI calls can still present a token minted by the MCP server with `--lease`.
381
+ - Not for the CLI. A CLI invocation is one process per call, so a lease it claimed would be reclaimable immediately by the dead-pid rule. `claim_page` is refused there. CLI calls can still present a token minted by the MCP server with `--lease`. Strict mode (`CDP_REQUIRE_LEASE`) is likewise MCP-only and for the same underlying reason, doubled: an auto-acquired lease would be reclaimable on arrival, worse than no lease at all, and reap would then be free to read that instantly-dead-pid record as an abandoned tab and close it on the next listing.
335
382
 
336
383
  ## How it's built
337
384
 
@@ -339,13 +386,14 @@ So every tab this toolkit creates is written to a creation ledger, and `list_pag
339
386
  src/
340
387
  client.ts # CdpConnection, openPage/withPage/openBrowser, resolveTarget, timeouts
341
388
  types.ts # Target, TargetSelector, Uid, CDP envelopes
342
- index.ts # TOOLS registry (39) + re-exported client primitives
389
+ index.ts # TOOLS registry (41) + re-exported client primitives
343
390
  cli.ts # the Bun CLI
344
391
  mcp.ts # stdio MCP server (exposes TOOLS via @modelcontextprotocol/sdk)
345
392
  manifest.ts # JSON Schemas advertised by the MCP server (one per tool)
346
- leases.ts # lease records, staleness, reclamation, assertLeaseOk
393
+ leases.ts # lease records, staleness, reclamation, assertLeaseOk, CDP_REQUIRE_LEASE
347
394
  leases-tools.ts # claim_page / release_page / list_leases
348
395
  origins.ts # tab creation ledger: who OPENED a tab, outliving its lease
396
+ reap.ts # closes abandoned agent tabs on list_pages / list_leases (strict mode)
349
397
  tools/
350
398
  pages.ts navigation.ts evaluate.ts snapshot.ts input.ts
351
399
  screenshot.ts emulation.ts dialogs.ts recorder.ts console.ts