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 CHANGED
@@ -5,6 +5,407 @@ 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.9.0] - 2026-08-09
9
+
10
+ Four complaints from a field agent driving an MV3 extension, closed without adding
11
+ a tool: the surface grows inside `evaluate_script`, the `target` selector
12
+ grammar, and error DX. Tool count stays 45. Two seats landed sequentially —
13
+ W2 (`label:` + key-echo) first, W1 (worker targets + idle-wake) on top of it —
14
+ and every protocol assertion below was probed against a live Chrome 151 before
15
+ being built on, because five 1.8.0 premises were folklore that measured false.
16
+
17
+ ### Added
18
+
19
+ - **`label:<name>` in the target-selector grammar, universal across every
20
+ page-taking tool.** Resolves an exact label match against BOTH the origin
21
+ ledger (tabs this toolkit created) and live lease records (a tab taken over
22
+ and claimed with a label, which has no origin record) — so a tab you named
23
+ is findable by that name regardless of which path put you in it. A miss
24
+ enumerates the labels that DO exist, deduped across both sources; an
25
+ ambiguity (the same label on two live targets) names both ids rather than
26
+ silently picking the first. **The grammar turned out to have three
27
+ independent copies** — `pickPage` (`src/shared-tools.ts`), `pickTarget`
28
+ (`src/client.ts`, the one `evaluate_script`/`click`/`hover`/… actually call),
29
+ and `pickContext` (`src/bidi/driver.ts`, Firefox) — a fact the live smoke
30
+ caught immediately when `label:` worked in `claim_page` but not in
31
+ `evaluate_script`. The lookup core is now one function, `resolveLiveLabel`
32
+ in `src/origins.ts`, wired into all three call sites, each keeping its own
33
+ error class. A new structural test, `test/manifest-grammar-drift.test.ts`,
34
+ asserts every manifest description that names `title:` also names `label:`,
35
+ so a fourth resolver copy (or a stale description) can't slip past review
36
+ silently again.
37
+
38
+ - **Key-echo validation on `evaluate_script`.** When `expression` is missing
39
+ or empty and the call instead carries `function`, `code`, `js`, `script`,
40
+ `fn`, or `body`, the error names the wrong key by value and says the right
41
+ one is `expression` — and, when the value looks like a function literal,
42
+ that function literals go in `expression` with `args`. One line, saving the
43
+ round trip the field complaint measured.
44
+
45
+ - **`evaluate_script` can now evaluate inside an MV3 extension's background
46
+ SERVICE WORKER, not just a page — no raw-CDP escape hatch needed.** Pass
47
+ `target: "worker:<extension-id>"` (or any substring of the worker's url,
48
+ e.g. `worker:background.js`) and read `chrome.storage.local`,
49
+ `chrome.runtime`, and the worker's own globals directly. Chrome-only
50
+ (capability `worker.targets`) and accepted by `evaluate_script` alone —
51
+ `worker:` is deliberately absent from `pickPage`'s page-only grammar
52
+ (closing/selecting/releasing a worker is meaningless) and refused there by
53
+ name, and a new drift-test arm asserts no OTHER tool's manifest description
54
+ ever names `worker:<`, so a later sweep can't advertise a Chrome-only,
55
+ one-tool selector as universal grammar. Firefox refuses the arm with a
56
+ capability-gap error naming `worker.targets`, per ADR-001 — WebDriver BiDi
57
+ has no extension-service-worker target concept to map onto.
58
+ **`wake` (default `true`)** asks Chrome to start the worker first when the
59
+ selector matches nothing or a known-but-stopped worker, then re-resolves;
60
+ `wake:false` fails fast with an error that teaches the MV3 eviction fact
61
+ instead of a bare "not found". Worker targets bypass the lease gate
62
+ entirely (`assertLeaseOk` is skipped for a worker-typed resolution in
63
+ `client.ts`): reap's "close the stale tab" is meaningless for a target
64
+ Chrome starts and stops on its own, and strict mode auto-acquiring a lease
65
+ per evaluate would mint a file keyed on an id that vanishes at the next
66
+ idle — garbage the page-only reap set can never collect. The page-only id
67
+ set reap and leases already keep (`ReapInput.livePageIds`) was left
68
+ unmerged on purpose.
69
+
70
+ ### Measured, not assumed — the protocol facts this release ships on
71
+
72
+ Every one of these was a hypothesis in the spec and a measurement against a
73
+ live Chrome 151.0.7922.109 before any line of feature code was written:
74
+
75
+ - **`evaluate_script` against a worker never needs a flattened browser
76
+ session.** `/json/list` exposes a running service worker with its own
77
+ `webSocketDebuggerUrl`, and a direct WebSocket connect + `Runtime.evaluate`
78
+ with `awaitPromise` works there exactly as it does against a page — proven
79
+ by `self.constructor.name === "ServiceWorkerGlobalScope"` in the same
80
+ round trip. The feature rides the existing per-target connect path with no
81
+ new connection model.
82
+ - **The "awaited value is lost over a flattened session" folklore is false on
83
+ Chrome 151, measured anyway even though it turned out not to matter.**
84
+ `Target.attachToTarget{flatten:true}` against the same worker returns the
85
+ real awaited value too. **No warning was shipped for a loss that cannot be
86
+ triggered.**
87
+ - **A stopped MV3 worker is invisible to every target listing — `/json/list`
88
+ and `Target.getTargets` under any filter — so "no such worker" almost
89
+ always means "idle-evicted," not "wrong id."** The only place a stopped
90
+ worker is visible at all is the registration inventory
91
+ `ServiceWorker.enable` pushes as events, which is why the miss/wake error
92
+ text says so explicitly.
93
+ - **`ServiceWorker.enable`/`startWorker` do not exist on the browser
94
+ endpoint** (`-32601 wasn't found`) — **they work on a flattened session over
95
+ any page**, including a plain `about:blank` tab with no relationship to the
96
+ extension. That's the wake mechanism: attach to whichever page target
97
+ happens to be open, enable the domain there, start the matched scope.
98
+ - **`startWorker` reports empty success for a scope that does not exist at
99
+ all** — its return value is worthless as proof a worker woke. A wake is
100
+ therefore verified only by re-reading the target list until the worker
101
+ reappears (bounded, 30 × 300ms), never by the command's own result.
102
+ - **`--load-extension` is inert on Chrome 151**, even with the documented
103
+ `--disable-features=DisableLoadExtensionCommandLineSwitch` unblock flag —
104
+ confirmed by checking the ServiceWorker registration inventory, which would
105
+ show a stopped-but-installed extension even if nothing were running, and
106
+ showed nothing at all. `Extensions.loadUnpacked` over CDP is the install
107
+ path that actually works, and is what the new extension smoke and any
108
+ future extension testing with this toolkit should use. `--headless=new` is
109
+ fine for driving an extension; no headed instance is required.
110
+
111
+ ### Notes
112
+
113
+ - Tool count is unchanged at **45** — this release grew `evaluate_script` and
114
+ the target-selector grammar, not the tool list.
115
+ - `docs/social-preview.html` and `docs/demo.tape` carry no version string and
116
+ needed no re-render for this release.
117
+ - New tests: `test/label-selector.test.ts`, `test/worker-selector.test.ts`,
118
+ two new arms in `test/manifest-grammar-drift.test.ts` (inclusion for
119
+ `label:`, exclusion for `worker:`), and a new live smoke,
120
+ `test/extension-smoke.ts`, against a fixture MV3 extension in
121
+ `test/fixtures/mv3-extension/` — 20/20 assertions, including a genuine idle
122
+ eviction (worker absence from `/json/list` asserted before waking) and a
123
+ strict-mode check that a worker evaluate mints zero lease files while a
124
+ page evaluate in the same run still mints one.
125
+
126
+ ## [1.8.0] - 2026-08-09
127
+
128
+ Two tracks land together, merged onto one integration branch. Track S closes the
129
+ gap 1.7.0's takeover mode opened: an agent can now claim a human's open tab, but
130
+ nothing detected the human was still there, and reap could destroy an
131
+ agent-abandoned tab the moment it went idle. Track P is four groups of input
132
+ parity chrome-devtools-mcp already had and this toolkit didn't: a wheel/scroll
133
+ tool, a raw mouse primitive, real HTML5 drag-and-drop, and navigation
134
+ history/downloads/permissions. Tool count moves 41 → 45. Every new
135
+ input-dispatching path (Track P) was wired through Track S's dispatch log as
136
+ part of the merge, not left as a follow-up — see "Changed" below.
137
+
138
+ ### Added
139
+
140
+ - **An activity beacon: `claim_page` and `list_leases` now know when a human is
141
+ already using a tab (`src/activity.ts`).** A tiny in-page script records the
142
+ timestamp of the last `pointerdown`/`mousedown`/`keydown`/`wheel`/`touchstart`,
143
+ installed at claim time on both `claim_page` modes and
144
+ `new_page{claim:true}`. On Chrome, `Page.addScriptToEvaluateOnNewDocument`
145
+ alone does **not** survive navigation under this toolkit's per-call connection
146
+ lifetime — the registration dies with the connection that made it, which is
147
+ every tool call, a fact verified against a live browser before anything was
148
+ built on it (`addScriptToEvaluateOnNewDocument` → new connection → navigate →
149
+ `null`). So the CDP driver now holds one connection open per beaconed tab
150
+ purely to keep the registration alive across navigations, bounded at 32
151
+ tabs with oldest-first eviction; an evicted tab still answers for input on
152
+ its current document, only the next navigation's re-arm is lost. Firefox
153
+ needs none of this — its BiDi session lives for the whole process, so a
154
+ `script.addPreloadScript` registration survives navigation for free, and was
155
+ implemented and live-verified rather than left absent, since the spec's
156
+ Firefox carve-out only applied if the plumbing was intractable and it wasn't.
157
+ CDP-dispatched input is `isTrusted:true` too, so the beacon alone cannot tell
158
+ a human click from the toolkit's own: every input-dispatching call writes to
159
+ an in-process dispatch log, and a beacon timestamp counts as human only when
160
+ it postdates this server's last dispatch on that tab by more than 1500ms.
161
+ `claim_page`/`list_leases` surface it as `humanActiveMs` — milliseconds since
162
+ input this server did not dispatch. **`null`/absent means NO DATA, never "no
163
+ human"**: a fresh tab, a tab whose every input was this server's own, and a
164
+ backend that cannot answer at all are all indistinguishable from each other
165
+ and from silence. A `claim_page` takeover of a tab a human used within the
166
+ last 30 seconds additionally carries a `contention` warning string, and **the
167
+ claim is never refused for it** — taking over a person's tab is what
168
+ takeover mode is for, so the warning informs a caller who already holds the
169
+ lease, it does not gate anything. Known blind spots, stated rather than
170
+ designed away: input inside a cross-origin iframe is invisible to the
171
+ beacon, and a *second* MCP server process's dispatches read as human to this
172
+ one, since dispatch-log correlation is scoped to this process.
173
+
174
+ - **Split reap horizon: reclaimable and destroyed are different moments, now
175
+ separated by `CDP_REAP_GRACE_MS`.** Before this release, `list_pages` and
176
+ `list_leases` closed an agent's tab the instant its lease read `expired`,
177
+ which is also the instant another agent becomes *entitled* to take it —
178
+ fine for handing the lease to someone else, destructive when applied to the
179
+ tab itself. An agent 20 minutes into a build between tool calls does not
180
+ expect to come back to a destroyed tab just because it could, in principle,
181
+ have been reclaimed. `staleAgentTabs` (`src/reap.ts`) stays a pure function
182
+ but now takes `now`/`reapGraceMs`: an `expired` lease qualifies for the
183
+ destructive close only once `now - lastUsedAt > ttlMs + reapGraceMs`.
184
+ `CDP_REAP_GRACE_MS` defaults to `2700000` (45 minutes), for a 60-minute total
185
+ fuse after last use. Lease *reclaimability* — `list_leases`' `stale` field —
186
+ is byte-for-byte unchanged at `ttlMs`; only the destructive close moved.
187
+ `dead-pid` is unaffected by grace and reaps immediately, because that
188
+ process is never coming back and there is nothing to wait for.
189
+
190
+ - **Renderer ping and lease observability on `list_pages`/`list_leases`.**
191
+ `list_pages` gains `probe?: boolean` (default false, byte-identical shape
192
+ when omitted): one bounded (500ms) `Runtime.evaluate` per page-type target,
193
+ run concurrently so one wedged tab cannot stall the rest of the listing
194
+ beyond its own budget. Adds `responsive: boolean` (false on a timeout, a
195
+ page-side exception, or an unreachable target — never an error for the
196
+ whole call) and, where the same round trip finds human-attributed input,
197
+ `humanActiveMs`, reusing the beacon's own discrimination rule rather than a
198
+ second implementation of it. Separately and unconditional on `probe`,
199
+ `list_leases` rows gain computed `idleMs` (now−lastUsedAt) and `expiresAt`
200
+ (lastUsedAt+ttlMs, the same value `claim_page` already returns under that
201
+ name), and `list_pages` entries for a tab under an active, readable lease of
202
+ the driver's own backend gain `lease: {label,pid,idleMs,expiresAt,stale}`.
203
+ Both are computed fresh in the tool layer on every call, never stored.
204
+
205
+ - **`scroll`, a new wheel-dispatch tool, on both backends.** Anchor at
206
+ `uid`/`selector`/`x`+`y` (element anchors are scrolled into view first, same
207
+ as `click`), or the viewport center if all three are omitted; at least one
208
+ of `deltaX`/`deltaY` is required, positive `deltaY` scrolling down and
209
+ positive `deltaX` scrolling right (wheel convention). Chrome dispatches
210
+ `Input.dispatchMouseEvent{type:"mouseWheel"}`; Firefox dispatches WebDriver
211
+ BiDi's `wheel` input source — both live-verified against a real browser, not
212
+ left on "it typechecks." A real bug surfaced along the way: Chrome
213
+ smooth-scrolls a wheel dispatch, so the command's ack resolves once the
214
+ event is *queued*, not once the resulting scroll has *settled*, and a
215
+ caller reading `scrollTop` immediately after could see the pre-scroll
216
+ value. Fixed with a capture-phase `scroll` listener armed before dispatch
217
+ (window-level, since `scroll` doesn't bubble but a capture-phase listener on
218
+ `window` still sees it), debounced 60ms with a 500ms hard cap rather than a
219
+ blind sleep sized wrong for both large and small deltas.
220
+
221
+ - **`dispatch_mouse`, the raw move/down/up primitive, Chrome-only.** Dispatch
222
+ exactly one `move`/`down`/`up` mouse event at absolute viewport coordinates
223
+ (`x`/`y` required on every call — CDP has no notion of a current pointer
224
+ position to default from); compose your own move/down/move/up sequences for
225
+ anything a physical mouse can do that `click`/`drag`'s fixed sequences
226
+ can't — canvas drag-painting, marquee/rubber-band selection, a custom
227
+ hit-testing widget. Takes the same `button`/`clickCount`/`modifiers` as
228
+ `click`. Lives outside the Driver abstraction by design, alongside
229
+ `heap.ts`/`screencast.ts`'s other chrome-only-tool modules, but is
230
+ lease-gated through the exact same choke point every other interaction tool
231
+ uses. Chrome-only, capability `input.raw`: absent from `tools/list` under
232
+ `--browser firefox`, never present-and-throwing.
233
+
234
+ - **`click` gains `modifiers` and `clickCount:3`.** `modifiers` (Alt/Control/
235
+ Meta/Shift, CDP bits 1/2/4/8) is held for both press and release, e.g.
236
+ `["Shift"]` for a shift-click; `clickCount:3` triple-clicks, selecting a
237
+ paragraph/line in most editors. `modifiers` is a **Chrome-only parameter on
238
+ an otherwise-universal tool**: a non-empty array throws a clear error under
239
+ `--browser firefox` rather than being silently dropped or ignored — a
240
+ documented param-level gap under ADR-001, not a missing tool.
241
+
242
+ - **Real HTML5 drag-and-drop (`drag mode:"html5"`), plus `steps`, `to:{x,y}`,
243
+ and `by:{dx,dy}` on the existing mouse-mode drag.** The spec's premise —
244
+ that synthetic mouse events cannot drive HTML5 drag-and-drop at all — is
245
+ **false**, measured against Chrome 151: `mode:"mouse"` (the default) does
246
+ start a real drag and can fire `dragstart`/`dragover`/`drop`. The actual gap
247
+ is that *which* `dragover` events reach the page depends on where the
248
+ interpolated pointer path happens to land, because mouse mode's drag
249
+ events follow that path rather than being dispatched at fixed points. At
250
+ the default `steps:2`, a drop zone written the standard way (`preventDefault`
251
+ only inside `dragover`, the HTML spec's own pattern) sees **zero** `dragover`
252
+ events and refuses the drop outright — a coin flip decided by geometry, not
253
+ a reliable "it doesn't work." `mode:"html5"` closes that gap deterministically:
254
+ `Input.setInterceptDrags(true)` arms interception, the mouse press/move
255
+ sequence runs, `Input.dragIntercepted` hands back the page's own `dragstart`
256
+ DragData, and the toolkit replays it as `dragEnter`→`dragOver`→`drop` exactly
257
+ at the destination — path-independent, and interception is always disabled
258
+ in a `finally`. Missing the intercepted event within 5 seconds (e.g. the
259
+ source isn't `draggable="true"`) is an actionable error, never a silent
260
+ no-op. `mode:"html5"` is Chrome-only (capability `input.html5Drag`, absent
261
+ from `REQUIRED_CAPABILITIES` so `drag` itself stays available on both
262
+ backends) and is rejected with a clear error under Firefox rather than
263
+ silently downgrading to mouse mode, which would report a drop that never
264
+ happened. `steps` (default 2, matching the exact pre-1.8.0 dispatch) sets
265
+ the interpolated-move count for mouse mode; `to` now also accepts an
266
+ absolute `{x,y}` alongside `uid`/`selector`, and a new top-level `by:{dx,dy}`
267
+ offsets from the source point instead (sliders, map panning, resize
268
+ handles) — exactly one of `to`/`by` is required, refused rather than
269
+ resolved by precedence when both or neither are given.
270
+
271
+ - **`navigate_page` gains `history: "back"|"forward"`, on both backends.**
272
+ Traverses this tab's session history like the browser's own Back/Forward
273
+ buttons: Chrome via `Page.getNavigationHistory` + `Page.navigateToHistoryEntry`,
274
+ Firefox via BiDi's `browsingContext.traverseHistory{delta:±1}`. Mutually
275
+ exclusive with `url`/`reload` — passing two of the three is refused by name,
276
+ never resolved by precedence. Going back from the first entry, or forward
277
+ from the last, is an **error naming the direction**, never a silent
278
+ no-op that navigates nowhere; both backends were made to raise the same
279
+ sentence (Firefox's own error never names a direction, so the BiDi driver
280
+ rewraps it). Waits for the same load milestone as an ordinary navigation.
281
+
282
+ - **`wait_for_download`, capture a real file download as a file on disk,
283
+ Chrome-only and MCP-server-only.** Returns `{path,suggestedFilename,bytes,
284
+ url,target}`, renaming Chrome's internal guid to the page's own filename
285
+ (collision-suffixed: `report.csv`, `report-1.csv`, ...). Two premises in the
286
+ original design were probed against a real browser before anything was
287
+ built, and both were **false**: `Browser.setDownloadBehavior` does **not**
288
+ persist past the arming client's disconnect (measured: an armed-then-closed
289
+ connection followed by a click landed zero files, no events, anywhere — the
290
+ download is *denied*, not merely misdirected), and the toolkit's per-call
291
+ CDP lifetime means every tool call is exactly such a disconnect. The fix is
292
+ `src/tools/browser-session.ts`: one lazily-opened, module-scoped connection
293
+ to the browser endpoint, held open for the process's life, arming download
294
+ capture once and keeping a ring buffer of completions — opened only when
295
+ `wait_for_download` or `grant_permissions` is first called, so a session
296
+ that never downloads pays nothing. **Ordering rule, not optional:** call
297
+ `wait_for_download{arm:true}` *before* the click that starts the download
298
+ (it arms and returns immediately: `{armed:true,downloadPath,pending}`), then
299
+ click, then call `wait_for_download` again to collect the finished file —
300
+ arming late loses the download with no file anywhere to recover it from.
301
+ **Browser-global side effect:** while armed, *every* download in the
302
+ browser, all tabs and origins, is redirected into the toolkit's downloads
303
+ directory for as long as the server runs. **MCP-server only, not a
304
+ preference:** the arm lives on a connection this server process holds open;
305
+ under the one-shot CLI that connection dies with the process before a
306
+ download could ever complete, so the tool needs the long-lived server the
307
+ same way `performance_start_trace`/`start_screen_recording` already do.
308
+ Chrome-only, capability `browser.downloads`: WebDriver BiDi has no command
309
+ to redirect a download to a chosen directory.
310
+
311
+ - **`grant_permissions`, pre-answer permission prompts for an origin,
312
+ Chrome-only and MCP-server-only.** `permissions: string[]` takes CDP
313
+ PermissionType values (`geolocation`, `notifications`, `clipboardReadWrite`,
314
+ `camera`, `microphone`, `midi`, ...; an unknown name is refused by Chrome
315
+ with the bad value in the message), keyed by **origin**, not tab — every
316
+ tab on that origin is affected, including ones opened later. `reset:true`
317
+ clears this server's previous grants first, or instead when no permissions
318
+ are given; CDP's reset is not itself origin-scoped, so it clears every
319
+ origin at once. The same probe that falsified `wait_for_download`'s
320
+ persistence assumption was run against `Browser.grantPermissions` on
321
+ spec, since the same per-connection-state trap applies here too, and it
322
+ sprang: a grant also does **not** survive the granting client's disconnect.
323
+ Shares `browser-session.ts`'s standing connection with `wait_for_download`
324
+ for exactly that reason. Chrome-only, capability `browser.permissions`:
325
+ absent from `tools/list` under Firefox, never present-and-throwing.
326
+
327
+ ### Changed (behavior, no flag)
328
+
329
+ - **Every input-dispatching path, old and new, now writes through one choke
330
+ point, closing a real bug the individual branches could not see.** Track P
331
+ was built against pre-1.8.0 main, before Track S's dispatch log existed, so
332
+ its `scroll` and html5-drag paths sent `Input.*` straight down the
333
+ connection. Merged as-is, this compiles, typechecks, and passes both
334
+ branches' full test suites while the activity beacon reports the toolkit's
335
+ own scroll as a human using the tab — an agent warned about contention with
336
+ itself, the exact failure the beacon exists to prevent, invisible to either
337
+ branch's own tests because neither branch could see the other's code.
338
+ `dispatch_mouse` lives outside the Driver abstraction and has no
339
+ `dispatchInput` to call, so the write moved to a new shared function,
340
+ `sendInput(conn, backend, targetId, method, params)` in `src/activity.ts`;
341
+ `CdpPageDriver.dispatchInput` now delegates to it and `dispatch-mouse.ts`
342
+ calls it directly. `Input.setInterceptDrags` (html5 drag's arm and its
343
+ `finally`) is sent but deliberately **not** recorded: it synthesizes no
344
+ page input, and logging it would suppress a real human input landing in the
345
+ same 1500ms window — classified by denylist, so an `Input.*` command nobody
346
+ thought to classify defaults to being logged, because over-warning is
347
+ recoverable and silent misattribution is the bug this feature exists to
348
+ avoid. Firefox needed no code change: Track P built its BiDi scroll and drag
349
+ on `performActions`, which already called `recordDispatch`, so it inherited
350
+ the log for free — the whole argument for having a choke point in the first
351
+ place. The invariant — nothing in `src/` sends `Input.*` except through
352
+ `sendInput` — is grep-verified post-merge and is now also a test
353
+ (`test/input-dispatch-wiring.test.ts`), mutation-checked by reverting
354
+ `scroll` to a raw send and confirming the test names the exact line and the
355
+ live smoke reproduces the bug (`humanActiveMs=202` on the toolkit's own
356
+ scroll, instead of `undefined`).
357
+
358
+ ### Residuals
359
+
360
+ - **A stale claim in `drag`'s own manifest description was caught and fixed
361
+ during integration, not by a fresh reader later.** An earlier pass on the
362
+ top-level `drag` description already stated the measured mouse-vs-html5
363
+ finding, but the `mode` property's own enum description still read "does
364
+ nothing on HTML5 draggable elements" — the exact folklore the rest of this
365
+ release measured false. Corrected to match; no test pinned the stale
366
+ string.
367
+ - **Second-MCP-server blind spot, stated plainly.** Two MCP server processes
368
+ driving the same browser cannot see each other's dispatch logs, so one
369
+ server's tool calls read as human activity to the other. Accepted: the
370
+ discrimination rule is honest about what it can and cannot see, and the
371
+ alternative (a shared dispatch log across processes) is a much larger
372
+ feature for a scenario this toolkit does not otherwise support (see
373
+ "Parallel tabs" — one writer per flow).
374
+ - **Cross-origin iframe input is invisible to the beacon,** since the
375
+ listener is installed on the top document only. Under-warning here is the
376
+ same accepted direction as the denylist above: a missed human input reads
377
+ as agent-only, never the reverse.
378
+ - **`dispatch_mouse`'s `modifiers` and `click`'s `modifiers` are two separate
379
+ parameters that happen to share an enum,** not one modifier system: each
380
+ tool validates and documents its own Firefox behavior independently.
381
+ - **Firefox's BiDi `scroll` path shipped once and was verified twice, in two
382
+ different seats.** It typechecked and passed unit tests against the real
383
+ BiDi capability set when Track P landed, but no Firefox was available to
384
+ that seat to run it live; the integration seat had one, ran it, and it
385
+ passed unmodified — the implementation was correct the first time, only the
386
+ live evidence was missing. Recorded here so "typechecks" is never mistaken
387
+ for "verified" on the next capability that ships without a live check.
388
+
389
+ ### Notes
390
+
391
+ - Tool count is now 45 (29 parity + 16 superset: `performance_trace`, the
392
+ cookie group, the network-mocking group, the lease group, the
393
+ screen-recording pair, `scroll`, `dispatch_mouse`, `wait_for_download`, and
394
+ `grant_permissions`); 34 under Firefox, which lacks the eleven
395
+ capability-gated tools — the pre-1.8.0 eight (tracing, heap-snapshot,
396
+ Lighthouse, screen-recording) plus `dispatch_mouse`, `wait_for_download`,
397
+ and `grant_permissions`. `scroll` and the extended `navigate_page`/`click`/
398
+ `drag` params ship on both backends; only `click`'s `modifiers` and `drag`'s
399
+ `mode:"html5"` stay Chrome-only, and only at the parameter level — the
400
+ tools themselves are universal.
401
+ - Two new live-smoke scripts, `bun run staleness:smoke` and `bun run
402
+ input:smoke`, are each a single process talking to a single browser:
403
+ `CDP_BASE` is a module-level constant captured at import time, so one
404
+ process cannot address two browsers on two ports. Each is therefore
405
+ env-driven — `staleness:smoke` honors `STALENESS_SMOKE_PORT` (default
406
+ 9501), `input:smoke` honors `CDP_SMOKE_PORT` (default 9513) — so the two
407
+ can run against separate scratch Chromes without colliding.
408
+
8
409
  ## [1.7.0] - 2026-08-09
9
410
 
10
411
  Four things land on top of 1.2's opt-in lease model, all under one new switch
@@ -469,6 +870,8 @@ First public release.
469
870
  - 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`.
470
871
  - Runtime: Bun ≥ 1.1 (recommended) or Node ≥ 22 (for the global `WebSocket`). Requires Chrome/Chromium started with `--remote-debugging-port=9222`.
471
872
 
873
+ [1.9.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.9.0
874
+ [1.8.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.8.0
472
875
  [1.7.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.7.0
473
876
  [1.6.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.6.0
474
877
  [1.0.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.0.0
package/CONTRACT.md CHANGED
@@ -18,6 +18,12 @@ writing a module.
18
18
 
19
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`.
20
20
 
21
+ **A worker-typed hit inside `resolveTarget` is the other deliberate exception, added in 1.9.0.** A `worker:<substring>` selector (Chrome only, capability `worker.targets`, `evaluate_script`'s alone) resolves to a service/shared worker target, and `resolveTarget` returns it before reaching `assertLeaseOk` — see the comment at the top of that branch in `src/client.ts`. A lease answers "who is driving this tab"; a worker is not a tab, has no reap-meaningful close, and strict-mode auto-acquire on it would mint a lease file keyed on an id that vanishes the moment Chrome idles the worker out, garbage the page-only reap set (`ReapInput.livePageIds`) can never collect. This also cannot be used to bypass a lease on an actual tab: there is no tab a worker resolution could stand in for.
22
+
23
+ **The `TargetSelector` grammar itself also has three independent copies, a separate fact from the lease choke points above** — `pickTarget` (`src/client.ts`, what `resolveTarget` calls, and what most tools reach through), `pickPage` (`src/shared-tools.ts`), and `pickContext` (`src/bidi/driver.ts`, Firefox). A prefix arm added to one does not exist for callers routed through another; `label:` (1.9.0) was first wired into only `pickPage` and passed every unit test while `evaluate_script` still rejected it, because `evaluate_script` calls `pickTarget`, not `pickPage`, and only the live smoke caught the gap. The shared lookup core for an arm that needs identical semantics everywhere (as `label:` does) belongs in its own module — see `resolveLiveLabel` in `src/origins.ts` — wired into all three call sites, each keeping its own error class (`CdpError` / `SharedToolError` / `DriverError`). `test/manifest-grammar-drift.test.ts` structurally asserts a completeness property (every `title:` mention also mentions `label:`) and, separately, an exclusion property for `worker:` (no tool other than `evaluate_script` may name it) — grep `startsWith("title:")` across `src/` before adding a new arm, and extend or scope that test for it.
24
+
25
+ 7. **Never dispatch `Input.*` outside `sendInput`.** As of 1.8.0 there is exactly one function that writes a synthesized input command to a Chrome connection: `sendInput(conn, backend, targetId, method, params)` in `src/activity.ts`. `CdpPageDriver.dispatchInput` (the per-driver choke point every interaction method already had to go through) delegates to it, and any tool built outside the Driver abstraction (`dispatch_mouse`, in `src/tools/dispatch-mouse.ts`, is the precedent) must call it directly rather than opening its own `conn.send("Input....", ...)`. This is what keeps the activity beacon's human/agent discrimination (`src/activity.ts`'s dispatch log) correct: a new input path that bypasses `sendInput` will typecheck, pass its own unit tests, and pass `bun test` in full, while silently making the toolkit's own input read as a human using the tab — exactly the 1.8.0 merge bug `test/input-dispatch-wiring.test.ts` now exists to catch (it walks every `.ts` under `src/`, strips comment-only lines, and fails on any line that both sends and names `Input.`). On the Firefox side, `BidiPageDriver.performActions` is the twin choke point and already calls `recordDispatch`; a new BiDi input path goes through it the same way `CdpPageDriver.dispatchInput` is used on Chrome. Mode-toggle commands that dispatch no page input themselves (`Input.setInterceptDrags`'s arm/disarm around HTML5 drag) are still sent through `sendInput` but are on a denylist that skips recording them — classify a new such command explicitly; the default for anything unclassified is to be logged, since over-warning a human is recoverable and silently misattributing their input to "agent" is not.
26
+
21
27
  ## Core client API (from `src/client.ts`)
