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.
@@ -44,7 +44,8 @@ import {
44
44
  type LeaseBackend, type LeaseStaleReason, type LeaseToken,
45
45
  } from "./leases.ts";
46
46
  import { installBeacon, probeRendererActivity, rendererProbeSupported } from "./activity.ts";
47
- import { newTrackedPage, originIndex, type PageOrigin } from "./origins.ts";
47
+ import { newTrackedPage, originIndex, resolveLiveLabel, type PageOrigin } from "./origins.ts";
48
+ import { isWorkerSelector, WORKER_SELECTOR_PAGE_ONLY_MESSAGE, WORKER_SELECTOR_UNSUPPORTED_MESSAGE } from "./workers.ts";
48
49
  import { reapStaleAgentTabs, type ReapedTab } from "./reap.ts";
49
50
 
50
51
  const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
@@ -132,6 +133,21 @@ export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selecto
132
133
  if (!p) throw new SharedToolError(`no page title containing '${needle}'`);
133
134
  return p;
134
135
  }
136
+ if (selector.startsWith("label:")) return pickByLabel(driver, pages, selector.slice(6));
137
+ if (isWorkerSelector(selector)) {
138
+ // THE PAGE-ONLY CONTRACT, STATED OUT LOUD. Every caller of pickPage
139
+ // (resolvePage -> close_page / select_page / release_page, claim_page's
140
+ // takeover) acts on a TAB, and the 1.9.0 "worker:" arm is deliberately NOT
141
+ // implemented here: it resolves against the page-only listing these callers
142
+ // pass, which by construction never contains a worker, so the arm could
143
+ // only ever miss. Refusing with the reason beats falling through to the
144
+ // bare-id lookup, whose "no target with id 'worker:foo'" reads as a typo.
145
+ // The arm lives in client.ts's pickTarget (Chrome's all-targets resolver),
146
+ // and only evaluate_script routes to it.
147
+ throw new SharedToolError(
148
+ backendOf(driver) === "firefox" ? WORKER_SELECTOR_UNSUPPORTED_MESSAGE : WORKER_SELECTOR_PAGE_ONLY_MESSAGE,
149
+ );
150
+ }
135
151
  const exactPage = pages.find((x) => x.id === selector);
136
152
  if (exactPage) return exactPage;
137
153
  const all = await driver.listPages({ all: true });
@@ -140,6 +156,35 @@ export async function pickPage(driver: BrowserDriver, pages: PageInfo[], selecto
140
156
  throw new SharedToolError(`no target with id '${selector}'`);
141
157
  }
142
158
 
159
+ /**
160
+ * "label:<name>" resolution for pickPage. The lookup itself (both stores,
161
+ * live-scoped) is shared with client.ts's pickTarget and bidi/driver.ts's
162
+ * pickContext via origins.ts's resolveLiveLabel, so the three copies of the
163
+ * selector grammar cannot drift on what counts as a hit, a miss or an
164
+ * ambiguity. Only the error wording is local, matching this file's
165
+ * SharedToolError idiom.
166
+ *
167
+ * Nothing is reaped here: this is a read, not a maintenance pass, and pickPage
168
+ * stays gate-free and side-effect-free over its inputs.
169
+ */
170
+ async function pickByLabel(driver: BrowserDriver, pages: PageInfo[], label: string): Promise<PageInfo> {
171
+ const { matchIds, knownLabels } = await resolveLiveLabel(backendOf(driver), label, pages.map((x) => x.id));
172
+ if (matchIds.length > 1) {
173
+ throw new SharedToolError(
174
+ `label 'label:${label}' matches more than one live target: ${matchIds.join(", ")}. Resolve by target id instead.`,
175
+ );
176
+ }
177
+ if (matchIds.length === 1) {
178
+ const p = pages.find((x) => x.id === matchIds[0]);
179
+ if (p) return p;
180
+ }
181
+ throw new SharedToolError(
182
+ knownLabels.length
183
+ ? `no live target with label '${label}' (labels currently in use: ${knownLabels.join(", ")})`
184
+ : `no live target with label '${label}' (no labels are currently assigned to any open target)`,
185
+ );
186
+ }
187
+
143
188
  /* -------------------------------- element locators -------------------------------- */
144
189
 
