@crouton-kit/capture 1.3.6 → 1.3.8

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 (43) hide show
  1. package/bin/capture +3269 -1281
  2. package/package.json +4 -1
  3. package/src/capture.ts +30 -0
  4. package/src/cdp/args.ts +113 -10
  5. package/src/cdp/bridge/client.ts +49 -0
  6. package/src/cdp/bridge/protocol.ts +137 -0
  7. package/src/cdp/bridge/server.ts +241 -0
  8. package/src/cdp/bridge/spawn.ts +127 -0
  9. package/src/cdp/client.ts +7 -1
  10. package/src/cdp/commands/bridge-serve.ts +43 -0
  11. package/src/cdp/commands/cdp.ts +203 -0
  12. package/src/cdp/commands/measure/census.ts +31 -0
  13. package/src/cdp/commands/measure/check.ts +35 -0
  14. package/src/cdp/commands/measure/diff.ts +32 -0
  15. package/src/cdp/commands/measure/explain.ts +31 -0
  16. package/src/cdp/commands/measure/index.ts +114 -0
  17. package/src/cdp/commands/measure/map-focus.ts +24 -0
  18. package/src/cdp/commands/measure/map-layers.ts +25 -0
  19. package/src/cdp/commands/measure/map-scroll.ts +24 -0
  20. package/src/cdp/commands/measure/snap.ts +36 -0
  21. package/src/cdp/commands/measure/sweep.ts +30 -0
  22. package/src/cdp/commands/motion/index.ts +60 -0
  23. package/src/cdp/commands/motion/jank.ts +22 -0
  24. package/src/cdp/commands/motion/mask.ts +23 -0
  25. package/src/cdp/commands/motion/rec.ts +33 -0
  26. package/src/cdp/commands/motion/response.ts +26 -0
  27. package/src/cdp/commands/motion/timeline.ts +27 -0
  28. package/src/cdp/coordinates.ts +158 -0
  29. package/src/cdp/dispatch.ts +8 -0
  30. package/src/cdp/domains.ts +75 -0
  31. package/src/cdp/recorder-bridge.ts +521 -0
  32. package/src/cdp/source-map.ts +309 -0
  33. package/src/cdp/targets.ts +8 -1
  34. package/src/cdp/timing.ts +82 -0
  35. package/src/cdp/types.ts +71 -0
  36. package/src/cdp/usage.ts +9 -0
  37. package/src/output/artifact.ts +520 -0
  38. package/src/output/diff.ts +111 -0
  39. package/src/output/render.ts +593 -0
  40. package/src/output/selector.ts +185 -0
  41. package/src/session/artifacts.ts +229 -0
  42. package/src/session/commands.ts +183 -20
  43. package/src/session-context.ts +50 -8
@@ -1,6 +1,5 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import * as os from 'os';
4
3
  import { spawn } from 'child_process';
