@crouton-kit/capture 1.3.7 → 1.3.9

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 (141) hide show
  1. package/bin/capture +32786 -9989
  2. package/package.json +9 -3
  3. package/src/capture.ts +86 -163
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -116
  6. package/src/cdp/bridge/protocol.ts +187 -1
  7. package/src/cdp/bridge/server.ts +211 -84
  8. package/src/cdp/bridge/spawn.ts +110 -1
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +35 -4
  11. package/src/cdp/commands/cdp.ts +266 -69
  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 +87 -0
  15. package/src/cdp/commands/measure/check.ts +109 -0
  16. package/src/cdp/commands/measure/diff.ts +120 -0
  17. package/src/cdp/commands/measure/explain.ts +133 -0
  18. package/src/cdp/commands/measure/index.ts +106 -0
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +52 -0
  21. package/src/cdp/commands/measure/map-layers.ts +59 -0
  22. package/src/cdp/commands/measure/map-scroll.ts +94 -0
  23. package/src/cdp/commands/measure/snap.ts +416 -0
  24. package/src/cdp/commands/measure/sweep.ts +227 -0
  25. package/src/cdp/commands/motion/index.ts +61 -0
  26. package/src/cdp/commands/motion/jank.ts +132 -0
  27. package/src/cdp/commands/motion/mask.ts +92 -0
  28. package/src/cdp/commands/motion/rec.ts +534 -0
  29. package/src/cdp/commands/motion/response.ts +76 -0
  30. package/src/cdp/commands/motion/timeline.ts +152 -0
  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/coordinates.ts +158 -0
  46. package/src/cdp/detect.ts +42 -13
  47. package/src/cdp/dispatch.ts +76 -26
  48. package/src/cdp/domains.ts +75 -0
  49. package/src/cdp/har-recorder.ts +648 -115
  50. package/src/cdp/leaf-grammar.ts +99 -0
  51. package/src/cdp/measure/census.ts +205 -0
  52. package/src/cdp/measure/check.ts +318 -0
  53. package/src/cdp/measure/collectors/animation.ts +385 -0
  54. package/src/cdp/measure/collectors/ax.ts +278 -0
  55. package/src/cdp/measure/collectors/focus.ts +1041 -0
  56. package/src/cdp/measure/collectors/forms.ts +905 -0
  57. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  58. package/src/cdp/measure/collectors/hittest.ts +835 -0
  59. package/src/cdp/measure/collectors/layers.ts +697 -0
  60. package/src/cdp/measure/collectors/media.ts +522 -0
  61. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  62. package/src/cdp/measure/collectors/queries.ts +464 -0
  63. package/src/cdp/measure/collectors/scroll.ts +599 -0
  64. package/src/cdp/measure/collectors/states.ts +992 -0
  65. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  66. package/src/cdp/measure/collectors/styles.ts +355 -0
  67. package/src/cdp/measure/collectors/text.ts +713 -0
  68. package/src/cdp/measure/diff.ts +470 -0
  69. package/src/cdp/measure/explain.ts +498 -0
  70. package/src/cdp/measure/map-ax.ts +633 -0
  71. package/src/cdp/measure/map-focus.ts +188 -0
  72. package/src/cdp/measure/map-layers.ts +177 -0
  73. package/src/cdp/measure/map-scroll.ts +317 -0
  74. package/src/cdp/measure/redaction.ts +51 -0
  75. package/src/cdp/measure/settle.ts +1051 -0
  76. package/src/cdp/measure/snapshot.ts +390 -0
  77. package/src/cdp/measure/sweep.ts +330 -0
  78. package/src/cdp/measure/types.ts +220 -0
  79. package/src/cdp/measure/v2/collectors.ts +19 -0
  80. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  81. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  82. package/src/cdp/measure/v2/target-lock.ts +15 -0
  83. package/src/cdp/measure/v2/target.ts +16 -0
  84. package/src/cdp/motion/jank.ts +433 -0
  85. package/src/cdp/motion/mask.ts +421 -0
  86. package/src/cdp/motion/recorder.ts +764 -0
  87. package/src/cdp/motion/response.ts +356 -0
  88. package/src/cdp/motion/timeline.ts +215 -0
  89. package/src/cdp/record.ts +5 -212
  90. package/src/cdp/recorder-bridge.ts +1894 -0
  91. package/src/cdp/recorder-client.ts +326 -0
  92. package/src/cdp/scope-lock.ts +73 -0
  93. package/src/cdp/screenshot.ts +199 -66
  94. package/src/cdp/source-map.ts +309 -0
  95. package/src/cdp/targets.ts +21 -1
  96. package/src/cdp/timing.ts +87 -0
  97. package/src/cdp/types.ts +87 -6
  98. package/src/cdp/viewport.ts +21 -0
  99. package/src/cdp.ts +12 -58
  100. package/src/command-help.ts +29 -0
  101. package/src/command-resolution.ts +22 -0
  102. package/src/command-walker.ts +35 -0
  103. package/src/contracts/a11y.ts +33 -0
  104. package/src/contracts/cursor.ts +35 -0
  105. package/src/contracts/index.ts +15 -0
  106. package/src/contracts/motion.ts +19 -0
  107. package/src/contracts/neutral.ts +56 -0
  108. package/src/contracts/primitives.ts +205 -0
  109. package/src/contracts/release.ts +17 -0
  110. package/src/contracts/results.ts +208 -0
  111. package/src/contracts/routes.ts +140 -0
  112. package/src/contracts/snapshot.ts +92 -0
  113. package/src/cursor/index.ts +13 -0
  114. package/src/cursor/service.ts +310 -0
  115. package/src/errors.ts +71 -0
  116. package/src/har-manager.ts +992 -41
  117. package/src/interact.ts +410 -98
  118. package/src/output/artifact-lifecycle.ts +371 -0
  119. package/src/output/artifact.ts +526 -0
  120. package/src/output/diff.ts +111 -0
  121. package/src/output/exact-raw.ts +114 -0
  122. package/src/output/render.ts +987 -0
  123. package/src/output/selector.ts +185 -0
  124. package/src/output/structured.ts +211 -0
  125. package/src/registry.ts +208 -0
  126. package/src/session/artifacts.ts +481 -0
  127. package/src/session/commands.ts +1237 -368
  128. package/src/session/coordinator.ts +251 -0
  129. package/src/session/har-filter.ts +57 -0
  130. package/src/session/log-tailer.ts +653 -0
  131. package/src/session-context.ts +302 -34
  132. package/src/vault/bundle.ts +32 -17
  133. package/src/cdp/commands/exec.ts +0 -105
  134. package/src/cdp/commands/har.ts +0 -128
  135. package/src/cdp/commands/tabs.ts +0 -183
  136. package/src/cdp/commands/traffic.ts +0 -121
  137. package/src/cdp/commands/ui.ts +0 -172
  138. package/src/cdp/execute.ts +0 -113
  139. package/src/cdp/har-output.ts +0 -47
  140. package/src/cdp/usage.ts +0 -77
  141. package/src/tab-session.ts +0 -91
