@crouton-kit/capture 1.3.8 → 1.3.10

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.
Files changed (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -1,46 +1,56 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { spawn } from 'child_process';
4
3
  import {
5
4
  createHarRecording,
6
5
  readHarRecording,
7
6
  deleteHarRecording,
7
+ harFilePath,
8
+ validateHarFile,
9
+ type HarFile,
10
+ type HAREntry,
8
11
  } from '../har-manager.js';
9
12
  import {
10
13
  getActiveSession,
11
14
  setActiveSession,
12
- clearActiveSession,
15
+ clearActiveSessionIf,
16
+ updateSessionState,
17
+ isActiveStateCandidate,
18
+ type ActiveSessionState,
13
19
  } from '../session-context.js';
14
- import { expandEqualsFlags } from '../cdp/args.js';
20
+ import { type ParsedArgs, type CDPTarget } from '../cdp/types.js';
15
21
  import { startBridge, stopBridge } from '../cdp/bridge/spawn.js';
22
+ import { normalizeFailure, failureResult, captureError, worldFailure } from '../errors.js';
23
+ import {
24
+ startSessionLogTailer,
25
+ stopSessionLogTailers,
26
+ } from './log-tailer.js';
27
+ import { teardownAnyLiveRecorderAtSessionStop, isTerminalRecStopFailure } from '../cdp/motion/recorder.js';
28
+ import {
29
+ emitResult,
30
+ fact,
31
+ text,
32
+ line,
33
+ lineList,
34
+ data,
35
+ capped,
36
+ type FactLine,
37
+ } from '../output/render.js';
16
38
  import {
17
39
  CAPTURE_ROOT,
18
- FILE_MODE,
40
+ MAX_LOG_LABEL_BYTES,
41
+ acquirePrivateLock,
19
42
  ensurePrivateDir,
43
+ rejectLogLabel,
20
44
  writeJsonPrivate,
45
+ readPrivateFile,
46
+ removeArtifactTree,
21
47
  type SnapMeta,
22
48
  type RecMeta,
23
49
  } from './artifacts.js';
50
+ import { beginSessionStop, admitSessionOperation, withSessionLifecycle } from './coordinator.js';
51
+ import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
24
52
 
25
- interface LogPid {
26
- pid: number;
27
- name: string;
28
- sourcePath: string;
29
- }
30
-
31
- interface Session {
32
- id: string;
33
- dir: string;
34
- harId: string | null;
35
- startedAt: string;
36
- url: string | null;
37
- targetId: string | null;
38
- stepCount: number;
39
- logPids: LogPid[];
40
- /** Set when started with --hold: one CDP browser connection held open for the session. */
41
- bridgeSocket: string | null;
42
- bridgePid: number | null;
43
- }
53
+ type Session = ActiveSessionState;
44
54
 
45
55
  interface BundleManifest {
46
56
  id: string;
@@ -48,47 +58,147 @@ interface BundleManifest {
48
58
  stoppedAt: string;
49
59
  duration: number;
50
60
  url: string | null;
51
- screenshots: Array<{ name: string; path: string }>;
61
+ shots: Array<{ name: string; path: string }>;
52
62
  har: { id: string; path: string; entryCount: number } | null;
53
- a11y: Array<{ name: string; path: string }>;
54
63
  logs: Array<{ name: string; path: string; lines: number }>;
55
64
  other: Array<{ name: string; path: string }>;
56
65
  /** `measure snap` artifacts collected from `measure/snaps/{id}/meta.json`. */
57
66
  snaps: Array<{ id: string; path: string; url: string | null; viewport: string | null; settled: boolean; capturedAt: string }>;
58
67
  /** `motion rec` artifacts collected from `motion/recs/{id}/meta.json`. */
59
- recs: Array<{ id: string; path: string; action: string | null; frames: number; durationMs: number; state: string }>;
68
+ recs: Array<{ id: string; path: string; action: string | null; frames: number; durationMs: number; state: string; viewportRestored: boolean | null }>;
69
+ /** Retry outcomes for viewport obligations retained by failed recorder starts. */
70
+ pendingViewportRestorations: Array<{ recId: string; viewportRestored: boolean | null }>;
71
+ }
72
+
73
+ function isNamedPathEntry(value: unknown): value is { name: string; path: string } {
74
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
75
+ const record = value as Record<string, unknown>;
76
+ return typeof record.name === 'string' && typeof record.path === 'string';
77
+ }
78
+
79
+ /** The one schema check for a persisted `bundle.json` manifest. A manifest is
80
+ * committed once and immutable, so any record failing this shape was written
81
+ * by an incompatible version or corrupted — never something to structurally
82
+ * trust into the renderer. */
83
+ function isBundleManifest(value: unknown): value is BundleManifest {
84
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
85
+ const record = value as Record<string, unknown>;
86
+ const validHar = record.har === null
87
+ || (!!record.har && typeof record.har === 'object' && !Array.isArray(record.har)
88
+ && typeof (record.har as Record<string, unknown>).id === 'string'
89
+ && typeof (record.har as Record<string, unknown>).path === 'string'
90
+ && typeof (record.har as Record<string, unknown>).entryCount === 'number');
91
+ return typeof record.id === 'string'
92
+ && typeof record.startedAt === 'string'
93
+ && typeof record.stoppedAt === 'string'
94
+ && typeof record.duration === 'number'
95
+ && (record.url === null || typeof record.url === 'string')
96
+ && Array.isArray(record.shots) && record.shots.every(isNamedPathEntry)
97
+ && validHar
98
+ && Array.isArray(record.logs) && record.logs.every(item => isNamedPathEntry(item) && typeof (item as unknown as Record<string, unknown>).lines === 'number')
99
+ && Array.isArray(record.other) && record.other.every(isNamedPathEntry)
100
+ && Array.isArray(record.snaps) && record.snaps.every(item => !!item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string' && typeof (item as Record<string, unknown>).path === 'string')
101
+ && Array.isArray(record.recs) && record.recs.every(item => !!item && typeof item === 'object' && typeof (item as Record<string, unknown>).id === 'string' && typeof (item as Record<string, unknown>).path === 'string')
102
+ && Array.isArray(record.pendingViewportRestorations);
103
+ }
104
+
105
+ /** Reads and VALIDATES one immutable `bundle.json`. The read is contained and
106
+ * no-follow (`readPrivateFile`); ENOENT propagates for callers that treat a
107
+ * missing bundle as "not stopped yet". Unparsable bytes or a schema failure
108
+ * throw a typed `invalid_bundle_manifest` artifact error instead of letting a
109
+ * structurally-trusted cast reach the renderer. */
110
+ function readBundleManifest(bundlePath: string): BundleManifest {
111
+ const raw = readPrivateFile(bundlePath).toString('utf-8');
112
+ let parsed: unknown;
113
+ try {
114
+ parsed = JSON.parse(raw);
115
+ } catch (cause) {
116
+ throw captureError('artifact', 'invalid_bundle_manifest', `bundle manifest at ${bundlePath} is not valid JSON; the record is corrupt and cannot be read as a capture bundle.`, cause);
117
+ }
118
+ if (!isBundleManifest(parsed)) {
119
+ throw captureError('artifact', 'invalid_bundle_manifest', `bundle manifest at ${bundlePath} does not match the capture bundle schema; it was written by an incompatible version or corrupted.`);
120
+ }
121
+ return parsed;
60
122
  }
61
123
 
124
+ /** The bundle-manifest section keys `session view --filter` can address. */
125
+ type SectionKey = 'shots' | 'har' | 'logs' | 'snaps' | 'recs' | 'other';
126
+
127
+ // The `--filter <name>` query names map onto manifest section keys. Most are
128
+ // identical; `measure`/`motion` are the query-facing names for the `snaps`/
129
+ // `recs` keys (matching the `measure`/`motion` command branches rather than
130
+ // the artifact-file naming), so they get an explicit mapping.
131
+ const VIEW_FILTERS: Record<string, SectionKey> = {
132
+ shots: 'shots',
133
+ har: 'har',
134
+ logs: 'logs',
135
+ measure: 'snaps',
136
+ motion: 'recs',
137
+ other: 'other',
138
+ };
139
+
140
+ /** User-facing label for each manifest section in the unfiltered view. */
141
+ const SECTION_LABELS: Record<SectionKey, string> = {
142
+ shots: 'shots',
143
+ har: 'har',
144
+ logs: 'logs',
145
+ snaps: 'measure',
146
+ recs: 'motion',
147
+ other: 'other',
148
+ };
149
+
62
150
  function sessionDir(id: string): string {
63
151
  return path.join(CAPTURE_ROOT, id);
64
152
  }
65
153
 
154
+ function lifecycleScopeLockPath(): string {
155
+ return path.join(CAPTURE_ROOT, `.session-lifecycle-${process.env.CRTR_NODE_ID ?? 'default'}`);
156
+ }
157
+
158
+ async function withLifecycleCoordinator<T>(action: () => Promise<T>): Promise<T> {
159
+ const handle = await acquirePrivateLock(lifecycleScopeLockPath(), {
160
+ acquireTimeoutMs: 120_000,
161
+ leaseMs: 1_000,
162
+ });
163
+ try {
164
+ return await action();
165
+ } finally {
166
+ handle.release();
167
+ }
168
+ }
169
+
170
+ function isSessionStopped(session: Session): boolean {
171
+ return Boolean(session.stoppedAt) || fs.existsSync(path.join(session.dir, 'bundle.json'));
172
+ }
173
+
66
174
  /** One-shot artifact session outside an active session — see `createOneshotSession`. */
67
175
  export interface OneshotSession {
68
176
  /** `oneshot-{id}`; also the dir name under `CAPTURE_ROOT`. */
69
177
  id: string;
70
178
  /** `{CAPTURE_ROOT}/oneshot-{id}` */
71
179
  dir: string;
72
- kind: 'measure' | 'motion';
73
- /** `{dir}/measure/snaps` or `{dir}/motion/recs`, already created private. */
180
+ kind: 'measure' | 'motion' | 'page';
181
+ /** `{dir}/measure/snaps`, `{dir}/motion/recs`, or `{dir}/page`, already created private. */
74
182
  artifactsDir: string;
75
183
  }
76
184
 
77
185
  /**
78
- * Creates the ephemeral artifact dir a URL-target `measure`/`motion` leaf
79
- * writes into when there is no active session: `oneshot-{id}/measure/snaps`
80
- * or `oneshot-{id}/motion/recs` under `CAPTURE_ROOT`. Holds only the one
81
- * subtree the caller needs — no HAR, no held bridge, no `.session.json` —
82
- * and is never registered as the active session. It is not bundled/torn
83
- * down by `session stop`; it accumulates under `/tmp` the same as any other
84
- * session dir until the OS reaps `/tmp`.
186
+ * Creates the ephemeral artifact dir a URL-target `measure`/`motion`/`page`
187
+ * leaf writes into when there is no active session: `oneshot-{id}/measure/snaps`,
188
+ * `oneshot-{id}/motion/recs`, or `oneshot-{id}/page` under `CAPTURE_ROOT`.
189
+ * Holds only the one subtree the caller needs — no HAR, no held bridge, no
190
+ * `.session.json` — and is never registered as the active session. It is not
191
+ * bundled/torn down by `session stop`; it accumulates under `/tmp` the same as
192
+ * any other session dir until the OS reaps `/tmp`.
85
193
  */
86
- export function createOneshotSession(kind: 'measure' | 'motion'): OneshotSession {
194
+ export function createOneshotSession(kind: 'measure' | 'motion' | 'page'): OneshotSession {
87
195
  const id = `oneshot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
88
196
  const dir = sessionDir(id);
89
197
  const artifactsDir = kind === 'measure'
90
198
  ? path.join(dir, 'measure', 'snaps')
91
- : path.join(dir, 'motion', 'recs');
199
+ : kind === 'motion'
200
+ ? path.join(dir, 'motion', 'recs')
201
+ : path.join(dir, 'page');
92
202
  ensurePrivateDir(artifactsDir);
93
203
  return { id, dir, kind, artifactsDir };
94
204
  }
@@ -116,6 +226,7 @@ function readRecMeta(recDir: string, fallbackId: string): BundleManifest['recs']
116
226
  frames: meta.frames ?? 0,
117
227
  durationMs: meta.durationMs ?? 0,
118
228
  state: meta.state ?? 'unknown',
229
+ viewportRestored: typeof meta.viewportRestored === 'boolean' ? meta.viewportRestored : null,
119
230
  };
120
231
  }
121
232
 
@@ -141,22 +252,40 @@ function sessionMetaPath(id: string): string {
141
252
  return path.join(sessionDir(id), '.session.json');
142
253
  }
143
254
 
255
+ /**
256
+ * Reads and VALIDATES one persisted `.session.json`. A missing file throws a
257
+ * typed `unknown_session` precondition error (leaf catch-alls emit it as
258
+ * `unknown_session`); unparsable bytes or a record failing the shared
259
+ * `isActiveStateCandidate` schema throw a typed `invalid_session_record`
260
+ * artifact error instead of letting a structurally-trusted cast reach the
261
+ * renderer (where a legacy-schema record used to crash `session list`
262
+ * unbranded via `fact` interpolation of undefined fields).
263
+ */
144
264
  function readSession(id: string): Session {
145
265
  const metaPath = sessionMetaPath(id);
146
- if (!fs.existsSync(metaPath)) {
147
- throw new Error(`No capture session found: ${id}`);
266
+ let raw: string;
267
+ try {
268
+ raw = readPrivateFile(metaPath).toString('utf-8');
269
+ } catch (error) {
270
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') throw captureError('precondition', 'unknown_session', `No capture session found: ${id}`);
271
+ throw error;
272
+ }
273
+ let parsed: unknown;
274
+ try {
275
+ parsed = JSON.parse(raw);
276
+ } catch (cause) {
277
+ 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);
278
+ }
279
+ if (!isActiveStateCandidate(parsed)) {
280
+ 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.`);
148
281
  }
149
- return JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as Session;
282
+ return parsed;
150
283
  }
151
284
 
152
285
  function generateId(): string {
153
286
  return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
154
287
  }
155
288
 
156
- function hasHelpFlag(args: string[]): boolean {
157
- return args.some((arg) => arg === '-h' || arg === '--help');
158
- }
159
-
160
289
  export async function waitForPageLoad(
161
290
  client: { waitReady(): Promise<void>; send(method: string, params?: Record<string, unknown>): Promise<unknown>; on(event: string, handler: (params: unknown) => void): void; },
162
291
  timeoutMs: number,
@@ -166,458 +295,1094 @@ export async function waitForPageLoad(
166
295
 
167
296
  return await new Promise<boolean>((resolve) => {
168
297
  const timer = setTimeout(() => resolve(true), timeoutMs);
169
- client.on('Page.loadEventFired', () => {
298
+ const done = (timedOut: boolean) => {
170
299
  clearTimeout(timer);
171
- resolve(false);
172
- });
300
+ resolve(timedOut);
301
+ };
302
+ client.on('Page.loadEventFired', () => done(false));
303
+ // A page that finished loading before we attached (about:blank, cached
304
+ // instant loads) never fires loadEventFired — check readyState directly.
305
+ client
306
+ .send('Runtime.evaluate', {
307
+ expression: 'document.readyState',
308
+ returnByValue: true,
309
+ })
310
+ .then((r) => {
311
+ const value = (r as { result?: { value?: unknown } } | undefined)?.result?.value;
312
+ if (value === 'complete') done(false);
313
+ })
314
+ .catch(() => {});
173
315
  });
174
316
  }
175
317
 
318
+ /** Root-help representation of this branch, assembled by `src/capture.ts`. */
319
+ export const COMMAND_BLOCK = `<command name="session">
320
+ the artifact container — a session opens a tab, records HAR, and bundles every artifact; while active, every command auto-targets its tab
321
+ use when starting scoped work against a page: start first, then every other capture command needs no --target/--port threading
322
+ start · stop · list · view · har · log — \`capture session -h\`
323
+ </command>`;
324
+
325
+ const START_USAGE = `capture session start [--url <url>] [--hold] — open a tab, record HAR, and set the active capture context.
326
+
327
+ input:
328
+ --url <url> Absolute URL to open in a fresh tab. Omit to start a session
329
+ with no tab (HAR-only until a later command targets one).
330
+ --hold Hold one CDP browser connection open for the session's
331
+ lifetime, so browser-level state (permission grants,
332
+ ServiceWorker enablement) survives across separate commands.
333
+ --port <n> CDP port to attach to; default is auto-detected across localhost.
334
+
335
+ Output:
336
+ One <session id=… path=… [url=…]> block: the session id, bundle dir, opened
337
+ tab, HAR recording id, and held-bridge pid when applicable. --json mirrors
338
+ the same fields.
339
+
340
+ effects:
341
+ Creates a private session dir with shots/, starts a HAR recording, opens the
342
+ tab (with --url), optionally holds a CDP bridge, and registers the session as
343
+ the active capture context so subsequent commands auto-target it.`;
344
+
345
+ const STOP_USAGE = `capture session stop <session-id> — finalize a session and write its bundle manifest.
346
+
347
+ input:
348
+ <session-id> The session to stop (from \`capture session list\`).
349
+
350
+ Output:
351
+ One <session-stopped id=… path=…> block: the bundle manifest path plus
352
+ counts (shots, HAR entries, logs, measure snaps, motion recs, other). --json
353
+ mirrors the same fields.
354
+
355
+ effects:
356
+ Kills log tailers, tears down the held CDP bridge, finalizes any live
357
+ recorder, collects every artifact into bundle.json, and clears the active
358
+ capture context.`;
359
+
360
+ const LIST_USAGE = `capture session list — list active and stopped capture sessions.
361
+
362
+ input:
363
+ (none)
364
+
365
+ output:
366
+ One <sessions count=…> block, one row per session: id, status
367
+ (active|stopped), start time, and URL when set. --json mirrors the same rows.
368
+
369
+ effects:
370
+ None — reads session metadata only.`;
371
+
372
+ const VIEW_USAGE = `capture session view <session-id> [--filter <section>] — read back a stopped session's bundle manifest.
373
+
374
+ input:
375
+ <session-id> A stopped session (from \`capture session list\`).
376
+ --filter <section> Show only one section: shots, har, logs, measure,
377
+ motion, or other. measure -> manifest snaps, motion ->
378
+ manifest recs.
379
+
380
+ Output:
381
+ One <session id=… path=… [filter=…]> block: the manifest sections (shots,
382
+ har, logs, measure snaps, motion recs, other), or a single section under
383
+ --filter. --json mirrors the same fields.
384
+
385
+ effects:
386
+ None — reads bundle.json only.`;
387
+
388
+ const HAR_USAGE = `capture session har [<session-id>] — read a session's recorded HTTP traffic as a selection list.
389
+
390
+ input:
391
+ <session-id> session to read; defaults to the active session. A
392
+ running session reads its live accumulating HAR; a
393
+ stopped one reads the bundled har.json.
394
+ --filter-url <pattern> substring or regex match on the request URL
395
+ --filter-status <spec> exact status (100-599), one-digit class prefix
396
+ (1-5, e.g. 4 = every 4xx), or ordered range
397
+ (e.g. 400-499); any other token is invalid_filter
398
+ --filter-method <method> HTTP method (GET, POST, …)
399
+ --limit <n> first n matching entries
400
+ --full inline per-entry detail (headers, post data,
401
+ response body — escaped and capped); bodies are
402
+ never inlined without it
403
+
404
+ output:
405
+ One <session-har id=… path=… source=live|bundle entries=… total=…> block,
406
+ one row per entry: method, status, URL, body size, start time. The path
407
+ attribute is the HAR file's absolute path — the full-fidelity pointer.
408
+ --json mirrors the same fields. WebSockets opened while a command was
409
+ recording appear as entries with _resourceType "websocket" and their frames
410
+ in _webSocketMessages (capped at 200 frames/socket, 4KB/frame); sockets
411
+ opened before recording started are not visible.
412
+
413
+ effects:
414
+ None — reads recorded HAR data only.`;
415
+
416
+ const LOG_USAGE = `capture session log <path> [--name <label>] [--session <id>] — tail an external log file into a session's logs/ dir.
417
+
418
+ input:
419
+ <path> log file to follow (must exist). Any filename is accepted — it
420
+ is only ever passed to \`tail\` as an argv element, never a shell.
421
+ --name <label> destination label; default is the source file's basename. One
422
+ bounded path component: no \`/\`, no \`.\`/\`..\`, no NUL, ≤ ${MAX_LOG_LABEL_BYTES} bytes.
423
+ --session <id> target session; defaults to the active session
424
+
425
+ output:
426
+ One <log-tail session=… path=…> block: tailer name, source path, destination
427
+ path, and tailer pid. --json mirrors the same fields.
428
+
429
+ effects:
430
+ Self-spawns one detached, identity-owned worker (its own process group) that
431
+ runs \`tail -f\` and appends ISO-8601-timestamped lines to the session's
432
+ contained logs/<name>.log. \`session stop\` drains it over its nonce-authenticated
433
+ control socket so its buffered output lands before the bundle is committed;
434
+ the identity-bearing tailer record is registered in the session metadata.`;
435
+
176
436
  function printSessionHelp(): void {
177
- console.log(`capture session — manage capture sessions
178
-
179
- Sub-commands:
180
- start [--url <url>] [--hold] Start a session (opens tab, records HAR, sets active context)
181
- --hold keeps one CDP browser connection open for the session
182
- stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
183
- list List active and stopped sessions
184
- view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs|measure|motion
185
- measure -> manifest.snaps, motion -> manifest.recs
186
-
187
- Why sessions: once started, every subsequent capture command auto-fills
188
- --target (the tab) and --har (the recording). No manual flag threading.
189
-
190
- Typical flow:
191
- 1. capture session start --url http://localhost:3000
192
- 2. Interact — no --target / --har needed:
193
- capture a11y --interactive
194
- capture click "Sign in"
195
- capture type "hi@me.com" --into "Email"
196
- capture screenshot
197
- capture navigate https://app.example.com/dashboard
198
- capture har read --filter-url /api
199
- 3. capture session stop <session-id>
200
- 4. capture session view <session-id>
201
-
202
- --hold: keeps one CDP browser connection open for the session's lifetime, so
203
- browser-level state (Browser.grantPermissions, ServiceWorker.enable, ...)
204
- survives across separate commands instead of reverting the instant each
205
- command's own connection closes. Use it together with \`capture cdp --browser\`:
206
-
207
- capture session start --url http://localhost:3000 --hold
208
- capture cdp Browser.grantPermissions --browser --params '{"origin":"http://localhost:3000","permissions":["notifications"]}'
209
- capture cdp ServiceWorker.enable --browser --target <pageTabId>
210
- capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> --params '{...}'
211
- capture session stop <session-id> # also tears down the held connection
212
-
213
- Related: capture log <path> [--name label] Tail a log into the active session
214
- See also: capture --help Full command list`);
437
+ console.log(`capture session — the artifact container: opens a tab, records HAR, bundles every artifact.
438
+
439
+ An active session auto-targets its tab and auto-appends recorded traffic — no
440
+ --target/--har threading. \`stop\` finalizes the session and writes its bundle
441
+ manifest; \`view\` reads that manifest back.
442
+
443
+ <subcommand name="start" args="[--url <url>] [--hold]" whenToUse="open a tab, start HAR, and set the active capture context"/>
444
+ <subcommand name="stop" args="<session-id>" whenToUse="finalize the session and write its bundle manifest"/>
445
+ <subcommand name="list" args="" whenToUse="show active and stopped sessions"/>
446
+ <subcommand name="view" args="<session-id> [--filter shots|har|logs|measure|motion|other]" whenToUse="read back a stopped session's bundle manifest"/>
447
+ <subcommand name="har" args="[<session-id>] [--filter-url <pattern>] [--filter-status <code>] [--filter-method <method>] [--limit <n>] [--full]" whenToUse="inspect recorded traffic — the live accumulating HAR of a running session or a stopped session's bundled har.json"/>
448
+ <subcommand name="log" args="<path> [--name <label>] [--session <id>]" whenToUse="tail an external log file into the session's logs/ dir"/>
449
+
450
+ capture session <leaf> -h Per-leaf usage`);
215
451
  }
216
452
 
217
453
  // ============================================================================
218
454
  // Session Commands
219
455
  // ============================================================================
220
456
 
221
- async function start(rawArgs: string[]): Promise<void> {
222
- const args = expandEqualsFlags(rawArgs);
223
- let url: string | null = null;
224
- let hold = false;
457
+ /**
458
+ * The external effects `session start` acquires, behind one seam so the
459
+ * transaction's acquisition/rollback ordering is testable with deterministic
460
+ * fakes instead of a real browser. The default (`productionStartWorld`) wires
461
+ * these to the real CDP layer.
462
+ */
463
+ export interface SessionStartWorld {
464
+ createHar(dir: string): Promise<string>;
465
+ deleteHar(harId: string): Promise<void>;
466
+ detectCdpPort(): Promise<number>;
467
+ openTab(port: number, url: string): Promise<CDPTarget>;
468
+ closeTarget(port: number, targetId: string): Promise<void>;
469
+ /** Attach to the freshly-opened tab and wait for load; returns page-load-timed-out. */
470
+ awaitTabReady(target: CDPTarget, url: string): Promise<boolean>;
471
+ startBridge(dir: string, port: number): Promise<{ socketPath: string; pid: number }>;
472
+ stopBridge(pid: number | null, socketPath: string | null): void;
473
+ publishActiveSession(session: Session): Promise<void>;
474
+ }
225
475
 
226
- for (let i = 0; i < args.length; i++) {
227
- if (args[i] === '--url' && args[i + 1]) {
228
- url = args[++i];
229
- } else if (args[i] === '--hold') {
230
- hold = true;
476
+ const productionStartWorld: SessionStartWorld = {
477
+ async createHar(dir) {
478
+ return (await createHarRecording(dir)).id;
479
+ },
480
+ deleteHar: deleteHarRecording,
481
+ async detectCdpPort() {
482
+ const { detectCdpPort } = await import('../cdp.js');
483
+ return detectCdpPort();
484
+ },
485
+ async openTab(port, url) {
486
+ const { openTab } = await import('../cdp.js');
487
+ return openTab(port, url);
488
+ },
489
+ async closeTarget(port, targetId) {
490
+ const { closeTarget } = await import('../cdp/targets.js');
491
+ return closeTarget(port, targetId);
492
+ },
493
+ async awaitTabReady(target, url) {
494
+ if (!target.webSocketDebuggerUrl) {
495
+ throw worldFailure(`capture session start could not attach to ${url}: missing WebSocket debugger URL. Reuse an existing tab with --target.`);
231
496
  }
232
- }
497
+ const { CDPClient } = await import('../cdp.js');
498
+ const client = new CDPClient(target.webSocketDebuggerUrl);
499
+ try {
500
+ try {
501
+ await client.send('Runtime.enable', {}, 5_000);
502
+ } catch (err) {
503
+ throw worldFailure(`capture session start could not attach to ${url}: ${err instanceof Error ? err.message : err}. Reuse an existing tab with --target.`, err);
504
+ }
505
+ return await waitForPageLoad(client, 10_000);
506
+ } finally {
507
+ client.close();
508
+ }
509
+ },
510
+ startBridge,
511
+ stopBridge,
512
+ publishActiveSession: setActiveSession,
513
+ };
233
514
 
234
- const id = generateId();
235
- const dir = sessionDir(id);
236
- ensurePrivateDir(path.join(dir, 'shots'));
237
- ensurePrivateDir(path.join(dir, 'a11y'));
515
+ let startWorld: SessionStartWorld = productionStartWorld;
238
516
 
239
- // Start HAR recording directly
240
- let harId: string | null = null;
241
- try {
242
- const harResult = createHarRecording();
243
- harId = harResult.id;
244
- } catch (err) {
245
- console.error(`Warning: could not start HAR recording: ${err instanceof Error ? err.message : err}`);
246
- }
517
+ /** Test seam: swap the effect world session start drives; pass nothing to restore production. */
518
+ export function __setSessionStartWorld(next?: Partial<SessionStartWorld>): void {
519
+ startWorld = next ? { ...productionStartWorld, ...next } : productionStartWorld;
520
+ }
247
521
 
248
- // Open tab if URL provided. Fail fast if the new target cannot attach.
249
- let targetId: string | null = null;
250
- let pageLoadTimedOut = false;
251
- let cdpPort: number | null = null;
252
- if (url || hold) {
253
- const { detectCdpPort } = await import('../cdp.js');
254
- cdpPort = await detectCdpPort();
522
+ /**
523
+ * Exact leaf positional cardinality, enforced at the leaf boundary BEFORE any
524
+ * filesystem/process/session effect (m7). The CLI validator in
525
+ * `src/cdp/args.ts` rejects the same shapes at the entrypoint; this wall
526
+ * covers the direct-call seam so surplus positionals are never silently
527
+ * ignored. Missing-argument rejection stays with each leaf's existing
528
+ * `missing_argument` check. Returns true when the invocation was rejected.
529
+ */
530
+ function rejectSurplusPositionals(parsed: ParsedArgs, leaf: string, max: number, usage: string): boolean {
531
+ const count = parsed.positional.length;
532
+ if (count <= max) return false;
533
+ const expected = max === 0 ? 'exactly 0' : max === 1 ? 'at most 1' : `at most ${max}`;
534
+ emitResult({
535
+ tag: 'error',
536
+ attrs: { command: `session ${leaf}`, code: 'invalid_input' },
537
+ summary: fact`received: ${count} positional argument(s); expected ${expected} — \`${usage}\`.`,
538
+ }, { json: parsed.json });
539
+ process.exitCode = 1;
540
+ return true;
541
+ }
542
+
543
+ /** One acquired resource: a label plus its reverse-order release. */
544
+ interface Acquisition {
545
+ label: string;
546
+ release(): Promise<void> | void;
547
+ }
548
+
549
+ async function start(parsed: ParsedArgs): Promise<void> {
550
+ if (parsed.help) {
551
+ console.log(START_USAGE);
552
+ return;
255
553
  }
256
- if (url) {
554
+
555
+ if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url>] [--hold]')) return;
556
+
557
+ return withLifecycleCoordinator(async () => {
558
+ const active = getActiveSession();
559
+ if (active && !isSessionStopped(active)) {
560
+ emitResult({
561
+ tag: 'error',
562
+ attrs: { command: 'session start', code: 'start_failed' },
563
+ summary: fact`A session is already active for this scope (${active.sessionId}); stop it first with \`session stop ${active.sessionId}\`.`,
564
+ followUp: text`Only one live session is allowed per capture scope at a time.`,
565
+ }, { json: parsed.json });
566
+ process.exitCode = 1;
567
+ return;
568
+ }
569
+
570
+ const url = parsed.url ?? null;
571
+ const hold = parsed.hold === true;
572
+ const id = generateId();
573
+ const dir = sessionDir(id);
574
+
575
+ // Every effect below is owned the instant it succeeds and released in exact
576
+ // reverse order if any later step throws. Nothing is committed outside this
577
+ // one transaction, so a failure leaves the scope byte-identical to before.
578
+ const acquired: Acquisition[] = [];
579
+ let harId: string | null = null;
580
+ let target: CDPTarget | null = null;
581
+ let pageLoadTimedOut = false;
582
+ let bridgeSocket: string | null = null;
583
+ let bridgePid: number | null = null;
584
+ let cdpPort: number | null = null;
585
+
257
586
  try {
258
- const { openTab, CDPClient } = await import('../cdp.js');
259
- const tab = await openTab(cdpPort!, url);
260
- targetId = tab.id;
261
-
262
- if (!targetId) {
263
- throw new Error(
264
- `capture session start could not attach to ${url}. Reuse an existing tab with --target.`,
265
- );
266
- }
267
- if (!tab.webSocketDebuggerUrl) {
268
- throw new Error(
269
- `capture session start could not attach to ${url}: missing WebSocket debugger URL. Reuse an existing tab with --target.`,
270
- );
271
- }
587
+ ensurePrivateDir(path.join(dir, 'shots'));
588
+ acquired.push({ label: 'artifact tree', release: () => removeArtifactTree(dir) });
272
589
 
273
- const client = new CDPClient(tab.webSocketDebuggerUrl);
274
- try {
275
- try {
276
- await client.send('Runtime.enable', {}, 5_000);
277
- } catch (err) {
278
- throw new Error(
279
- `capture session start could not attach to ${url}: ${err instanceof Error ? err.message : err}. Reuse an existing tab with --target.`,
280
- );
281
- }
282
- pageLoadTimedOut = await waitForPageLoad(client, 10_000);
283
- } finally {
284
- client.close();
590
+ harId = await startWorld.createHar(dir);
591
+ const acquiredHarId = harId;
592
+ acquired.push({ label: 'HAR recording', release: () => startWorld.deleteHar(acquiredHarId) });
593
+
594
+ if (url || hold) {
595
+ cdpPort = parsed.port ?? await startWorld.detectCdpPort();
285
596
  }
286
- } catch (err) {
287
- if (harId) {
288
- try {
289
- deleteHarRecording(harId);
290
- } catch {
291
- /* best effort */
292
- }
597
+
598
+ if (url) {
599
+ target = await startWorld.openTab(cdpPort!, url);
600
+ const openedPort = cdpPort!;
601
+ const openedTargetId = target.id;
602
+ acquired.push({ label: 'opened target', release: () => startWorld.closeTarget(openedPort, openedTargetId) });
603
+ pageLoadTimedOut = await startWorld.awaitTabReady(target, url);
293
604
  }
294
- throw err;
295
- }
296
- }
297
605
 
298
- // Hold a CDP browser connection open for the session's lifetime so
299
- // browser-level state (permission grants, ServiceWorker enablement)
300
- // survives across separate `capture` commands instead of reverting the
301
- // instant each command's own connection closes.
302
- let bridgeSocket: string | null = null;
303
- let bridgePid: number | null = null;
304
- if (hold) {
305
- try {
306
- const bridge = await startBridge(dir, cdpPort!);
307
- bridgeSocket = bridge.socketPath;
308
- bridgePid = bridge.pid;
309
- } catch (err) {
310
- if (harId) {
311
- try {
312
- deleteHarRecording(harId);
313
- } catch {
314
- /* best effort */
315
- }
606
+ // Hold a CDP browser connection open for the session's lifetime so
607
+ // browser-level state (permission grants, ServiceWorker enablement)
608
+ // survives across separate `capture` commands instead of reverting the
609
+ // instant each command's own connection closes.
610
+ if (hold) {
611
+ const bridge = await startWorld.startBridge(dir, cdpPort!);
612
+ bridgeSocket = bridge.socketPath;
613
+ bridgePid = bridge.pid;
614
+ acquired.push({ label: 'held bridge', release: () => startWorld.stopBridge(bridgePid, bridgeSocket) });
316
615
  }
317
- throw err;
616
+
617
+ const session: Session = {
618
+ sessionId: id,
619
+ dir,
620
+ harId,
621
+ startedAt: new Date().toISOString(),
622
+ stoppedAt: null,
623
+ stopping: false,
624
+ url,
625
+ targetId: target?.id ?? null,
626
+ port: cdpPort,
627
+ stepCount: 0,
628
+ logPids: [],
629
+ bridgeSocket,
630
+ bridgePid,
631
+ };
632
+ // The active index + `.session.json` are published together (last).
633
+ // Its rollback is a scope-owned compare-clear: a no-op unless THIS
634
+ // attempt's publication actually landed.
635
+ acquired.push({ label: 'active publication', release: () => clearActiveSessionIf(id) });
636
+ await startWorld.publishActiveSession(session);
637
+ } catch (primary) {
638
+ await rollbackStart(acquired, parsed, primary);
639
+ return;
318
640
  }
319
- }
320
641
 
321
- const session: Session = {
322
- id,
323
- dir,
324
- harId,
325
- startedAt: new Date().toISOString(),
326
- url,
327
- targetId,
328
- stepCount: 0,
329
- logPids: [],
330
- bridgeSocket,
331
- bridgePid,
332
- };
333
- writeJsonPrivate(sessionMetaPath(id), session);
334
-
335
- // Set as active session for auto-defaults
336
- setActiveSession({
337
- sessionId: id,
338
- dir,
339
- harId,
340
- targetId,
341
- stepCount: 0,
342
- bridgeSocket,
642
+ const rows: FactLine[] = [fact`bundle dir: ${dir}`];
643
+ if (target) rows.push(fact`tab ${target.id} opened at ${url ?? ''}`);
644
+ if (pageLoadTimedOut) rows.push(fact`page load timed out after 10000ms; the session stays attached to target ${target?.id ?? ''}`);
645
+ if (harId) rows.push(fact`HAR recording ${harId} — traffic auto-appends while the session is active`);
646
+ if (hold) rows.push(fact`held CDP bridge: pid ${bridgePid ?? 0}`);
647
+
648
+ emitResult({
649
+ tag: 'session',
650
+ attrs: { id, path: dir, ...(url ? { url } : {}) },
651
+ summary: fact`Session ${id} started; it is now the active capture context.`,
652
+ sections: [lineList(rows)],
653
+ followUp: fact`capture session stop ${id}`,
654
+ }, { json: parsed.json });
343
655
  });
656
+ }
344
657
 
345
- // Output for agent consumption
346
- const result = {
347
- sessionId: id,
348
- bundleDir: dir,
349
- harId,
350
- targetId,
351
- pageLoadTimedOut,
352
- shotsDir: path.join(dir, 'shots'),
353
- a11yDir: path.join(dir, 'a11y'),
354
- held: hold,
355
- };
356
- console.log(JSON.stringify(result, null, 2));
357
-
358
- // Agent-friendly next steps on stderr
359
- console.error(`\nCapture session started: ${id}`);
360
- if (targetId) {
361
- console.error(`Tab opened — session context active. No need to pass --target or --har.`);
362
- if (pageLoadTimedOut) {
363
- console.error(`Page load timed out, but the session is attached to target ${targetId.slice(0, 8)}. Use \`capture exec --target ${targetId.slice(0, 8)}\` or \`capture list\` if you need to recover it.`);
658
+ /**
659
+ * Releases every acquired resource in exact reverse order, then renders the
660
+ * primary start failure. Cleanup failures never replace the primary error —
661
+ * they are aggregated onto it so both survive.
662
+ */
663
+ async function rollbackStart(acquired: Acquisition[], parsed: ParsedArgs, primary: unknown): Promise<void> {
664
+ const cleanupFailures: string[] = [];
665
+ for (let i = acquired.length - 1; i >= 0; i -= 1) {
666
+ try {
667
+ await acquired[i].release();
668
+ } catch (error) {
669
+ cleanupFailures.push(`${acquired[i].label}: ${normalizeFailure(error).descriptor.message}`);
364
670
  }
365
671
  }
366
- if (hold) {
367
- console.error(`CDP bridge held (pid ${bridgePid}) — browser-level state now survives across commands via: capture cdp <Method> --browser [--params '<json>']`);
368
- }
369
- console.error(`\nWhen done: capture session stop ${id}`);
672
+
673
+ const primaryMessage = normalizeFailure(primary).descriptor.message;
674
+ const summary = cleanupFailures.length === 0
675
+ ? fact`Session could not start: ${primaryMessage}`
676
+ : fact`Session could not start: ${primaryMessage} (rollback also failed: ${cleanupFailures.join('; ')})`;
677
+
678
+ emitResult({
679
+ tag: 'error',
680
+ attrs: { command: 'session start', code: 'start_failed' },
681
+ summary,
682
+ followUp: text`Ensure a CDP-enabled browser is running — \`capture tab list\` is the probe.`,
683
+ }, { json: parsed.json });
684
+ process.exitCode = 1;
370
685
  }
371
686
 
372
- export function logCommand(rawArgs: string[]): void {
373
- const args = expandEqualsFlags(rawArgs);
374
- if (hasHelpFlag(args)) {
375
- console.log('Usage: capture log <path> [--name label] [--session <id>]');
687
+ // ============================================================================
688
+ // session log — shell-free, contained, identity-owned log tailing (C2)
689
+ // ============================================================================
690
+
691
+ async function logTail(parsed: ParsedArgs): Promise<void> {
692
+ if (parsed.help) {
693
+ console.log(LOG_USAGE);
376
694
  return;
377
695
  }
378
696
 
379
- const sourcePath = args[0];
697
+ if (rejectSurplusPositionals(parsed, 'log', 1, 'capture session log <path> [--name <label>] [--session <id>]')) return;
698
+
699
+ const sourcePath = parsed.positional[0];
380
700
  if (!sourcePath) {
381
- console.error('Usage: capture log <path> [--name label] [--session <id>]');
382
- process.exit(1);
701
+ emitResult({
702
+ tag: 'error',
703
+ attrs: { command: 'session log', code: 'missing_argument' },
704
+ summary: fact`received: \`session log\`; expected: \`session log <path> [--name <label>] [--session <id>]\`.`,
705
+ }, { json: parsed.json });
706
+ process.exitCode = 1;
707
+ return;
383
708
  }
384
709
 
385
- const resolved = path.resolve(sourcePath);
386
- if (!fs.existsSync(resolved)) {
387
- throw new Error(`Log file not found: ${resolved}`);
710
+ const source = path.resolve(sourcePath);
711
+ const name = parsed.name ?? path.basename(source, path.extname(source));
712
+ const labelReason = rejectLogLabel(name);
713
+ if (labelReason) {
714
+ const labelHint = parsed.name === undefined && labelReason === `exceeds ${MAX_LOG_LABEL_BYTES} bytes`
715
+ ? ' Pass `--name <short>` to choose a shorter destination label.'
716
+ : '';
717
+ emitResult({
718
+ tag: 'error',
719
+ attrs: { command: 'session log', code: 'invalid_label' },
720
+ summary: fact`invalid destination label (${labelReason}); expected one bounded filename component.${labelHint}`,
721
+ }, { json: parsed.json });
722
+ process.exitCode = 1;
723
+ return;
388
724
  }
389
725
 
390
- let name: string | null = null;
391
- let sessionId: string | null = null;
392
- for (let i = 1; i < args.length; i++) {
393
- if (args[i] === '--name' && args[i + 1]) name = args[++i];
394
- if (args[i] === '--session' && args[i + 1]) sessionId = args[++i];
726
+ // Source symlinks are valid caller selections; the path is passed only as one
727
+ // argv element to `tail` after the worker's `--` option terminator.
728
+ if (!fs.existsSync(source)) {
729
+ emitResult({
730
+ tag: 'error',
731
+ attrs: { command: 'session log', code: 'log_file_not_found' },
732
+ summary: fact`received: ${source}; expected an existing log file to follow.`,
733
+ }, { json: parsed.json });
734
+ process.exitCode = 1;
735
+ return;
395
736
  }
396
737
 
738
+ let sessionId = parsed.session ?? null;
397
739
  if (!sessionId) {
398
740
  const active = getActiveSession();
399
741
  if (!active) {
400
- throw new Error('No active capture session. Start one or pass --session <id>.');
742
+ emitResult({
743
+ tag: 'error',
744
+ attrs: { command: 'session log', code: 'no_active_session' },
745
+ summary: fact`received: \`session log\` with no --session and no active session; expected an active session or an explicit --session <id>.`,
746
+ followUp: text`Run \`capture session start\` or pass --session <id> from \`capture session list\`.`,
747
+ }, { json: parsed.json });
748
+ process.exitCode = 1;
749
+ return;
401
750
  }
402
751
  sessionId = active.sessionId;
403
752
  }
404
753
 
405
- const session = readSession(sessionId);
406
- name = name ?? path.basename(resolved, path.extname(resolved));
754
+ let session: Session;
755
+ try {
756
+ session = readSession(sessionId);
757
+ } catch {
758
+ emitResult({
759
+ tag: 'error',
760
+ attrs: { command: 'session log', code: 'unknown_session' },
761
+ summary: fact`No capture session found: ${sessionId}.`,
762
+ followUp: text`Run \`capture session list\` to see known sessions.`,
763
+ }, { json: parsed.json });
764
+ process.exitCode = 1;
765
+ return;
766
+ }
407
767
 
408
- const logsDir = path.join(session.dir, 'logs');
409
- ensurePrivateDir(logsDir);
768
+ // Finalized truth is checked before operation admission, directory creation,
769
+ // descriptor open, process spawn, socket creation, or metadata replacement.
770
+ if (isSessionStopped(session)) {
771
+ emitResult({
772
+ tag: 'error',
773
+ attrs: { command: 'session log', code: 'session_stopped' },
774
+ summary: fact`session ${session.sessionId} is finalized; its bundle is immutable and cannot accept a new log tailer.`,
775
+ }, { json: parsed.json });
776
+ process.exitCode = 1;
777
+ return;
778
+ }
410
779
 
411
- const destPath = path.join(logsDir, `${name}.log`);
412
- const outFd = fs.openSync(destPath, 'a', FILE_MODE);
413
- fs.chmodSync(destPath, FILE_MODE);
780
+ const operation = await admitSessionOperation(session.dir);
781
+ try {
782
+ const result = await withSessionLifecycle(session.dir, async () => startSessionLogTailer({
783
+ sessionDir: session.dir,
784
+ sourcePath: source,
785
+ name,
786
+ register: async record => {
787
+ const current = readSession(session.sessionId);
788
+ await updateSessionState(session.dir, {
789
+ logPids: [...(current.logPids ?? []), record],
790
+ });
791
+ },
792
+ unregister: async record => {
793
+ const current = readSession(session.sessionId);
794
+ await updateSessionState(session.dir, {
795
+ logPids: (current.logPids ?? []).filter(entry => entry.nonce !== record.nonce || entry.pid !== record.pid),
796
+ });
797
+ },
798
+ }));
799
+
800
+ emitResult({
801
+ tag: 'log-tail',
802
+ attrs: { session: session.sessionId, path: result.destPath },
803
+ summary: fact`Tailing ${source} into the session logs/ dir.`,
804
+ sections: [lineList([
805
+ fact`name: ${name}`,
806
+ fact`source: ${source}`,
807
+ fact`dest: ${result.destPath}`,
808
+ fact`tailer pid: ${result.pid} — drained at \`session stop\``,
809
+ ])],
810
+ }, { json: parsed.json });
811
+ } catch (error) {
812
+ emitResult(failureResult(error), { json: parsed.json });
813
+ process.exitCode = 1;
814
+ } finally {
815
+ await operation.release();
816
+ }
817
+ }
414
818
 
415
- const child = spawn(
416
- 'sh',
417
- ['-c', `tail -f "${resolved}" | perl -MPOSIX -ne 'print strftime("%Y-%m-%dT%H:%M:%SZ",gmtime())." ".$_'`],
418
- { detached: true, stdio: ['ignore', outFd, 'ignore'] },
419
- );
420
- child.unref();
421
- fs.closeSync(outFd);
819
+ // ============================================================================
820
+ // session har — the session-owned HAR read surface (D4)
821
+ // ============================================================================
422
822
 
423
- const pid = child.pid!;
424
- session.logPids.push({ pid, name, sourcePath: resolved });
425
- writeJsonPrivate(sessionMetaPath(session.id), session);
823
+ interface HarSource {
824
+ har: HarFile;
825
+ /** Absolute HAR file path — the block's full-fidelity pointer. */
826
+ path: string;
827
+ source: 'live' | 'bundle';
828
+ }
426
829
 
427
- console.log(JSON.stringify({ name, sourcePath: resolved, destPath, pid }, null, 2));
830
+ /**
831
+ * Locates a session's HAR data: the live accumulating recording while the
832
+ * session runs, the bundled `har.json` once it is stopped. Returns an
833
+ * `unavailable` reason instead of a source when the session has no readable
834
+ * HAR (recording never started, or the file is gone).
835
+ */
836
+ async function locateSessionHar(session: Session): Promise<HarSource | { unavailable: string }> {
837
+ const stopped = fs.existsSync(path.join(session.dir, 'bundle.json'));
838
+ if (stopped) {
839
+ const harPath = path.join(session.dir, 'har.json');
840
+ let raw: string;
841
+ try {
842
+ raw = readPrivateFile(harPath).toString('utf-8');
843
+ } catch (error) {
844
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
845
+ return { unavailable: 'the stopped session bundled no HAR (recording never started or captured nothing)' };
846
+ }
847
+ throw error;
848
+ }
849
+ let parsed: unknown;
850
+ try {
851
+ parsed = JSON.parse(raw);
852
+ } catch (cause) {
853
+ throw captureError('artifact', 'invalid_har_file', `bundled HAR at ${harPath} is not valid JSON; the record is corrupt and cannot be read as a HAR file.`, cause);
854
+ }
855
+ return { har: validateHarFile(parsed, `bundled HAR ${harPath}`), path: harPath, source: 'bundle' };
856
+ }
857
+ if (!session.harId) {
858
+ return { unavailable: 'the running session has no HAR recording (it could not be started with the session)' };
859
+ }
860
+ try {
861
+ const live = await readHarRecording(session.harId);
862
+ return { har: live, path: harFilePath(session.harId), source: 'live' };
863
+ } catch {
864
+ return { unavailable: `the live HAR recording file is missing: ${harFilePath(session.harId)}` };
865
+ }
866
+ }
867
+
868
+ /** One selection-list row: method, status, URL, body size, start time. Body
869
+ * content is NEVER inlined here (I-7) — `--full` is the only opt-in. */
870
+ function harEntryRow(e: HAREntry): FactLine {
871
+ const bodyText = e.response.content?.text;
872
+ const sizePart = typeof bodyText === 'string'
873
+ ? fact`${Buffer.byteLength(bodyText, 'utf-8')} bytes`
874
+ : text`body not captured`;
875
+ return line(
876
+ fact`${e.request.method} ${e.response.status} `,
877
+ data(e.request.url, 300),
878
+ text` — `,
879
+ sizePart,
880
+ fact` — started ${e.startedDateTime}`,
881
+ );
428
882
  }
429
883
 
430
- async function stop(args: string[]): Promise<void> {
431
- const id = args[0];
884
+ /** `--full` inline detail for one entry: headers, post data, and response
885
+ * body — every value escaped and capped through data()/fact. */
886
+ function harEntryDetail(e: HAREntry, index: number): FactLine {
887
+ const rows: FactLine[] = [line(fact`${index + 1}. `, harEntryRow(e))];
888
+ for (const h of e.request.headers ?? []) {
889
+ rows.push(fact` req ${h.name}: ${h.value}`);
890
+ }
891
+ if (e.request.postData?.text !== undefined) {
892
+ rows.push(fact` post data: ${capped(e.request.postData.text, 2000)}`);
893
+ }
894
+ for (const h of e.response.headers ?? []) {
895
+ rows.push(fact` res ${h.name}: ${h.value}`);
896
+ }
897
+ const bodyText = e.response.content?.text;
898
+ rows.push(
899
+ typeof bodyText === 'string'
900
+ ? fact` body: ${capped(bodyText, 2000)}`
901
+ : text` body: not captured`,
902
+ );
903
+ return lineList(rows);
904
+ }
905
+
906
+ async function har(parsed: ParsedArgs): Promise<void> {
907
+ if (parsed.help) {
908
+ console.log(HAR_USAGE);
909
+ return;
910
+ }
911
+
912
+ if (rejectSurplusPositionals(parsed, 'har', 1, 'capture session har [<session-id>] [--filter-status <spec>] …')) return;
913
+
914
+ // Leaf-local filter grammar parses BEFORE any session/HAR lookup (M9/A4):
915
+ // an invalid --filter-status wins over an unknown session or a corrupt/
916
+ // missing artifact, and the active-session index is never consulted for it.
917
+ let statusMatch: StatusPredicate | null = null;
918
+ if (parsed.filterStatus !== undefined) {
919
+ try {
920
+ statusMatch = parseStatusFilter(parsed.filterStatus);
921
+ } catch (error) {
922
+ const { descriptor } = normalizeFailure(error);
923
+ emitResult({
924
+ tag: 'error',
925
+ attrs: { command: 'session har', code: descriptor.code },
926
+ summary: fact`${descriptor.message}`,
927
+ }, { json: parsed.json });
928
+ process.exitCode = 1;
929
+ return;
930
+ }
931
+ }
932
+
933
+ let id = parsed.positional[0] ?? null;
432
934
  if (!id) {
433
- console.error('Usage: capture session stop <session-id>');
434
- process.exit(1);
935
+ const active = getActiveSession();
936
+ if (!active) {
937
+ emitResult({
938
+ tag: 'error',
939
+ attrs: { command: 'session har', code: 'no_active_session' },
940
+ summary: fact`received: \`session har\` with no <session-id> and no active session; expected an active session or an explicit session id.`,
941
+ followUp: text`Run \`capture session list\` to find a session id.`,
942
+ }, { json: parsed.json });
943
+ process.exitCode = 1;
944
+ return;
945
+ }
946
+ id = active.sessionId;
435
947
  }
436
948
 
437
- const session = readSession(id);
438
- const stoppedAt = new Date().toISOString();
439
- const startMs = new Date(session.startedAt).getTime();
440
- const duration = Date.now() - startMs;
949
+ let session: Session;
950
+ try {
951
+ session = readSession(id);
952
+ } catch {
953
+ emitResult({
954
+ tag: 'error',
955
+ attrs: { command: 'session har', code: 'unknown_session' },
956
+ summary: fact`No capture session found: ${id}.`,
957
+ followUp: text`Run \`capture session list\` to see known sessions.`,
958
+ }, { json: parsed.json });
959
+ process.exitCode = 1;
960
+ return;
961
+ }
962
+
963
+ const located = await locateSessionHar(session);
964
+ if ('unavailable' in located) {
965
+ emitResult({
966
+ tag: 'error',
967
+ attrs: { command: 'session har', code: 'har_unavailable' },
968
+ summary: fact`Session ${id} has no readable HAR: ${located.unavailable}.`,
969
+ }, { json: parsed.json });
970
+ process.exitCode = 1;
971
+ return;
972
+ }
441
973
 
442
- // Kill log tailers
443
- for (const lp of session.logPids ?? []) {
444
- try { process.kill(-lp.pid, 'SIGTERM'); } catch { /* already dead */ }
974
+ let entries = located.har.log.entries;
975
+ const total = entries.length;
976
+ const filters: string[] = [];
977
+
978
+ if (parsed.filterUrl) {
979
+ const pattern = parsed.filterUrl;
980
+ let re: RegExp | null = null;
981
+ try { re = new RegExp(pattern, 'i'); } catch { re = null; }
982
+ entries = entries.filter((e) =>
983
+ re ? re.test(e.request.url) : e.request.url.toLowerCase().includes(pattern.toLowerCase()),
984
+ );
985
+ filters.push(`url~${pattern}`);
445
986
  }
446
- if (session.logPids?.length) {
447
- await new Promise((r) => setTimeout(r, 200));
987
+ if (statusMatch) {
988
+ const matches = statusMatch;
989
+ entries = entries.filter((e) => matches(e.response.status));
990
+ filters.push(`status=${parsed.filterStatus}`);
448
991
  }
992
+ if (parsed.filterMethod) {
993
+ const m = parsed.filterMethod.toUpperCase();
994
+ entries = entries.filter((e) => e.request.method.toUpperCase() === m);
995
+ filters.push(`method=${m}`);
996
+ }
997
+ if (typeof parsed.limit === 'number' && parsed.limit > 0) {
998
+ entries = entries.slice(0, parsed.limit);
999
+ filters.push(`limit=${parsed.limit}`);
1000
+ }
1001
+
1002
+ const summary = filters.length > 0
1003
+ ? fact`${entries.length} of ${total} entries match (${filters.join(', ')}).`
1004
+ : fact`${total} entries.`;
1005
+
1006
+ const sections = parsed.full
1007
+ ? entries.map((e, i) => harEntryDetail(e, i))
1008
+ : [lineList(entries.map((e, i) => line(fact`${i + 1}. `, harEntryRow(e))))];
1009
+
1010
+ emitResult({
1011
+ tag: 'session-har',
1012
+ attrs: {
1013
+ id,
1014
+ path: located.path,
1015
+ source: located.source,
1016
+ entries: entries.length,
1017
+ total,
1018
+ },
1019
+ summary,
1020
+ sections,
1021
+ }, { json: parsed.json });
1022
+ }
1023
+
1024
+ function bundleRows(manifest: BundleManifest): FactLine[] {
1025
+ return [
1026
+ fact`shots: ${manifest.shots.length}`,
1027
+ manifest.har ? fact`har: ${manifest.har.entryCount} entries — ${manifest.har.path}` : fact`har: 0 entries`,
1028
+ fact`logs: ${manifest.logs.length}`,
1029
+ fact`measure snaps: ${manifest.snaps.length}`,
1030
+ fact`motion recs: ${manifest.recs.length}`,
1031
+ fact`other: ${manifest.other.length}`,
1032
+ ];
1033
+ }
449
1034
 
450
- // Tear down the held CDP bridge, if any — releases the browser-level
451
- // connection (and any grants/target-enablement it was keeping alive).
452
- if (session.bridgePid || session.bridgeSocket) {
453
- stopBridge(session.bridgePid, session.bridgeSocket);
1035
+ function emitStoppedFromManifest(id: string, sessionDir: string, manifest: BundleManifest, parsed: ParsedArgs): void {
1036
+ emitResult({
1037
+ tag: 'session-stopped',
1038
+ attrs: { id, path: path.join(sessionDir, 'bundle.json') },
1039
+ summary: fact`Session ${id} stopped after ${manifest.duration}ms; bundle manifest written.`,
1040
+ sections: [lineList(bundleRows(manifest))],
1041
+ followUp: fact`capture session view ${id}`,
1042
+ }, { json: parsed.json });
1043
+ }
1044
+
1045
+ async function stop(parsed: ParsedArgs): Promise<void> {
1046
+ if (parsed.help) {
1047
+ console.log(STOP_USAGE);
1048
+ return;
454
1049
  }
455
1050
 
456
- // Collect screenshots
457
- const shotsDir = path.join(session.dir, 'shots');
458
- const screenshots = fs.existsSync(shotsDir)
459
- ? fs.readdirSync(shotsDir)
460
- .filter((f) => f.endsWith('.png') || f.endsWith('.jpg'))
461
- .map((f) => ({ name: f, path: path.join(shotsDir, f) }))
462
- : [];
1051
+ if (rejectSurplusPositionals(parsed, 'stop', 1, 'capture session stop <session-id>')) return;
463
1052
 
464
- // Collect a11y snapshots
465
- const a11yDir = path.join(session.dir, 'a11y');
466
- const a11y = fs.existsSync(a11yDir)
467
- ? fs.readdirSync(a11yDir)
468
- .filter((f) => f.endsWith('.json') || f.endsWith('.txt'))
469
- .map((f) => ({ name: f, path: path.join(a11yDir, f) }))
470
- : [];
1053
+ const id = parsed.positional[0];
1054
+ if (!id) {
1055
+ emitResult({
1056
+ tag: 'error',
1057
+ attrs: { command: 'session stop', code: 'missing_argument' },
1058
+ summary: fact`received: \`session stop\`; expected: \`session stop <session-id>\`.`,
1059
+ followUp: text`Run \`capture session list\` to find a session id.`,
1060
+ }, { json: parsed.json });
1061
+ process.exitCode = 1;
1062
+ return;
1063
+ }
471
1064
 
472
- // Collect HAR directly from har-manager
473
- let har: BundleManifest['har'] = null;
474
- if (session.harId) {
1065
+ return withLifecycleCoordinator(async () => {
1066
+ let session: Session;
475
1067
  try {
476
- const harData = readHarRecording(session.harId);
477
- if (harData) {
1068
+ session = readSession(id);
1069
+ } catch {
1070
+ emitResult({
1071
+ tag: 'error',
1072
+ attrs: { command: 'session stop', code: 'unknown_session' },
1073
+ summary: fact`No capture session found: ${id}.`,
1074
+ followUp: text`Run \`capture session list\` to see known sessions.`,
1075
+ }, { json: parsed.json });
1076
+ process.exitCode = 1;
1077
+ return;
1078
+ }
1079
+
1080
+ const stopAdmission = await beginSessionStop(session.dir);
1081
+ let committed = false;
1082
+ try {
1083
+ // A process waiting behind another stop must re-read finalized truth after
1084
+ // winning the per-session stop lock. Existing bundles are immutable.
1085
+ session = readSession(id);
1086
+ const bundlePath = path.join(session.dir, 'bundle.json');
1087
+ try {
1088
+ const manifest = readBundleManifest(bundlePath);
1089
+ // The bundle commit is authoritative even if the committing process
1090
+ // died before post-commit cleanup. Complete those idempotent steps
1091
+ // without ever replacing the immutable manifest.
1092
+ if (session.harId) {
1093
+ try { await deleteHarRecording(session.harId); }
1094
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
1095
+ }
1096
+ await updateSessionState(session.dir, { stoppedAt: manifest.stoppedAt, stopping: false });
1097
+ clearActiveSessionIf(id);
1098
+ emitStoppedFromManifest(id, session.dir, manifest, parsed);
1099
+ committed = true;
1100
+ return;
1101
+ } catch (error) {
1102
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
1103
+ if (session.stoppedAt) throw new Error(`finalized session ${id} is missing its immutable bundle`);
1104
+ }
1105
+
1106
+ session = await updateSessionState(session.dir, { stopping: true });
1107
+ const stoppedAt = new Date().toISOString();
1108
+ const startMs = new Date(session.startedAt ?? stoppedAt).getTime();
1109
+ const duration = Date.now() - startMs;
1110
+
1111
+ // Drain/terminate every registered log tailer identity-first BEFORE the
1112
+ // bundle is committed: a live writer into logs/ would falsify the immutable
1113
+ // bundle. A verified-alive tailer that cannot be drained or terminated
1114
+ // throws here and fails the stop, preserving the failure.
1115
+ await stopSessionLogTailers(session.logPids ?? []);
1116
+ if (session.bridgePid || session.bridgeSocket) stopBridge(session.bridgePid, session.bridgeSocket);
1117
+
1118
+ let recorderTeardown: Awaited<ReturnType<typeof teardownAnyLiveRecorderAtSessionStop>> | null = null;
1119
+ try {
1120
+ recorderTeardown = await teardownAnyLiveRecorderAtSessionStop(session.dir);
1121
+ } catch (error) {
1122
+ // A terminal `rec-stop` failure (the bridge authenticated the request
1123
+ // and explicitly refused — e.g. a fatal HAR drain) must NOT be
1124
+ // swallowed into a warning: it escapes to the outer `stop_failed`
1125
+ // lane below, so no bundle is committed and no live HAR/recorder
1126
+ // handle is deleted while admitted append work may still be lost.
1127
+ // Non-fatal teardown noise (dead handle, liveness-unknown, malformed
1128
+ // handle) keeps the existing warn-and-continue policy.
1129
+ if (isTerminalRecStopFailure(error)) throw error;
1130
+ console.error(`Warning: could not finalize active recording: ${error instanceof Error ? error.message : error}`);
1131
+ }
1132
+
1133
+ const shotsDir = path.join(session.dir, 'shots');
1134
+ const shots = fs.existsSync(shotsDir)
1135
+ ? fs.readdirSync(shotsDir).filter(name => name.endsWith('.png') || name.endsWith('.jpg')).map(name => ({ name, path: path.join(shotsDir, name) }))
1136
+ : [];
1137
+
1138
+ let har: BundleManifest['har'] = null;
1139
+ if (session.harId) {
1140
+ const harData = await readHarRecording(session.harId);
478
1141
  const harPath = path.join(session.dir, 'har.json');
479
1142
  writeJsonPrivate(harPath, harData);
480
1143
  har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
481
- // Clean up the HAR recording
482
- try { deleteHarRecording(session.harId); } catch { /* best effort */ }
483
1144
  }
484
- } catch (err) {
485
- console.error(`Warning: could not read HAR: ${err instanceof Error ? err.message : err}`);
1145
+
1146
+ const logsDir = path.join(session.dir, 'logs');
1147
+ const logs = fs.existsSync(logsDir)
1148
+ ? fs.readdirSync(logsDir).filter(name => name.endsWith('.log')).map(name => {
1149
+ const filePath = path.join(logsDir, name);
1150
+ const content = fs.readFileSync(filePath, 'utf-8');
1151
+ return { name, path: filePath, lines: content ? content.split('\n').filter(Boolean).length : 0 };
1152
+ })
1153
+ : [];
1154
+ const snaps = collectSnaps(session.dir);
1155
+ const recs = collectRecs(session.dir);
1156
+ const knownDirs = new Set(['shots', 'logs', 'measure', 'motion', '.har', '.stop.lock', '.operations.lock']);
1157
+ const knownFiles = new Set(['.session.json', '.operations.json', 'har.json', 'bundle.json', 'bridge.sock']);
1158
+ const other = fs.readdirSync(session.dir)
1159
+ .filter(name => !knownDirs.has(name) && !knownFiles.has(name))
1160
+ .map(name => ({ name, path: path.join(session.dir, name) }));
1161
+
1162
+ const manifest: BundleManifest = {
1163
+ id: session.sessionId,
1164
+ startedAt: session.startedAt ?? '',
1165
+ stoppedAt,
1166
+ duration,
1167
+ url: session.url ?? null,
1168
+ shots,
1169
+ har,
1170
+ logs,
1171
+ other,
1172
+ snaps,
1173
+ recs,
1174
+ pendingViewportRestorations: recorderTeardown?.pendingViewportRestorations ?? [],
1175
+ };
1176
+
1177
+ // The immutable bundle is the commit point. Live HAR deletion, stopped
1178
+ // metadata, and pointer compare-clear are strictly post-commit.
1179
+ writeJsonPrivate(bundlePath, manifest);
1180
+ if (session.harId) await deleteHarRecording(session.harId);
1181
+ await updateSessionState(session.dir, { stoppedAt, stopping: false });
1182
+ clearActiveSessionIf(id);
1183
+ committed = true;
1184
+
1185
+ const rows = bundleRows(manifest);
1186
+ if (recorderTeardown && 'recId' in recorderTeardown) rows.push(fact`recorder ${recorderTeardown.recId} finalized — state ${recorderTeardown.state}, viewport restored ${String(recorderTeardown.viewportRestored)}`);
1187
+ if (recorderTeardown?.pendingViewportRestorations.length) rows.push(fact`pending viewport restorations: ${recorderTeardown.pendingViewportRestorations.length}`);
1188
+ emitResult({
1189
+ tag: 'session-stopped',
1190
+ attrs: { id, path: bundlePath },
1191
+ summary: fact`Session ${id} stopped after ${duration}ms; bundle manifest written.`,
1192
+ sections: [lineList(rows)],
1193
+ followUp: fact`capture session view ${id}`,
1194
+ }, { json: parsed.json });
1195
+ } catch (error) {
1196
+ try { await updateSessionState(session.dir, { stopping: false }); } catch { /* preserve stop failure */ }
1197
+ emitResult({
1198
+ tag: 'error',
1199
+ attrs: { command: 'session stop', code: 'stop_failed' },
1200
+ summary: fact`Session ${id} could not be stopped: ${error instanceof Error ? error.message : String(error)}`,
1201
+ }, { json: parsed.json });
1202
+ process.exitCode = 1;
1203
+ } finally {
1204
+ await stopAdmission.finish(committed);
486
1205
  }
1206
+ });
1207
+ }
1208
+
1209
+ function list(parsed: ParsedArgs): void {
1210
+ if (parsed.help) {
1211
+ console.log(LIST_USAGE);
1212
+ return;
487
1213
  }
488
1214
 
489
- // Collect log files
490
- const logsDir = path.join(session.dir, 'logs');
491
- const logs = fs.existsSync(logsDir)
492
- ? fs.readdirSync(logsDir)
493
- .filter((f) => f.endsWith('.log'))
494
- .map((f) => {
495
- const filePath = path.join(logsDir, f);
496
- const content = fs.readFileSync(filePath, 'utf-8');
497
- const lines = content ? content.split('\n').filter(Boolean).length : 0;
498
- return { name: f, path: filePath, lines };
1215
+ if (rejectSurplusPositionals(parsed, 'list', 0, 'capture session list')) return;
1216
+
1217
+ const sessions = fs.existsSync(CAPTURE_ROOT)
1218
+ ? fs.readdirSync(CAPTURE_ROOT)
1219
+ .filter((d) => fs.existsSync(sessionMetaPath(d)))
1220
+ .map((d) => {
1221
+ const session = readSession(d);
1222
+ const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
1223
+ return { id: session.sessionId, startedAt: session.startedAt ?? '', url: session.url ?? null, status: hasBundled ? 'stopped' : 'active' };
499
1224
  })
500
1225
  : [];
501
1226
 
502
- // Collect measure snapshots and motion recordings
503
- const snaps = collectSnaps(session.dir);
504
- const recs = collectRecs(session.dir);
505
-
506
- // Collect anything else dropped in the session dir
507
- const knownDirs = new Set(['shots', 'a11y', 'logs', 'measure', 'motion']);
508
- const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json', 'bridge.sock']);
509
- const other = fs.readdirSync(session.dir)
510
- .filter((f) => !knownDirs.has(f) && !knownFiles.has(f))
511
- .map((f) => ({ name: f, path: path.join(session.dir, f) }));
512
-
513
- // Clear active session context
514
- clearActiveSession();
515
-
516
- const manifest: BundleManifest = {
517
- id: session.id,
518
- startedAt: session.startedAt,
519
- stoppedAt,
520
- duration,
521
- url: session.url,
522
- screenshots,
523
- har,
524
- a11y,
525
- logs,
526
- other,
527
- snaps,
528
- recs,
529
- };
530
-
531
- const bundlePath = path.join(session.dir, 'bundle.json');
532
- writeJsonPrivate(bundlePath, manifest);
533
-
534
- console.log(JSON.stringify({
535
- bundlePath,
536
- summary: {
537
- duration,
538
- screenshots: screenshots.length,
539
- harEntries: har?.entryCount ?? 0,
540
- a11ySnapshots: a11y.length,
541
- logFiles: logs.length,
542
- otherFiles: other.length,
543
- snaps: snaps.length,
544
- recs: recs.length,
545
- },
546
- }, null, 2));
547
-
548
- console.error(`\nBundle written: ${bundlePath}`);
549
- console.error(`Read it: capture session view ${id}`);
550
- }
551
-
552
- function list(): void {
553
- if (!fs.existsSync(CAPTURE_ROOT)) {
554
- console.log('[]');
1227
+ if (sessions.length === 0) {
1228
+ emitResult({
1229
+ tag: 'sessions',
1230
+ attrs: { count: 0 },
1231
+ summary: text`No capture sessions.`,
1232
+ }, { json: parsed.json });
555
1233
  return;
556
1234
  }
557
1235
 
558
- const sessions = fs.readdirSync(CAPTURE_ROOT)
559
- .filter((d) => fs.existsSync(sessionMetaPath(d)))
560
- .map((d) => {
561
- const session = readSession(d);
562
- const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
563
- return { id: session.id, startedAt: session.startedAt, url: session.url, status: hasBundled ? 'stopped' : 'active' };
564
- });
1236
+ const rows = sessions.map((s) =>
1237
+ line(
1238
+ fact`${s.id} — ${s.status} — started ${s.startedAt}`,
1239
+ s.url ? fact` — ${s.url}` : text``,
1240
+ ),
1241
+ );
565
1242
 
566
- console.log(JSON.stringify(sessions, null, 2));
1243
+ emitResult({
1244
+ tag: 'sessions',
1245
+ attrs: { count: sessions.length },
1246
+ sections: [lineList(rows)],
1247
+ }, { json: parsed.json });
567
1248
  }
568
1249
 
569
- // `session view --filter <name>` reads a manifest section by the same key
570
- // name for most sections (screenshots|har|a11y|logs|other), but `measure`
571
- // and `motion` are the query-facing filter names for the `snaps`/`recs`
572
- // manifest keys (matching the `measure`/`motion` command branches, not the
573
- // artifact-file naming), so they need an explicit alias.
574
- const VIEW_FILTER_ALIASES: Record<string, keyof BundleManifest> = {
575
- measure: 'snaps',
576
- motion: 'recs',
577
- };
1250
+ /** Renders one manifest section into the per-entry rows the view command prints. */
1251
+ function sectionRows(manifest: BundleManifest, key: SectionKey): FactLine[] {
1252
+ switch (key) {
1253
+ case 'shots':
1254
+ return manifest.shots.map((s) => fact`${s.name} — ${s.path}`);
1255
+ case 'har':
1256
+ return manifest.har
1257
+ ? [fact`har.json — ${manifest.har.entryCount} entries — ${manifest.har.path}`]
1258
+ : [];
1259
+ case 'logs':
1260
+ return manifest.logs.map((l) => fact`${l.name} — ${l.lines} lines — ${l.path}`);
1261
+ case 'snaps':
1262
+ return manifest.snaps.map((s) =>
1263
+ fact`${s.id} — url ${s.url ?? '(none)'} — viewport ${s.viewport ?? '(none)'} — settled ${String(s.settled)} — ${s.path}`);
1264
+ case 'recs':
1265
+ return manifest.recs.map((r) =>
1266
+ fact`${r.id} — ${r.action ?? '(none)'} — ${r.frames} frames, ${r.durationMs}ms, ${r.state} — ${r.path}`);
1267
+ case 'other':
1268
+ return manifest.other.map((o) => fact`${o.name} — ${o.path}`);
1269
+ }
1270
+ }
578
1271
 
579
- function view(rawArgs: string[]): void {
580
- const args = expandEqualsFlags(rawArgs);
581
- const id = args[0];
582
- if (!id) {
583
- console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y|logs|other|measure|motion]');
584
- process.exit(1);
1272
+ function view(parsed: ParsedArgs): void {
1273
+ if (parsed.help) {
1274
+ console.log(VIEW_USAGE);
1275
+ return;
585
1276
  }
586
1277
 
587
- const session = readSession(id);
588
- const bundlePath = path.join(session.dir, 'bundle.json');
1278
+ if (rejectSurplusPositionals(parsed, 'view', 1, 'capture session view <session-id> [--filter <section>]')) return;
589
1279
 
590
- if (!fs.existsSync(bundlePath)) {
591
- console.error(`Session ${id} hasn't been stopped yet. Run: capture session stop ${id}`);
592
- process.exit(1);
1280
+ const id = parsed.positional[0];
1281
+ if (!id) {
1282
+ emitResult({
1283
+ tag: 'error',
1284
+ attrs: { command: 'session view', code: 'missing_argument' },
1285
+ summary: fact`received: \`session view\`; expected: \`session view <session-id>\`.`,
1286
+ followUp: text`Run \`capture session list\` to find a session id.`,
1287
+ }, { json: parsed.json });
1288
+ process.exitCode = 1;
1289
+ return;
593
1290
  }
594
1291
 
595
- const manifest = JSON.parse(fs.readFileSync(bundlePath, 'utf-8')) as BundleManifest;
1292
+ let session: Session;
1293
+ try {
1294
+ session = readSession(id);
1295
+ } catch {
1296
+ emitResult({
1297
+ tag: 'error',
1298
+ attrs: { command: 'session view', code: 'unknown_session' },
1299
+ summary: fact`No capture session found: ${id}.`,
1300
+ followUp: text`Run \`capture session list\` to see known sessions.`,
1301
+ }, { json: parsed.json });
1302
+ process.exitCode = 1;
1303
+ return;
1304
+ }
596
1305
 
597
- const filter = args.find((_, i) => args[i - 1] === '--filter');
598
- if (filter) {
599
- const key = VIEW_FILTER_ALIASES[filter] ?? (filter as keyof BundleManifest);
600
- const section = manifest[key];
601
- console.log(JSON.stringify(section, null, 2));
602
- } else {
603
- console.log(JSON.stringify(manifest, null, 2));
1306
+ const bundlePath = path.join(session.dir, 'bundle.json');
1307
+ let manifest: BundleManifest;
1308
+ try {
1309
+ manifest = readBundleManifest(bundlePath);
1310
+ } catch (error) {
1311
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
1312
+ emitResult({
1313
+ tag: 'error',
1314
+ attrs: { command: 'session view', code: 'session_not_stopped' },
1315
+ summary: fact`Session ${id} has not been stopped; there is no bundle manifest yet.`,
1316
+ followUp: fact`capture session stop ${id}`,
1317
+ }, { json: parsed.json });
1318
+ process.exitCode = 1;
1319
+ return;
1320
+ }
1321
+ throw error;
604
1322
  }
605
- }
606
1323
 
607
- export async function sessionMain(args: string[]): Promise<void> {
608
- const [subcommand, ...rest] = args;
1324
+ if (parsed.filter !== undefined) {
1325
+ const key = VIEW_FILTERS[parsed.filter];
1326
+ if (!key) {
1327
+ emitResult({
1328
+ tag: 'error',
1329
+ attrs: { command: 'session view', code: 'invalid_filter' },
1330
+ summary: fact`received: --filter ${parsed.filter}; expected one of: shots, har, logs, measure, motion, other.`,
1331
+ followUp: text`Re-run \`capture session view <id> --filter <section>\` with a valid section.`,
1332
+ }, { json: parsed.json });
1333
+ process.exitCode = 1;
1334
+ return;
1335
+ }
609
1336
 
610
- if (!subcommand || hasHelpFlag(args)) {
611
- printSessionHelp();
1337
+ const rows = sectionRows(manifest, key);
1338
+ emitResult({
1339
+ tag: 'session',
1340
+ attrs: { id, path: bundlePath, filter: parsed.filter },
1341
+ summary: fact`${parsed.filter}: ${rows.length} entries`,
1342
+ sections: [lineList(rows)],
1343
+ }, { json: parsed.json });
612
1344
  return;
613
1345
  }
614
1346
 
615
- switch (subcommand) {
1347
+ const keys: SectionKey[] = ['shots', 'har', 'logs', 'snaps', 'recs', 'other'];
1348
+ const sections = keys.map((key) => {
1349
+ const rows = sectionRows(manifest, key);
1350
+ return lineList([
1351
+ fact`${SECTION_LABELS[key]}: ${rows.length}`,
1352
+ ...rows.map((r) => line(text` `, r)),
1353
+ ]);
1354
+ });
1355
+
1356
+ emitResult({
1357
+ tag: 'session',
1358
+ attrs: { id, path: bundlePath },
1359
+ summary: fact`Session ${id}: started ${manifest.startedAt}, stopped ${manifest.stoppedAt}, ${manifest.duration}ms.`,
1360
+ sections,
1361
+ }, { json: parsed.json });
1362
+ }
1363
+
1364
+ export async function sessionMain(parsed: ParsedArgs, _args: string[]): Promise<void> {
1365
+ const leaf = parsed.positional[0];
1366
+ const rest: ParsedArgs = { ...parsed, positional: parsed.positional.slice(1) };
1367
+
1368
+ switch (leaf) {
616
1369
  case 'start': return start(rest);
617
1370
  case 'stop': return stop(rest);
618
- case 'list': return list();
1371
+ case 'list': return list(rest);
619
1372
  case 'view': return view(rest);
620
- default:
1373
+ case 'har': return har(rest);
1374
+ case 'log': return logTail(rest);
1375
+ case undefined:
621
1376
  printSessionHelp();
1377
+ return;
1378
+ default:
1379
+ emitResult({
1380
+ tag: 'error',
1381
+ attrs: { command: 'session', code: 'unknown_subcommand' },
1382
+ summary: fact`received: \`session ${leaf}\`; expected one of: start, stop, list, view, har, log.`,
1383
+ followUp: text`Run \`capture session -h\` for usage.`,
1384
+ }, { json: parsed.json });
1385
+ process.exitCode = 1;
1386
+ return;
622
1387
  }
623
1388
  }