22
28
 
23
29
  ```ts
@@ -67,7 +73,9 @@ export async function click(args: ClickArgs): Promise<unknown>;
67
73
  - Recorder buffers: `${ARTIFACT_DIR}/rec-<targetId>.jsonl`.
68
74
  - 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
75
  - 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.
76
+ - 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. As of 1.8.0 an `expired` lease qualifies for that close only past `ttlMs + CDP_REAP_GRACE_MS` (default 45 extra minutes) — `list_leases`' `stale`/reclaimable reading is unchanged at `ttlMs`; only the destructive close moved later. `dead-pid` is unaffected by grace.
77
+ - Beacon sessions (`src/activity.ts`, `src/cdp/driver.ts`): a `BeaconSessions` pool of held Chrome connections, one per tab with an installed activity beacon, bounded at 32 with oldest-first eviction — the same module-scope pattern `recorder.ts`/`network_mock.ts` already use for their own persistent per-target sessions. Exists only because `Page.addScriptToEvaluateOnNewDocument`'s registration dies with the connection that made it (verified against a live browser, not assumed), so surviving navigation under this toolkit's per-call lifetime requires holding a connection open; eviction degrades (the current document's listeners keep answering, only the next navigation's re-arm is lost) rather than breaking. Firefox needs no equivalent: its `LifetimeModel` is `"session"`, so a `script.addPreloadScript` registration on the one memoized BiDi connection per port already lives as long as that connection does.
78
+ - The browser-session connection (`src/tools/browser-session.ts`): one lazily-opened, module-scoped connection to the CDP browser endpoint (not a page endpoint), held open for the process's life, backing `wait_for_download` and `grant_permissions`. Opened only on first use of either tool, so a session that never touches downloads or permissions pays nothing. Exists because `Browser.setDownloadBehavior` and `Browser.grantPermissions` were measured — probed against a real browser before either tool was written — to NOT persist past the arming/granting client's disconnect, which this toolkit's per-call connection lifetime would otherwise hit on every single call. Both tools are therefore MCP-server-only capabilities, the same category as `performance_start_trace`/`stop_screen_recording`'s persistent-connection requirement, for the same underlying reason.
71
79
 
72
80
  ## The element-reference scheme (READ if you touch snapshot or input)
73
81