@@ -1,41 +1,56 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import * as os from 'os';
4
- import { spawn } from 'child_process';
5
3
  import {
6
4
  createHarRecording,
7
5
  readHarRecording,
8
6
  deleteHarRecording,
7
+ harFilePath,
8
+ validateHarFile,
9
+ type HarFile,
10
+ type HAREntry,
9
11
  } from '../har-manager.js';
10
12
  import {
11
13
  getActiveSession,
12
14
  setActiveSession,
13
- clearActiveSession,
15
+ clearActiveSessionIf,
16
+ updateSessionState,
17
+ isActiveStateCandidate,
18
+ type ActiveSessionState,
14
19
  } from '../session-context.js';
15
- import { expandEqualsFlags } from '../cdp/args.js';
20
+ import { type ParsedArgs, type CDPTarget } from '../cdp/types.js';
16
21
  import { startBridge, stopBridge } from '../cdp/bridge/spawn.js';
17
-
18
- const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
19
-
20
- interface LogPid {
21
- pid: number;
22
- name: string;
23
- sourcePath: string;
24
- }
25
-
26
- interface Session {
27
- id: string;
28
- dir: string;
29
- harId: string | null;
30
- startedAt: string;
31
- url: string | null;
32
- targetId: string | null;
33
- stepCount: number;
34
- logPids: LogPid[];
35
- /** Set when started with --hold: one CDP browser connection held open for the session. */
36
- bridgeSocket: string | null;
37
- bridgePid: number | null;
38
- }
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';
38
+ import {
39
+ CAPTURE_ROOT,
40
+ MAX_LOG_LABEL_BYTES,
41
+ acquirePrivateLock,
42
+ ensurePrivateDir,
43
+ rejectLogLabel,
44
+ writeJsonPrivate,
45
+ readPrivateFile,
46
+ removeArtifactTree,
47
+ type SnapMeta,
48
+ type RecMeta,
49
+ } from './artifacts.js';
50
+ import { beginSessionStop, admitSessionOperation, withSessionLifecycle } from './coordinator.js';
51
+ import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
52
+
53
+ type Session = ActiveSessionState;
39
54
 
