@crouton-kit/capture 1.3.14 → 1.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crouton-kit/capture",
3
- "version": "1.3.14",
3
+ "version": "1.3.16",
4
4
  "description": "Browser automation and UI validation via CDP — session-based screenshots, HAR, a11y, click/type, and JS exec",
5
5
  "bin": {
6
6
  "capture": "./bin/capture"
package/src/cdp/args.ts CHANGED
@@ -60,7 +60,7 @@ function validateKnownLeaf(command: string, positional: readonly string[]): void
60
60
  const branches: Record<string, readonly string[]> = {
61
61
  session: ['start', 'stop', 'list', 'view', 'har', 'log'],
62
62
  page: ['click', 'type', 'scroll', 'navigate', 'exec', 'shot', 'elements'],
63
- tab: ['list', 'open', 'reset', 'network'],
63
+ tab: ['list', 'open', 'close', 'reset', 'network'],
64
64
  measure: ['snap', 'check', 'diff', 'census', 'explain', 'sweep', 'map'],
65
65
  motion: ['rec', 'mask', 'timeline', 'jank', 'response'],
66
66
  lib: ['list', 'search', 'show', 'read'],
@@ -179,9 +179,12 @@ export function validateCliInvocation(parsed: ParsedArgs): void {
179
179
  throw invalidInput('motion rec lifecycle flags cannot be combined with one-shot flags.');
180
180
  }
181
181
  } else {
182
- requireCount(values, 1, 1, 'motion rec');
182
+ // The URL is optional: omitting it records the active session tab
183
+ // (enforced downstream once session state is known — this pure
184
+ // validator only bounds cardinality at 0..1 and checks a supplied URL).
185
+ requireCount(values, 0, 1, 'motion rec');
183
186
  if (!parsed.do) throw invalidInput('motion rec one-shot requires --do.');
184
- assertParseableUrl(values[0], 'recording URL');
187
+ if (values[0] !== undefined) assertParseableUrl(values[0], 'recording URL');
185
188
  parseDoAction(parsed.do);
186
189
  }
187
190
  if (parsed.viewport !== undefined) {
@@ -213,7 +216,7 @@ export function parseCliSyntax(argv: string[]): ParsedArgs {
213
216
  const next = rest[i + 1];
214
217
  if (VALUE_FLAGS.has(arg)) valueFor(arg, next);
215
218
 
216
- if (arg === '--port') { parsed.port = integer(valueFor(arg, next), '--port', 1, 65535); i++; }
219
+ if (arg === '--port') { parsed.port = integer(valueFor(arg, next), '--port', 1, 65535); parsed.portSource = 'flag'; i++; }
217
220
  else if (arg === '--out') { parsed.out = valueFor(arg, next); i++; }
218
221
  else if (arg === '--json') parsed.json = true;
219
222
  else if (arg === '--duration') { parsed.duration = durationMs(valueFor(arg, next)); i++; }
@@ -313,10 +316,11 @@ export function resolveCliContext(parsed: ParsedArgs): ParsedArgs {
313
316
  // A session target is inseparable from the endpoint that created it.
314
317
  // Prefer that endpoint over ambient CDP_PORT, but retain an explicit
315
318
  // --port override for deliberate multi-browser work.
316
- if (parsed.port === undefined && session.port !== undefined && session.port !== null) parsed.port = session.port;
319
+ if (parsed.port === undefined && session.port !== undefined && session.port !== null) { parsed.port = session.port; parsed.portSource = 'session'; }
317
320
  }
318
321
  if (parsed.port === undefined && process.env.CDP_PORT !== undefined) {
319
322
  parsed.port = integer(process.env.CDP_PORT, 'CDP_PORT', 1, 65535);
323
+ parsed.portSource = 'env';
320
324
  }
321
325
  // Preserve historical stale-index cleanup once endpoint validation has
322
326
  // succeeded; only the malformed-env throw path deliberately leaves it alone.
@@ -56,7 +56,8 @@ Input:
56
56
  --params <json> JSON-encoded params object for the method. Spec deviation: params stay inline JSON (no file indirection) — raw CDP params are small protocol-defined objects.
57
57
  --wait-event <Domain.event> wait for (and return) the next occurrence of a protocol event; combinable with a method (the method is sent first) or usable alone.
58
58
  --browser route through the held connection (session start --hold) instead of a one-shot page websocket. Connection-scoped state (permission grants, domain enables) reverts the instant its connection closes — it survives across commands only inside a held session.
59
- --target <id> with --browser: attach a flattened CDP session on the held connection to this target (for target-scoped domains); without --browser: the page target to run against. 8-char id prefix accepted.
59
+ --port <port> CDP endpoint. An explicit flag selects that endpoint even when an active session holds another browser connection.
60
+ --target <id> with --browser: attach a flattened CDP session on the held connection to this target (for target-scoped domains), including an already-open tab adopted by \`session start --hold\` without --url; without --browser: the page target to run against. 8-char id prefix accepted.
60
61
  --timeout <ms> event-wait timeout (default ${DEFAULT_TIMEOUT_MS}ms).
61
62
 
62
63
  Output:
@@ -220,8 +221,10 @@ export async function runBrowserScope(
220
221
  ): Promise<void> {
221
222
  const flagTarget = parsed.targetSource === 'flag' ? parsed.target : undefined;
222
223
  const session = getActiveSession();
223
- if (session?.bridgeSocket && fs.existsSync(session.bridgeSocket)) {
224
- const resp = await deps.sendBridgeRequest(session.bridgeSocket, {
224
+ const bridgeSocket = session?.bridgeSocket;
225
+ const heldBridgeActive = Boolean(bridgeSocket && fs.existsSync(bridgeSocket));
226
+ if (parsed.portSource !== 'flag' && bridgeSocket && heldBridgeActive) {
227
+ const resp = await deps.sendBridgeRequest(bridgeSocket, {
225
228
  method,
226
229
  params,
227
230
  targetId: flagTarget,
@@ -247,9 +250,14 @@ export async function runBrowserScope(
247
250
  }
248
251
 
249
252
  // In-flight diagnostic (stderr): the one-shot connection semantics fact.
253
+ const connectionReason = parsed.portSource === 'flag'
254
+ ? heldBridgeActive
255
+ ? `Using explicitly selected CDP port ${parsed.port} instead of the active session bridge`
256
+ : `Using explicitly selected CDP port ${parsed.port}`
257
+ : 'No held session bridge active';
250
258
  console.error(
251
- 'No held session bridge active \u2014 this one-shot connection closes when the command exits, ' +
252
- 'so any browser-level grant or target enablement made here reverts immediately after.',
259
+ `${connectionReason} — this one-shot connection closes when the command exits, ` +
260
+ 'so any browser-level grant or target enablement made here reverts immediately after.',
253
261
  );
254
262
 
255
263
  const port = parsed.port ?? (await deps.detectCdpPort());
@@ -0,0 +1,98 @@
1
+ import { captureError, invalidInput } from '../../../errors.js';
2
+ import { detectCdpPort } from '../../detect.js';
3
+ import { closeTarget, findTabById } from '../../targets.js';
4
+ import { getActiveSession } from '../../../session-context.js';
5
+ import { withSessionScopeLifecycle } from '../../../session/coordinator.js';
6
+ import { type CDPTarget, type ParsedArgs } from '../../types.js';
7
+ import {
8
+ data,
9
+ emitResult,
10
+ fact,
11
+ line,
12
+ text,
13
+ type RenderableResult,
14
+ } from '../../../output/render.js';
15
+
16
+ const USAGE = `capture tab close — close one explicitly identified background browser tab.
17
+
18
+ input:
19
+ <target> required — full tab target id or an unambiguous 8-character prefix
20
+ --port <port> CDP endpoint (default: the auto-discovered preferred endpoint)
21
+
22
+ output: <tab-closed port=… target=…> — the closed tab's full target id and last observed URL.
23
+ effects: closes exactly one page tab. Refuses unless any active session's background-tab ownership is known, and never closes that session's tab; reset or stop the session first.`;
24
+
25
+ export function buildTabClosedResult(tab: CDPTarget, port: number): RenderableResult {
26
+ return {
27
+ tag: 'tab-closed',
28
+ attrs: { port, target: tab.id },
29
+ summary: line(text`tab `, data(tab.id.slice(0, 8)), text` closed: `, data(tab.url, 300)),
30
+ };
31
+ }
32
+
33
+ export async function cmdTabClose(parsed: ParsedArgs, _args: string[]): Promise<void> {
34
+ if (parsed.help) {
35
+ console.log(USAGE);
36
+ return;
37
+ }
38
+
39
+ const target = parsed.positional[0];
40
+ if (!target) {
41
+ throw invalidInput('received: no target; expected: capture tab close <target> [--port <port>].', 'missing_argument');
42
+ }
43
+ if (target.length < 8) {
44
+ throw invalidInput(`received: target ${target}; expected: a full target id or an unambiguous prefix of at least 8 characters.`, 'target_prefix_too_short');
45
+ }
46
+
47
+ let port: number;
48
+ try {
49
+ port = parsed.port ?? await detectCdpPort();
50
+ } catch {
51
+ throw captureError(
52
+ 'world',
53
+ 'no_cdp_endpoint',
54
+ 'received: no --port, and no CDP endpoint was discovered on localhost; expected: a running CDP-enabled browser (or an explicit --port <port>). capture tab list probes every localhost CDP endpoint.',
55
+ );
56
+ }
57
+
58
+ const tab = await withSessionScopeLifecycle(async () => {
59
+ let resolved: CDPTarget | null;
60
+ try {
61
+ resolved = await findTabById(port, target);
62
+ } catch (error) {
63
+ throw captureError('precondition', 'target_unavailable', `received: target ${target} on port ${port}; expected: one unambiguous page-tab target id. ${error instanceof Error ? error.message : String(error)}`, error);
64
+ }
65
+ if (!resolved || resolved.type !== 'page') {
66
+ throw captureError(
67
+ 'precondition',
68
+ 'target_unavailable',
69
+ `received: target ${target} on port ${port}; expected: one existing page-tab target id (8-character prefix accepted). capture tab list shows available tabs.`,
70
+ );
71
+ }
72
+
73
+ const active = getActiveSession();
74
+ if (active && !active.targetId) {
75
+ throw captureError(
76
+ 'precondition',
77
+ 'active_session_target_unknown',
78
+ `session ${active.sessionId} has not established its target yet, so capture tab close cannot prove ${resolved.id} is a background tab. Target the session with a page command first, or stop the session with capture session stop ${active.sessionId}.`,
79
+ );
80
+ }
81
+ if (active?.targetId === resolved.id) {
82
+ throw captureError(
83
+ 'precondition',
84
+ 'active_session_target',
85
+ `target ${resolved.id} is the active session ${active.sessionId}'s tab; capture tab close never leaves an active session pointing at a closed target. Use capture tab reset <url> to replace it or capture session stop ${active.sessionId} first.`,
86
+ );
87
+ }
88
+
89
+ try {
90
+ await closeTarget(port, resolved.id);
91
+ } catch (error) {
92
+ throw captureError('world', 'close_failed', `closing target ${resolved.id} on port ${port} failed: ${error instanceof Error ? error.message : String(error)}`, error);
93
+ }
94
+ return resolved;
95
+ });
96
+
97
+ emitResult(buildTabClosedResult(tab, port), { json: parsed.json });
98
+ }
@@ -12,14 +12,15 @@ import { invalidInput } from '../../../errors.js';
12
12
  import { type ParsedArgs } from '../../types.js';
