cdp-toolkit 1.8.0 → 1.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdp-toolkit",
3
- "version": "1.8.0",
3
+ "version": "1.9.1",
4
4
  "description": "A lightweight chrome-devtools-mcp alternative: an MCP server + CLI that drives the tabs you name, one target per call over one direct WebSocket, with a bounded timeout on every call, so a stuck page can't wedge your agent or force a /mcp restart. Chrome over CDP and Firefox over WebDriver BiDi, with tools a backend can't run absent from tools/list rather than throwing mid-task. 45 tools including built-in network mocking, a cookie group, tab leases with human-activity detection, real HTML5 drag-and-drop, raw mouse/scroll dispatch, downloads, permission grants, and tab-to-video screen recording so several agents can drive one browser without stealing each other's tabs or a human's. No Puppeteer, no all-target fan-out.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -80,7 +80,8 @@
80
80
  "firefox:smoke": "bun run ./test/firefox-smoke.ts",
81
81
  "lease:smoke": "bun run ./test/lease-smoke.ts",
82
82
  "staleness:smoke": "bun run ./test/staleness-smoke.ts",
83
- "input:smoke": "bun run ./test/input-smoke.ts"
83
+ "input:smoke": "bun run ./test/input-smoke.ts",
84
+ "extension:smoke": "bun run ./test/extension-smoke.ts"
84
85
  },