40
55
  interface BundleManifest {
41
56
  id: string;
@@ -43,37 +58,234 @@ interface BundleManifest {
43
58
  stoppedAt: string;
44
59
  duration: number;
45
60
  url: string | null;
46
- screenshots: Array<{ name: string; path: string }>;
61
+ shots: Array<{ name: string; path: string }>;
47
62
  har: { id: string; path: string; entryCount: number } | null;
48
- a11y: Array<{ name: string; path: string }>;
49
63
  logs: Array<{ name: string; path: string; lines: number }>;
50
64
  other: Array<{ name: string; path: string }>;
65
+ /** `measure snap` artifacts collected from `measure/snaps/{id}/meta.json`. */
66
+ snaps: Array<{ id: string; path: string; url: string | null; viewport: string | null; settled: boolean; capturedAt: string }>;
67
+ /** `motion rec` artifacts collected from `motion/recs/{id}/meta.json`. */
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';
51
77
  }
52
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;
122
+ }
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
+
53
150
  function sessionDir(id: string): string {
54
151
  return path.join(CAPTURE_ROOT, id);
55
152
  }
56
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
+
174
+ /** One-shot artifact session outside an active session — see `createOneshotSession`. */
175
+ export interface OneshotSession {
176
+ /** `oneshot-{id}`; also the dir name under `CAPTURE_ROOT`. */
177
+ id: string;
178
+ /** `{CAPTURE_ROOT}/oneshot-{id}` */
179
+ dir: string;
180
+ kind: 'measure' | 'motion' | 'page';
181
+ /** `{dir}/measure/snaps`, `{dir}/motion/recs`, or `{dir}/page`, already created private. */
182
+ artifactsDir: string;
183
+ }
184
+
185
+ /**
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`.
193
+ */
194
+ export function createOneshotSession(kind: 'measure' | 'motion' | 'page'): OneshotSession {
195
+ const id = `oneshot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
196
+ const dir = sessionDir(id);
197
+ const artifactsDir = kind === 'measure'
198
+ ? path.join(dir, 'measure', 'snaps')
199
+ : kind === 'motion'
200
+ ? path.join(dir, 'motion', 'recs')
201
+ : path.join(dir, 'page');
202
+ ensurePrivateDir(artifactsDir);
203
+ return { id, dir, kind, artifactsDir };
204
+ }
205
+
206
+ /** Reads one `measure/snaps/{id}/meta.json` and shapes it into a manifest `snaps[]` entry. */
207
+ function readSnapMeta(snapDir: string, fallbackId: string): BundleManifest['snaps'][number] {
208
+ const meta = JSON.parse(fs.readFileSync(path.join(snapDir, 'meta.json'), 'utf-8')) as Partial<SnapMeta>;
209
+ return {
210
+ id: meta.id ?? fallbackId,
211
+ path: snapDir,
212
+ url: meta.url ?? null,
213
+ viewport: meta.viewport ?? null,
214
+ settled: meta.settled ?? false,
215
+ capturedAt: meta.capturedAt ?? '',
216
+ };
217
+ }
218
+
219
+ /** Reads one `motion/recs/{id}/meta.json` and shapes it into a manifest `recs[]` entry. */
220
+ function readRecMeta(recDir: string, fallbackId: string): BundleManifest['recs'][number] {
221
+ const meta = JSON.parse(fs.readFileSync(path.join(recDir, 'meta.json'), 'utf-8')) as Partial<RecMeta>;
222
+ return {
223
+ id: meta.id ?? fallbackId,
224
+ path: recDir,
225
+ action: meta.action ?? null,
226
+ frames: meta.frames ?? 0,
227
+ durationMs: meta.durationMs ?? 0,
228
+ state: meta.state ?? 'unknown',
229
+ viewportRestored: typeof meta.viewportRestored === 'boolean' ? meta.viewportRestored : null,
230
+ };
231
+ }
232
+
233
+ /** Collects finalized snapshots under `{session.dir}/measure/snaps/{id}/meta.json`. */
234
+ function collectSnaps(dir: string): BundleManifest['snaps'] {
235
+ const snapsRoot = path.join(dir, 'measure', 'snaps');
236
+ if (!fs.existsSync(snapsRoot)) return [];
237
+ return fs.readdirSync(snapsRoot)
238
+ .filter((name) => fs.existsSync(path.join(snapsRoot, name, 'meta.json')))
239
+ .map((name) => readSnapMeta(path.join(snapsRoot, name), name));
240
+ }
241
+
242
+ /** Collects finalized recordings under `{session.dir}/motion/recs/{id}/meta.json`. */
243
+ function collectRecs(dir: string): BundleManifest['recs'] {
244
+ const recsRoot = path.join(dir, 'motion', 'recs');
245
+ if (!fs.existsSync(recsRoot)) return [];
246
+ return fs.readdirSync(recsRoot)
247
+ .filter((name) => fs.existsSync(path.join(recsRoot, name, 'meta.json')))
248
+ .map((name) => readRecMeta(path.join(recsRoot, name), name));
249
+ }
250
+
57
251
  function sessionMetaPath(id: string): string {
58
252
  return path.join(sessionDir(id), '.session.json');
59
253
  }
60
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
+ */
61
264
  function readSession(id: string): Session {
62
265
  const metaPath = sessionMetaPath(id);
63
- if (!fs.existsSync(metaPath)) {
64
- 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;
65
272
  }
66
- return JSON.parse(fs.readFileSync(metaPath, 'utf-8')) as Session;
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.`);
281
+ }
282
+ return parsed;
67
283
  }