13
13
  import { cmdTabList } from './list.js';
14
14
  import { cmdTabOpen } from './open.js';
15
+ import { cmdTabClose } from './close.js';
15
16
  import { cmdTabReset } from './reset.js';
16
17
  import { cmdTabNetwork } from './network.js';
17
18
 
18
19
  /** Root-help representation of this branch, assembled by `src/capture.ts`. */
19
20
  export const COMMAND_BLOCK = `<command name="tab">
20
- tab and connection plumbing — endpoint/tab discovery, open/reset tabs, connection-level network emulation
21
- use when finding a CDP endpoint or tab, opening/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
22
- list · open · reset · network — \`capture tab -h\`
21
+ tab and connection plumbing — endpoint/tab discovery, lifecycle, connection-level network emulation
22
+ use when finding a CDP endpoint or tab, opening/closing/replacing a tab, or toggling connectivity; \`tab list\` is the probe for a running browser
23
+ list · open · close · reset · network — \`capture tab -h\`
23
24
  </command>`;
24
25
 
25
26
  export const TAB_USAGE = `capture tab — tab and connection plumbing: discovery, lifecycle, network emulation.
@@ -32,6 +33,7 @@ the session's {target, port} pair together.
32
33
 
33
34
  <subcommand name="list" args="[--port <port>]" whenToUse="discover CDP endpoints and the tabs open on them"/>
34
35
  <subcommand name="open" args="<url> [--new] [--port <port>]" whenToUse="open a URL and get its tab id"/>
36
+ <subcommand name="close" args="<target> [--port <port>]" whenToUse="close one explicitly identified background tab"/>
35
37
  <subcommand name="reset" args="<url> [--port <port>]" whenToUse="abandon a stuck tab and open a fresh one (refuses while a recording is active; reaps dead recorder handles; updates the active session's {target, port} together)"/>
36
38
  <subcommand name="network" args="<offline|online>" whenToUse="toggle connection-level network emulation for a tab"/>
37
39
 
@@ -46,6 +48,8 @@ export async function tabMain(parsed: ParsedArgs, args: string[]): Promise<void>
46
48
  return cmdTabList(rest, args);
47
49
  case 'open':
48
50
  return cmdTabOpen(rest, args);
51
+ case 'close':
52
+ return cmdTabClose(rest, args);
49
53
  case 'reset':
50
54
  return cmdTabReset(rest, args);
51
55
  case 'network':
@@ -22,6 +22,7 @@ import { getActiveSession, updateSessionState } from '../../../session-context.j
22
22
  import {
23
23
  admitSessionOperation,
24
24
  withSessionLifecycle,
25
+ withSessionScopeLifecycle,
25
26
  scanRecorderHandles,
26
27
  clearDanglingRecorderPointer,
27
28
  type LifecycleSeams,
@@ -123,38 +124,40 @@ export async function cmdTabReset(parsed: ParsedArgs, _args: string[]): Promise<
123
124
  const url = parsed.positional[0];
124
125
  if (!url) throw invalidInput('received: no URL; expected: capture tab reset <url> [--port <port>].', 'missing_argument');
125
126
 
126
- const session = getActiveSession();
127
- if (!session) {
128
- let port: number;
129
- try {
130
- port = parsed.port ?? (await deps.detectCdpPort());
131
- } catch {
132
- throw captureError('world', 'no_cdp_endpoint', 'received: no --port, and no CDP endpoint was discovered on localhost; expected: a running CDP-enabled browser (or an explicit --port <port>).');
133
- }
134
- const tab = await openTabOrThrow(port, url);
135
- await boundedLoadWait(tab);
136
- emitResult(buildTabResetResult(tab, port, false), { json: parsed.json });
137
- return;
138
- }
139
-
140
- const op = await admitSessionOperation(session.dir);
141
- try {
142
- const result = await withSessionLifecycle(session.dir, async () => {
143
- const fresh = getActiveSession();
144
- if (!fresh || fresh.dir !== session.dir) throw captureError('precondition', 'session_unavailable', 'The active capture session is no longer available.');
145
- const scan = scanRecorderHandles(session.dir, deps.lifecycle);
146
- if (scan.some(h => h.classification === 'unknown')) throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness; refusing to reset.');
147
- if (scan.some(h => h.classification === 'malformed')) throw captureError('precondition', 'recorder_unavailable', 'A malformed recorder handle exists on this session; resolve it before resetting.');
148
- if (scan.some(h => h.classification === 'live')) throw captureError('precondition', 'recorder_active', 'A recording is active on this session; stop it first: `capture motion rec --stop`. Reset never rebinds a live recorder.');
149
- await clearDanglingRecorderPointer(session.dir, scan);
150
- const port = fresh.port ?? parsed.port ?? (await deps.detectCdpPort());
127
+ return withSessionScopeLifecycle(async () => {
128
+ const session = getActiveSession();
129
+ if (!session) {
130
+ let port: number;
131
+ try {
132
+ port = parsed.port ?? (await deps.detectCdpPort());
133
+ } catch {
134
+ throw captureError('world', 'no_cdp_endpoint', 'received: no --port, and no CDP endpoint was discovered on localhost; expected: a running CDP-enabled browser (or an explicit --port <port>).');
135
+ }
151
136
  const tab = await openTabOrThrow(port, url);
152
137
  await boundedLoadWait(tab);
153
- await updateSessionState(session.dir, { targetId: tab.id, port });
154
- return buildTabResetResult(tab, port, true);
155
- }, deps.lifecycle);
156
- emitResult(result, { json: parsed.json });
157
- } finally {
158
- await op.release();
159
- }
138
+ emitResult(buildTabResetResult(tab, port, false), { json: parsed.json });
139
+ return;
140
+ }
141
+
142
+ const op = await admitSessionOperation(session.dir);
143
+ try {
144
+ const result = await withSessionLifecycle(session.dir, async () => {
145
+ const fresh = getActiveSession();
146
+ if (!fresh || fresh.dir !== session.dir) throw captureError('precondition', 'session_unavailable', 'The active capture session is no longer available.');
147
+ const scan = scanRecorderHandles(session.dir, deps.lifecycle);
148
+ if (scan.some(h => h.classification === 'unknown')) throw captureError('world', 'recorder_liveness_unknown', 'Cannot determine recorder liveness; refusing to reset.');
149
+ if (scan.some(h => h.classification === 'malformed')) throw captureError('precondition', 'recorder_unavailable', 'A malformed recorder handle exists on this session; resolve it before resetting.');
150
+ if (scan.some(h => h.classification === 'live')) throw captureError('precondition', 'recorder_active', 'A recording is active on this session; stop it first: `capture motion rec --stop`. Reset never rebinds a live recorder.');
151
+ await clearDanglingRecorderPointer(session.dir, scan);
152
+ const port = fresh.port ?? parsed.port ?? (await deps.detectCdpPort());
153
+ const tab = await openTabOrThrow(port, url);
154
+ await boundedLoadWait(tab);
155
+ await updateSessionState(session.dir, { targetId: tab.id, port });
156
+ return buildTabResetResult(tab, port, true);
157
+ }, deps.lifecycle);
158
+ emitResult(result, { json: parsed.json });
159
+ } finally {
160
+ await op.release();
161
+ }
162
+ });
160
163
  }
@@ -10,6 +10,7 @@ import {
10
10
  appendToHarRecording as appendToHar,
11
11
  } from '../har-manager.js';
12
12
  import { getActiveSession, updateActiveSession, type ActiveSessionState } from '../session-context.js';
13
+ import { withSessionScopeLifecycle } from '../session/coordinator.js';
13
14
  import { RecorderHeldClient, isRecorderHeldClient } from './recorder-client.js';
14
15
  import { recDirFor, readRecorderJson } from './motion/recorder.js';
15
16
  import { captureError, invalidInput } from '../errors.js';
@@ -35,6 +36,7 @@ function getPortFromWebSocketDebuggerUrl(url?: string): number | null {
35
36
  export interface ConnectionSeams {
36
37
  getActiveSession: () => ActiveSessionState | null;
37
38
  updateActiveSession: (patch: Partial<ActiveSessionState>) => Promise<ActiveSessionState | null>;
39
+ withSessionScopeLifecycle: <T>(action: () => Promise<T>) => Promise<T>;
38
40
  resolveTab: (parsed: ParsedArgs) => Promise<{ port: number; tab: CDPTarget } | null>;
39
41
  createClient: (wsUrl: string) => CDPClient;
40
42
  appendHar: typeof appendToHar;
@@ -58,6 +60,7 @@ function defaultResolveTab(parsed: ParsedArgs): Promise<{ port: number; tab: CDP
58
60
  let seams: ConnectionSeams = {
59
61
  getActiveSession: () => getActiveSession(),
60
62
  updateActiveSession,
63
+ withSessionScopeLifecycle,
61
64
  resolveTab: defaultResolveTab,
62
65
  createClient: (wsUrl) => new CDPClient(wsUrl),
63
66
  appendHar: appendToHar,
@@ -232,10 +235,16 @@ export async function connectForCommand(
232
235
  // endpoint precedence already resolved before this leaf ran.
233
236
  const port = resolved?.port ?? getPortFromWebSocketDebuggerUrl(tab.webSocketDebuggerUrl);
234
237
 
235
- // Lazy target establishment: publish `{targetId, port}` as one atomic pair
236
- // through U03's metadata helper so a subsequent command finds both together.
238
+ // Lazy target establishment shares the scope lifecycle boundary with tab
239
+ // close and session start/stop. Re-read under that boundary so a stale
240
+ // pre-resolution snapshot cannot repoint a replacement session.
237
241
  if (activeSession && !activeSession.targetId) {
238
- await seams.updateActiveSession({ targetId: tab.id, port: port ?? null });
242
+ await seams.withSessionScopeLifecycle(async () => {
243
+ const current = seams.getActiveSession();
244
+ if (current?.sessionId === activeSession.sessionId && !current.targetId) {
245
+ await seams.updateActiveSession({ targetId: tab.id, port: port ?? null });
246
+ }
247
+ });
239
248
  }
240
249
 
241
250
  console.error(
package/src/cdp/types.ts CHANGED
@@ -10,6 +10,11 @@ export interface ParsedArgs {
10
10
  command: string;
11
11
  positional: string[];
12
12
  port?: number;
13
+ /** Where `port` was assigned from: an actual `--port` flag, active-session
14
+ * fallback, or the ambient `CDP_PORT` env var. Browser-scope CDP uses a
15
+ * held session bridge unless this is `'flag'`, which explicitly selects a
16
+ * different browser endpoint. */
17
+ portSource?: 'flag' | 'session' | 'env';
13
18
  out?: string;
14
19
  json?: boolean;
15
20
  duration?: number;
@@ -54,6 +54,7 @@ export const RESULT_TAGS = [
54
54
  'screenshot',
55
55
  'tabs',
56
56
  'tab-opened',
57
+ 'tab-closed',
57
58
  'tab-reset',
58
59
  'network',
59
60
  'cdp-result',
@@ -38,7 +38,6 @@ import {
38
38
  import {
39
39
  CAPTURE_ROOT,
40
40
  MAX_LOG_LABEL_BYTES,
41
- acquirePrivateLock,
42
41
  ensurePrivateDir,
43
42
  rejectLogLabel,
44
43
  writeJsonPrivate,
@@ -47,7 +46,7 @@ import {
47
46
  type SnapMeta,
48
47
  type RecMeta,
49
48
  } from './artifacts.js';
50
- import { beginSessionStop, admitSessionOperation, withSessionLifecycle } from './coordinator.js';
49
+ import { beginSessionStop, admitSessionOperation, withSessionLifecycle, withSessionScopeLifecycle } from './coordinator.js';
51
50
  import { parseStatusFilter, type StatusPredicate } from './har-filter.js';
52
51
 
53
52
  type Session = ActiveSessionState;
@@ -151,22 +150,6 @@ function sessionDir(id: string): string {
151
150
  return path.join(CAPTURE_ROOT, id);
152
151
  }
153
152
 
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
153
  function isSessionStopped(session: Session): boolean {
171
154
  return Boolean(session.stoppedAt) || fs.existsSync(path.join(session.dir, 'bundle.json'));
172
155
  }
@@ -336,6 +319,8 @@ input:
336
319
  --hold Hold one CDP browser connection open for the session's
337
320
  lifetime, so browser-level state (permission grants,
338
321
  ServiceWorker enablement) survives across separate commands.
322
+ With no --url, later --target <existing-id> commands adopt
323
+ an already-open tab without creating a new one.
339
324
  --port <n> CDP port to attach to; default is auto-detected across localhost.
340
325
 
341
326
  Output:
@@ -567,7 +552,7 @@ async function start(parsed: ParsedArgs): Promise<void> {
567
552
 
568
553
  if (rejectSurplusPositionals(parsed, 'start', 0, 'capture session start [--url <url>] [--hold]')) return;
569
554
 
570
- return withLifecycleCoordinator(async () => {
555
+ return withSessionScopeLifecycle(async () => {
571
556
  const active = getActiveSession();
572
557
  if (active && !isSessionStopped(active)) {
573
558
  emitResult({
@@ -1075,7 +1060,7 @@ async function stop(parsed: ParsedArgs): Promise<void> {
1075
1060
  return;
1076
1061
  }
1077
1062
 
1078
- return withLifecycleCoordinator(async () => {
1063
+ return withSessionScopeLifecycle(async () => {
1079
1064
  let session: Session;
1080
1065
  try {
1081
1066
  session = readSession(id);
@@ -3,6 +3,7 @@ import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
4
  import { captureError } from '../errors.js';
5
5
  import {
6
+ CAPTURE_ROOT,
6
7
  acquirePrivateLock,
7
8
  assertUnderCaptureRoot,
8
9
  readPrivateFile,
@@ -34,6 +35,23 @@ export interface SessionStopAdmission {
34
35
  finish(success: boolean): Promise<void>;
35
36
  }
36
37
 
38
+ function scopeLifecycleLockPath(): string {
39
+ return path.join(CAPTURE_ROOT, `.session-lifecycle-${process.env.CRTR_NODE_ID ?? 'default'}`);
40
+ }
41
+
42
+ /** Serializes active-session publication, teardown, and target ownership changes for one capture scope. */
43
+ export async function withSessionScopeLifecycle<T>(action: () => Promise<T>): Promise<T> {
44
+ const lock = await acquirePrivateLock(scopeLifecycleLockPath(), {
45
+ acquireTimeoutMs: 120_000,
46
+ leaseMs: 1_000,
47
+ });
48
+ try {
49
+ return await action();
50
+ } finally {
51
+ lock.release();
52
+ }
53
+ }
54
+
37
55
  function statePath(sessionDir: string): string {
38
56
  return path.join(assertUnderCaptureRoot(sessionDir), '.operations.json');
39
57
  }