cdp-toolkit 1.8.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,124 @@ 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
+
8
126
  ## [1.8.0] - 2026-08-09
9
127
 
10
128
  Two tracks land together, merged onto one integration branch. Track S closes the
@@ -752,6 +870,7 @@ First public release.
752
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`.
753
871
  - Runtime: Bun ≥ 1.1 (recommended) or Node ≥ 22 (for the global `WebSocket`). Requires Chrome/Chromium started with `--remote-debugging-port=9222`.
754
872
 
873
+ [1.9.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.9.0
755
874
  [1.8.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.8.0
756
875
  [1.7.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.7.0
757
876
  [1.6.0]: https://github.com/sblattj/cdp-toolkit/releases/tag/v1.6.0
package/CONTRACT.md CHANGED
@@ -18,6 +18,10 @@ 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
+
21
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.
22
26
 
23
27
  ## Core client API (from `src/client.ts`)
package/README.md CHANGED
@@ -128,7 +128,7 @@ bun run mcp:smoke # spawn the server + a real initialize/tools-list/tools-call
128
128
 
129
129
  Each point below leads with the symptom you've probably hit, then the cause, then the fix.
130
130
 
131
- - **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.
131
+ - **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> | label:<name> | <targetId>`) and attaches a single WebSocket to just that page, so a busy tab you did not name is never touched.
132
132
  - **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`).
133
133
  - **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.
134
134
  - **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.
@@ -253,7 +253,7 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
253
253
  | `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. |
254
254
  | `navigate_page` | `Page.navigate` / `Page.reload` + load events, or `history:'back'\|'forward'` (Chrome: `Page.getNavigationHistory` + `Page.navigateToHistoryEntry`; Firefox: `browsingContext.traverseHistory`) | Returns `{url,frameId,waitedFor}` (no auto-snapshot; a history move also returns `traversed:'back'\|'forward'`). `waitUntil` supports `load`/`domcontentloaded`. `reload:true` (+ `ignoreCache:true` for a hard reload). `url`/`reload`/`history` are mutually exclusive; going back from the first entry (or forward from the last) is an error naming the direction, never a silent no-op. Works on both backends. |
255
255
  | `wait_for` | `Runtime.evaluate` (poll `innerText`) | Text-substring waiting only; throws on timeout rather than returning `{found:false}`. |
256
- | `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. Toolkit addition: optional `savePath` writes the value to a JSON file and keeps it out of the response entirely. |
256
+ | `evaluate_script` | `Runtime.evaluate` / `callFunctionOn` | No live `page`/element handle; `args` are plain JSON. Main-world context only. Toolkit addition: optional `savePath` writes the value to a JSON file and keeps it out of the response entirely. If `expression` is missing/empty and the call instead carries `function`/`code`/`js`/`script`/`fn`/`body`, the error names the wrong key and points at `expression`. The one tool that also accepts `target: "worker:<substring>"` (Chrome only, capability `worker.targets`) to evaluate inside an MV3 extension's background service worker — see "Driving MV3 extensions" below. |
257
257
  | `list_cookies` *(superset)* | `Network.getCookies` | Reads the target page's cookie store, httpOnly cookies included, which `document.cookie` and therefore `evaluate_script` cannot see. Page-scoped on purpose, not the browser-wide jar (`Storage.getCookies`), so it answers for the tab you named. Optional `domain`/`name` filters; optional `savePath` writes the array to a JSON file and returns `{path,bytes,count,target}` with no cookie value in the response. |
258
258
  | `set_cookie` *(superset)* | `Network.setCookie` | Writes one cookie, httpOnly and secure ones included, which `document.cookie` cannot create. Either `url` or `domain` is required and the call is refused with an error when neither is given. Chrome's `success:false` refusal is raised as an error rather than reported as a write. Answers `{set:true,target}` and never echoes the value back. `path` is passed through as given, never defaulted. |
259
259
  | `delete_cookies` *(superset)* | `Network.deleteCookies` | Removes the named cookie, httpOnly ones included. Requires `name` plus `url` or `domain`, so a name-only call cannot sweep the store; optional `path` narrows further. Answers `{deleted:true,target}` with no count, because neither protocol reports one; read `list_cookies` before and after for a real count. |
@@ -293,6 +293,25 @@ The 29 parity tools are 1:1 with `chrome-devtools-mcp`; the 16 superset tools (`
293
293
  | `wait_for_download` *(superset)* | `Browser.setDownloadBehavior` + `Browser.downloadWillBegin`/`downloadProgress` on a standing browser-endpoint connection | Waits for a download to finish and returns it as a real file on disk: `{path,suggestedFilename,bytes,url,target}`. **Ordering rule, not optional:** call `wait_for_download{arm:true}` *before* the click that starts the download, then click, then call `wait_for_download` again to collect it — arming this late is not a preference, it's because Chrome reverts the download-behavior override the instant the arming client disconnects, and denies an unarmed download outright. **Browser-global side effect:** while armed, *every* download in the browser is redirected into the toolkit's downloads directory. **MCP-server only:** the arm lives on a connection this server process holds open; the one-shot CLI's connection dies with the process, so nothing is captured there. Chrome-only (`browser.downloads`). |