68
284
 
69
285
  function generateId(): string {
70
286
  return `cap-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
71
287
  }
72
288
 
73
- function hasHelpFlag(args: string[]): boolean {
74
- return args.some((arg) => arg === '-h' || arg === '--help');
75
- }
76
-
77
289
  export async function waitForPageLoad(
78
290
  client: { waitReady(): Promise<void>; send(method: string, params?: Record<string, unknown>): Promise<unknown>; on(event: string, handler: (params: unknown) => void): void; },
79
291
  timeoutMs: number,
@@ -83,437 +295,1094 @@ export async function waitForPageLoad(
83
295
 
84
296
  return await new Promise<boolean>((resolve) => {
85
297
  const timer = setTimeout(() => resolve(true), timeoutMs);
86
- client.on('Page.loadEventFired', () => {
298
+ const done = (timedOut: boolean) => {
87
299
  clearTimeout(timer);
88
- resolve(false);
89
- });
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(() => {});
90
315
  });
91
316
  }
92
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
+
93
436
  function printSessionHelp(): void {
94
- console.log(`capture session — manage capture sessions
95
-
96
- Sub-commands:
97
- start [--url <url>] [--hold] Start a session (opens tab, records HAR, sets active context)
98
- --hold keeps one CDP browser connection open for the session
99
- stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
100
- list List active and stopped sessions
101
- view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs
102
-
103
- Why sessions: once started, every subsequent capture command auto-fills
104
- --target (the tab) and --har (the recording). No manual flag threading.
105
-
106
- Typical flow:
107
- 1. capture session start --url http://localhost:3000
108
- 2. Interact — no --target / --har needed:
109
- capture a11y --interactive
110
- capture click "Sign in"
111
- capture type "hi@me.com" --into "Email"
112
- capture screenshot
113
- capture navigate https://app.example.com/dashboard
114
- capture har read --filter-url /api
115
- 3. capture session stop <session-id>
116
- 4. capture session view <session-id>
117
-
118
- --hold: keeps one CDP browser connection open for the session's lifetime, so
119
- browser-level state (Browser.grantPermissions, ServiceWorker.enable, ...)
120
- survives across separate commands instead of reverting the instant each
121
- command's own connection closes. Use it together with \`capture cdp --browser\`:
122
-
123
- capture session start --url http://localhost:3000 --hold
124
- capture cdp Browser.grantPermissions --browser --params '{"origin":"http://localhost:3000","permissions":["notifications"]}'
125
- capture cdp ServiceWorker.enable --browser --target <pageTabId>
126
- capture cdp ServiceWorker.deliverPushMessage --browser --target <pageTabId> --params '{...}'
127
- capture session stop <session-id> # also tears down the held connection
128
-
129
- Related: capture log <path> [--name label] Tail a log into the active session
130
- 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`);
131
451
  }
132
452
 
133
453
  // ============================================================================
134
454
  // Session Commands
135
455
  // ============================================================================
136
456
 
137
- async function start(rawArgs: string[]): Promise<void> {
138
- const args = expandEqualsFlags(rawArgs);
139
- let url: string | null = null;
140
- 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
+ }
141
475
 
142
- for (let i = 0; i < args.length; i++) {
143
- if (args[i] === '--url' && args[i + 1]) {
144
- url = args[++i];
145
- } else if (args[i] === '--hold') {
146
- 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.`);
147
496
  }