145
190
  function locatorOf(args: { uid?: DriverUid; selector?: string }): ElementLocator {
@@ -476,14 +521,70 @@ function sinkRequested(savePath?: string): savePath is string {
476
521
  return savePath !== undefined && savePath !== "";
477
522
  }
478
523
 
524
+ /** Keys a caller reaches for out of habit from other automation APIs, none of
525
+ * which this tool reads: the code always goes in 'expression'. Checked in
526
+ * this order so the error names one wrong key, deterministically, when a
527
+ * caller happens to pass more than one. */
528
+ const EXPRESSION_ALIAS_KEYS = ["function", "code", "js", "script", "fn", "body"] as const;
529
+
530
+ /**
531
+ * Turn a `worker:<substring>` target into a concrete target id, waking an
532
+ * idle-evicted MV3 service worker on the way when `wake` is not false.
533
+ *
534
+ * WHY THIS RETURNS AN ID RATHER THAN DRIVING THE WORKER ITSELF. Once resolved,
535
+ * a worker is just a target with its own webSocketDebuggerUrl, and a DIRECT
536
+ * connection to it evaluates exactly like a page does — `Runtime.evaluate` with
537
+ * `awaitPromise` returns real awaited values there (measured on Chrome 151;
538
+ * `chrome.storage.local.get(null)` comes back as an object, not undefined). So
539
+ * the worker rides the ordinary page() acquisition path from here, and every
540
+ * behavior around it — the args/function form, exception reporting, and above
541
+ * all savePath's no-value-in-the-response secret hygiene — is the SAME CODE,
542
+ * not a parallel copy that could quietly diverge on the leak-shaped edge.
543
+ *
544
+ * Exported for the unit tests, which need to reach the capability refusal
545
+ * without a browser.
546
+ */
547
+ export async function resolveWorkerSelectorFor(
548
+ driver: BrowserDriver,
549
+ selector: string,
550
+ wake: boolean,
551
+ ): Promise<PageInfo> {
552
+ if (!driver.capabilities.has("worker.targets") || typeof driver.resolveWorkerTarget !== "function") {
553
+ throw new SharedToolError(`evaluateScript: ${WORKER_SELECTOR_UNSUPPORTED_MESSAGE}`);
554
+ }
555
+ return driver.resolveWorkerTarget(selector, { wake });
556
+ }
557
+
479
558
  export async function evaluateScript(
480
559
  driver: BrowserDriver,
481
- args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[]; savePath?: string },
560
+ args: { target?: TargetSelector; expression: string; awaitPromise?: boolean; args?: unknown[]; savePath?: string; wake?: boolean },
482
561
  ): Promise<unknown> {
483
562
  if (typeof args.expression !== "string" || args.expression.length === 0) {
563
+ const wrongKey = EXPRESSION_ALIAS_KEYS.find((k) => (args as Record<string, unknown>)[k] !== undefined);
564
+ if (wrongKey !== undefined) {
565
+ throw new SharedToolError(
566
+ `evaluateScript: you passed '${wrongKey}'; the key is 'expression' — function literals go in 'expression', invoked with 'args'.`,
567
+ );
568
+ }
484
569
  throw new SharedToolError("evaluateScript: 'expression' must be a non-empty string");
485
570
  }
486
- return withPage(driver, args.target, async (page) => {
571
+ if (args.wake !== undefined && typeof args.wake !== "boolean") {
572
+ throw new SharedToolError("evaluateScript: 'wake' must be a boolean");
573
+ }
574
+ // `wake` is REFUSED on a page selector rather than ignored. Accepting it there
575
+ // would read as "this call will wake something", and the caller would never
576
+ // learn that the argument did nothing at all.
577
+ if (args.wake !== undefined && !isWorkerSelector(args.target)) {
578
+ throw new SharedToolError(
579
+ "evaluateScript: 'wake' only applies to a target of the form 'worker:<substring>'; a page target is never asleep.",
580
+ );
581
+ }
582
+ // Resolved to a bare target id BEFORE acquisition, so the page() call below is
583
+ // the same one every other selector makes.
584
+ const target = isWorkerSelector(args.target)
585
+ ? (await resolveWorkerSelectorFor(driver, args.target, args.wake ?? true)).id
586
+ : args.target;
587
+ return withPage(driver, target, async (page) => {
487
588
  const value = await page.evaluate(args.expression, { args: args.args, awaitPromise: args.awaitPromise ?? true });
488
589
  // No savePath: byte-identical to the pre-sink behavior, the value itself.
489
590
  // A thrown page-side exception never reaches here, so it still surfaces as
@@ -12,11 +12,18 @@
12
12
  * unique per-capture file), Page.reload, capture for `durationMs` (default
13
13
  * 2500ms), stop, then read+return the console entries from that capture. The
14
14
  * both-domains capture means a network reload never wipes console history.
15
+ *
16
+ * SERVICE WORKERS (1.9.1): list_console_messages accepts `worker:<substring>`
17
+ * and a bare worker target id, Chrome only (Capability "worker.targets"). A
18
+ * worker's console.log arrives as Runtime.consoleAPICalled on its own session
19
+ * (measured; `Log.entryAdded` never fires there). There is no reload to drive a
20
+ * worker capture, so reload:true means "listen for durationMs" — see
21
+ * recorder.ts's header.
15
22
  */
16
23
  import { readFile } from "node:fs/promises";
17
- import { CdpError, resolveTarget } from "../client.ts";
24
+ import { CdpError } from "../client.ts";
18
25
  import type { TargetSelector } from "../types.ts";
19
- import { recFile, captureWindow } from "./recorder.ts";
26
+ import { assertWakeApplies, recFile, captureWindow, resolveRecorderTarget } from "./recorder.ts";
20
27
  import type { RecLine } from "./recorder.ts";
21
28
 
22
29
  const DEFAULT_CAPTURE_MS = 2500;
@@ -44,6 +51,8 @@ export interface ListConsoleMessagesArgs {
44
51
  reload?: boolean;
45
52
  /** Capture window for reload mode (ms). Default 2500. */
46
53
  durationMs?: number;
54
+ /** Worker targets only, and only with reload:true — start an idle-evicted MV3 worker first (default true). */
55
+ wake?: boolean;
47
56
  }
48
57
 
49
58
  export interface GetConsoleMessageArgs {
@@ -144,8 +153,9 @@ export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): P
144
153
  messages: ConsoleEntry[];
145
154
  droppedWrites?: number;
146
155
  }> {
156
+ assertWakeApplies(args.target, args.wake, args.reload === true);
147
157
  if (args.reload) {
148
- const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
158
+ const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS, { wake: args.wake });
149
159
  await cap.stop();
150
160
  const messages = await readConsoleEntries(cap.file);
151
161
  return {
@@ -156,7 +166,7 @@ export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): P
156
166
  };
157
167
  }
158
168
 
159
- const target = await resolveTarget(args.target);
169
+ const target = await resolveRecorderTarget(args.target, { capture: false });
160
170
  const messages = await readConsoleEntries(recFile(target.id));
161
171
  return {
162
172
  target: { id: target.id, url: target.url, title: target.title },
@@ -170,7 +180,14 @@ export async function listConsoleMessages(args: ListConsoleMessagesArgs = {}): P
170
180
  * existing buffer for the target. Throws if the index is out of range.
171
181
  */
172
182
  export async function getConsoleMessage(args: GetConsoleMessageArgs = {}): Promise<ConsoleEntry> {
173
- const target = await resolveTarget(args.target);
183
+ // Reads the SAME buffer list_console_messages just returned, so it resolves
184
+ // through the same read-path resolver: a worker target already reached this
185
+ // code through the 1.9.0 bare-id/`worker:` arm, and routing it here is what
186
+ // makes the miss message match the buffer it is actually reading. It is
187
+ // deliberately NOT advertised as worker-capable in the manifest (see
188
+ // WORKER_CAPABLE_TOOLS): index-into-a-buffer is not the worker story, and the
189
+ // drift test keeps the advertised list to the four tools that are.
190
+ const target = await resolveRecorderTarget(args.target, { capture: false });
174
191
  const messages = await readConsoleEntries(recFile(target.id));
175
192
  const index = args.index ?? 0;
176
193
  const entry = messages[index];
@@ -184,8 +201,8 @@ export async function getConsoleMessage(args: GetConsoleMessageArgs = {}): Promi
184
201
 
185
202
  /*
186
203
  * CDP methods / domains used (via recorder.ts + this module):
187
- * Page.enable (reload mode)
188
- * Page.reload (reload mode)
204
+ * Page.enable (reload mode, PAGE targets only)
205
+ * Page.reload (reload mode, PAGE targets only)
189
206
  * Runtime.enable / Runtime.consoleAPICalled / Runtime.exceptionThrown (recorder)
190
207
  * Log.enable / Log.entryAdded (recorder)
191
208
  *
@@ -16,11 +16,19 @@
16
16
  * and calls Network.getResponseBody before closing. Because a reload re-mints
17
17
  * requestIds, body fetch is matched by `url` (stable), not by a carried-over
18
18
  * requestId; see getNetworkRequest for the exact contract.
19
+ *
20
+ * SERVICE WORKERS (1.9.1): both tools accept `worker:<substring>` and a bare
21
+ * worker target id, Chrome only (Capability "worker.targets"). The capture is
22
+ * the same recorder, over the worker's own connection; what differs is that
23
+ * there is no reload to drive it (see recorder.ts's header for the measurements)
24
+ * — the window listens while the worker works, so the traffic has to be
25
+ * triggered during it. `wake` starts an idle-evicted worker first, and is
26
+ * accepted only where it can actually do something.
19
27
  */
20
28
  import { readFile } from "node:fs/promises";
21
- import { CdpError, resolveTarget } from "../client.ts";
29
+ import { CdpError } from "../client.ts";
22
30
  import type { TargetSelector } from "../types.ts";
23
- import { recFile, captureWindow } from "./recorder.ts";
31
+ import { assertWakeApplies, recFile, captureWindow, resolveRecorderTarget } from "./recorder.ts";
24
32
  import type { RecLine } from "./recorder.ts";
25
33
 
26
34
  const DEFAULT_CAPTURE_MS = 2500;
@@ -55,6 +63,8 @@ export interface ListNetworkRequestsArgs {
55
63
  durationMs?: number;
56
64
  /** Only return requests whose URL contains this substring. */
57
65
  filterUrl?: string;
66
+ /** Worker targets only, and only with reload:true — start an idle-evicted MV3 worker first (default true). */
67
+ wake?: boolean;
58
68
  }
59
69
 
60
70
  export interface GetNetworkRequestArgs {
@@ -66,6 +76,8 @@ export interface GetNetworkRequestArgs {
66
76
  /** Fetch the response body too (drives a fresh reload capture; use with `url`). */
67
77
  includeBody?: boolean;
68
78
  durationMs?: number;
79
+ /** Worker targets only, and only with includeBody+url — start an idle-evicted MV3 worker first (default true). */
80
+ wake?: boolean;
69
81
  }
70
82
 
71
83
  /** Coerce a CDP headers object to a string-keyed/string-valued record. */
@@ -166,14 +178,15 @@ export async function listNetworkRequests(args: ListNetworkRequestsArgs = {}): P
166
178
  let resolved: { id: string; url: string; title: string };
167
179
  let droppedWrites: number | undefined;
168
180
 
181
+ assertWakeApplies(args.target, args.wake, args.reload === true);
169
182
  if (args.reload) {
170
- const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
183
+ const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS, { wake: args.wake });
171
184
  await cap.stop();
172
185
  file = cap.file;
173
186
  resolved = cap.resolved;
174
187
  droppedWrites = cap.droppedWrites();
175
188
  } else {
176
- const target = await resolveTarget(args.target);
189
+ const target = await resolveRecorderTarget(args.target, { capture: false });
177
190
  file = recFile(target.id);
178
191
  resolved = { id: target.id, url: target.url, title: target.title };
179
192
  }
@@ -214,9 +227,10 @@ export async function getNetworkRequest(args: GetNetworkRequestArgs = {}): Promi
214
227
  const describeSelector = (): string =>
215
228
  args.requestId ? `requestId ${args.requestId}` : `url ~ '${args.url}'`;
216
229
 
230
+ assertWakeApplies(args.target, args.wake, Boolean(args.includeBody && args.url));
217
231
  // Body fetch by URL: a fresh reload capture (url is stable across reload).
218
232
  if (args.includeBody && args.url) {
219
- const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS);
233
+ const cap = await captureWindow(args.target, args.durationMs ?? DEFAULT_CAPTURE_MS, { wake: args.wake });
220
234
  try {
221
235
  await cap.flush(); // ensure the buffer is complete; conn stays open for the body fetch
222
236
  const match = (await readNetworkRequests(cap.file)).find((r) => r.url.includes(args.url!));
@@ -239,7 +253,7 @@ export async function getNetworkRequest(args: GetNetworkRequestArgs = {}): Promi
239
253
  }
240
254
 
241
255
  // Metadata path: read the target's existing buffer (also the requestId+includeBody case).
242
- const target = await resolveTarget(args.target);
256
+ const target = await resolveRecorderTarget(args.target, { capture: false });
243
257
  const match = pick(await readNetworkRequests(recFile(target.id)));
244
258
  if (!match) {
245
259
  throw new CdpError(
@@ -264,7 +278,7 @@ export async function getNetworkRequest(args: GetNetworkRequestArgs = {}): Promi
264
278
  * Network.requestWillBeSent / .responseReceived / .loadingFinished /
265
279
  * .loadingFailed (events) (recorder)
266
280
  * Network.getResponseBody (includeBody mode)
267
- * Page.enable / Page.reload (reload mode)
281
+ * Page.enable / Page.reload (reload mode, PAGE targets only)
268
282
  *
269
283
  * Parity gaps vs chrome-devtools-mcp:
270
284
  * - Response bodies require a fresh reload capture (CDP serves bodies only from
@@ -38,11 +38,47 @@
38
38
  * reload-driven via `captureWindow`. `startRecorder` resolves only AFTER the CDP
39
39
  * domains are enabled, so a caller can trigger navigation/reload immediately and
40
40
  * be sure events are caught.
41
+ *
42
+ * SERVICE WORKERS (1.9.1)
43
+ * -----------------------
44
+ * A `worker:<substring>` (or bare worker-id) target records the same two domains
45
+ * over the worker's OWN connection, which is the whole point of the feature: the
46
+ * outbound fetch an MV3 background worker makes is a Network event on that
47
+ * session and nothing else can see it reliably. Measured on Chrome 151.0.7922.109
48
+ * before any of this was written:
49
+ *
50
+ * - `Network.enable` succeeds on a direct worker connection and
51
+ * requestWillBeSent / responseReceived / loadingFinished all arrive for a
52
+ * fetch the worker performs; `Network.getResponseBody` serves its body.
53
+ * - `Runtime.consoleAPICalled` delivers the worker's console.log on the same
54
+ * session. (`Log.enable` also succeeds but emits nothing there.)
55
+ * - `Page.enable` and `Page.reload` DO NOT EXIST on a worker session: both
56
+ * answer `-32601 ... wasn't found`. So a worker capture window cannot be
57
+ * reload-driven, and this module skips both instead of catching a failure —
58
+ * the window simply LISTENS for durationMs while the worker runs.
59
+ * - A held session SUPPRESSES MV3 idle-eviction (measured: alive and still
60
+ * emitting Network events after 60s held; evicted ~30s after the detach). A
61
+ * recording therefore keeps the worker alive for exactly as long as it runs,
62
+ * which is a documented side effect of these tools, not an accident.
63
+ * - An evicted worker that is woken has ALREADY run its top-level code by the
64
+ * time the recorder attaches, so a request the worker makes at startup can be
65
+ * missed. Trigger the work you want to observe during the window.
41
66
  */
42
67
  import { mkdir, appendFile, writeFile, copyFile } from "node:fs/promises";
43
- import { openPage, resolveTarget } from "../client.ts";
68
+ import { CdpError, listTargets, openPage, resolveTarget } from "../client.ts";
44
69
  import type { CdpConnection } from "../client.ts";
70
+ import { resolveWorkerSelector } from "../cdp/workers.ts";
45
71
  import type { Target, TargetSelector } from "../types.ts";
72
+ import {
73
+ isWorkerSelector,
74
+ isWorkerTargetType,
75
+ resolveWorkerTargets,
76
+ workerAmbiguityMessage,
77
+ workerBufferReadMissMessage,
78
+ workerNeedle,
79
+ workerWakeMisuseMessage,
80
+ WORKER_EMPTY_NEEDLE_MESSAGE,
81
+ } from "../workers.ts";
46
82
 
47
83
  /** Artifact / buffer directory (shared with the rest of the toolkit). */
48
84
  export const ARTIFACT_DIR = process.env.CDP_ARTIFACT_DIR ?? "/tmp/cdp-toolkit";
@@ -62,6 +98,52 @@ function delay(ms: number): Promise<void> {
62
98
  return new Promise((r) => setTimeout(r, ms));
63
99
  }
64
100
 
101
+ /* ------------------------- worker targets (1.9.1) ------------------------- */
102
+
103
+ /**
104
+ * Resolve the target for a console/network call, which is the ONE place the
105
+ * three reader tools differ from every other tool: a capture may start a
106
+ * stopped worker, a read never may.
107
+ *
108
+ * `capture: true` — a recording is about to be made, so a `worker:` selector
109
+ * goes through the waking resolver (Chrome-only, `cdp/workers.ts`), default
110
+ * wake:true, exactly like evaluate_script.
111
+ * `capture: false` — an existing buffer is about to be read, so the same
112
+ * selector resolves against the RUNNING workers only, and a miss gets the
113
+ * read-path message rather than a promise that waking would help (it would
114
+ * not: the buffer is keyed by a target id the restart re-mints).
115
+ *
116
+ * Non-worker selectors are untouched: they go through client.ts's resolveTarget,
117
+ * lease gate and all.
118
+ */
119
+ export async function resolveRecorderTarget(
120
+ target: TargetSelector,
121
+ opts: { capture: boolean; wake?: boolean },
122
+ ): Promise<Target> {
123
+ if (!isWorkerSelector(target)) return resolveTarget(target);
124
+ if (opts.capture) return resolveWorkerSelector(target, { wake: opts.wake ?? true });
125
+
126
+ const needle = workerNeedle(target);
127
+ if (needle === "") throw new CdpError(WORKER_EMPTY_NEEDLE_MESSAGE);
128
+ const { matches, liveWorkers } = resolveWorkerTargets(await listTargets(), needle);
129
+ if (matches.length > 1) throw new CdpError(workerAmbiguityMessage(needle, matches));
130
+ if (matches.length === 1) return matches[0]!;
131
+ throw new CdpError(workerBufferReadMissMessage(needle, liveWorkers));
132
+ }
133
+
134
+ /**
135
+ * Validate `wake` for the three reader tools. It is REFUSED where it would do
136
+ * nothing rather than accepted and ignored — the evaluate_script precedent: an
137
+ * accepted argument reads as "this call will wake something", and a caller who
138
+ * is wrong about that never finds out.
139
+ */
140
+ export function assertWakeApplies(target: TargetSelector, wake: boolean | undefined, capture: boolean): void {
141
+ if (wake === undefined) return;
142
+ if (typeof wake !== "boolean") throw new CdpError("'wake' must be a boolean");
143
+ if (!isWorkerSelector(target)) throw new CdpError(workerWakeMisuseMessage("not-a-worker-selector"));
144
+ if (!capture) throw new CdpError(workerWakeMisuseMessage("read-only-call"));
145
+ }
146
+
65
147
  /** Network CDP events we persist. */
66
148
  const NETWORK_EVENTS = [
67
149
  "Network.requestWillBeSent",
@@ -186,11 +268,16 @@ export async function startRecorder(target: TargetSelector, opts: RecorderOption
186
268
  }
187
269
 
188
270
  /**
189
- * One-shot reload capture used by the console/network tools. Records BOTH
190
- * domains into a unique per-capture file, reloads the page, waits `durationMs`,
191
- * and (on stop) publishes the capture as the target's shared "latest" buffer.
192
- * The returned handle keeps the connection open until `stop()`, so a caller can
193
- * `flush()` then fetch response bodies before closing.
271
+ * One-shot capture used by the console/network tools. Records BOTH domains into
272
+ * a unique per-capture file, waits `durationMs`, and (on stop) publishes the
273
+ * capture as the target's shared "latest" buffer. The returned handle keeps the
274
+ * connection open until `stop()`, so a caller can `flush()` then fetch response
275
+ * bodies before closing.
276
+ *
277
+ * A PAGE window reloads to generate the traffic. A WORKER window does not (and
278
+ * cannot: `Page.*` is absent on a worker session) — it listens while the worker
279
+ * runs, and the session it holds keeps that worker from being idle-evicted for
280
+ * the length of the window.
194
281
  */
195
282
  export interface CaptureWindow {
196
283
  /** The unique per-capture buffer file (read this for isolated results). */
@@ -199,6 +286,8 @@ export interface CaptureWindow {
199
286
  conn: CdpConnection;
200
287
  /** Resolved target identity. */
201
288
  resolved: { id: string; url: string; title: string };
289
+ /** How the window was driven: "reload" for a page, "listen" for a worker. */
290
+ mode: "reload" | "listen";
202
291
  /** Buffer-write failure count. */
203
292
  droppedWrites(): number;
204
293
  /** Await in-flight appends without closing (bodies stay fetchable). */
@@ -210,9 +299,15 @@ export interface CaptureWindow {
210
299
  export async function captureWindow(
211
300
  target: TargetSelector,
212
301
  durationMs: number,
213
- opts: { timeoutMs?: number } = {},
302
+ opts: { timeoutMs?: number; wake?: boolean } = {},
214
303
  ): Promise<CaptureWindow> {
215
- const resolved = await resolveTarget(target);
304
+ const resolved = await resolveRecorderTarget(target, { capture: true, wake: opts.wake });
305
+ // A WORKER window is "listen", not "reload": Page.enable/Page.reload answer
306
+ // -32601 on a worker session (measured), and there is nothing to reload
307
+ // anyway — the caller triggers the worker's work during the window. Keyed off
308
+ // the RESOLVED TYPE rather than the selector shape, so a bare worker target id
309
+ // takes the same branch as `worker:<substring>`.
310
+ const isWorker = isWorkerTargetType(resolved.type);
216
311
  const captureId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
217
312
  const file = captureFile(resolved.id, captureId);
218
313
  // Record both domains so neither tool clobbers the other's history.
@@ -223,13 +318,16 @@ export async function captureWindow(
223
318
  file,
224
319
  timeoutMs: opts.timeoutMs,
225
320
  });
226
- await rec.conn.send("Page.enable");
227
- await rec.conn.send("Page.reload", { ignoreCache: false });
321
+ if (!isWorker) {
322
+ await rec.conn.send("Page.enable");
323
+ await rec.conn.send("Page.reload", { ignoreCache: false });
324
+ }
228
325
  await delay(durationMs);
229
326
  return {
230
327
  file: rec.file,
231
328
  conn: rec.conn,
232
329
  resolved: { id: rec.target.id, url: rec.target.url, title: rec.target.title },
330
+ mode: isWorker ? "listen" : "reload",
233
331
  droppedWrites: rec.droppedWrites,
234
332
  flush: () => rec.flush(),
235
333
  async stop(): Promise<void> {
@@ -257,6 +355,8 @@ export async function captureWindow(
257
355
  * Runtime.exceptionThrown (event)
258
356
  * Log.enable
259
357
  * Log.entryAdded (event)
358
+ * Page.enable / Page.reload (PAGE capture windows only: absent on a worker)
359
+ * ServiceWorker.enable / .startWorker (via cdp/workers.ts, worker wake only)
260
360
  *
261
361
  * Parity gaps vs chrome-devtools-mcp:
262
362
  * - Buffering is to a per-target JSONL file on disk, not an in-memory ring; the
package/src/types.ts CHANGED
@@ -41,6 +41,28 @@ export interface CdpEvent<P = Record<string, unknown>> {
41
41
  * - "index:N" -> Nth page-type target (0-based)
42
42
  * - "url:<substring>" -> first page whose url contains substring
43
43
  * - "title:<substring>" -> first page whose title contains substring
44
+ * - "label:<name>" -> the page-type target whose label EXACTLY
45
+ * matches `name`, checked against both the origin ledger (agent-created
46
+ * tabs, see src/origins.ts) and live lease records (covers a taken-over
47
+ * tab claimed with a label, which has no origin record — see
48
+ * src/leases-tools.ts's claimPage). The resolved id must be present in
49
+ * the listing this call is resolving against; a label whose tab is gone
50
+ * falls through to the normal no-match error. A miss enumerates the
51
+ * labels that DO exist; two live targets sharing a label is an ambiguity
52
+ * error naming both ids, never a silent first-match. See pickPage in
53
+ * src/shared-tools.ts.
54
+ * - "worker:<substring>" -> a service_worker/shared_worker target whose
55
+ * URL contains the substring (an MV3 extension's background worker is
56
+ * `chrome-extension://<id>/<script>`, so both the extension id and the
57
+ * script name match). NOT UNIVERSAL, unlike every arm above it: resolved
58
+ * only by Chrome's pickTarget (client.ts's resolveTarget), accepted by the
59
+ * four tools in WORKER_CAPABLE_TOOLS (evaluate_script, which evaluates in a
60
+ * worker, plus list_network_requests / get_network_request /
61
+ * list_console_messages, which record one), refused with a capability error
62
+ * on Firefox and by the page-only resolvers. It reads the UNFILTERED target
63
+ * listing, because a worker is not a page target — and a worker that is
64
+ * idle-evicted is in no listing at all, which is what `wake` exists for.
65
+ * See src/workers.ts and src/cdp/workers.ts.
44
66
  */
45
67
  export type TargetSelector = string | undefined;
46
68