294
294
  | `grant_permissions` *(superset)* | `Browser.grantPermissions` / `Browser.resetPermissions` on the same standing connection | Grants permissions (`geolocation`, `notifications`, `clipboardReadWrite`, ...) for an origin up front, so a page doesn't show a prompt no agent can click. Keyed by **origin**, not tab — every tab on that origin is affected, including ones opened later. `reset:true` clears this server's previous grants first (or instead); note CDP's reset is not origin-scoped, so it clears every origin at once. Same MCP-server-only reasoning as `wait_for_download`: Chrome discards the grant the moment the granting connection closes. Chrome-only (`browser.permissions`). |
295
295
 
296
+ ## Driving MV3 extensions
297
+
298
+ An MV3 extension's logic lives in a background **service worker**, not a page, so `chrome.storage.local`, `chrome.runtime`, and everything else the extension owns is unreachable through a page-typed selector. `evaluate_script` is the one tool that also accepts `target: "worker:<substring>"` (Chrome only, capability `worker.targets`) — any substring of the worker's url, so `worker:<extension-id>` and `worker:background.js` both work:
299
+
300
+ ```json
301
+ {"tool": "evaluate_script", "arguments": {"target": "worker:ekgaohljhieodkfggjkfgmmamfpngdhn", "expression": "chrome.storage.local.get()", "args": []}}
302
+ ```
303
+
304
+ A few facts worth knowing before you reach for it, all measured against a real browser rather than assumed:
305
+
306
+ - **An idle MV3 worker is evicted within seconds of its last activity, and once evicted it is invisible to every target listing** — not "stopped and still visible," genuinely absent from `/json/list` and `Target.getTargets` under any filter. So a `worker:` miss almost always means "asleep," not "wrong id," and the error says so.
307
+ - **`wake` defaults to `true`:** a selector that matches nothing, or a known-but-stopped worker, is started for you first, then re-resolved. Pass `wake:false` to fail fast instead — the error then teaches the eviction fact rather than reading like a typo.
308
+ - **A wake is verified by re-reading the target list, never by the start command's own result** — Chrome's start command reports success even for a scope that doesn't exist, so nothing here trusts it.
309
+ - **Worker targets bypass the lease gate entirely.** `claim_page`/strict mode never mint a lease for a worker evaluate; a worker id is Chrome's to create and destroy, not this toolkit's to fence.
310
+ - **If you're loading your own unpacked extension for testing, `--load-extension` does nothing on recent Chrome**, even with the documented unblock flag. Use `Extensions.loadUnpacked` over CDP instead (what this toolkit's own extension smoke does).
311
+ - **The extension id is a hash of the unpacked extension's absolute path**, so it's deterministic across loads from the same directory but differs from the id a packed/store install would get — don't hardcode an id copied from a different machine's path.
312
+
313
+ `worker:` is deliberately absent everywhere else — `close_page`/`select_page`/`release_page` and `claim_page`'s `target` stay page-only, refusing the arm by name rather than silently missing, and Firefox refuses it with a capability error (`worker.targets`), since WebDriver BiDi has no extension-service-worker target to map onto.
314
+
296
315
  ## Parallel tabs: many agents, one browser
297
316
 
298
317
  Two agents pointed at the same Chrome both resolve `target: active` to whatever tab the human last focused. Nothing errors. Each one gets plausible, well-formed output from the wrong page: a snapshot of someone else's form, a fill that lands in someone else's field. A crash gets caught; a silent wrong-tab success does not.
@@ -320,11 +339,11 @@ That auto-acquired lease is a weaker tier than an explicit one, and the differen
320
339
  - **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.
321
340
  - **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.
322
341
 
323
- 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.
342
+ 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:`, `label:`, 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.
324
343
 
325
344
  **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.
326
345
 
327
- **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.
346
+ **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>`, `label:<name>`, 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.
328
347
 
329
348
  **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.
330
349