148
- }
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
+ };
149
514
 
150
- const id = generateId();
151
- const dir = sessionDir(id);
152
- fs.mkdirSync(path.join(dir, 'shots'), { recursive: true });
153
- fs.mkdirSync(path.join(dir, 'a11y'), { recursive: true });
515
+ let startWorld: SessionStartWorld = productionStartWorld;
154
516
 
155
- // Start HAR recording directly
156
- let harId: string | null = null;
157
- try {
158
- const harResult = createHarRecording();
159
- harId = harResult.id;
160
- } catch (err) {
161
- console.error(`Warning: could not start HAR recording: ${err instanceof Error ? err.message : err}`);
162
- }
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
+ }
163
521
 
164
- // Open tab if URL provided. Fail fast if the new target cannot attach.
165
- let targetId: string | null = null;
166
- let pageLoadTimedOut = false;
167
- let cdpPort: number | null = null;
168
- if (url || hold) {
169
- const { detectCdpPort } = await import('../cdp.js');
170
- 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;
171
553
  }
172
- 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
+
173
586
  try {
174
- const { openTab, CDPClient } = await import('../cdp.js');
175
- const tab = await openTab(cdpPort!, url);
176
- targetId = tab.id;
177
-
178
- if (!targetId) {
179
- throw new Error(
180
- `capture session start could not attach to ${url}. Reuse an existing tab with --target.`,
181
- );
182
- }
183
- if (!tab.webSocketDebuggerUrl) {
184
- throw new Error(
185
- `capture session start could not attach to ${url}: missing WebSocket debugger URL. Reuse an existing tab with --target.`,
186
- );
187
- }
587
+ ensurePrivateDir(path.join(dir, 'shots'));
588
+ acquired.push({ label: 'artifact tree', release: () => removeArtifactTree(dir) });
188
589
 
189
- const client = new CDPClient(tab.webSocketDebuggerUrl);
190
- try {
191
- try {
192
- await client.send('Runtime.enable', {}, 5_000);
193
- } catch (err) {
194
- throw new Error(
195
- `capture session start could not attach to ${url}: ${err instanceof Error ? err.message : err}. Reuse an existing tab with --target.`,
196
- );
197
- }
198
- pageLoadTimedOut = await waitForPageLoad(client, 10_000);
199
- } finally {
200
- 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();
201
596
  }
202
- } catch (err) {
203
- if (harId) {
204
- try {
205
- deleteHarRecording(harId);
206
- } catch {
207
- /* best effort */
208
- }
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);
209
604
  }
210
- throw err;
211
- }
212
- }
213
605
 
214
- // Hold a CDP browser connection open for the session's lifetime so
215
- // browser-level state (permission grants, ServiceWorker enablement)
216
- // survives across separate `capture` commands instead of reverting the
217
- // instant each command's own connection closes.
218
- let bridgeSocket: string | null = null;
219
- let bridgePid: number | null = null;
220
- if (hold) {
221
- try {
222
- const bridge = await startBridge(dir, cdpPort!);
223
- bridgeSocket = bridge.socketPath;
224
- bridgePid = bridge.pid;
225
- } catch (err) {
226
- if (harId) {
227
- try {
228
- deleteHarRecording(harId);
229
- } catch {
230
- /* best effort */
231
- }
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) });
232
615
  }
