@crouton-kit/capture 1.4.27 → 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;
@@ -10969,7 +10980,7 @@ function validateHarAppendBatch(value, source) {
10969
10980
  return { entries, incompleteLifecycles };
10970
10981
  }
10971
10982
  function captureVersion() {
10972
- const version = "1.4.27";
10983
+ const version = "1.4.28";
10973
10984
  return typeof version === "string" && version.length > 0 ? version : "development";
10974
10985
  }
10975
10986
  function token() {
@@ -13071,7 +13082,7 @@ function wallTime(value) {
13071
13082
  }
13072
13083
  }
13073
13084
  function captureVersion2() {
13074
- const version = "1.4.27";
13085
+ const version = "1.4.28";
13075
13086
  return typeof version === "string" && version.length > 0 ? version : "development";
13076
13087
  }
13077
13088
  function deepFreeze(value) {
@@ -230406,7 +230417,7 @@ function buildBrowserLaunchedResult(browser, swept) {
230406
230417
  line(text`mode `, data(browser.headless ? "headless" : "headed"))
230407
230418
  ])
230408
230419
  ];
230409
- 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.`);
230410
230421
  return {
230411
230422
  tag: "browser-launched",
230412
230423
  attrs: { port: browser.port, pid: browser.pid, headless: browser.headless },
@@ -230454,7 +230465,7 @@ input:
230454
230465
  --headed run with a visible window instead of headless
230455
230466
 
230456
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).
230457
- 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.`;
230458
230469
  }
230459
230470
  });
230460
230471
 
@@ -230632,7 +230643,7 @@ input:
230632
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)
230633
230644
 
230634
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.
230635
- 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.`;
230636
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>".`;
230637
230648
  }
230638
230649
  });
@@ -267641,7 +267652,7 @@ I/O contract: flags and positionals on input; one rendered prose block on stdout
267641
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.`;
267642
267653
  }
267643
267654
  function printVersion() {
267644
- const declared = "1.4.27";
267655
+ const declared = "1.4.28";
267645
267656
  if (declared) {
267646
267657
  console.log(declared);
267647
267658
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.4.27",
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;
@@ -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;