@crouton-kit/capture 1.3.17 → 1.3.18

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
@@ -7093,6 +7093,9 @@ var init_artifacts = __esm({
7093
7093
  function activeScopeKey() {
7094
7094
  return process.env.CRTR_NODE_ID ?? "default";
7095
7095
  }
7096
+ function activeSessionScopeKey() {
7097
+ return activeScopeKey();
7098
+ }
7096
7099
  function getActivePath() {
7097
7100
  return path2.join(CAPTURE_ROOT, `.active-${activeScopeKey()}`);
7098
7101
  }
@@ -8379,6 +8382,32 @@ var init_a11y = __esm({
8379
8382
  });
8380
8383
 
8381
8384
  // src/cdp/record.ts
8385
+ function isMissingDefaultExecutionContext(err) {
8386
+ return err instanceof Error && err.message === "Cannot find default execution context";
8387
+ }
8388
+ async function readReadyStateWithRetry(client, timeout) {
8389
+ const deadline = Date.now() + timeout;
8390
+ let lastRetryableError;
8391
+ while (true) {
8392
+ const remaining = deadline - Date.now();
8393
+ if (remaining <= 0) {
8394
+ if (lastRetryableError) throw lastRetryableError;
8395
+ throw new Error("Runtime.evaluate deadline exceeded");
8396
+ }
8397
+ try {
8398
+ const result = await client.send("Runtime.evaluate", {
8399
+ expression: "document.readyState",
8400
+ returnByValue: true
8401
+ }, remaining);
8402
+ return result?.result?.value;
8403
+ } catch (err) {
8404
+ const remainingAfterAttempt = deadline - Date.now();
8405
+ if (!isMissingDefaultExecutionContext(err) || remainingAfterAttempt <= 0) throw err;
8406
+ lastRetryableError = err;
8407
+ await new Promise((resolve11) => setTimeout(resolve11, Math.min(50, remainingAfterAttempt)));
8408
+ }
8409
+ }
8410
+ }
8382
8411
  async function navigateAndWait(port, url, options = {}) {
8383
8412
  const timeout = options.timeout ?? 1e4;
8384
8413
  if (!options.forceNew) {
@@ -8412,11 +8441,7 @@ async function navigateAndWait(port, url, options = {}) {
8412
8441
  }, timeout);
8413
8442
  });
8414
8443
  try {
8415
- const result = await client.send("Runtime.evaluate", {
8416
- expression: "document.readyState",
8417
- returnByValue: true
8418
- });
8419
- const readyState = result?.result?.value;
8444
+ const readyState = await readReadyStateWithRetry(client, timeout);
8420
8445
  if (readyState !== "complete") {
8421
8446
  await loadPromise;
8422
8447
  if (loadTimeoutError) throw loadTimeoutError;
@@ -9842,7 +9867,7 @@ function validateHarAppendBatch(value, source) {
9842
9867
  return { entries, incompleteLifecycles };
9843
9868
  }
9844
9869
  function captureVersion() {
9845
- const version = "1.3.17";
9870
+ const version = "1.3.18";
9846
9871
  return typeof version === "string" && version.length > 0 ? version : "development";
9847
9872
  }
9848
9873
  function token() {
@@ -11601,7 +11626,7 @@ function wallTime(value) {
11601
11626
  }
11602
11627
  }
11603
11628
  function captureVersion2() {
11604
- const version = "1.3.17";
11629
+ const version = "1.3.18";
11605
11630
  return typeof version === "string" && version.length > 0 ? version : "development";
11606
11631
  }
11607
11632
  function deepFreeze(value) {
@@ -13825,25 +13850,30 @@ function collectRecs(dir) {
13825
13850
  function sessionMetaPath2(id) {
13826
13851
  return path12.join(sessionDir(id), ".session.json");
13827
13852
  }
13853
+ function tryReadSession(id) {
13854
+ const metaPath2 = sessionMetaPath2(id);
13855
+ const bad = (reason) => ({ ok: false, id, metaPath: metaPath2, reason });
13856
+ let parsed;
13857
+ try {
13858
+ parsed = JSON.parse(readPrivateFile(metaPath2).toString("utf-8"));
13859
+ } catch (error) {
13860
+ if (error.code === "ENOENT") throw error;
13861
+ return bad("not valid JSON; the record is corrupt");
13862
+ }
13863
+ if (!isActiveStateCandidate(parsed)) return bad("does not match the session record schema; written by an incompatible version or corrupted");
13864
+ return { ok: true, session: parsed };
13865
+ }
13828
13866
  function readSession(id) {
13829
13867
  const metaPath2 = sessionMetaPath2(id);
13830
- let raw;
13868
+ let read;
13831
13869
  try {
13832
- raw = readPrivateFile(metaPath2).toString("utf-8");
13870
+ read = tryReadSession(id);
13833
13871
  } catch (error) {
13834
13872
  if (error.code === "ENOENT") throw captureError("precondition", "unknown_session", `No capture session found: ${id}`);
13835
13873
  throw error;
13836
13874
  }
13837
- let parsed;
13838
- try {
13839
- parsed = JSON.parse(raw);
13840
- } catch (cause) {
13841
- throw captureError("artifact", "invalid_session_record", `session metadata at ${metaPath2} is not valid JSON; the record is corrupt and cannot be read as a capture session.`, cause);
13842
- }
13843
- if (!isActiveStateCandidate(parsed)) {
13844
- throw captureError("artifact", "invalid_session_record", `session metadata at ${metaPath2} does not match the capture session record schema; it was written by an incompatible version or corrupted.`);
13845
- }
13846
- return parsed;
13875
+ if (!read.ok) throw captureError("artifact", "invalid_session_record", `session metadata at ${metaPath2} ${read.reason}.`);
13876
+ return read.session;
13847
13877
  }
13848
13878
  function generateId() {
13849
13879
  return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
@@ -13907,9 +13937,9 @@ async function start(parsed) {
13907
13937
  if (active && !isSessionStopped(active)) {
13908
13938
  emitResult({
13909
13939
  tag: "error",
13910
- attrs: { command: "session start", code: "start_failed" },
13911
- summary: fact`A session is already active for this scope (${active.sessionId}); stop it first with \`session stop ${active.sessionId}\`.`,
13912
- followUp: text`Only one live session is allowed per capture scope at a time.`
13940
+ attrs: { command: "session start", code: "session_already_active" },
13941
+ summary: fact`Session ${active.sessionId} is already live in this capture scope (${activeSessionScopeKey()}); one live session per scope.`,
13942
+ followUp: fact`Scopes are per-agent (CRTR_NODE_ID), so this session is yours — a concurrent agent's session never blocks yours. Keep using it (every command auto-targets its tab), or run \`capture session stop ${active.sessionId}\` to start fresh. To drive a different tab without ending it, pass \`--target <target-id>\` from \`capture tab list\`.`
13913
13943
  }, { json: parsed.json });
13914
13944
  process.exitCode = 1;
13915
13945
  return;
@@ -14433,12 +14463,21 @@ function list(parsed) {
14433
14463
  return;
14434
14464
  }
14435
14465
  if (rejectSurplusPositionals(parsed, "list", 0, "capture session list")) return;
14436
- const sessions = fs9.existsSync(CAPTURE_ROOT) ? fs9.readdirSync(CAPTURE_ROOT).filter((d) => fs9.existsSync(sessionMetaPath2(d))).map((d) => {
14437
- const session = readSession(d);
14438
- const hasBundled = fs9.existsSync(path12.join(session.dir, "bundle.json"));
14439
- return { id: session.sessionId, startedAt: session.startedAt ?? "", url: session.url ?? null, status: hasBundled ? "stopped" : "active" };
14466
+ const reads = fs9.existsSync(CAPTURE_ROOT) ? fs9.readdirSync(CAPTURE_ROOT).filter((d) => fs9.existsSync(sessionMetaPath2(d))).flatMap((d) => {
14467
+ try {
14468
+ return [tryReadSession(d)];
14469
+ } catch {
14470
+ return [];
14471
+ }
14440
14472
  }) : [];
14441
- if (sessions.length === 0) {
14473
+ const sessions = reads.flatMap((read) => {
14474
+ if (!read.ok) return [];
14475
+ const session = read.session;
14476
+ const hasBundled = fs9.existsSync(path12.join(session.dir, "bundle.json"));
14477
+ return [{ id: session.sessionId, startedAt: session.startedAt ?? "", url: session.url ?? null, status: hasBundled ? "stopped" : "active" }];
14478
+ });
14479
+ const unreadable = reads.flatMap((read) => read.ok ? [] : [read]);
14480
+ if (reads.length === 0) {
14442
14481
  emitResult({
14443
14482
  tag: "sessions",
14444
14483
  attrs: { count: 0 },
@@ -14452,10 +14491,14 @@ function list(parsed) {
14452
14491
  s.url ? fact` — ${s.url}` : text``
14453
14492
  )
14454
14493
  );
14494
+ const unreadableRows = unreadable.map(
14495
+ (u) => line(fact`${u.id} — unreadable — ${u.metaPath} — ${u.reason}`)
14496
+ );
14455
14497
  emitResult({
14456
14498
  tag: "sessions",
14457
- attrs: { count: sessions.length },
14458
- sections: [lineList(rows)]
14499
+ attrs: { count: sessions.length, unreadable: unreadable.length },
14500
+ sections: [lineList([...rows, ...unreadableRows])],
14501
+ ...unreadable.length > 0 ? { followUp: text`Unreadable records are corrupt or written by an incompatible version; they are inert. Delete the named .session.json (or its session dir) to drop the row.` } : {}
14459
14502
  }, { json: parsed.json });
14460
14503
  }
14461
14504
  function sectionRows(manifest, key) {
@@ -14665,8 +14708,11 @@ input:
14665
14708
  (none)
14666
14709
 
14667
14710
  output:
14668
- One <sessions count=\u2026> block, one row per session: id, status
14711
+ One <sessions count=\u2026 unreadable=\u2026> block, one row per session: id, status
14669
14712
  (active|stopped), start time, and URL when set. --json mirrors the same rows.
14713
+ A record that is corrupt or written by an incompatible version lists as its
14714
+ own "<id> \u2014 unreadable \u2014 <path> \u2014 <reason>" row and is excluded from count;
14715
+ one bad record never hides the others.
14670
14716
 
14671
14717
  effects:
14672
14718
  None \u2014 reads session metadata only.`;
@@ -228435,11 +228481,11 @@ async function runBrowserScope(method, params, parsed, timeoutMs, deps7 = DEFAUL
228435
228481
  const attached = await client.send("Target.attachToTarget", {
228436
228482
  targetId: tab.id,
228437
228483
  flatten: true
228438
- });
228484
+ }, timeoutMs);
228439
228485
  sessionId = attached.sessionId;
228440
228486
  }
228441
228487
  const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : void 0;
228442
- const result = method ? await client.send(method, params ?? {}, 6e4, sessionId) : void 0;
228488
+ const result = method ? await client.send(method, params ?? {}, timeoutMs, sessionId) : void 0;
228443
228489
  const event = eventPromise ? await eventPromise : void 0;
228444
228490
  emitCdpResult({
228445
228491
  method,
@@ -228476,7 +228522,7 @@ async function runPageScope(method, params, parsed, timeoutMs, connect = connect
228476
228522
  }
228477
228523
  } else {
228478
228524
  const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : void 0;
228479
- result = method ? await client.send(method, params ?? {}) : void 0;
228525
+ result = method ? await client.send(method, params ?? {}, timeoutMs) : void 0;
228480
228526
  event = eventPromise ? await eventPromise : void 0;
228481
228527
  }
228482
228528
  emitCdpResult({
@@ -228532,7 +228578,7 @@ Input:
228532
228578
  --browser route through the held connection (session start --hold) instead of a one-shot page websocket. Connection-scoped state (permission grants, domain enables) reverts the instant its connection closes \u2014 it survives across commands only inside a held session.
228533
228579
  --port <port> CDP endpoint. An explicit flag selects that endpoint even when an active session holds another browser connection.
228534
228580
  --target <id> with --browser: attach a flattened CDP session on the held connection to this target (for target-scoped domains), including an already-open tab adopted by \`session start --hold\` without --url; without --browser: the page target to run against. 8-char id prefix accepted.
228535
- --timeout <ms> event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms).
228581
+ --timeout <ms> one-shot method-response and event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms). WebSocket connection setup is separately bounded to 5000ms.
228536
228582
 
228537
228583
  Output:
228538
228584
  <cdp-result method=\u2026 wait-event=\u2026 scope=\u2026> \u2014 the protocol result and/or awaited event as an escaped, length-capped JSON payload. --json mirrors the same block with the payload at full fidelity.
@@ -241172,7 +241218,7 @@ CDP_PORT / CDP_TARGET pin the browser + tab for orchestrators \u2014 precedence:
241172
241218
  explicit flag > active session > env.`;
241173
241219
  }
241174
241220
  function printVersion() {
241175
- const declared = "1.3.17";
241221
+ const declared = "1.3.18";
241176
241222
  if (declared) {
241177
241223
  console.log(declared);
241178
241224
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.17",
3
+ "version": "1.3.18",
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"
@@ -58,7 +58,7 @@ Input:
58
58
  --browser route through the held connection (session start --hold) instead of a one-shot page websocket. Connection-scoped state (permission grants, domain enables) reverts the instant its connection closes — it survives across commands only inside a held session.
59
59
  --port <port> CDP endpoint. An explicit flag selects that endpoint even when an active session holds another browser connection.
60
60
  --target <id> with --browser: attach a flattened CDP session on the held connection to this target (for target-scoped domains), including an already-open tab adopted by \`session start --hold\` without --url; without --browser: the page target to run against. 8-char id prefix accepted.
61
- --timeout <ms> event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms).
61
+ --timeout <ms> one-shot method-response and event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms). WebSocket connection setup is separately bounded to 5000ms.
62
62
 
63
63
  Output:
64
64
  <cdp-result method=… wait-event=… scope=…> — the protocol result and/or awaited event as an escaped, length-capped JSON payload. --json mirrors the same block with the payload at full fidelity.
@@ -73,7 +73,7 @@ Effects:
73
73
  * command wiring against a stub without a live browser.
74
74
  */
75
75
  export interface CdpScopeClient {
76
- send(method: string, params?: Record<string, unknown>): Promise<unknown>;
76
+ send(method: string, params?: Record<string, unknown>, timeout?: number): Promise<unknown>;
77
77
  on(event: string, handler: (params: unknown) => void): void;
78
78
  close(): void;
79
79
  }
@@ -279,11 +279,11 @@ export async function runBrowserScope(
279
279
  const attached = (await client.send('Target.attachToTarget', {
280
280
  targetId: tab.id,
281
281
  flatten: true,
282
- })) as { sessionId: string };
282
+ }, timeoutMs)) as { sessionId: string };
283
283
  sessionId = attached.sessionId;
284
284
  }
285
285
  const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
286
- const result = method ? await client.send(method, params ?? {}, 60000, sessionId) : undefined;
286
+ const result = method ? await client.send(method, params ?? {}, timeoutMs, sessionId) : undefined;
287
287
  const event = eventPromise ? await eventPromise : undefined;
288
288
  emitCdpResult({
289
289
  method,
@@ -349,7 +349,7 @@ export async function runPageScope(
349
349
  }
350
350
  } else {
351
351
  const eventPromise = parsed.waitEvent ? waitForEventOnce(client, parsed.waitEvent, timeoutMs) : undefined;
352
- result = method ? await client.send(method, params ?? {}) : undefined;
352
+ result = method ? await client.send(method, params ?? {}, timeoutMs) : undefined;
353
353
  event = eventPromise ? await eventPromise : undefined;
354
354
  }
355
355
  emitCdpResult({
package/src/cdp/record.ts CHANGED
@@ -11,6 +11,42 @@ import { type CDPTarget } from './types.js';
11
11
  * load-wait helper. Navigation of an existing tab lives in
12
12
  * `./commands/page/navigate.ts` via ordinary page targeting.
13
13
  */
14
+ function isMissingDefaultExecutionContext(err: unknown): boolean {
15
+ return err instanceof Error && err.message === 'Cannot find default execution context';
16
+ }
17
+
18
+ /**
19
+ * Arc can report the page load before it has installed the default execution
20
+ * context (notably for hash-routed Gmail URLs). Retry only that known transient
21
+ * CDP error; every other Runtime.evaluate failure remains fatal.
22
+ */
23
+ export async function readReadyStateWithRetry(
24
+ client: Pick<CDPClient, 'send'>,
25
+ timeout: number,
26
+ ): Promise<unknown> {
27
+ const deadline = Date.now() + timeout;
28
+ let lastRetryableError: unknown;
29
+ while (true) {
30
+ const remaining = deadline - Date.now();
31
+ if (remaining <= 0) {
32
+ if (lastRetryableError) throw lastRetryableError;
33
+ throw new Error('Runtime.evaluate deadline exceeded');
34
+ }
35
+ try {
36
+ const result = await client.send('Runtime.evaluate', {
37
+ expression: 'document.readyState',
38
+ returnByValue: true,
39
+ }, remaining);
40
+ return (result as { result?: { value?: unknown } } | undefined)?.result?.value;
41
+ } catch (err) {
42
+ const remainingAfterAttempt = deadline - Date.now();
43
+ if (!isMissingDefaultExecutionContext(err) || remainingAfterAttempt <= 0) throw err;
44
+ lastRetryableError = err;
45
+ await new Promise<void>((resolve) => setTimeout(resolve, Math.min(50, remainingAfterAttempt)));
46
+ }
47
+ }
48
+ }
49
+
14
50
  export async function navigateAndWait(
15
51
  port: number,
16
52
  url: string,
@@ -59,11 +95,7 @@ export async function navigateAndWait(
59
95
  });
60
96
 
61
97
  try {
62
- const result = await client.send('Runtime.evaluate', {
63
- expression: 'document.readyState',
64
- returnByValue: true,
65
- });
66
- const readyState = (result as { result?: { value?: unknown } } | undefined)?.result?.value;
98
+ const readyState = await readReadyStateWithRetry(client, timeout);
67
99
  if (readyState !== 'complete') {
68
100
  await loadPromise;
69
101
  if (loadTimeoutError) throw loadTimeoutError;
@@ -11,6 +11,7 @@ import {
11
11
  } from '../har-manager.js';
12
12
  import {
13
13
  getActiveSession,
14
+ activeSessionScopeKey,
14
15
  setActiveSession,
15
16
  clearActiveSessionIf,
16
17
  updateSessionState,
@@ -235,10 +236,38 @@ function sessionMetaPath(id: string): string {
235
236
  return path.join(sessionDir(id), '.session.json');
236
237
  }
237
238
 
239
+ /** Outcome of reading one persisted record without failing the caller: either
240
+ * the validated session or the short reason its `.session.json` is unreadable. */
241
+ type SessionRead =
242
+ | { ok: true; session: Session }
243
+ | { ok: false; id: string; metaPath: string; reason: string };
244
+
245
+ /**
246
+ * Reads one record without throwing. A listing is what an agent reaches for
247
+ * precisely when it is working out what state it is in, so one corrupt or
248
+ * legacy-schema record on a shared capture root must not make every other
249
+ * session invisible — `list` renders the reason as its own unreadable row.
250
+ * A missing file is not a read outcome here; only `readSession` (targeted
251
+ * commands) turns that into `unknown_session`.
252
+ */
253
+ function tryReadSession(id: string): SessionRead {
254
+ const metaPath = sessionMetaPath(id);
255
+ const bad = (reason: string): SessionRead => ({ ok: false, id, metaPath, reason });
256
+ let parsed: unknown;
257
+ try {
258
+ parsed = JSON.parse(readPrivateFile(metaPath).toString('utf-8'));
259
+ } catch (error) {
260
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw error;
261
+ return bad('not valid JSON; the record is corrupt');
262
+ }
263
+ if (!isActiveStateCandidate(parsed)) return bad('does not match the session record schema; written by an incompatible version or corrupted');
264
+ return { ok: true, session: parsed };
265
+ }
266
+
238
267
  /**
239
- * Reads and VALIDATES one persisted `.session.json`. A missing file throws a
240
- * typed `unknown_session` precondition error (leaf catch-alls emit it as
241
- * `unknown_session`); unparsable bytes or a record failing the shared
268
+ * Reads and VALIDATES one persisted `.session.json` for a command that named
269
+ * this session. A missing file throws a typed `unknown_session` precondition
270
+ * error; unparsable bytes or a record failing the shared
242
271
  * `isActiveStateCandidate` schema throw a typed `invalid_session_record`
243
272
  * artifact error instead of letting a structurally-trusted cast reach the
244
273
  * renderer (where a legacy-schema record used to crash `session list`
@@ -246,23 +275,15 @@ function sessionMetaPath(id: string): string {
246
275
  */
247
276
  function readSession(id: string): Session {
248
277
  const metaPath = sessionMetaPath(id);
249
- let raw: string;
278
+ let read: SessionRead;
250
279
  try {
251
- raw = readPrivateFile(metaPath).toString('utf-8');
280
+ read = tryReadSession(id);
252
281
  } catch (error) {
253
282
  if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw captureError('precondition', 'unknown_session', `No capture session found: ${id}`);
254
283
  throw error;
255
284
  }
256
- let parsed: unknown;
257
- try {
258
- parsed = JSON.parse(raw);
259
- } catch (cause) {
260
- throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} is not valid JSON; the record is corrupt and cannot be read as a capture session.`, cause);
261
- }
262
- if (!isActiveStateCandidate(parsed)) {
263
- throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} does not match the capture session record schema; it was written by an incompatible version or corrupted.`);
264
- }
265
- return parsed;
285
+ if (!read.ok) throw captureError('artifact', 'invalid_session_record', `session metadata at ${metaPath} ${read.reason}.`);
286
+ return read.session;
266
287
  }
267
288
 
268
289
  function generateId(): string {
@@ -354,8 +375,11 @@ input:
354
375
  (none)
355
376
 
356
377
  output:
357
- One <sessions count=…> block, one row per session: id, status
378
+ One <sessions count=… unreadable=…> block, one row per session: id, status
358
379
  (active|stopped), start time, and URL when set. --json mirrors the same rows.
380
+ A record that is corrupt or written by an incompatible version lists as its
381
+ own "<id> — unreadable — <path> — <reason>" row and is excluded from count;
382
+ one bad record never hides the others.
359
383
 
360
384
  effects:
361
385
  None — reads session metadata only.`;
@@ -557,9 +581,9 @@ async function start(parsed: ParsedArgs): Promise<void> {
557
581
  if (active && !isSessionStopped(active)) {
558
582
  emitResult({
559
583
  tag: 'error',
560
- attrs: { command: 'session start', code: 'start_failed' },
561
- summary: fact`A session is already active for this scope (${active.sessionId}); stop it first with \`session stop ${active.sessionId}\`.`,
562
- followUp: text`Only one live session is allowed per capture scope at a time.`,
584
+ attrs: { command: 'session start', code: 'session_already_active' },
585
+ summary: fact`Session ${active.sessionId} is already live in this capture scope (${activeSessionScopeKey()}); one live session per scope.`,
586
+ followUp: fact`Scopes are per-agent (CRTR_NODE_ID), so this session is yours — a concurrent agent's session never blocks yours. Keep using it (every command auto-targets its tab), or run \`capture session stop ${active.sessionId}\` to start fresh. To drive a different tab without ending it, pass \`--target <target-id>\` from \`capture tab list\`.`,
563
587
  }, { json: parsed.json });
564
588
  process.exitCode = 1;
565
589
  return;
@@ -1212,17 +1236,24 @@ function list(parsed: ParsedArgs): void {
1212
1236
 
1213
1237
  if (rejectSurplusPositionals(parsed, 'list', 0, 'capture session list')) return;
1214
1238
 
1215
- const sessions = fs.existsSync(CAPTURE_ROOT)
1239
+ const reads = fs.existsSync(CAPTURE_ROOT)
1216
1240
  ? fs.readdirSync(CAPTURE_ROOT)
1217
1241
  .filter((d) => fs.existsSync(sessionMetaPath(d)))
1218
- .map((d) => {
1219
- const session = readSession(d);
1220
- const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
1221
- return { id: session.sessionId, startedAt: session.startedAt ?? '', url: session.url ?? null, status: hasBundled ? 'stopped' : 'active' };
1242
+ .flatMap((d) => {
1243
+ // A record deleted between readdir and read is simply gone, not unreadable.
1244
+ try { return [tryReadSession(d)]; } catch { return []; }
1222
1245
  })
1223
1246
  : [];
1224
1247
 
1225
- if (sessions.length === 0) {
1248
+ const sessions = reads.flatMap((read) => {
1249
+ if (!read.ok) return [];
1250
+ const session = read.session;
1251
+ const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
1252
+ return [{ id: session.sessionId, startedAt: session.startedAt ?? '', url: session.url ?? null, status: hasBundled ? 'stopped' : 'active' }];
1253
+ });
1254
+ const unreadable = reads.flatMap((read) => (read.ok ? [] : [read]));
1255
+
1256
+ if (reads.length === 0) {
1226
1257
  emitResult({
1227
1258
  tag: 'sessions',
1228
1259
  attrs: { count: 0 },
@@ -1237,11 +1268,17 @@ function list(parsed: ParsedArgs): void {
1237
1268
  s.url ? fact` — ${s.url}` : text``,
1238
1269
  ),
1239
1270
  );
1271
+ const unreadableRows = unreadable.map((u) =>
1272
+ line(fact`${u.id} — unreadable — ${u.metaPath} — ${u.reason}`),
1273
+ );
1240
1274
 
1241
1275
  emitResult({
1242
1276
  tag: 'sessions',
1243
- attrs: { count: sessions.length },
1244
- sections: [lineList(rows)],
1277
+ attrs: { count: sessions.length, unreadable: unreadable.length },
1278
+ sections: [lineList([...rows, ...unreadableRows])],
1279
+ ...(unreadable.length > 0
1280
+ ? { followUp: text`Unreadable records are corrupt or written by an incompatible version; they are inert. Delete the named .session.json (or its session dir) to drop the row.` }
1281
+ : {}),
1245
1282
  }, { json: parsed.json });
1246
1283
  }
1247
1284