233
- 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;
234
640
  }
235
- }
236
641
 
237
- const session: Session = {
238
- id,
239
- dir,
240
- harId,
241
- startedAt: new Date().toISOString(),
242
- url,
243
- targetId,
244
- stepCount: 0,
245
- logPids: [],
246
- bridgeSocket,
247
- bridgePid,
248
- };
249
- fs.writeFileSync(sessionMetaPath(id), JSON.stringify(session, null, 2));
250
-
251
- // Set as active session for auto-defaults
252
- setActiveSession({
253
- sessionId: id,
254
- dir,
255
- harId,
256
- targetId,
257
- stepCount: 0,
258
- 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 });
259
655
  });
656
+ }
260
657
 
261
- // Output for agent consumption
262
- const result = {
263
- sessionId: id,
264
- bundleDir: dir,
265
- harId,
266
- targetId,
267
- pageLoadTimedOut,
268
- shotsDir: path.join(dir, 'shots'),
269
- a11yDir: path.join(dir, 'a11y'),
270
- held: hold,
271
- };
272
- console.log(JSON.stringify(result, null, 2));
273
-
274
- // Agent-friendly next steps on stderr
275
- console.error(`\nCapture session started: ${id}`);
276
- if (targetId) {
277
- console.error(`Tab opened — session context active. No need to pass --target or --har.`);
278
- if (pageLoadTimedOut) {
279
- 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}`);
280
670
  }
281
671
  }
282
- if (hold) {
283
- console.error(`CDP bridge held (pid ${bridgePid}) — browser-level state now survives across commands via: capture cdp <Method> --browser [--params '<json>']`);
284
- }
285
- 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;
286
685
  }
287
686
 
288
- export function logCommand(rawArgs: string[]): void {
289
- const args = expandEqualsFlags(rawArgs);
290
- if (hasHelpFlag(args)) {
291
- 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);
292
694
  return;
293
695
  }
294
696
 
295
- 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];
296
700
  if (!sourcePath) {
297
- console.error('Usage: capture log <path> [--name label] [--session <id>]');
298
- 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;
299
708
  }
300
709
 
301
- const resolved = path.resolve(sourcePath);
302
- if (!fs.existsSync(resolved)) {
303
- 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;
304
724
  }
305
725
 
306
- let name: string | null = null;
307
- let sessionId: string | null = null;
308
- for (let i = 1; i < args.length; i++) {
309
- if (args[i] === '--name' && args[i + 1]) name = args[++i];
310
- 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;
311
736
  }
312
737
 
738
+ let sessionId = parsed.session ?? null;
313
739
  if (!sessionId) {
314
740
  const active = getActiveSession();
315
741
  if (!active) {
316
- 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;
317
750
  }
318
751
  sessionId = active.sessionId;
319
752
  }
320
753
 
321
- const session = readSession(sessionId);
322
- 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
+ }
323
767
 
324
- const logsDir = path.join(session.dir, 'logs');
325
- fs.mkdirSync(logsDir, { recursive: true });
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
+ }
326
779
 
327
- const destPath = path.join(logsDir, `${name}.log`);
328
- const outFd = fs.openSync(destPath, 'a');
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
+ }
329
818
 
330
- const child = spawn(
331
- 'sh',
332
- ['-c', `tail -f "${resolved}" | perl -MPOSIX -ne 'print strftime("%Y-%m-%dT%H:%M:%SZ",gmtime())." ".$_'`],
333
- { detached: true, stdio: ['ignore', outFd, 'ignore'] },
334
- );
335
- child.unref();
336
- fs.closeSync(outFd);
819
+ // ============================================================================
820
+ // session har — the session-owned HAR read surface (D4)
821
+ // ============================================================================
337
822
 
338
- const pid = child.pid!;
339
- session.logPids.push({ pid, name, sourcePath: resolved });
340
- fs.writeFileSync(sessionMetaPath(session.id), JSON.stringify(session, null, 2));
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
+ }
341
829
 
342
- 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
+ );
882
+ }
883
+
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);
343
904
  }
344
905
 
345
- async function stop(args: string[]): Promise<void> {
346
- const id = args[0];
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;
347
934
  if (!id) {
348
- console.error('Usage: capture session stop <session-id>');
349
- 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;
350
947
  }
351
948
 
352
- const session = readSession(id);
353
- const stoppedAt = new Date().toISOString();
354
- const startMs = new Date(session.startedAt).getTime();
355
- 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
+ }
356
973
 
357
- // Kill log tailers
358
- for (const lp of session.logPids ?? []) {
359
- 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}`);
360
986
  }
