@crouton-kit/capture 1.4.26 → 1.4.28

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/bin/capture CHANGED
@@ -5654,8 +5654,12 @@ function forgetRecordFile(file) {
5654
5654
  }
5655
5655
  }
5656
5656
  function stillOurs(record5) {
5657
+ return processOwnership(record5) === "ours";
5658
+ }
5659
+ function processOwnership(record5) {
5657
5660
  const observed = processPidBirthProvider.read(record5.pid);
5658
- return observed.status === "found" && sameBirth(observed.identity, record5.birth);
5661
+ if (observed.status === "unknown") return "unknown";
5662
+ return observed.status === "found" && sameBirth(observed.identity, record5.birth) ? "ours" : "gone";
5659
5663
  }
5660
5664
  function removeProfile(profileDir) {
5661
5665
  fs2.rmSync(assertUnderCaptureRoot(profileDir), { recursive: true, force: true });
@@ -5677,26 +5681,33 @@ async function reap(record5) {
5677
5681
  forgetRecordFile(recordPath(record5.token));
5678
5682
  removeProfile(record5.profileDir);
5679
5683
  }
5680
- async function endpointAnswers(port) {
5684
+ async function probeEndpoint(port) {
5681
5685
  try {
5682
5686
  const response = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, {
5683
5687
  signal: AbortSignal.timeout(ENDPOINT_PROBE_MS)
5684
5688
  });
5685
- return response.ok;
5686
- } catch {
5687
- return false;
5689
+ return response.ok ? "up" : "down";
5690
+ } catch (error4) {
5691
+ const code = error4.cause?.code;
5692
+ return code === "ECONNREFUSED" ? "down" : "unknown";
5688
5693
  }
5689
5694
  }
5695
+ async function endpointAnswers(port) {
5696
+ return await probeEndpoint(port) === "up";
5697
+ }
5690
5698
  async function sweepOwnedBrowsers() {
5691
5699
  const reaped = [];
5692
5700
  for (const record5 of readOwnedBrowsers()) {
5693
- if (!stillOurs(record5)) {
5701
+ const ownership2 = processOwnership(record5);
5702
+ if (ownership2 === "unknown") continue;
5703
+ if (ownership2 === "gone") {
5694
5704
  forgetRecordFile(recordPath(record5.token));
5695
5705
  removeProfile(record5.profileDir);
5696
5706
  reaped.push({ port: record5.port, pid: record5.pid, reason: "exited" });
5697
5707
  continue;
5698
5708
  }
5699
- if (record5.port === null || !await endpointAnswers(record5.port)) {
5709
+ if (record5.port === null && Date.now() - Date.parse(record5.startedAt) <= READY_TIMEOUT_MS + ENDPOINT_PROBE_MS) continue;
5710
+ if (record5.port === null || await probeEndpoint(record5.port) === "down") {
5700
5711
  await reap(record5);
5701
5712
  reaped.push({ port: record5.port, pid: record5.pid, reason: "unreachable" });
5702
5713
  continue;
@@ -5930,7 +5941,7 @@ __export(targets_exports, {
5930
5941
  scoreTabUrlMatch: () => scoreTabUrlMatch
5931
5942
  });
5932
5943
  async function getBrowserClient(port) {
5933
- const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`);
5944
+ const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, { signal: AbortSignal.timeout(BROWSER_PROBE_TIMEOUT_MS) });
5934
5945
  if (!versionResp.ok) {
5935
5946
  throw new Error(
5936
5947
  `Failed to connect to CDP on port ${port}: ${versionResp.status}`
@@ -5952,8 +5963,8 @@ async function getBrowserClient(port) {
5952
5963
  async function listTargets(port) {
5953
5964
  const { client } = await getBrowserClient(port);
5954
5965
  try {
5955
- await client.send("Target.setDiscoverTargets", { discover: true });
5956
- const result = await client.send("Target.getTargets");
5966
+ await client.send("Target.setDiscoverTargets", { discover: true }, BROWSER_PROBE_TIMEOUT_MS);
5967
+ const result = await client.send("Target.getTargets", {}, BROWSER_PROBE_TIMEOUT_MS);
5957
5968
  return result.targetInfos.map((t) => ({
5958
5969
  id: t.targetId,
5959
5970
  title: t.title,
@@ -5991,25 +6002,40 @@ async function findTabById(port, targetId) {
5991
6002
  }
5992
6003
  return null;
5993
6004
  }
6005
+ function unlistedTargetError(query, failures) {
6006
+ const detail = failures.map(({ port, error: error4 }) => `port ${port}: ${error4 instanceof Error ? error4.message : String(error4)}`).join("; ");
6007
+ return captureError(
6008
+ "precondition",
6009
+ "target_unavailable",
6010
+ `No tab found for "${query}", and ${failures.length} endpoint(s) could not list their tabs (${detail}). Run "capture tab list" to see which endpoints answer.`,
6011
+ failures[0]?.error
6012
+ );
6013
+ }
5994
6014
  async function findTabByIdInPorts(targetId, ports, finder = findTabById) {
6015
+ const failures = [];
5995
6016
  for (const port of ports) {
5996
6017
  try {
5997
6018
  const tab = await finder(port, targetId);
5998
6019
  if (tab) return { port, tab };
5999
- } catch {
6020
+ } catch (error4) {
6021
+ failures.push({ port, error: error4 });
6000
6022
  }
6001
6023
  }
6024
+ if (failures.length > 0) throw unlistedTargetError(targetId, failures);
6002
6025
  return null;
6003
6026
  }
6004
6027
  async function findUnambiguousPageTabInPorts(ports, lister = listTargets) {
6005
6028
  const candidates = [];
6029
+ const failures = [];
6006
6030
  for (const port of ports) {
6007
6031
  try {
6008
6032
  for (const tab of await lister(port)) if (tab.type === "page") candidates.push({ port, tab });
6009
- } catch {
6033
+ } catch (error4) {
6034
+ failures.push({ port, error: error4 });
6010
6035
  }
6011
6036
  }
6012
6037
  if (candidates.length > 1) throw new AmbiguousPageTargetError(candidates);
6038
+ if (candidates.length === 0 && failures.length > 0) throw unlistedTargetError("(any page tab)", failures);
6013
6039
  return candidates[0] ?? null;
6014
6040
  }
6015
6041
  function normalizeUrlForMatch(value) {
@@ -6076,6 +6102,7 @@ async function findResponsivePageTabByUrl(port, url) {
6076
6102
  async function findTabByUrlAcrossEndpoints(url, preferredPort) {
6077
6103
  const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
6078
6104
  let best = null;
6105
+ const failures = [];
6079
6106
  for (const port of ports) {
6080
6107
  try {
6081
6108
  const targets = await listTargets(port);
@@ -6086,10 +6113,13 @@ async function findTabByUrlAcrossEndpoints(url, preferredPort) {
6086
6113
  best = { port, tab, score };
6087
6114
  }
6088
6115
  }
6089
- } catch {
6116
+ } catch (error4) {
6117
+ failures.push({ port, error: error4 });
6090
6118
  }
6091
6119
  }
6092
- return best ? { port: best.port, tab: best.tab } : null;
6120
+ if (best) return { port: best.port, tab: best.tab };
6121
+ if (failures.length > 0) throw unlistedTargetError(url, failures);
6122
+ return null;
6093
6123
  }
6094
6124
  function requireTargetId(targetId, url) {
6095
6125
  if (!targetId) {
@@ -6129,13 +6159,15 @@ async function openTab(port, url) {
6129
6159
  client.close();
6130
6160
  }
6131
6161
  }
6132
- var AmbiguousPageTargetError;
6162
+ var BROWSER_PROBE_TIMEOUT_MS, AmbiguousPageTargetError;
6133
6163
  var init_targets = __esm({
6134
6164
  "src/cdp/targets.ts"() {
6135
6165
  init_client();
6136
6166
  init_loopback();
6137
6167
  init_detect();
6138
6168
  init_browser_process();
6169
+ init_errors();
6170
+ BROWSER_PROBE_TIMEOUT_MS = 1e4;
6139
6171
  AmbiguousPageTargetError = class extends Error {
6140
6172
  constructor(candidates) {
6141
6173
  super(`More than one page tab is available:
@@ -8671,7 +8703,7 @@ async function captureScreenshotWithCssViewport(client, viewport2, options) {
8671
8703
  }
8672
8704
  async function viewportScopedCapture(client, viewport2, options, includeCssViewport = false) {
8673
8705
  const MAX_DIM = 1600;
8674
- const SCREENSHOT_REQUEST_TIMEOUT_MS = 3e4;
8706
+ const SCREENSHOT_REQUEST_TIMEOUT_MS = options?.requestTimeoutMs ?? 3e4;
8675
8707
  let ownsDeviceMetricsOverride = false;
8676
8708
  let primaryFailed = false;
8677
8709
  let primaryError;
@@ -9031,7 +9063,10 @@ async function navigateAndWait(port, url, options = {}) {
9031
9063
  }
9032
9064
  const targets = await listTargets(port);
9033
9065
  const refreshed = targets.find((t) => t.id === tab.id);
9034
- return refreshed ?? tab;
9066
+ if (!refreshed) {
9067
+ throw new Error(`the browser accepted the new tab ${tab.id} but no longer lists it \u2014 it dropped the tab. Open it again.`);
9068
+ }
9069
+ return refreshed;
9035
9070
  }
9036
9071
  var init_record = __esm({
9037
9072
  "src/cdp/record.ts"() {
@@ -10945,7 +10980,7 @@ function validateHarAppendBatch(value, source) {
10945
10980
  return { entries, incompleteLifecycles };
10946
10981
  }
10947
10982
  function captureVersion() {
10948
- const version = "1.4.26";
10983
+ const version = "1.4.28";
10949
10984
  return typeof version === "string" && version.length > 0 ? version : "development";
10950
10985
  }
10951
10986
  function token() {
@@ -13047,7 +13082,7 @@ function wallTime(value) {
13047
13082
  }
13048
13083
  }
13049
13084
  function captureVersion2() {
13050
- const version = "1.4.26";
13085
+ const version = "1.4.28";
13051
13086
  return typeof version === "string" && version.length > 0 ? version : "development";
13052
13087
  }
13053
13088
  function deepFreeze(value) {
@@ -16777,6 +16812,24 @@ async function applyActiveSessionNetworkConditions(client, session, targetId) {
16777
16812
  uploadThroughput: offline ? 0 : -1
16778
16813
  });
16779
16814
  }
16815
+ function setupDeadlineMs(parsed) {
16816
+ return parsed.timeoutMs ?? DEFAULT_SETUP_DEADLINE_MS;
16817
+ }
16818
+ async function withinDeadline(ms, phase, work) {
16819
+ let timer;
16820
+ const expired = new Promise((_, reject) => {
16821
+ timer = setTimeout(() => reject(captureError(
16822
+ "world",
16823
+ "command_deadline_exceeded",
16824
+ `${phase} did not finish within ${ms}ms \u2014 the tab is not answering CDP (its main thread may be blocked, or the tab throttled). Raise --timeout to wait longer.`
16825
+ )), ms);
16826
+ });
16827
+ try {
16828
+ return await Promise.race([work, expired]);
16829
+ } finally {
16830
+ clearTimeout(timer);
16831
+ }
16832
+ }
16780
16833
  function deriveActionLabel(parsed) {
16781
16834
  if (parsed.command === "type") {
16782
16835
  return `type:${parsed.into ?? "focused element"}`;
@@ -16864,7 +16917,7 @@ async function connectForCommand(parsed) {
16864
16917
  const client = seams.createClient(tab.webSocketDebuggerUrl);
16865
16918
  try {
16866
16919
  await client.waitReady();
16867
- await applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id);
16920
+ await withinDeadline(setupDeadlineMs(parsed), "Restoring the session network state", applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id));
16868
16921
  return { client, tab };
16869
16922
  } catch (err) {
16870
16923
  client.close();
@@ -16916,8 +16969,8 @@ function finishConsole(collectors) {
16916
16969
  }
16917
16970
  async function withConnection(parsed, fn, opts = {}) {
16918
16971
  const { client, tab } = await connectForCommand(parsed);
16919
- const collectors = await startCollectors(parsed, client);
16920
16972
  try {
16973
+ const collectors = await withinDeadline(setupDeadlineMs(parsed), "Starting page collectors", startCollectors(parsed, client));
16921
16974
  const result = await fn(client, tab);
16922
16975
  if (collectors.harRecorder && parsed.har) {
16923
16976
  const settle = opts.settle !== void 0 ? opts.settle : 3e3;
@@ -16934,8 +16987,8 @@ async function withConnection(parsed, fn, opts = {}) {
16934
16987
  }
16935
16988
  async function withPageAction(parsed, opts, fn) {
16936
16989
  const { client, tab } = await connectForCommand(parsed);
16937
- const collectors = await startCollectors(parsed, client);
16938
16990
  try {
16991
+ const collectors = await withinDeadline(setupDeadlineMs(parsed), "Starting page collectors", startCollectors(parsed, client));
16939
16992
  const result = await fn(client, tab);
16940
16993
  const requestedMs = opts.settleMs;
16941
16994
  const t0 = seams.now();
@@ -16963,7 +17016,7 @@ async function withPageAction(parsed, opts, fn) {
16963
17016
  client.close();
16964
17017
  }
16965
17018
  }
16966
- var fs14, import_node_perf_hooks2, seams;
17019
+ var fs14, import_node_perf_hooks2, seams, DEFAULT_SETUP_DEADLINE_MS;
16967
17020
  var init_connection = __esm({
16968
17021
  "src/cdp/connection.ts"() {
16969
17022
  fs14 = __toESM(require("fs"));
@@ -16990,6 +17043,7 @@ var init_connection = __esm({
16990
17043
  now: () => import_node_perf_hooks2.performance.now(),
16991
17044
  sleep: (ms) => new Promise((r) => setTimeout(r, ms))
16992
17045
  };
17046
+ DEFAULT_SETUP_DEADLINE_MS = 3e4;
16993
17047
  }
16994
17048
  });
16995
17049
 
@@ -229200,7 +229254,7 @@ async function focusScopedEvaluate(client, expression, evaluationTimeoutMs) {
229200
229254
  let primaryError;
229201
229255
  try {
229202
229256
  ownsFocusOverride = true;
229203
- await client.send("Emulation.setFocusEmulationEnabled", { enabled: true });
229257
+ await client.send("Emulation.setFocusEmulationEnabled", { enabled: true }, evaluationTimeoutMs);
229204
229258
  try {
229205
229259
  return await client.send("Runtime.evaluate", {
229206
229260
  expression,
@@ -229224,7 +229278,7 @@ async function focusScopedEvaluate(client, expression, evaluationTimeoutMs) {
229224
229278
  } finally {
229225
229279
  if (ownsFocusOverride) {
229226
229280
  try {
229227
- await client.send("Emulation.setFocusEmulationEnabled", { enabled: false });
229281
+ await client.send("Emulation.setFocusEmulationEnabled", { enabled: false }, evaluationTimeoutMs);
229228
229282
  } catch (error4) {
229229
229283
  const cleanupError = captureError(
229230
229284
  "cleanup",
@@ -229706,6 +229760,7 @@ async function cmdPageShot(parsed, _args) {
229706
229760
  fullPage: parsed.fullPage,
229707
229761
  crop: requestedCrop,
229708
229762
  zoom,
229763
+ requestTimeoutMs: parsed.timeoutMs,
229709
229764
  ...colorScheme ? { beforeCapture: () => finishColorTransitions(client) } : {}
229710
229765
  });
229711
229766
  const outPath = await resolveOutPath(parsed);
@@ -229746,6 +229801,7 @@ input:
229746
229801
  --out <path> caller-owned destination PNG; default: the active session's shots/ sequence, or a fresh one-shot bundle when no session is active
229747
229802
  --artifact-dir <path> root for that sessionless one-shot bundle (ignored with --out or an active session)
229748
229803
  --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
229804
+ --timeout <ms> deadline for connecting to the tab and for each screenshot request (default: 30000 for connecting and each screenshot request); a tab that does not answer in time fails with command_deadline_exceeded or a CDP request timeout instead of waiting
229749
229805
  --json mirror the result as JSON
229750
229806
  output:
229751
229807
  <screenshot path=\u2026 width=\u2026 height=\u2026 css-width=\u2026 css-height=\u2026 css-x=\u2026 css-y=\u2026 css-to-image-x=\u2026 css-to-image-y=\u2026 effective-downscale-x=\u2026 effective-downscale-y=\u2026 scroll-x=\u2026 scroll-y=\u2026 emulation=none|viewport|full-page color-scheme=dark|light> \u2014 saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, visual-viewport scroll offset at capture, any effective downscale below 1 image px/CSS px, byte size, crop provenance, and any requested transient emulation
@@ -230361,7 +230417,7 @@ function buildBrowserLaunchedResult(browser, swept) {
230361
230417
  line(text`mode `, data(browser.headless ? "headless" : "headed"))
230362
230418
  ])
230363
230419
  ];
230364
- if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited, stopped answering CDP, or gone idle.`);
230420
+ if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited, refused CDP connections, or gone idle.`);
230365
230421
  return {
230366
230422
  tag: "browser-launched",
230367
230423
  attrs: { port: browser.port, pid: browser.pid, headless: browser.headless },
@@ -230409,7 +230465,7 @@ input:
230409
230465
  --headed run with a visible window instead of headless
230410
230466
 
230411
230467
  output: <browser-launched port=\u2026 pid=\u2026> \u2014 the port to pass as --port (or CDP_PORT), the pid capture owns, and the executable that answered with its provenance (CAPTURE_BROWSER, puppeteer-cache, or system).
230412
- effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 6e4} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited, stopped answering CDP, or gone idle.`;
230468
+ effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 6e4} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited, refused CDP connections, or gone idle.`;
230413
230469
  }
230414
230470
  });
230415
230471
 
@@ -230587,7 +230643,7 @@ input:
230587
230643
  --port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint; an active session's endpoint does not narrow it (CDP_PORT still pins one endpoint)
230588
230644
 
230589
230645
  output: <tabs endpoints=\u2026 tabs=\u2026> \u2014 one section per reachable endpoint (port, app, page-tab count, preferred marker, and a capture-owned pid marker on any browser \`tab launch\` started) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
230590
- effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, stopped answering CDP, or gone idle \u2014 browsers capture did not start are never signalled.`;
230646
+ effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, refused CDP connections, or gone idle \u2014 browsers capture did not start are never signalled.`;
230591
230647
  ARC_ENDPOINT_NOTE = text` note: Arc exposes only tabs opened over CDP as page targets; the tabs in its windows are not listed. Open one with "capture tab open <url> --port <port>", then "capture session start --port <port> --target <id>".`;
230592
230648
  }
230593
230649
  });
@@ -267596,7 +267652,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
267596
267652
  A CDP-enabled browser must be running; \`capture tab list\` is the probe, and \`capture tab launch\` starts one capture owns and reaps when nothing is (never hand-roll a detached browser \u2014 nothing reaps that). Capture never signals a browser it did not start, so attaching to your own with --port is unchanged. CDP_PORT / CDP_TARGET pin the browser + tab for target-selecting commands \u2014 precedence: explicit flag > active session > env. \`session start\` adopts only an explicit --target. When CRTR_NODE_ID is set (a crtr agent node), a browser command with no --port, no --target and no active session of that node is refused instead of falling back to CDP_PORT, CDP_TARGET or localhost discovery, because the browser found could belong to another node: run \`capture session start --url <url>\`, or pass --port or --target. \`capture tab list\` and commands that only read a recorded artifact are not refused.`;
267597
267653
  }
267598
267654
  function printVersion() {
267599
- const declared = "1.4.26";
267655
+ const declared = "1.4.28";
267600
267656
  if (declared) {
267601
267657
  console.log(declared);
267602
267658
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.4.26",
3
+ "version": "1.4.28",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
@@ -191,8 +191,16 @@ function forgetRecordFile(file: string): void {
191
191
  * ONLY gate that authorizes a signal; an `unknown` read (a provider that could
192
192
  * not answer) is deliberately NOT a kill authorization. */
193
193
  function stillOurs(record: OwnedBrowser): boolean {
194
+ return processOwnership(record) === 'ours';
195
+ }
196
+
197
+ /** `gone` — the process exited, or its pid was recycled by a stranger; `unknown` —
198
+ * the provider could not answer (e.g. its `sysctl` read timed out on a loaded
199
+ * host), which says nothing about whether the browser is alive. */
200
+ function processOwnership(record: OwnedBrowser): 'ours' | 'gone' | 'unknown' {
194
201
  const observed = processPidBirthProvider.read(record.pid);
195
- return observed.status === 'found' && sameBirth(observed.identity, record.birth);
202
+ if (observed.status === 'unknown') return 'unknown';
203
+ return observed.status === 'found' && sameBirth(observed.identity, record.birth) ? 'ours' : 'gone';
196
204
  }
197
205
 
198
206
  /**
@@ -229,17 +237,26 @@ async function reap(record: OwnedBrowser): Promise<void> {
229
237
  removeProfile(record.profileDir);
230
238
  }
231
239
 
232
- async function endpointAnswers(port: number): Promise<boolean> {
240
+ /** `up` — answered; `down` — the connection was refused or the answer was an
241
+ * error, so nothing is serving CDP there; `unknown` — no answer within the probe
242
+ * window or the request failed some other way. A browser starved of CPU on a
243
+ * loaded host answers slowly, so `unknown` is never evidence it is unusable. */
244
+ async function probeEndpoint(port: number): Promise<'up' | 'down' | 'unknown'> {
233
245
  try {
234
246
  const response = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, {
235
247
  signal: AbortSignal.timeout(ENDPOINT_PROBE_MS),
236
248
  });
237
- return response.ok;
238
- } catch {
239
- return false;
249
+ return response.ok ? 'up' : 'down';
250
+ } catch (error) {
251
+ const code = (error as { cause?: { code?: unknown } }).cause?.code;
252
+ return code === 'ECONNREFUSED' ? 'down' : 'unknown';
240
253
  }
241
254
  }
242
255
 
256
+ async function endpointAnswers(port: number): Promise<boolean> {
257
+ return (await probeEndpoint(port)) === 'up';
258
+ }
259
+
243
260
  /**
244
261
  * Reconciles the registry with reality and reaps what no longer earns its
245
262
  * memory. Run at the top of every `tab` leaf that touches owned browsers — this
@@ -249,7 +266,12 @@ async function endpointAnswers(port: number): Promise<boolean> {
249
266
  export async function sweepOwnedBrowsers(): Promise<ReapedBrowser[]> {
250
267
  const reaped: ReapedBrowser[] = [];
251
268
  for (const record of readOwnedBrowsers()) {
252
- if (!stillOurs(record)) {
269
+ const ownership = processOwnership(record);
270
+ // An unreadable process identity proves nothing: removing the profile of a
271
+ // browser that is in fact running would pull its user-data-dir out from
272
+ // under it. Leave the record for a sweep that can read it.
273
+ if (ownership === 'unknown') continue;
274
+ if (ownership === 'gone') {
253
275
  // Crashed, or SIGKILLed by someone else. Nothing to signal — just drop
254
276
  // the claim and the profile it left behind.
255
277
  forgetRecordFile(recordPath(record.token));
@@ -257,7 +279,11 @@ export async function sweepOwnedBrowsers(): Promise<ReapedBrowser[]> {
257
279
  reaped.push({ port: record.port, pid: record.pid, reason: 'exited' });
258
280
  continue;
259
281
  }
260
- if (record.port === null || !(await endpointAnswers(record.port))) {
282
+ // A record with no port is a launch in flight in another capture process
283
+ // (the registry is shared by every agent on the host), not a dead browser —
284
+ // until the launch's own readiness deadline has passed.
285
+ if (record.port === null && Date.now() - Date.parse(record.startedAt) <= READY_TIMEOUT_MS + ENDPOINT_PROBE_MS) continue;
286
+ if (record.port === null || (await probeEndpoint(record.port)) === 'down') {
261
287
  await reap(record);
262
288
  reaped.push({ port: record.port, pid: record.pid, reason: 'unreachable' });
263
289
  continue;
@@ -177,7 +177,7 @@ async function focusScopedEvaluate(client: ExecClient, expression: string, evalu
177
177
  // Emulate focus so network requests the code triggers aren't deferred
178
178
  // by the browser, without foregrounding the tab.
179
179
  ownsFocusOverride = true;
180
- await client.send('Emulation.setFocusEmulationEnabled', { enabled: true });
180
+ await client.send('Emulation.setFocusEmulationEnabled', { enabled: true }, evaluationTimeoutMs);
181
181
  try {
182
182
  return (await client.send('Runtime.evaluate', {
183
183
  expression,
@@ -201,7 +201,7 @@ async function focusScopedEvaluate(client: ExecClient, expression: string, evalu
201
201
  } finally {
202
202
  if (ownsFocusOverride) {
203
203
  try {
204
- await client.send('Emulation.setFocusEmulationEnabled', { enabled: false });
204
+ await client.send('Emulation.setFocusEmulationEnabled', { enabled: false }, evaluationTimeoutMs);
205
205
  } catch (error) {
206
206
  const cleanupError = captureError(
207
207
  'cleanup',
@@ -47,6 +47,7 @@ input:
47
47
  --out <path> caller-owned destination PNG; default: the active session's shots/ sequence, or a fresh one-shot bundle when no session is active
48
48
  --artifact-dir <path> root for that sessionless one-shot bundle (ignored with --out or an active session)
49
49
  --target <tabId> | --url <pattern> | --port <n> tab targeting; defaults to the active session tab
50
+ --timeout <ms> deadline for connecting to the tab and for each screenshot request (default: 30000 for connecting and each screenshot request); a tab that does not answer in time fails with command_deadline_exceeded or a CDP request timeout instead of waiting
50
51
  --json mirror the result as JSON
51
52
  output:
52
53
  <screenshot path=… width=… height=… css-width=… css-height=… css-x=… css-y=… css-to-image-x=… css-to-image-y=… effective-downscale-x=… effective-downscale-y=… scroll-x=… scroll-y=… emulation=none|viewport|full-page color-scheme=dark|light> — saved path, PNG pixel dimensions, the CSS-pixel region the image covers (its page-coordinate origin and size) with the CSS-to-image scale derived from it, visual-viewport scroll offset at capture, any effective downscale below 1 image px/CSS px, byte size, crop provenance, and any requested transient emulation
@@ -410,7 +411,7 @@ export async function cmdPageShot(parsed: ParsedArgs, _args: string[]): Promise<
410
411
  cropProvenance = { source: 'coordinates', requested: requestedCrop, zoom };
411
412
  }
412
413
  const screenshot = await captureScreenshotWithCssViewport(client, viewport, {
413
- fullPage: parsed.fullPage, crop: requestedCrop, zoom,
414
+ fullPage: parsed.fullPage, crop: requestedCrop, zoom, requestTimeoutMs: parsed.timeoutMs,
414
415
  ...(colorScheme ? { beforeCapture: () => finishColorTransitions(client) } : {}),
415
416
  });
416
417
  const outPath = await resolveOutPath(parsed);
@@ -21,7 +21,7 @@ input:
21
21
  --headed run with a visible window instead of headless
22
22
 
23
23
  output: <browser-launched port=… pid=…> — the port to pass as --port (or CDP_PORT), the pid capture owns, and the executable that answered with its provenance (CAPTURE_BROWSER, puppeteer-cache, or system).
24
- effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 60000} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited, stopped answering CDP, or gone idle.`;
24
+ effects: spawns a detached browser with a private profile under CAPTURE_ROOT, and registers it as capture-owned. Capture stops it on \`capture tab quit\`, or on the first later capture invocation once it has been unused for ${IDLE_REAP_MS / 60000} minutes; a browser capture did not start is never signalled. Also sweeps previously-launched browsers that have exited, refused CDP connections, or gone idle.`;
25
25
 
26
26
  /** Pure `<browser-launched>` result builder — exported for tests. */
27
27
  export function buildBrowserLaunchedResult(
@@ -34,7 +34,7 @@ export function buildBrowserLaunchedResult(
34
34
  line(text`mode `, data(browser.headless ? 'headless' : 'headed')),
35
35
  ]),
36
36
  ];
37
- if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited, stopped answering CDP, or gone idle.`);
37
+ if (swept > 0) sections.push(fact`swept ${swept} previously-launched browser(s) that had exited, refused CDP connections, or gone idle.`);
38
38
  return {
39
39
  tag: 'browser-launched',
40
40
  attrs: { port: browser.port, pid: browser.pid, headless: browser.headless },
@@ -33,7 +33,7 @@ input:
33
33
  --port <port> probe one endpoint only, instead of discovering every localhost CDP endpoint; an active session's endpoint does not narrow it (CDP_PORT still pins one endpoint)
34
34
 
35
35
  output: <tabs endpoints=… tabs=…> — one section per reachable endpoint (port, app, page-tab count, preferred marker, and a capture-owned pid marker on any browser \`tab launch\` started) listing each page tab's target id (first 8 chars, enough for --target), title, and url; an endpoint that failed the tab fetch is an endpoints-unreachable fact, never a silent omission.
36
- effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, stopped answering CDP, or gone idle — browsers capture did not start are never signalled.`;
36
+ effects: probes local CDP endpoints over HTTP/WebSocket; no page-observable writes. Also sweeps capture-launched browsers, stopping any that have exited, refused CDP connections, or gone idle — browsers capture did not start are never signalled.`;
37
37
 
38
38
  export interface TabRow {
39
39
  id: string;
@@ -97,6 +97,42 @@ export async function applyActiveSessionNetworkConditions(
97
97
  });
98
98
  }
99
99
 
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // Command deadline — a page that stops answering CDP (blocked main thread,
103
+ // throttled background tab) never replies to anything, so every wait on it
104
+ // must be bounded by capture itself.
105
+ // ---------------------------------------------------------------------------
106
+
107
+ /** Bound on connecting to a tab and starting its collectors when the caller declares no `--timeout`. */
108
+ export const DEFAULT_SETUP_DEADLINE_MS = 30_000;
109
+
110
+ /** The deadline for connecting a page command to its tab: the declared `--timeout`, else the default. */
111
+ function setupDeadlineMs(parsed: ParsedArgs): number {
112
+ return parsed.timeoutMs ?? DEFAULT_SETUP_DEADLINE_MS;
113
+ }
114
+
115
+ /**
116
+ * Races `work` against `ms`. On expiry it rejects with `command_deadline_exceeded`
117
+ * naming the phase; the caller's `finally` closes the client, which rejects the
118
+ * abandoned work's pending requests.
119
+ */
120
+ async function withinDeadline<T>(ms: number, phase: string, work: Promise<T>): Promise<T> {
121
+ let timer: NodeJS.Timeout | undefined;
122
+ const expired = new Promise<never>((_, reject) => {
123
+ timer = setTimeout(() => reject(captureError(
124
+ 'world',
125
+ 'command_deadline_exceeded',
126
+ `${phase} did not finish within ${ms}ms — the tab is not answering CDP (its main thread may be blocked, or the tab throttled). Raise --timeout to wait longer.`,
127
+ )), ms);
128
+ });
129
+ try {
130
+ return await Promise.race([work, expired]);
131
+ } finally {
132
+ clearTimeout(timer);
133
+ }
134
+ }
135
+
100
136
  /**
101
137
  * Builds a short, human-readable label for this command invocation, used to
102
138
  * tag every marked (`Input.dispatch*`) CDP call the recorder-held adapter
@@ -251,7 +287,7 @@ export async function connectForCommand(
251
287
  await client.waitReady();
252
288
  // Reissue the session's persisted offline/online state on this fresh
253
289
  // connection — network emulation belongs to a connection, not the tab.
254
- await applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id);
290
+ await withinDeadline(setupDeadlineMs(parsed), 'Restoring the session network state', applyActiveSessionNetworkConditions(client, seams.getActiveSession(), tab.id));
255
291
  return { client, tab };
256
292
  } catch (err) {
257
293
  client.close();
@@ -339,9 +375,9 @@ export async function withConnection<T>(
339
375
  opts: { settle?: number } = {},
340
376
  ): Promise<T> {
341
377
  const { client, tab } = await connectForCommand(parsed);
342
- const collectors = await startCollectors(parsed, client);
343
378
 
344
379
  try {
380
+ const collectors = await withinDeadline(setupDeadlineMs(parsed), 'Starting page collectors', startCollectors(parsed, client));
345
381
  const result = await fn(client, tab);
346
382
 
347
383
  if (collectors.harRecorder && parsed.har) {
@@ -400,9 +436,9 @@ export async function withPageAction<T>(
400
436
  fn: (client: CDPClient, tab: CDPTarget) => Promise<T>,
401
437
  ): Promise<{ result: T; settle: SettleFacts }> {
402
438
  const { client, tab } = await connectForCommand(parsed);
403
- const collectors = await startCollectors(parsed, client);
404
439
 
405
440
  try {
441
+ const collectors = await withinDeadline(setupDeadlineMs(parsed), 'Starting page collectors', startCollectors(parsed, client));
406
442
  // Action callback first — a rejection here claims no settle.
407
443
  const result = await fn(client, tab);
408
444
 
package/src/cdp/record.ts CHANGED
@@ -111,5 +111,11 @@ export async function navigateAndWait(
111
111
  // Refresh target info — find by ID to avoid returning a different tab with same URL
112
112
  const targets = await listTargets(port);
113
113
  const refreshed = targets.find((t) => t.id === tab.id);
114
- return refreshed ?? tab;
114
+ // Some browsers (Arc) acknowledge Target.createTarget and then drop the page.
115
+ // Reporting an id the browser no longer lists hands the caller a target every
116
+ // later command rejects, so the open itself fails instead.
117
+ if (!refreshed) {
118
+ throw new Error(`the browser accepted the new tab ${tab.id} but no longer lists it — it dropped the tab. Open it again.`);
119
+ }
120
+ return refreshed;
115
121
  }
@@ -31,7 +31,7 @@ export interface ScreenshotCrop {
31
31
  readonly height: number;
32
32
  }
33
33
 
34
- type ScreenshotOptions = { fullPage?: boolean; crop?: ScreenshotCrop; zoom?: number; beforeCapture?: () => Promise<void> };
34
+ type ScreenshotOptions = { fullPage?: boolean; crop?: ScreenshotCrop; zoom?: number; beforeCapture?: () => Promise<void>; requestTimeoutMs?: number };
35
35
 
36
36
  /** A region is reportable only if every number is finite and its size is positive; anything else would produce a fabricated scale. */
37
37
  function validRegion(region: CapturedRegion): CapturedRegion | undefined {
@@ -151,7 +151,7 @@ async function viewportScopedCapture(
151
151
  includeCssViewport = false,
152
152
  ): Promise<{ png: Buffer; cssViewport?: CapturedRegion; scrollOffset?: ScrollOffset }> {
153
153
  const MAX_DIM = 1600; // headroom below Anthropic's 2000px many-image limit
154
- const SCREENSHOT_REQUEST_TIMEOUT_MS = 30_000;
154
+ const SCREENSHOT_REQUEST_TIMEOUT_MS = options?.requestTimeoutMs ?? 30_000;
155
155
  let ownsDeviceMetricsOverride = false;
156
156
  let primaryFailed = false;
157
157
  let primaryError: unknown;
@@ -3,6 +3,10 @@ import { CDP_LOOPBACK_HOST } from './loopback.js';
3
3
  import { detectCdpPortsAsync } from './detect.js';
4
4
  import { touchOwnedBrowser } from './browser-process.js';
5
5
  import { type CDPTarget } from './types.js';
6
+ import { captureError } from '../errors.js';
7
+
8
+ /** A browser that accepts the connection but never answers `/json/version` or `Target.getTargets` must fail the command, not stall it. */
9
+ const BROWSER_PROBE_TIMEOUT_MS = 10_000;
6
10
 
7
11
  /**
8
12
  * Connects to the BROWSER-level CDP websocket (not a page/tab target).
@@ -14,7 +18,7 @@ import { type CDPTarget } from './types.js';
14
18
  export async function getBrowserClient(
15
19
  port: number,
16
20
  ): Promise<{ client: CDPClient; browserWsUrl: string }> {
17
- const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`);
21
+ const versionResp = await fetch(`http://${CDP_LOOPBACK_HOST}:${port}/json/version`, { signal: AbortSignal.timeout(BROWSER_PROBE_TIMEOUT_MS) });
18
22
  if (!versionResp.ok) {
19
23
  throw new Error(
20
24
  `Failed to connect to CDP on port ${port}: ${versionResp.status}`,
@@ -46,8 +50,8 @@ export async function listTargets(port: number): Promise<CDPTarget[]> {
46
50
 
47
51
  try {
48
52
  // Enable target discovery to get all targets including pages
49
- await client.send('Target.setDiscoverTargets', { discover: true });
50
- const result = (await client.send('Target.getTargets')) as {
53
+ await client.send('Target.setDiscoverTargets', { discover: true }, BROWSER_PROBE_TIMEOUT_MS);
54
+ const result = (await client.send('Target.getTargets', {}, BROWSER_PROBE_TIMEOUT_MS)) as {
51
55
  targetInfos: Array<{
52
56
  targetId: string;
53
57
  type: string;
@@ -109,19 +113,36 @@ export async function findTabById(
109
113
  return null;
110
114
  }
111
115
 
116
+ /**
117
+ * A selector that matched nothing while some endpoint failed to list its tabs
118
+ * is not "no such tab": the failure is the answer. `tab list` reports the same
119
+ * failure as an unreachable endpoint, so the two commands name the same fact.
120
+ */
121
+ function unlistedTargetError(query: string, failures: ReadonlyArray<{ port: number; error: unknown }>): Error {
122
+ const detail = failures.map(({ port, error }) => `port ${port}: ${error instanceof Error ? error.message : String(error)}`).join('; ');
123
+ return captureError(
124
+ 'precondition',
125
+ 'target_unavailable',
126
+ `No tab found for "${query}", and ${failures.length} endpoint(s) could not list their tabs (${detail}). Run "capture tab list" to see which endpoints answer.`,
127
+ failures[0]?.error,
128
+ );
129
+ }
130
+
112
131
  export async function findTabByIdInPorts(
113
132
  targetId: string,
114
133
  ports: number[],
115
134
  finder: (port: number, targetId: string) => Promise<CDPTarget | null> = findTabById,
116
135
  ): Promise<{ port: number; tab: CDPTarget } | null> {
136
+ const failures: Array<{ port: number; error: unknown }> = [];
117
137
  for (const port of ports) {
118
138
  try {
119
139
  const tab = await finder(port, targetId);
120
140
  if (tab) return { port, tab };
121
- } catch {
122
- // Skip endpoints that fail or are ambiguous on a different port.
141
+ } catch (error) {
142
+ failures.push({ port, error });
123
143
  }
124
144
  }
145
+ if (failures.length > 0) throw unlistedTargetError(targetId, failures);
125
146
  return null;
126
147
  }
127
148
 
@@ -138,14 +159,17 @@ export async function findUnambiguousPageTabInPorts(
138
159
  lister: (port: number) => Promise<CDPTarget[]> = listTargets,
139
160
  ): Promise<{ port: number; tab: CDPTarget } | null> {
140
161
  const candidates: Array<{ port: number; tab: CDPTarget }> = [];
162
+ const failures: Array<{ port: number; error: unknown }> = [];
141
163
  for (const port of ports) {
142
164
  try {
143
165
  for (const tab of await lister(port)) if (tab.type === 'page') candidates.push({ port, tab });
144
- } catch {
145
- // An unavailable endpoint cannot supply a target.
166
+ } catch (error) {
167
+ failures.push({ port, error });
146
168
  }
147
169
  }
148
170
  if (candidates.length > 1) throw new AmbiguousPageTargetError(candidates);
171
+ // One page tab on a reachable endpoint is only "the" tab when no other endpoint hid a second one.
172
+ if (candidates.length === 0 && failures.length > 0) throw unlistedTargetError('(any page tab)', failures);
149
173
  return candidates[0] ?? null;
150
174
  }
151
175
 
@@ -232,6 +256,7 @@ export async function findTabByUrlAcrossEndpoints(
232
256
  ): Promise<{ port: number; tab: CDPTarget } | null> {
233
257
  const ports = resolveSearchPorts(await detectCdpPortsAsync(), preferredPort);
234
258
  let best: { port: number; tab: CDPTarget; score: number } | null = null;
259
+ const failures: Array<{ port: number; error: unknown }> = [];
235
260
 
236
261
  for (const port of ports) {
237
262
  try {
@@ -243,12 +268,14 @@ export async function findTabByUrlAcrossEndpoints(
243
268
  best = { port, tab, score };
244
269
  }
245
270
  }
246
- } catch {
247
- // Skip endpoints that fail to respond.
271
+ } catch (error) {
272
+ failures.push({ port, error });
248
273
  }
249
274
  }
250
275
 
251
- return best ? { port: best.port, tab: best.tab } : null;
276
+ if (best) return { port: best.port, tab: best.tab };
277
+ if (failures.length > 0) throw unlistedTargetError(url, failures);
278
+ return null;
252
279
  }
253
280
 
254
281
  export function requireTargetId(