85
86
  "devDependencies": {
86
87
  "@types/node": "22.10.10",
@@ -33,6 +33,8 @@ import {
33
33
  type ScreenshotOptions, type ScrollOptions, type SnapshotNode, type UidStability,
34
34
  } from "../driver.ts";
35
35
  import { assertLeaseOk } from "../leases.ts";
36
+ import { resolveLiveLabel } from "../origins.ts";
37
+ import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "../workers.ts";
36
38
  import { BEACON_FUNCTION_DECLARATION, BEACON_READ_EXPRESSION, BEACON_SOURCE, recordDispatch } from "../activity.ts";
37
39
  import { BidiConnection, BidiError, connectBidiSession } from "./client.ts";
38
40
  import { takeStampedSnapshot } from "./snapshot.ts";
@@ -384,6 +386,32 @@ async function pickContext(
384
386
  for (const c of contexts) if ((await safeTitle(conn, c.context)).includes(needle)) return c;
385
387
  throw driverError("no-such-target", `no context with title containing '${needle}'`);
386
388
  }
389
+ if (selector.startsWith("label:")) {
390
+ const label = selector.slice(6);
391
+ const { matchIds, knownLabels } = await resolveLiveLabel("firefox", label, contexts.map((c) => c.context));
392
+ if (matchIds.length > 1) {
393
+ throw driverError("no-such-target", `label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
394
+ }
395
+ if (matchIds.length === 1) {
396
+ const hit = contexts.find((c) => c.context === matchIds[0]);
397
+ if (hit) return hit;
398
+ }
399
+ throw driverError(
400
+ "no-such-target",
401
+ knownLabels.length
402
+ ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})`
403
+ : `no live target with label '${label}' (no labels are currently assigned to any open target)`,
404
+ );
405
+ }
406
+ if (isWorkerSelector(selector)) {
407
+ // Capability-gated, not unimplemented: "unsupported" is the DriverErrorCode
408
+ // that tells a caller to check the capability rather than retry. The tool
409
+ // itself stays available on Firefox — only this arm of the grammar is
410
+ // refused, exactly as drag's mode:'html5' is. Refusing loudly beats the
411
+ // alternative the bare-id fall-through would produce, which is a confusing
412
+ // "no context matching 'worker:abc'" that reads like a typo.
413
+ throw driverError("unsupported", WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
414
+ }
387
415
  const exact = contexts.find((c) => c.context === selector);
388
416
  if (exact) return exact;
389
417
  throw driverError("no-such-target", `no context matching '${selector}'`);
package/src/bidi-tools.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  import type { BrowserDriver, InterceptRule, PageDriver, PageInfo } from "./driver.ts";
11
11
  import { selectRule, buildFulfillParams, effectiveAction } from "./tools/network_mock.ts";
12
12
  import type { TargetSelector } from "./types.ts";
13
+ import { isWorkerSelector, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "./workers.ts";
13
14
 
14
15
  class BidiToolError extends Error {}
15
16
 
@@ -33,6 +34,22 @@ async function withPage<T>(driver: BrowserDriver, target: TargetSelector | undef
33
34
  const consoleBuffers = new Map<string, Array<Record<string, unknown>>>();
34
35
  const networkBuffers = new Map<string, Array<Record<string, unknown>>>();
35
36
 
37
+ /**
38
+ * Refuse the Chrome-only `worker:<substring>` arm BEFORE a browser is launched.
39
+ *
40
+ * pickContext already refuses it (bidi/driver.ts), but only after driver.page()
41
+ * has dialed — and on Firefox that means LAUNCHING a browser for a call that
42
+ * cannot possibly succeed. The gate is written as a capability check rather than
43
+ * "this file is the BiDi one" so it stays true if a future BiDi driver ever
44
+ * gains the capability, and the message is the shared one, so the teaching does
45
+ * not fork.
46
+ */
47
+ function refuseWorkerSelector(driver: BrowserDriver, target: TargetSelector): void {
48
+ if (isWorkerSelector(target) && !driver.capabilities.has("worker.targets")) {
49
+ throw new BidiToolError(WORKER_SELECTOR_UNSUPPORTED_MESSAGE);
50
+ }
51
+ }
52
+
36
53
  function bufferFor(map: Map<string, Array<Record<string, unknown>>>, id: string): Array<Record<string, unknown>> {
37
54
  let b = map.get(id);
38
55
  if (!b) { b = []; map.set(id, b); }
@@ -59,6 +76,7 @@ export async function listConsoleMessages(
59
76
  driver: BrowserDriver,
60
77
  args: { target?: TargetSelector; reload?: boolean; durationMs?: number } = {},
61
78
  ): Promise<{ messages: Array<Record<string, unknown>>; count: number }> {
79
+ refuseWorkerSelector(driver, args.target);
62
80
  return withPage(driver, args.target, async (page) => {
63
81
  if (args.reload) await captureFresh(page, args.durationMs ?? 2500);
64
82
  const buf = bufferFor(consoleBuffers, page.info.id);
@@ -70,6 +88,7 @@ export async function getConsoleMessage(
70
88
  driver: BrowserDriver,
71
89
  args: { target?: TargetSelector; index?: number } = {},
72
90
  ): Promise<Record<string, unknown>> {
91
+ refuseWorkerSelector(driver, args.target);
73
92
  return withPage(driver, args.target, async (page) => {
74
93
  const buf = bufferFor(consoleBuffers, page.info.id);
75
94
  const idx = args.index ?? 0;
@@ -83,6 +102,7 @@ export async function listNetworkRequests(
83
102
  driver: BrowserDriver,
84
103
  args: { target?: TargetSelector; reload?: boolean; durationMs?: number; filterUrl?: string } = {},
85
104
  ): Promise<{ requests: Array<Record<string, unknown>>; count: number }> {
105
+ refuseWorkerSelector(driver, args.target);
86
106
  return withPage(driver, args.target, async (page) => {
87
107
  if (args.reload) await captureFresh(page, args.durationMs ?? 2500);
88
108
  let buf = bufferFor(networkBuffers, page.info.id);
@@ -95,6 +115,7 @@ export async function getNetworkRequest(
95
115
  driver: BrowserDriver,
96
116
  args: { target?: TargetSelector; requestId?: string; url?: string } = {},
97
117
  ): Promise<Record<string, unknown>> {
118
+ refuseWorkerSelector(driver, args.target);
98
119
  if (!args.requestId && !args.url) throw new BidiToolError("get_network_request requires requestId or url");
99
120
  return withPage(driver, args.target, async (page) => {
100
121
  const buf = bufferFor(networkBuffers, page.info.id);
package/src/cdp/driver.ts CHANGED
@@ -10,6 +10,7 @@ import type { Target, TargetSelector } from "../types.ts";
10
10
  import { LeaseConflictError } from "../leases.ts";
11
11
  import { BEACON_READ_EXPRESSION, BEACON_SOURCE, BeaconSessions, RENDERER_PROBE_EXPRESSION, RENDERER_PROBE_TIMEOUT_MS, sendInput } from "../activity.ts";
12
12
  import { resolveUid } from "../tools/snapshot.ts";
13
+ import { resolveWorkerSelector } from "./workers.ts";
13
14
  import { buildFulfillParams, effectiveAction, selectRule } from "../tools/network_mock.ts";
14
15
  import {
15
16
  LEGACY_NUMERIC_UID, interpolatePoints, type BrowserCookie, type BrowserDriver, type Capability, type DeleteCookiesFilter, type HandledDialogInfo, type DriverError, type DriverErrorCode, type DriverEvent,
@@ -348,7 +349,7 @@ const CDP_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
348
349
  "screenshot.fullPage", "screenshot.element", "network.intercept",
349
350
  "snapshot.accessibilityTree", "input.insertTextAtomic", "locate.text", "locate.xpath",
350
351
  "capture.screencast", "input.raw", "input.html5Drag",
351
- "browser.downloads", "browser.permissions",
352
+ "browser.downloads", "browser.permissions", "worker.targets",
352
353
  ]);
353
354
  /* ---------------------------------- PageDriver ---------------------------------- */
354
355
  class CdpPageDriver implements PageDriver {
@@ -1106,6 +1107,20 @@ class CdpBrowserDriver implements BrowserDriver {
1106
1107
  await withBrowserConn((conn) => conn.send("Target.activateTarget", { targetId: target.id }));
1107
1108
  return { id: target.id, url: target.url, title: target.title };
1108
1109
  }
1110
+ /**
1111
+ * Capability "worker.targets". Resolution and the wake live in ./workers.ts;
1112
+ * this is the driver-shaped door onto them.
1113
+ *
1114
+ * The catch is noSuchTarget for the same reason page() uses it: a genuine
1115
+ * "nothing matched" is a CdpError (numeric code) and must surface as a
1116
+ * `no-such-target` DriverError, while a typed error from underneath — a lease
1117
+ * conflict, an fs errno — passes through with its class intact. The teaching
1118
+ * text in the miss message is the CdpError's message and survives relabeling.
1119
+ */
1120
+ async resolveWorkerTarget(selector: string, opts: { wake: boolean }): Promise<PageInfo> {
1121
+ const target = await resolveWorkerSelector(selector, opts).catch(noSuchTarget);
1122
+ return { id: target.id, url: target.url, title: target.title, type: target.type };
1123
+ }
1109
1124
  async page(selector: TargetSelector): Promise<PageDriver> {
1110
1125
  const { conn, target } = await openPage(selector).catch(noSuchTarget);
1111
1126
  // Deliberately NOT enabling ANY domain here (previously eagerly enabled Page+Runtime on every
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Chrome-only: resolving a "worker:<substring>" selector, including WAKING an
3
+ * idle-evicted MV3 service worker so there is something to resolve.
4
+ *
5
+ * EVERY PROTOCOL CHOICE BELOW WAS MEASURED against Chrome 151.0.7922.109 for
6
+ * 1.9.0, because the obvious ones are wrong:
7
+ *
8
+ * 1. `ServiceWorker.enable` and `ServiceWorker.startWorker` DO NOT EXIST on the
9
+ * browser endpoint — both answer `-32601 wasn't found` there. The domain is
10
+ * reachable only over a session attached to a PAGE target. Any page works;
11
+ * a plain `about:blank` tab hosted the wake in the probe, so no page on the
12
+ * extension's own origin is needed. That is why this opens a browser
13
+ * connection and then attaches a flattened session to some page, rather
14
+ * than talking to the browser endpoint directly.
15
+ *
16
+ * 2. A STOPPED worker is not a target. It is absent from `/json/list` and from
17
+ * `Target.getTargets` under every filter tried (including an explicit
18
+ * all-types filter, and after `Target.setDiscoverTargets`). The ONLY place a
19
+ * stopped worker is visible is the registration inventory that
20
+ * `ServiceWorker.enable` pushes as `workerRegistrationUpdated` /
21
+ * `workerVersionUpdated`. So matching a substring against a sleeping worker
22
+ * means matching against those events, not against a target list.
23
+ *
24
+ * 3. `ServiceWorker.startWorker` RETURNS EMPTY SUCCESS FOR A SCOPE THAT DOES
25
+ * NOT EXIST — a completely invented `chrome-extension://aaaa.../` scope
26
+ * answers `{}` exactly like a real one. Its result is therefore worthless as
27
+ * evidence, and nothing here reports a wake on the strength of it: the
28
+ * caller re-reads the target list and only a worker that actually shows up
29
+ * counts. This is the one dishonesty the feature could most easily ship.
30
+ *
31
+ * WHY THE CONNECTION IS ALWAYS CLOSED IN A `finally`. Two reasons, and the
32
+ * second is not obvious: a leaked socket keeps the process alive (the standing
33
+ * harness rule), AND an attached CDP session KEEPS A SERVICE WORKER FROM BEING
34
+ * EVICTED. A connection left open here would suppress the very eviction this
35
+ * module exists to recover from, so the leak would hide itself.
36
+ */
37
+ import { CdpError, listTargets, openBrowser } from "../client.ts";
38
+ import type { Target } from "../types.ts";
39
+ import {
40
+ resolveWorkerTargets,
41
+ workerAmbiguityMessage,
42
+ workerMissMessage,
43
+ workerNeedle,
44
+ WORKER_EMPTY_NEEDLE_MESSAGE,
45
+ } from "../workers.ts";
46
+
47
+ /** Bounded wait for the first inventory event after ServiceWorker.enable. A
48
+ * browser with no registrations at all never sends one, so this must time out
49
+ * rather than hang; the settle that follows collects the rest of the batch. */
50
+ const INVENTORY_FIRST_EVENT_MS = 1_500;
51
+ const INVENTORY_SETTLE_MS = 250;
52
+ /** Wake poll budget: 30 x 300ms = 9s. The probe saw a worker reappear in well
53
+ * under 2s; the headroom is for a cold worker whose top-level code does work. */
54
+ const WAKE_POLL_MS = 300;
55
+ const WAKE_POLL_ATTEMPTS = 30;
56
+
57
+ const NO_HOST_PAGE_MESSAGE =
58
+ "cannot start a service worker: the ServiceWorker CDP domain is only reachable over a page session, " +
59
+ "and this browser has no page target open. Open any tab (new_page) and retry — the tab does not have " +
60
+ "to belong to the extension.";
61
+
62
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
63
+
64
+ interface RegistrationEvent {
65
+ registrationId?: unknown;
66
+ scopeURL?: unknown;
67
+ isDeleted?: unknown;
68
+ }
69
+ interface VersionEvent {
70
+ registrationId?: unknown;
71
+ scriptURL?: unknown;
72
+ runningStatus?: unknown;
73
+ }
74
+
75
+ export interface WorkerStartAttempt {
76
+ /** True only when a matching registration was found AND a start was issued.
77
+ * Says nothing about whether the worker actually came up: see fact 3. */
78
+ startRequested: boolean;
79
+ /** Every registered scope this browser reported, for the miss message. */
80
+ scopes: string[];
81
+ }
82
+
83
+ /**
84
+ * Read the service-worker registration inventory and, when exactly one
85
+ * registration matches `needle`, ask Chrome to start it.
86
+ *
87
+ * Matching is against the version's scriptURL OR the registration's scopeURL,
88
+ * so both `worker:<extension-id>` (which appears in both) and
89
+ * `worker:background.js` (which appears only in the scriptURL) resolve.
90
+ */
91
+ export async function requestWorkerStart(needle: string): Promise<WorkerStartAttempt> {
92
+ const conn = await openBrowser();
93
+ try {
94
+ const host = (await listTargets()).find((t) => t.type === "page");
95
+ if (!host) throw new CdpError(NO_HOST_PAGE_MESSAGE);
96
+ const { sessionId } = await conn.send<{ sessionId?: string }>("Target.attachToTarget", {
97
+ targetId: host.id,
98
+ flatten: true,
99
+ });
100
+ if (!sessionId) throw new CdpError("could not attach a session to host the ServiceWorker domain");
101
+
102
+ const scopeById = new Map<string, string>();
103
+ const versions: Array<{ registrationId: string; scriptURL: string }> = [];
104
+ conn.on("ServiceWorker.workerRegistrationUpdated", (params) => {
105
+ for (const r of (params.registrations as RegistrationEvent[] | undefined) ?? []) {
106
+ if (r.isDeleted === true) continue;
107
+ scopeById.set(String(r.registrationId), String(r.scopeURL ?? ""));
108
+ }
109
+ });
110
+ conn.on("ServiceWorker.workerVersionUpdated", (params) => {
111
+ for (const v of (params.versions as VersionEvent[] | undefined) ?? []) {
112
+ versions.push({ registrationId: String(v.registrationId), scriptURL: String(v.scriptURL ?? "") });
113
+ }
114
+ });
115
+
116
+ await conn.send("ServiceWorker.enable", {}, { sessionId });
117
+ // The inventory arrives as events AFTER the enable response, so waiting on
118
+ // the response alone would read an empty map every time.
119
+ await conn.waitFor("ServiceWorker.workerVersionUpdated", undefined, INVENTORY_FIRST_EVENT_MS).catch(() => undefined);
120
+ await sleep(INVENTORY_SETTLE_MS);
121
+
122
+ const scopes = [...new Set(scopeById.values())].filter((s) => s.length > 0).sort();
123
+ const matched = new Set<string>();
124
+ for (const v of versions) {
125
+ const scope = scopeById.get(v.registrationId);
126
+ if (scope === undefined) continue;
127
+ if (v.scriptURL.includes(needle) || scope.includes(needle)) matched.add(scope);
128
+ }
129
+ if (matched.size === 0) return { startRequested: false, scopes };
130
+ if (matched.size > 1) {
131
+ throw new CdpError(
132
+ `'worker:${needle}' matches more than one registered service-worker scope: ${[...matched].sort().join(", ")}. ` +
133
+ "Narrow the substring.",
134
+ );
135
+ }
136
+ await conn.send("ServiceWorker.startWorker", { scopeURL: [...matched][0]! }, { sessionId });
137
+ return { startRequested: true, scopes };
138
+ } finally {
139
+ // See the header: a held session also suppresses eviction.
140
+ conn.close();
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Resolve a `worker:<substring>` selector to a live worker target, waking an
146
+ * evicted worker first when `wake` is true (the default at the tool surface).
147
+ *
148
+ * The wake path is: match running targets -> nothing -> ask for a start ->
149
+ * RE-READ THE TARGET LIST until the worker appears or the budget runs out.
150
+ * The re-read is the only evidence accepted that a wake happened.
151
+ */
152
+ export async function resolveWorkerSelector(selector: string, opts: { wake: boolean }): Promise<Target> {
153
+ const needle = workerNeedle(selector);
154
+ if (needle === "") throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
155
+
156
+ const first = resolveWorkerTargets(await listTargets(), needle);
157
+ if (first.matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, first.matches));
158
+ if (first.matches.length === 1) return first.matches[0]!;
159
+
160
+ if (!opts.wake) {
161
+ throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers: first.liveWorkers }));
162
+ }
163
+
164
+ const { startRequested, scopes } = await requestWorkerStart(needle);
165
+ if (startRequested) {
166
+ for (let i = 0; i < WAKE_POLL_ATTEMPTS; i++) {
167
+ await sleep(WAKE_POLL_MS);
168
+ const again = resolveWorkerTargets(await listTargets(), needle);
169
+ if (again.matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, again.matches));
170
+ if (again.matches.length === 1) return again.matches[0]!;
171
+ }
172
+ }
173
+ const final = resolveWorkerTargets(await listTargets(), needle);
174
+ throw new CdpError(
175
+ workerMissMessage(needle, { wakeAttempted: true, liveWorkers: final.liveWorkers, registeredScopes: scopes }),
176
+ );
177
+ }
package/src/cli.ts CHANGED
@@ -48,7 +48,11 @@ Backend selection: --browser chrome|firefox, else the CDP_BROWSER env var, else
48
48
  zero behavior change for existing users). Firefox is LAUNCHED per invocation (it cannot be attached
49
49
  to), used for exactly one tool call, then disposed before the process exits.
50
50
 
51
- Target selector grammar: active | index:N | url:<substr> | title:<substr> | <targetId>
51
+ Target selector grammar: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>
52
+ plus worker:<substr> (Chrome only) for evaluate_script, list_network_requests, get_network_request
53
+ and list_console_messages ONLY: an MV3 extension's background service worker, e.g.
54
+ worker:<extension-id>. An idle-evicted worker is started first unless wake:false. Recording a
55
+ worker keeps it alive for the length of the capture.
52
56
  Leases: pass --lease <token> to act on a tab another agent claimed with claim_page (MCP only).
53
57
  Run list_leases to see who holds what; release_page --lease <token> gives a lease back.
54
58
  Run with --list to print every tool name available under the selected backend.
package/src/client.ts CHANGED
@@ -5,6 +5,16 @@
5
5
  */
6
6
  import type { CdpResponse, Target, TargetSelector } from "./types.ts";
7
7
  import { assertLeaseOk } from "./leases.ts";
8
+ import { resolveLiveLabel } from "./origins.ts";
9
+ import {
10
+ isWorkerSelector,
11
+ isWorkerTargetType,
12
+ resolveWorkerTargets,
13
+ workerAmbiguityMessage,
14
+ workerMissMessage,
15
+ workerNeedle,
16
+ WORKER_EMPTY_NEEDLE_MESSAGE,
17
+ } from "./workers.ts";
8
18
 
9
19
  /** Base HTTP origin of the DevTools endpoint. Override with CDP_BASE. */
10
20
  export const BASE = process.env.CDP_BASE ?? "http://127.0.0.1:9222";
@@ -197,7 +207,19 @@ export async function browserWsUrl(): Promise<string> {
197
207
  * the ambient lease scope instead (see leases.ts). */
198
208
  export async function resolveTarget(selector: TargetSelector, opts: { lease?: string } = {}): Promise<Target> {
199
209
  const targets = await listTargets();
200
- const hit = pickTarget(targets, selector);
210
+ const hit = await pickTarget(targets, selector);
211
+ // WORKER TARGETS BYPASS THE LEASE GATE ENTIRELY, and that is a decision, not
212
+ // an omission. A lease answers "who is driving this TAB", and every
213
+ // consequence of holding one is a tab consequence: reap CLOSES an abandoned
214
+ // leased tab, and "close the stale tab" is meaningless for a service worker
215
+ // that Chrome starts and stops on its own schedule (see ReapInput.livePageIds,
216
+ // which is deliberately fed the page-only listing for exactly this reason).
217
+ // Letting strict mode auto-acquire here would mint a lease file per worker
218
+ // evaluate, keyed on an id that vanishes the moment the worker idles out —
219
+ // garbage that the page-only reap set can never collect. It also cannot be
220
+ // used to sneak past a lease: a worker is not a tab, so there is no tab
221
+ // someone else holds that this could reach.
222
+ if (isWorkerTargetType(hit.type)) return hit;
201
223
  // THE Chrome choke point. Every tool reaches a page through here, so a tool
202
224
  // added after this shipped is protected with no action from its author.
203
225
  //
@@ -211,7 +233,7 @@ export async function resolveTarget(selector: TargetSelector, opts: { lease?: st
211
233
  return hit;
212
234
  }
213
235
 
214
- function pickTarget(targets: Target[], selector: TargetSelector): Target {
236
+ async function pickTarget(targets: Target[], selector: TargetSelector): Promise<Target> {
215
237
  const pages = targets.filter((t) => t.type === "page");
216
238
  if (!selector || selector === "active") {
217
239
  if (!pages.length) throw new CdpError("no page targets open");
@@ -234,6 +256,34 @@ function pickTarget(targets: Target[], selector: TargetSelector): Target {
234
256
  if (!hit) throw new CdpError(`no page title containing '${needle}'`);
235
257
  return hit;
236
258
  }
259
+ if (selector.startsWith("label:")) {
260
+ const label = selector.slice(6);
261
+ const { matchIds, knownLabels } = await resolveLiveLabel("chrome", label, pages.map((t) => t.id));
262
+ if (matchIds.length > 1) {
263
+ throw new CdpError(`label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`);
264
+ }
265
+ if (matchIds.length === 1) {
266
+ const hit = pages.find((t) => t.id === matchIds[0]);
267
+ if (hit) return hit;
268
+ }
269
+ throw new CdpError(
270
+ knownLabels.length
271
+ ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})`
272
+ : `no live target with label '${label}' (no labels are currently assigned to any open target)`,
273
+ );
274
+ }
275
+ if (isWorkerSelector(selector)) {
276
+ // Resolved against the UNFILTERED listing, never `pages`: a worker is not a
277
+ // page target and would not be in it. Waking an evicted worker is NOT done
278
+ // here — this stays a pure pick over what the browser already reports, and
279
+ // the wake path lives in cdp/workers.ts, reached only from evaluate_script.
280
+ const needle = workerNeedle(selector);
281
+ if (needle === "") throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
282
+ const { matches, liveWorkers } = resolveWorkerTargets(targets, needle);
283
+ if (matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, matches));
284
+ if (matches.length === 1) return matches[0]!;
285
+ throw new CdpError(workerMissMessage(needle, { wakeAttempted: false, liveWorkers }));
286
+ }
237
287
  // bare id
238
288
  const byId = targets.find((t) => t.id === selector);
239
289
  if (!byId) throw new CdpError(`no target with id '${selector}'`);
package/src/driver.ts CHANGED
@@ -142,7 +142,8 @@ export type Capability =
142
142
  | "input.raw" // raw single mouse-event dispatch (dispatch_mouse's move/down/up primitive); Chrome only
143
143
  | "input.html5Drag" // real HTML5 drag events (drag's mode:"html5"); Chrome only, see PageDriver.drag
144
144
  | "browser.downloads" // capture a file download to a known path (wait_for_download); Chrome only
145
- | "browser.permissions"; // grant/reset browser permissions for an origin (grant_permissions); Chrome only
145
+ | "browser.permissions" // grant/reset browser permissions for an origin (grant_permissions); Chrome only
146
+ | "worker.targets"; // address a service/shared worker as a target ("worker:<substr>"), incl. waking an evicted MV3 worker; Chrome only
146
147
 
147
148
  /**
148
149
  * Tools whose availability depends on a capability. A tool absent from this
@@ -563,6 +564,31 @@ export interface BrowserDriver {
563
564
  */
564
565
  probeRenderer?(targetId: string, timeoutMs?: number): Promise<{ responsive: boolean; beaconTs: number | null }>;
565
566
 
567
+ /**
568
+ * Resolve a "worker:<substring>" selector to a concrete service/shared-worker
569
+ * target, STARTING an idle-evicted MV3 service worker first when `wake` is
570
+ * true. The returned PageInfo's id is a plain target id, so the caller then
571
+ * acquires it through the ordinary page() path.
572
+ *
573
+ * OPTIONAL **and** gated by Capability "worker.targets", unlike
574
+ * installActivityBeacon / readActivityBeacon / probeRenderer, which are
575
+ * optional but deliberately NOT capabilities. The difference is what a caller
576
+ * can observe: those three produce an absent FIELD on a result that is
577
+ * otherwise complete, so silence is the honest answer. This one decides
578
+ * whether an argument the caller explicitly wrote is honoured, and silently
579
+ * resolving `worker:...` to something else — or to nothing — would be a wrong
580
+ * answer rather than a missing annotation. So it refuses out loud, exactly as
581
+ * drag's mode:'html5' does behind "input.html5Drag" (see resolveDragMode).
582
+ * No tool is gated out of tools/list: evaluate_script is universal, only this
583
+ * one arm of its `target` grammar is Chrome-only.
584
+ *
585
+ * MUST NOT report a wake it cannot see: an implementation is required to
586
+ * confirm the worker is back in the target listing rather than trusting the
587
+ * start command's own result. See cdp/workers.ts for why that is not
588
+ * paranoia.
589
+ */
590
+ resolveWorkerTarget?(selector: string, opts: { wake: boolean }): Promise<PageInfo>;
591
+
566
592
  /** Tear down the transport: process exit (CLI) or shutdown (MCP server).
567
593
  * Idempotent. MUST NOT be called by a tool. */
568
594
  dispose(): Promise<void>;
@@ -73,7 +73,7 @@ export interface ClaimPageResult {
73
73
  * path for "work in the tab I have open", which previously had none, because
74
74
  * `targetId` demands an exact id a human never has to hand and no id at all
75
75
  * meant a brand new tab. `target` takes the toolkit's whole selector grammar
76
- * (active | index:N | url:<substr> | title:<substr> | <targetId>).
76
+ * (active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>).
77
77
  *
78
78
  * `targetId` is unchanged and still accepted; `target` is the same thing with a
79
79
  * grammar, so exactly one of the two may be given.
@@ -123,7 +123,7 @@ export async function claimPage(
123
123
  const hasTargetId = typeof args.targetId === "string" && args.targetId.length > 0;
124
124
  if (hasTarget && hasTargetId) {
125
125
  throw new LeaseToolError(
126
- "claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.",
126
+ "claim_page takes at most one of 'target' (any selector: active | index:N | url:<substr> | title:<substr> | label:<name> | <targetId>) or 'targetId' (an exact target id, kept for back-compat). Pass 'target' alone to claim an open tab, or neither to open a fresh one.",
127
127
  );
128
128
  }
129
129
  const backend = backendOf(driver);