361
- if (session.logPids?.length) {
362
- 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}`);
363
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
+ }
1034
+
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
+ }
364
1044
 
365
- // Tear down the held CDP bridge, if any — releases the browser-level
366
- // connection (and any grants/target-enablement it was keeping alive).
367
- if (session.bridgePid || session.bridgeSocket) {
368
- stopBridge(session.bridgePid, session.bridgeSocket);
1045
+ async function stop(parsed: ParsedArgs): Promise<void> {
1046
+ if (parsed.help) {
1047
+ console.log(STOP_USAGE);
1048
+ return;
369
1049
  }
370
1050
 
371
- // Collect screenshots
372
- const shotsDir = path.join(session.dir, 'shots');
373
- const screenshots = fs.existsSync(shotsDir)
374
- ? fs.readdirSync(shotsDir)
375
- .filter((f) => f.endsWith('.png') || f.endsWith('.jpg'))
376
- .map((f) => ({ name: f, path: path.join(shotsDir, f) }))
377
- : [];
1051
+ if (rejectSurplusPositionals(parsed, 'stop', 1, 'capture session stop <session-id>')) return;
378
1052
 
379
- // Collect a11y snapshots
380
- const a11yDir = path.join(session.dir, 'a11y');
381
- const a11y = fs.existsSync(a11yDir)
382
- ? fs.readdirSync(a11yDir)
383
- .filter((f) => f.endsWith('.json') || f.endsWith('.txt'))
384
- .map((f) => ({ name: f, path: path.join(a11yDir, f) }))
385
- : [];
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
+ }
1064
+
1065
+ return withLifecycleCoordinator(async () => {
1066
+ let session: Session;
1067
+ try {
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
+ }
386
1079
 
387
- // Collect HAR directly from har-manager
388
- let har: BundleManifest['har'] = null;
389
- if (session.harId) {
1080
+ const stopAdmission = await beginSessionStop(session.dir);
1081
+ let committed = false;
390
1082
  try {
391
- const harData = readHarRecording(session.harId);
392
- if (harData) {
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);
393
1141
  const harPath = path.join(session.dir, 'har.json');
394
- fs.writeFileSync(harPath, JSON.stringify(harData, null, 2));
1142
+ writeJsonPrivate(harPath, harData);
395
1143
  har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
396
- // Clean up the HAR recording
397
- try { deleteHarRecording(session.harId); } catch { /* best effort */ }
398
1144
  }
399
- } catch (err) {
400
- 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);
401
1205
  }
1206
+ });
1207
+ }
1208
+
1209
+ function list(parsed: ParsedArgs): void {
1210
+ if (parsed.help) {
1211
+ console.log(LIST_USAGE);
1212
+ return;
402
1213
  }
403
1214
 
404
- // Collect log files
405
- const logsDir = path.join(session.dir, 'logs');
406
- const logs = fs.existsSync(logsDir)
407
- ? fs.readdirSync(logsDir)
408
- .filter((f) => f.endsWith('.log'))
409
- .map((f) => {
410
- const filePath = path.join(logsDir, f);
411
- const content = fs.readFileSync(filePath, 'utf-8');
412
- const lines = content ? content.split('\n').filter(Boolean).length : 0;
413
- 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' };
414
1224
  })
415
1225
  : [];
416
1226
 
417
- // Collect anything else dropped in the session dir
418
- const knownDirs = new Set(['shots', 'a11y', 'logs']);
419
- const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json', 'bridge.sock']);
420
- const other = fs.readdirSync(session.dir)
421
- .filter((f) => !knownDirs.has(f) && !knownFiles.has(f))
422
- .map((f) => ({ name: f, path: path.join(session.dir, f) }));
423
-
424
- // Clear active session context
425
- clearActiveSession();
426
-
427
- const manifest: BundleManifest = {
428
- id: session.id,
429
- startedAt: session.startedAt,
430
- stoppedAt,
431
- duration,
432
- url: session.url,
433
- screenshots,
434
- har,
435
- a11y,
436
- logs,
437
- other,
438
- };
1227
+ if (sessions.length === 0) {
1228
+ emitResult({
1229
+ tag: 'sessions',
1230
+ attrs: { count: 0 },
1231
+ summary: text`No capture sessions.`,
1232
+ }, { json: parsed.json });
1233
+ return;
1234
+ }
439
1235
 
440
- const bundlePath = path.join(session.dir, 'bundle.json');
441
- fs.writeFileSync(bundlePath, JSON.stringify(manifest, null, 2));
442
-
443
- console.log(JSON.stringify({
444
- bundlePath,
445
- summary: {
446
- duration,
447
- screenshots: screenshots.length,
448
- harEntries: har?.entryCount ?? 0,
449
- a11ySnapshots: a11y.length,
450
- logFiles: logs.length,
451
- otherFiles: other.length,
452
- },
453
- }, null, 2));
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
+ );
454
1242
 
455
- console.error(`\nBundle written: ${bundlePath}`);
456
- console.error(`Read it: capture session view ${id}`);
1243
+ emitResult({
1244
+ tag: 'sessions',
1245
+ attrs: { count: sessions.length },
1246
+ sections: [lineList(rows)],
1247
+ }, { json: parsed.json });
457
1248
  }
458
1249
 
459
- function list(): void {
460
- if (!fs.existsSync(CAPTURE_ROOT)) {
461
- console.log('[]');
462
- return;
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}`);
463
1269
  }