5
4
  import {
6
5
  createHarRecording,
@@ -13,8 +12,15 @@ import {
13
12
  clearActiveSession,
14
13
  } from '../session-context.js';
15
14
  import { expandEqualsFlags } from '../cdp/args.js';
16
-
17
- const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
15
+ import { startBridge, stopBridge } from '../cdp/bridge/spawn.js';
16
+ import {
17
+ CAPTURE_ROOT,
18
+ FILE_MODE,
19
+ ensurePrivateDir,
20
+ writeJsonPrivate,
21
+ type SnapMeta,
22
+ type RecMeta,
23
+ } from './artifacts.js';
18
24
 
19
25
  interface LogPid {
20
26
  pid: number;
@@ -31,6 +37,9 @@ interface Session {
31
37
  targetId: string | null;
32
38
  stepCount: number;
33
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;
34
43
  }
35
44
 
36
45
  interface BundleManifest {
@@ -44,12 +53,90 @@ interface BundleManifest {
44
53
  a11y: Array<{ name: string; path: string }>;
45
54
  logs: Array<{ name: string; path: string; lines: number }>;
46
55
  other: Array<{ name: string; path: string }>;
56
+ /** `measure snap` artifacts collected from `measure/snaps/{id}/meta.json`. */
57
+ snaps: Array<{ id: string; path: string; url: string | null; viewport: string | null; settled: boolean; capturedAt: string }>;
58
+ /** `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 }>;
47
60
  }
48
61
 
49
62
  function sessionDir(id: string): string {
50
63
  return path.join(CAPTURE_ROOT, id);
51
64
  }
52
65
 
66
+ /** One-shot artifact session outside an active session — see `createOneshotSession`. */
67
+ export interface OneshotSession {
68
+ /** `oneshot-{id}`; also the dir name under `CAPTURE_ROOT`. */
69
+ id: string;
70
+ /** `{CAPTURE_ROOT}/oneshot-{id}` */
71
+ dir: string;
72
+ kind: 'measure' | 'motion';
73
+ /** `{dir}/measure/snaps` or `{dir}/motion/recs`, already created private. */
74
+ artifactsDir: string;
75
+ }
76
+
77
+ /**
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`.
85
+ */
86
+ export function createOneshotSession(kind: 'measure' | 'motion'): OneshotSession {
87
+ const id = `oneshot-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
88
+ const dir = sessionDir(id);
89
+ const artifactsDir = kind === 'measure'
90
+ ? path.join(dir, 'measure', 'snaps')
91
+ : path.join(dir, 'motion', 'recs');
92
+ ensurePrivateDir(artifactsDir);
93
+ return { id, dir, kind, artifactsDir };
94
+ }
95
+
96
+ /** Reads one `measure/snaps/{id}/meta.json` and shapes it into a manifest `snaps[]` entry. */
97
+ function readSnapMeta(snapDir: string, fallbackId: string): BundleManifest['snaps'][number] {
98
+ const meta = JSON.parse(fs.readFileSync(path.join(snapDir, 'meta.json'), 'utf-8')) as Partial<SnapMeta>;
99
+ return {
100
+ id: meta.id ?? fallbackId,
101
+ path: snapDir,
102
+ url: meta.url ?? null,
103
+ viewport: meta.viewport ?? null,
104
+ settled: meta.settled ?? false,
105
+ capturedAt: meta.capturedAt ?? '',
106
+ };
107
+ }
108
+
109
+ /** Reads one `motion/recs/{id}/meta.json` and shapes it into a manifest `recs[]` entry. */
110
+ function readRecMeta(recDir: string, fallbackId: string): BundleManifest['recs'][number] {
111
+ const meta = JSON.parse(fs.readFileSync(path.join(recDir, 'meta.json'), 'utf-8')) as Partial<RecMeta>;
112
+ return {
113
+ id: meta.id ?? fallbackId,
114
+ path: recDir,
115
+ action: meta.action ?? null,
116
+ frames: meta.frames ?? 0,
117
+ durationMs: meta.durationMs ?? 0,
118
+ state: meta.state ?? 'unknown',
119
+ };
120
+ }
121
+
122
+ /** Collects finalized snapshots under `{session.dir}/measure/snaps/{id}/meta.json`. */
123
+ function collectSnaps(dir: string): BundleManifest['snaps'] {
124
+ const snapsRoot = path.join(dir, 'measure', 'snaps');
125
+ if (!fs.existsSync(snapsRoot)) return [];
126
+ return fs.readdirSync(snapsRoot)
127
+ .filter((name) => fs.existsSync(path.join(snapsRoot, name, 'meta.json')))
128
+ .map((name) => readSnapMeta(path.join(snapsRoot, name), name));
129
+ }
130
+
131
+ /** Collects finalized recordings under `{session.dir}/motion/recs/{id}/meta.json`. */
132
+ function collectRecs(dir: string): BundleManifest['recs'] {
133
+ const recsRoot = path.join(dir, 'motion', 'recs');
134
+ if (!fs.existsSync(recsRoot)) return [];
135
+ return fs.readdirSync(recsRoot)
136
+ .filter((name) => fs.existsSync(path.join(recsRoot, name, 'meta.json')))
137
+ .map((name) => readRecMeta(path.join(recsRoot, name), name));
138
+ }
139
+
53
140
  function sessionMetaPath(id: string): string {
54
141
  return path.join(sessionDir(id), '.session.json');
55
142
  }
@@ -90,10 +177,12 @@ function printSessionHelp(): void {
90
177
  console.log(`capture session — manage capture sessions
91
178
 
92
179
  Sub-commands:
93
- start [--url <url>] Start a session (opens tab, records HAR, sets active context)
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
94
182
  stop <session-id> Finalize and bundle artifacts (screenshots, HAR, a11y, logs)
95
183
  list List active and stopped sessions
96
- view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs
184
+ view <id> [--filter section] View bundle manifest; section = screenshots|har|a11y|logs|measure|motion
185
+ measure -> manifest.snaps, motion -> manifest.recs
97
186
 
98
187
  Why sessions: once started, every subsequent capture command auto-fills
99
188
  --target (the tab) and --har (the recording). No manual flag threading.
@@ -110,6 +199,17 @@ Typical flow:
110
199
  3. capture session stop <session-id>
111
200
  4. capture session view <session-id>
112
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
+
113
213
  Related: capture log <path> [--name label] Tail a log into the active session
114
214
  See also: capture --help Full command list`);
115
215
  }
@@ -121,17 +221,20 @@ See also: capture --help Full command list`);
121
221
  async function start(rawArgs: string[]): Promise<void> {
122
222
  const args = expandEqualsFlags(rawArgs);
123
223
  let url: string | null = null;
224
+ let hold = false;
124
225
 
125
226
  for (let i = 0; i < args.length; i++) {
126
227
  if (args[i] === '--url' && args[i + 1]) {
127
228
  url = args[++i];
229
+ } else if (args[i] === '--hold') {
230
+ hold = true;
128
231
  }
129
232
  }
130
233
 
131
234
  const id = generateId();
132
235
  const dir = sessionDir(id);
133
- fs.mkdirSync(path.join(dir, 'shots'), { recursive: true });
134
- fs.mkdirSync(path.join(dir, 'a11y'), { recursive: true });
236
+ ensurePrivateDir(path.join(dir, 'shots'));
237
+ ensurePrivateDir(path.join(dir, 'a11y'));
135
238
 
136
239
  // Start HAR recording directly
137
240
  let harId: string | null = null;
@@ -145,11 +248,15 @@ async function start(rawArgs: string[]): Promise<void> {
145
248
  // Open tab if URL provided. Fail fast if the new target cannot attach.
146
249
  let targetId: string | null = null;
147
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();
255
+ }
148
256
  if (url) {
149
257
  try {
150
- const { detectCdpPort, openTab, CDPClient } = await import('../cdp.js');
151
- const port = await detectCdpPort();
152
- const tab = await openTab(port, url);
258
+ const { openTab, CDPClient } = await import('../cdp.js');
259
+ const tab = await openTab(cdpPort!, url);
153
260
  targetId = tab.id;
154
261
 
155
262
  if (!targetId) {
@@ -188,6 +295,29 @@ async function start(rawArgs: string[]): Promise<void> {
188
295
  }
189
296
  }
190
297
 
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
+ }
316
+ }
317
+ throw err;
318
+ }
319
+ }
320
+
191
321
  const session: Session = {
192
322
  id,
193
323
  dir,
@@ -197,8 +327,10 @@ async function start(rawArgs: string[]): Promise<void> {
197
327
  targetId,
198
328
  stepCount: 0,
199
329
  logPids: [],
330
+ bridgeSocket,
331
+ bridgePid,
200
332
  };
201
- fs.writeFileSync(sessionMetaPath(id), JSON.stringify(session, null, 2));
333
+ writeJsonPrivate(sessionMetaPath(id), session);
202
334
 
203
335
  // Set as active session for auto-defaults
204
336
  setActiveSession({
@@ -207,6 +339,7 @@ async function start(rawArgs: string[]): Promise<void> {
207
339
  harId,
208
340
  targetId,
209
341
  stepCount: 0,
342
+ bridgeSocket,
210
343
  });
211
344
 
212
345
  // Output for agent consumption
@@ -218,6 +351,7 @@ async function start(rawArgs: string[]): Promise<void> {
218
351
  pageLoadTimedOut,
219
352
  shotsDir: path.join(dir, 'shots'),
220
353
  a11yDir: path.join(dir, 'a11y'),
354
+ held: hold,
221
355
  };
222
356
  console.log(JSON.stringify(result, null, 2));
223
357
 
@@ -229,6 +363,9 @@ async function start(rawArgs: string[]): Promise<void> {
229
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.`);
230
364
  }
231
365
  }
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
+ }
232
369
  console.error(`\nWhen done: capture session stop ${id}`);
233
370
  }
234
371
 
@@ -269,10 +406,11 @@ export function logCommand(rawArgs: string[]): void {
269
406
  name = name ?? path.basename(resolved, path.extname(resolved));
270
407
 
271
408
  const logsDir = path.join(session.dir, 'logs');
272
- fs.mkdirSync(logsDir, { recursive: true });
409
+ ensurePrivateDir(logsDir);
273
410
 
274
411
  const destPath = path.join(logsDir, `${name}.log`);
275
- const outFd = fs.openSync(destPath, 'a');
412
+ const outFd = fs.openSync(destPath, 'a', FILE_MODE);
413
+ fs.chmodSync(destPath, FILE_MODE);
276
414
 
277
415
  const child = spawn(
278
416
  'sh',
@@ -284,7 +422,7 @@ export function logCommand(rawArgs: string[]): void {
284
422
 
285
423
  const pid = child.pid!;
286
424
  session.logPids.push({ pid, name, sourcePath: resolved });
287
- fs.writeFileSync(sessionMetaPath(session.id), JSON.stringify(session, null, 2));
425
+ writeJsonPrivate(sessionMetaPath(session.id), session);
288
426
 
289
427
  console.log(JSON.stringify({ name, sourcePath: resolved, destPath, pid }, null, 2));
290
428
  }
@@ -309,6 +447,12 @@ async function stop(args: string[]): Promise<void> {
309
447
  await new Promise((r) => setTimeout(r, 200));
310
448
  }
311
449
 
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);
454
+ }
455
+
312
456
  // Collect screenshots
313
457
  const shotsDir = path.join(session.dir, 'shots');
314
458
  const screenshots = fs.existsSync(shotsDir)
@@ -332,7 +476,7 @@ async function stop(args: string[]): Promise<void> {
332
476
  const harData = readHarRecording(session.harId);
333
477
  if (harData) {
334
478
  const harPath = path.join(session.dir, 'har.json');
335
- fs.writeFileSync(harPath, JSON.stringify(harData, null, 2));
479
+ writeJsonPrivate(harPath, harData);
336
480
  har = { id: session.harId, path: harPath, entryCount: harData.log.entries.length };
337
481
  // Clean up the HAR recording
338
482
  try { deleteHarRecording(session.harId); } catch { /* best effort */ }
@@ -355,9 +499,13 @@ async function stop(args: string[]): Promise<void> {
355
499
  })
356
500
  : [];
357
501
 
502
+ // Collect measure snapshots and motion recordings
503
+ const snaps = collectSnaps(session.dir);
504
+ const recs = collectRecs(session.dir);
505
+
358
506
  // Collect anything else dropped in the session dir
359
- const knownDirs = new Set(['shots', 'a11y', 'logs']);
360
- const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json']);
507
+ const knownDirs = new Set(['shots', 'a11y', 'logs', 'measure', 'motion']);
508
+ const knownFiles = new Set(['.session.json', 'har.json', 'bundle.json', 'bridge.sock']);
361
509
  const other = fs.readdirSync(session.dir)
362
510
  .filter((f) => !knownDirs.has(f) && !knownFiles.has(f))
363
511
  .map((f) => ({ name: f, path: path.join(session.dir, f) }));
@@ -376,10 +524,12 @@ async function stop(args: string[]): Promise<void> {
376
524
  a11y,
377
525
  logs,
378
526
  other,
527
+ snaps,
528
+ recs,
379
529
  };
380
530
 
381
531
  const bundlePath = path.join(session.dir, 'bundle.json');
382
- fs.writeFileSync(bundlePath, JSON.stringify(manifest, null, 2));
532
+ writeJsonPrivate(bundlePath, manifest);
383
533
 
384
534
  console.log(JSON.stringify({
385
535
  bundlePath,
@@ -390,6 +540,8 @@ async function stop(args: string[]): Promise<void> {
390
540
  a11ySnapshots: a11y.length,
391
541
  logFiles: logs.length,
392
542
  otherFiles: other.length,
543
+ snaps: snaps.length,
544
+ recs: recs.length,
393
545
  },
394
546
  }, null, 2));
395
547
 
@@ -414,11 +566,21 @@ function list(): void {
414
566
  console.log(JSON.stringify(sessions, null, 2));
415
567
  }
416
568
 
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
+ };
578
+
417
579
  function view(rawArgs: string[]): void {
418
580
  const args = expandEqualsFlags(rawArgs);
419
581
  const id = args[0];
420
582
  if (!id) {
421
- console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y]');
583
+ console.error('Usage: capture session view <session-id> [--filter screenshots|har|a11y|logs|other|measure|motion]');
422
584
  process.exit(1);
423
585
  }
424
586
 
@@ -434,7 +596,8 @@ function view(rawArgs: string[]): void {
434
596
 
435
597
  const filter = args.find((_, i) => args[i - 1] === '--filter');
436
598
  if (filter) {
437
- const section = manifest[filter as keyof BundleManifest];
599
+ const key = VIEW_FILTER_ALIASES[filter] ?? (filter as keyof BundleManifest);
600
+ const section = manifest[key];
438
601
  console.log(JSON.stringify(section, null, 2));
439
602
  } else {
440
603
  console.log(JSON.stringify(manifest, null, 2));
@@ -2,16 +2,33 @@
2
2
  * Session Context — persists active session state so CDP commands
3
3
  * can auto-fill --target, --har, and --out without manual threading.
4
4
  *
5
- * State is written to /tmp/capture-sessions/.active (JSON).
5
+ * State is written to /tmp/capture-sessions/.active-<scope> (JSON), where
6
+ * <scope> isolates concurrent callers (see activeScopeKey() below) so one
7
+ * caller's `session start` never clobbers another's active pointer.
6
8
  * Stale sessions (dir deleted, crashed process) are cleaned up on read.
7
9
  */
8
10
 
9
11
  import * as fs from 'fs';
10
12
  import * as path from 'path';
11
- import * as os from 'os';
13
+ import { CAPTURE_ROOT } from './session/artifacts.js';
12
14
 
13
- const CAPTURE_ROOT = path.join(os.tmpdir(), 'capture-sessions');
14
- const ACTIVE_PATH = path.join(CAPTURE_ROOT, '.active');
15
+ /**
16
+ * The active-session pointer is scoped per caller so concurrent, unrelated
17
+ * capture invocations never clobber one another's "active session" — e.g.
18
+ * two crtr agent nodes each running `capture session start` in parallel.
19
+ * When invoked from a crtr node, CRTR_NODE_ID scopes the pointer to that
20
+ * node (stable across separate tool calls within the node's lifetime).
21
+ * Outside crtr (a bare interactive terminal), there's no such identity, so
22
+ * fall back to the single legacy pointer — the original single-user,
23
+ * single-session-at-a-time contract still holds there.
24
+ */
25
+ function activeScopeKey(): string {
26
+ return process.env.CRTR_NODE_ID ?? 'default';
27
+ }
28
+
29
+ function getActivePath(): string {
30
+ return path.join(CAPTURE_ROOT, `.active-${activeScopeKey()}`);
31
+ }
15
32
 
16
33
  export interface ActiveSessionState {
17
34
  sessionId: string;
@@ -19,12 +36,22 @@ export interface ActiveSessionState {
19
36
  harId: string | null;
20
37
  targetId: string | null;
21
38
  stepCount: number;
39
+ /** Unix socket of the session's held CDP bridge (`session start --hold`), if any. */
40
+ bridgeSocket?: string | null;
41
+ /**
42
+ * The id of the recording (`motion/recs/{recId}`) currently live under
43
+ * this session, if `motion rec --start` has armed one. Cleared by
44
+ * `motion rec --stop` (or a stale-recorder reap). See
45
+ * {@link setActiveRecId}/{@link clearActiveRecId}/{@link getActiveRecId}.
46
+ */
47
+ activeRecId?: string | null;
22
48
  }
23
49
 
24
50
  export function getActiveSession(): ActiveSessionState | null {
25
51
  try {
26
- if (!fs.existsSync(ACTIVE_PATH)) return null;
27
- const state = JSON.parse(fs.readFileSync(ACTIVE_PATH, 'utf-8')) as ActiveSessionState;
52
+ const activePath = getActivePath();
53
+ if (!fs.existsSync(activePath)) return null;
54
+ const state = JSON.parse(fs.readFileSync(activePath, 'utf-8')) as ActiveSessionState;
28
55
  // Validate session dir still exists — clean up stale files
29
56
  if (!fs.existsSync(state.dir)) {
30
57
  clearActiveSession();
@@ -38,12 +65,12 @@ export function getActiveSession(): ActiveSessionState | null {
38
65
 
39
66
  export function setActiveSession(state: ActiveSessionState): void {
40
67
  fs.mkdirSync(CAPTURE_ROOT, { recursive: true });
41
- fs.writeFileSync(ACTIVE_PATH, JSON.stringify(state, null, 2));
68
+ fs.writeFileSync(getActivePath(), JSON.stringify(state, null, 2));
42
69
  }
43
70
 
44
71
  export function clearActiveSession(): void {
45
72
  try {
46
- fs.unlinkSync(ACTIVE_PATH);
73
+ fs.unlinkSync(getActivePath());
47
74
  } catch {
48
75
  // Already gone
49
76
  }
@@ -57,6 +84,21 @@ export function updateActiveSession(patch: Partial<ActiveSessionState>): ActiveS
57
84
  return updated;
58
85
  }
59
86
 
87
+ /** Records `recId` as the active session's live recording. */
88
+ export function setActiveRecId(recId: string): ActiveSessionState | null {
89
+ return updateActiveSession({ activeRecId: recId });
90
+ }
91
+
92
+ /** Clears the active session's live-recording pointer (on `rec --stop` / stale reap). */
93
+ export function clearActiveRecId(): ActiveSessionState | null {
94
+ return updateActiveSession({ activeRecId: null });
95
+ }
96
+
97
+ /** Reads the active session's live recording id, or `null` if none. */
98
+ export function getActiveRecId(): string | null {
99
+ return getActiveSession()?.activeRecId ?? null;
100
+ }
101
+
60
102
  function sanitizeLabel(label: string): string {
61
103
  return label
62
104
  .toLowerCase()