464
-
465
- const sessions = fs.readdirSync(CAPTURE_ROOT)
466
- .filter((d) => fs.existsSync(sessionMetaPath(d)))
467
- .map((d) => {
468
- const session = readSession(d);
469
- const hasBundled = fs.existsSync(path.join(session.dir, 'bundle.json'));
470
- return { id: session.id, startedAt: session.startedAt, url: session.url, status: hasBundled ? 'stopped' : 'active' };
471
- });
472
-
473
- console.log(JSON.stringify(sessions, null, 2));
474
1270
  }
475
1271
 
476
- function view(rawArgs: string[]): void {
477
- const args = expandEqualsFlags(rawArgs);
478
- const id = args[0];
479
- if (!id) {
480
- console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y]');
481
- process.exit(1);
1272
+ function view(parsed: ParsedArgs): void {
1273
+ if (parsed.help) {
1274
+ console.log(VIEW_USAGE);
1275
+ return;
482
1276
  }
483
1277
 
484
- const session = readSession(id);
485
- const bundlePath = path.join(session.dir, 'bundle.json');
1278
+ if (rejectSurplusPositionals(parsed, 'view', 1, 'capture session view <session-id> [--filter <section>]')) return;
486
1279
 
487
- if (!fs.existsSync(bundlePath)) {
488
- console.error(`Session ${id} hasn't been stopped yet. Run: capture session stop ${id}`);
489
- 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;
490
1290
  }
491
1291
 
492
- 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
+ }
493
1305
 
494
- const filter = args.find((_, i) => args[i - 1] === '--filter');
495
- if (filter) {
496
- const section = manifest[filter as keyof BundleManifest];
497
- console.log(JSON.stringify(section, null, 2));
498
- } else {
499
- 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;
500
1322
  }
501
- }
502
1323
 
503
- export async function sessionMain(args: string[]): Promise<void> {
504
- 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
+ }
505
1336
 
506
- if (!subcommand || hasHelpFlag(args)) {
507
- 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 });
508
1344
  return;
509
1345
  }
510
1346
 
511
- 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) {
512
1369
  case 'start': return start(rest);
513
1370
  case 'stop': return stop(rest);
514
- case 'list': return list();
1371
+ case 'list': return list(rest);
515
1372
  case 'view': return view(rest);
516
- default:
1373
+ case 'har': return har(rest);
1374
+ case 'log': return logTail(rest);
1375
+ case undefined:
517
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;
518
1387
  }
519
1388
  }