@crouton-kit/capture 1.3.8 → 1.3.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/bin/capture +29841 -8539
  2. package/package.json +6 -3
  3. package/src/capture.ts +86 -173
  4. package/src/cdp/a11y.ts +113 -14
  5. package/src/cdp/args.ts +300 -205
  6. package/src/cdp/bridge/protocol.ts +93 -6
  7. package/src/cdp/bridge/server.ts +118 -53
  8. package/src/cdp/bridge/spawn.ts +53 -6
  9. package/src/cdp/client.ts +13 -3
  10. package/src/cdp/commands/bridge-serve.ts +20 -14
  11. package/src/cdp/commands/cdp.ts +259 -90
  12. package/src/cdp/commands/gate-guard.ts +43 -0
  13. package/src/cdp/commands/lib.ts +339 -213
  14. package/src/cdp/commands/measure/census.ts +76 -20
  15. package/src/cdp/commands/measure/check.ts +100 -26
  16. package/src/cdp/commands/measure/diff.ts +109 -21
  17. package/src/cdp/commands/measure/explain.ts +121 -19
  18. package/src/cdp/commands/measure/index.ts +31 -39
  19. package/src/cdp/commands/measure/map-ax.ts +52 -0
  20. package/src/cdp/commands/measure/map-focus.ts +41 -13
  21. package/src/cdp/commands/measure/map-layers.ts +49 -15
  22. package/src/cdp/commands/measure/map-scroll.ts +85 -15
  23. package/src/cdp/commands/measure/snap.ts +405 -25
  24. package/src/cdp/commands/measure/sweep.ts +217 -20
  25. package/src/cdp/commands/motion/index.ts +18 -17
  26. package/src/cdp/commands/motion/jank.ts +119 -9
  27. package/src/cdp/commands/motion/mask.ts +81 -12
  28. package/src/cdp/commands/motion/rec.ts +520 -19
  29. package/src/cdp/commands/motion/response.ts +65 -15
  30. package/src/cdp/commands/motion/timeline.ts +139 -14
  31. package/src/cdp/commands/page/click.ts +194 -0
  32. package/src/cdp/commands/page/elements.ts +154 -0
  33. package/src/cdp/commands/page/exec.ts +317 -0
  34. package/src/cdp/commands/page/index.ts +72 -0
  35. package/src/cdp/commands/page/navigate.ts +330 -0
  36. package/src/cdp/commands/page/scroll.ts +107 -0
  37. package/src/cdp/commands/page/shot.ts +208 -0
  38. package/src/cdp/commands/page/type.ts +104 -0
  39. package/src/cdp/commands/tab/index.ts +59 -0
  40. package/src/cdp/commands/tab/list.ts +167 -0
  41. package/src/cdp/commands/tab/network.ts +162 -0
  42. package/src/cdp/commands/tab/open.ts +90 -0
  43. package/src/cdp/commands/tab/reset.ts +160 -0
  44. package/src/cdp/connection.ts +368 -37
  45. package/src/cdp/detect.ts +42 -13
  46. package/src/cdp/dispatch.ts +74 -28
  47. package/src/cdp/har-recorder.ts +648 -115
  48. package/src/cdp/leaf-grammar.ts +99 -0
  49. package/src/cdp/measure/census.ts +205 -0
  50. package/src/cdp/measure/check.ts +318 -0
  51. package/src/cdp/measure/collectors/animation.ts +385 -0
  52. package/src/cdp/measure/collectors/ax.ts +278 -0
  53. package/src/cdp/measure/collectors/focus.ts +1041 -0
  54. package/src/cdp/measure/collectors/forms.ts +905 -0
  55. package/src/cdp/measure/collectors/geometry.ts +1038 -0
  56. package/src/cdp/measure/collectors/hittest.ts +835 -0
  57. package/src/cdp/measure/collectors/layers.ts +697 -0
  58. package/src/cdp/measure/collectors/media.ts +522 -0
  59. package/src/cdp/measure/collectors/pixels.ts +1584 -0
  60. package/src/cdp/measure/collectors/queries.ts +464 -0
  61. package/src/cdp/measure/collectors/scroll.ts +599 -0
  62. package/src/cdp/measure/collectors/states.ts +992 -0
  63. package/src/cdp/measure/collectors/style-provenance.ts +643 -0
  64. package/src/cdp/measure/collectors/styles.ts +355 -0
  65. package/src/cdp/measure/collectors/text.ts +713 -0
  66. package/src/cdp/measure/diff.ts +470 -0
  67. package/src/cdp/measure/explain.ts +498 -0
  68. package/src/cdp/measure/map-ax.ts +633 -0
  69. package/src/cdp/measure/map-focus.ts +188 -0
  70. package/src/cdp/measure/map-layers.ts +177 -0
  71. package/src/cdp/measure/map-scroll.ts +317 -0
  72. package/src/cdp/measure/redaction.ts +51 -0
  73. package/src/cdp/measure/settle.ts +1051 -0
  74. package/src/cdp/measure/snapshot.ts +390 -0
  75. package/src/cdp/measure/sweep.ts +330 -0
  76. package/src/cdp/measure/types.ts +220 -0
  77. package/src/cdp/measure/v2/collectors.ts +19 -0
  78. package/src/cdp/measure/v2/orchestrator.ts +46 -0
  79. package/src/cdp/measure/v2/snap-selection.ts +33 -0
  80. package/src/cdp/measure/v2/target-lock.ts +15 -0
  81. package/src/cdp/measure/v2/target.ts +16 -0
  82. package/src/cdp/motion/jank.ts +433 -0
  83. package/src/cdp/motion/mask.ts +421 -0
  84. package/src/cdp/motion/recorder.ts +764 -0
  85. package/src/cdp/motion/response.ts +356 -0
  86. package/src/cdp/motion/timeline.ts +215 -0
  87. package/src/cdp/record.ts +5 -212
  88. package/src/cdp/recorder-bridge.ts +1478 -105
  89. package/src/cdp/recorder-client.ts +326 -0
  90. package/src/cdp/scope-lock.ts +73 -0
  91. package/src/cdp/screenshot.ts +199 -66
  92. package/src/cdp/targets.ts +21 -1
  93. package/src/cdp/timing.ts +15 -10
  94. package/src/cdp/types.ts +22 -7
  95. package/src/cdp/viewport.ts +21 -0
  96. package/src/cdp.ts +12 -58
  97. package/src/command-help.ts +29 -0
  98. package/src/command-resolution.ts +22 -0
  99. package/src/command-walker.ts +35 -0
  100. package/src/contracts/a11y.ts +33 -0
  101. package/src/contracts/cursor.ts +35 -0
  102. package/src/contracts/index.ts +15 -0
  103. package/src/contracts/motion.ts +19 -0
  104. package/src/contracts/neutral.ts +56 -0
  105. package/src/contracts/primitives.ts +205 -0
  106. package/src/contracts/release.ts +17 -0
  107. package/src/contracts/results.ts +208 -0
  108. package/src/contracts/routes.ts +140 -0
  109. package/src/contracts/snapshot.ts +92 -0
  110. package/src/cursor/index.ts +13 -0
  111. package/src/cursor/service.ts +310 -0
  112. package/src/errors.ts +71 -0
  113. package/src/har-manager.ts +992 -41
  114. package/src/interact.ts +410 -98
  115. package/src/output/artifact-lifecycle.ts +371 -0
  116. package/src/output/artifact.ts +6 -0
  117. package/src/output/exact-raw.ts +114 -0
  118. package/src/output/render.ts +396 -2
  119. package/src/output/structured.ts +211 -0
  120. package/src/registry.ts +208 -0
  121. package/src/session/artifacts.ts +452 -200
  122. package/src/session/commands.ts +1162 -397
  123. package/src/session/coordinator.ts +251 -0
  124. package/src/session/har-filter.ts +57 -0
  125. package/src/session/log-tailer.ts +653 -0
  126. package/src/session-context.ts +279 -51
  127. package/src/vault/bundle.ts +32 -17
  128. package/src/cdp/commands/exec.ts +0 -105
  129. package/src/cdp/commands/har.ts +0 -128
  130. package/src/cdp/commands/tabs.ts +0 -183
  131. package/src/cdp/commands/traffic.ts +0 -121
  132. package/src/cdp/commands/ui.ts +0 -172
  133. package/src/cdp/execute.ts +0 -113
  134. package/src/cdp/har-output.ts +0 -47
  135. package/src/cdp/usage.ts +0 -79
  136. package/src/tab-session.ts +0 -91
@@ -1,96 +1,318 @@
1
1
  /**
2
- * Session Context — persists active session state so CDP commands
3
- * can auto-fill --target, --har, and --out without manual threading.
2
+ * Session Context — persists active session state so CDP commands can target
3
+ * the session tab and write session-owned artifacts without manual threading.
4
4
  *
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.
8
- * Stale sessions (dir deleted, crashed process) are cleaned up on read.
5
+ * A scoped, private active-index `.active-<scope>` only tracks the active
6
+ * session identity and directory; mutable/live state lives in the canonical
7
+ * per-session `.session.json` file under that directory.
9
8
  */
10
9
 
11
- import * as fs from 'fs';
12
- import * as path from 'path';
13
- import { CAPTURE_ROOT } from './session/artifacts.js';
10
+ import * as fs from 'node:fs';
11
+ import * as path from 'node:path';
12
+ import {
13
+ CAPTURE_ROOT,
14
+ acquirePrivateLock,
15
+ assertUnderCaptureRoot,
16
+ ensurePrivateDir,
17
+ parseRegisteredLogTailer,
18
+ readPrivateFile,
19
+ removeArtifactTree,
20
+ unlinkPrivateFile,
21
+ writeJsonPrivate,
22
+ type RecMeta,
23
+ type RegisteredLogTailer,
24
+ type SnapMeta,
25
+ } from './session/artifacts.js';
14
26
 
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
27
  function activeScopeKey(): string {
26
28
  return process.env.CRTR_NODE_ID ?? 'default';
27
29
  }
28
30
 
31
+ export function activeSessionScopeKey(): string {
32
+ return activeScopeKey();
33
+ }
34
+
29
35
  function getActivePath(): string {
30
36
  return path.join(CAPTURE_ROOT, `.active-${activeScopeKey()}`);
31
37
  }
32
38
 
39
+ interface ActiveSessionIndex {
40
+ sessionId: string;
41
+ dir: string;
42
+ }
43
+
44
+ /** The sole authoritative mutable record stored in `<dir>/.session.json`. */
33
45
  export interface ActiveSessionState {
34
46
  sessionId: string;
35
47
  dir: string;
36
48
  harId: string | null;
49
+ startedAt?: string;
50
+ url?: string | null;
37
51
  targetId: string | null;
38
52
  stepCount: number;
53
+ /** Identity-bearing registered log tailer handles owned by this session. */
54
+ logPids?: RegisteredLogTailer[];
55
+ /**
56
+ * Optional CDP endpoint socket port used by this session.
57
+ * Session index files never keep this; it is only in `.session.json`.
58
+ */
59
+ port?: number | null;
39
60
  /** Unix socket of the session's held CDP bridge (`session start --hold`), if any. */
40
61
  bridgeSocket?: string | null;
62
+ /** Network.emulateNetworkConditions offline state for the session target. */
63
+ networkOffline?: boolean;
64
+ /** Child process id of the held CDP bridge, if any. */
65
+ bridgePid?: number | null;
41
66
  /**
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}.
67
+ * The id of the recording (`motion/recs/{recId}`) currently live under this
68
+ * session, if `motion rec --start` has armed one.
46
69
  */
47
70
  activeRecId?: string | null;
71
+ /** Marker written after successful stop to keep stale active indices from blocking.
72
+ * It is not part of the active index; only read from `.session.json`.
73
+ */
74
+ stoppedAt?: string | null;
75
+ /** Optional per-session run-state marker used by later lifecycle units. */
76
+ stopping?: boolean;
77
+ }
78
+
79
+ function isIndex(value: unknown): value is ActiveSessionIndex {
80
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
81
+ const record = value as Record<string, unknown>;
82
+ return typeof record.sessionId === 'string' && typeof record.dir === 'string';
83
+ }
84
+
85
+ /** Directory-entry identity of the `.active-<scope>` pointer, for compare-clear. */
86
+ interface ActiveEntryIdentity {
87
+ dev: string;
88
+ ino: string;
89
+ }
90
+
91
+ function statActiveEntry(): ActiveEntryIdentity | null {
92
+ try {
93
+ const stat = fs.lstatSync(getActivePath(), { bigint: true });
94
+ return { dev: stat.dev.toString(), ino: stat.ino.toString() };
95
+ } catch (error) {
96
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
97
+ throw error;
98
+ }
99
+ }
100
+
101
+ interface ActivePointer {
102
+ index: ActiveSessionIndex | null;
103
+ /** Identity of the exact entry this read observed; null when no entry existed. */
104
+ entry: ActiveEntryIdentity | null;
105
+ }
106
+
107
+ /**
108
+ * Reads the active pointer together with the identity of the directory entry
109
+ * it came from. The identity is captured BEFORE the content read, so a stale
110
+ * determination made from these bytes can only ever authorize clearing this
111
+ * exact inode — never a pointer published after it.
112
+ */
113
+ function readActivePointer(): ActivePointer {
114
+ const entry = statActiveEntry();
115
+ if (!entry) return { index: null, entry: null };
116
+ try {
117
+ const raw = readPrivateFile(getActivePath()).toString('utf-8');
118
+ const parsed = JSON.parse(raw) as unknown;
119
+ if (!isIndex(parsed)) return { index: null, entry };
120
+ return { index: { sessionId: parsed.sessionId, dir: parsed.dir }, entry };
121
+ } catch {
122
+ return { index: null, entry };
123
+ }
124
+ }
125
+
126
+ function sessionMetaPath(sessionDir: string): string {
127
+ return path.join(sessionDir, '.session.json');
128
+ }
129
+
130
+ function sessionStateLockPath(sessionDir: string): string {
131
+ return path.join(assertUnderCaptureRoot(sessionDir), '.session-state.lock');
132
+ }
133
+
134
+ async function withSessionStateLock<T>(sessionDir: string, action: () => T): Promise<T> {
135
+ const lock = await acquirePrivateLock(sessionStateLockPath(sessionDir), {
136
+ acquireTimeoutMs: 30_000,
137
+ leaseMs: 500,
138
+ });
139
+ try {
140
+ return action();
141
+ } finally {
142
+ lock.release();
143
+ }
144
+ }
145
+
146
+ /** The one schema check for a persisted `.session.json` record. Exported so
147
+ * every session-record reader (here and `session/commands.ts`) validates the
148
+ * same shape instead of trusting parsed JSON structurally. */
149
+ export function isActiveStateCandidate(value: unknown): value is ActiveSessionState {
150
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
151
+ const record = value as Record<string, unknown>;
152
+ const nullableString = (field: unknown): boolean => field === undefined || field === null || typeof field === 'string';
153
+ const nullablePid = (field: unknown): boolean => field === undefined || field === null || (Number.isSafeInteger(field) && (field as number) > 0);
154
+ const validLogs = record.logPids === undefined
155
+ || (Array.isArray(record.logPids) && record.logPids.every(item => parseRegisteredLogTailer(item) !== undefined));
156
+ return typeof record.sessionId === 'string' && record.sessionId.length > 0
157
+ && typeof record.dir === 'string'
158
+ && (record.harId === null || typeof record.harId === 'string')
159
+ && nullableString(record.startedAt)
160
+ && nullableString(record.url)
161
+ && (record.targetId === null || typeof record.targetId === 'string')
162
+ && typeof record.stepCount === 'number'
163
+ && Number.isSafeInteger(record.stepCount)
164
+ && record.stepCount >= 0
165
+ && validLogs
166
+ && (record.port === undefined || record.port === null || (Number.isInteger(record.port) && (record.port as number) > 0 && (record.port as number) <= 65535))
167
+ && nullableString(record.bridgeSocket)
168
+ && nullablePid(record.bridgePid)
169
+ && nullableString(record.activeRecId)
170
+ && nullableString(record.stoppedAt)
171
+ && (record.stopping === undefined || typeof record.stopping === 'boolean');
48
172
  }
49
173
 
50
- export function getActiveSession(): ActiveSessionState | null {
174
+ function clearActivePath(): void {
175
+ const activePath = getActivePath();
51
176
  try {
52
- const activePath = getActivePath();
53
- if (!fs.existsSync(activePath)) return null;
54
- const state = JSON.parse(fs.readFileSync(activePath, 'utf-8')) as ActiveSessionState;
55
- // Validate session dir still exists — clean up stale files
56
- if (!fs.existsSync(state.dir)) {
57
- clearActiveSession();
177
+ unlinkPrivateFile(activePath);
178
+ } catch (error) {
179
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
180
+ // A stale planted symlink is itself an index entry, never a target to follow.
181
+ try { removeArtifactTree(activePath); } catch { /* stale cleanup is best effort */ }
182
+ }
183
+ }
184
+
185
+ /**
186
+ * Compare-clear (A2/U03): removes the active pointer only while the directory
187
+ * entry is still the exact inode the deciding read observed. A concurrent
188
+ * `session start` publishes its pointer as a fresh inode via rename, so a
189
+ * pointer republished after that read is never unlinked by a slow stale-clean
190
+ * and the scope's one-live-session cardinality is preserved.
191
+ */
192
+ function clearActiveEntry(expected: ActiveEntryIdentity | null): void {
193
+ if (!expected) return;
194
+ const current = statActiveEntry();
195
+ if (!current || current.dev !== expected.dev || current.ino !== expected.ino) return;
196
+ clearActivePath();
197
+ }
198
+
199
+ /**
200
+ * Reads active session metadata. Endpoint resolution uses `cleanStale: false`
201
+ * while it decides whether a malformed ambient CDP_PORT is relevant: that
202
+ * preserves a stale pointer for the invocation error path. Normal command
203
+ * consumers retain the default eager stale-index cleanup.
204
+ */
205
+ export function getActiveSession({ cleanStale = true }: { cleanStale?: boolean } = {}): ActiveSessionState | null {
206
+ const pointer = readActivePointer();
207
+ const clean = (): void => { if (cleanStale) clearActiveEntry(pointer.entry); };
208
+ const index = pointer.index;
209
+ if (!index) {
210
+ clean();
211
+ return null;
212
+ }
213
+
214
+ try {
215
+ const sessionDir = assertUnderCaptureRoot(path.resolve(index.dir));
216
+ if (sessionDir !== index.dir) throw new Error('active session directory escaped capture root');
217
+
218
+ try {
219
+ readPrivateFile(path.join(sessionDir, 'bundle.json'));
220
+ clean();
58
221
  return null;
222
+ } catch (error) {
223
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
59
224
  }
225
+
226
+ const raw = readPrivateFile(sessionMetaPath(sessionDir)).toString('utf-8');
227
+ const parsed = JSON.parse(raw) as unknown;
228
+ if (!isActiveStateCandidate(parsed)) {
229
+ clean();
230
+ return null;
231
+ }
232
+
233
+ const state = parsed as ActiveSessionState;
234
+ if (state.sessionId !== index.sessionId || state.dir !== sessionDir) {
235
+ clean();
236
+ return null;
237
+ }
238
+ if (state.stoppedAt) {
239
+ clean();
240
+ return null;
241
+ }
242
+
243
+ ensurePrivateDir(sessionDir);
60
244
  return state;
61
245
  } catch {
246
+ // Any malformed/corrupt pointer or metadata file is stale. Endpoint
247
+ // precedence may inspect it without mutation; all ordinary callers clean.
248
+ clean();
62
249
  return null;
63
250
  }
64
251
  }
65
252
 
66
- export function setActiveSession(state: ActiveSessionState): void {
67
- fs.mkdirSync(CAPTURE_ROOT, { recursive: true });
68
- fs.writeFileSync(getActivePath(), JSON.stringify(state, null, 2));
253
+ export async function setActiveSession(state: ActiveSessionState): Promise<void> {
254
+ const sessionDir = ensurePrivateDir(state.dir);
255
+ const next: ActiveSessionState = {
256
+ ...state,
257
+ dir: path.resolve(sessionDir),
258
+ sessionId: state.sessionId,
259
+ harId: state.harId,
260
+ targetId: state.targetId,
261
+ stepCount: Number.isSafeInteger(state.stepCount) ? state.stepCount : 0,
262
+ };
263
+ const index: ActiveSessionIndex = { sessionId: next.sessionId, dir: next.dir };
264
+
265
+ await withSessionStateLock(next.dir, () => {
266
+ writeJsonPrivate(sessionMetaPath(next.dir), next);
267
+ writeJsonPrivate(getActivePath(), index);
268
+ });
69
269
  }
70
270
 
71
271
  export function clearActiveSession(): void {
72
- try {
73
- fs.unlinkSync(getActivePath());
74
- } catch {
75
- // Already gone
272
+ clearActivePath();
273
+ }
274
+
275
+ export function clearActiveSessionIf(sessionId: string): void {
276
+ const pointer = readActivePointer();
277
+ if (pointer.index?.sessionId === sessionId) {
278
+ clearActiveEntry(pointer.entry);
76
279
  }
77
280
  }
78
281
 
79
- export function updateActiveSession(patch: Partial<ActiveSessionState>): ActiveSessionState | null {
282
+ async function mutateSessionState(
283
+ sessionDir: string,
284
+ mutate: (current: ActiveSessionState) => ActiveSessionState,
285
+ ): Promise<ActiveSessionState> {
286
+ const canonical = assertUnderCaptureRoot(sessionDir);
287
+ return withSessionStateLock(canonical, () => {
288
+ const metaPath = sessionMetaPath(canonical);
289
+ const current = JSON.parse(readPrivateFile(metaPath).toString('utf-8')) as unknown;
290
+ if (!isActiveStateCandidate(current) || current.dir !== canonical) throw new Error(`invalid session metadata: ${metaPath}`);
291
+ const next = mutate(current);
292
+ const updated = { ...next, dir: current.dir, sessionId: current.sessionId };
293
+ if (!isActiveStateCandidate(updated)) throw new Error(`invalid session metadata update: ${metaPath}`);
294
+ writeJsonPrivate(metaPath, updated);
295
+ return updated;
296
+ });
297
+ }
298
+
299
+ export function updateSessionState(sessionDir: string, patch: Partial<ActiveSessionState>): Promise<ActiveSessionState> {
300
+ return mutateSessionState(sessionDir, current => ({ ...current, ...patch }));
301
+ }
302
+
303
+ export async function updateActiveSession(patch: Partial<ActiveSessionState>): Promise<ActiveSessionState | null> {
80
304
  const current = getActiveSession();
81
305
  if (!current) return null;
82
- const updated = { ...current, ...patch };
83
- setActiveSession(updated);
84
- return updated;
306
+ return updateSessionState(current.dir, patch);
85
307
  }
86
308
 
87
309
  /** Records `recId` as the active session's live recording. */
88
- export function setActiveRecId(recId: string): ActiveSessionState | null {
310
+ export function setActiveRecId(recId: string): Promise<ActiveSessionState | null> {
89
311
  return updateActiveSession({ activeRecId: recId });
90
312
  }
91
313
 
92
314
  /** Clears the active session's live-recording pointer (on `rec --stop` / stale reap). */
93
- export function clearActiveRecId(): ActiveSessionState | null {
315
+ export function clearActiveRecId(): Promise<ActiveSessionState | null> {
94
316
  return updateActiveSession({ activeRecId: null });
95
317
  }
96
318
 
@@ -99,6 +321,11 @@ export function getActiveRecId(): string | null {
99
321
  return getActiveSession()?.activeRecId ?? null;
100
322
  }
101
323
 
324
+ /** Records the active session target's requested network emulation state. */
325
+ export function setActiveNetworkOffline(offline: boolean): ActiveSessionState | null {
326
+ return updateActiveSession({ networkOffline: offline });
327
+ }
328
+
102
329
  function sanitizeLabel(label: string): string {
103
330
  return label
104
331
  .toLowerCase()
@@ -112,16 +339,17 @@ function sanitizeLabel(label: string): string {
112
339
  * Format: {dir}/shots/{NN}-{action}-{sanitized-label}.png
113
340
  * Increments stepCount in the session state.
114
341
  */
115
- export function nextStepPath(action: string, label: string): string | null {
342
+ export async function nextStepPath(action: string, label: string): Promise<string | null> {
116
343
  const session = getActiveSession();
117
344
  if (!session) return null;
118
345
 
119
- const step = session.stepCount + 1;
120
- const nn = String(step).padStart(2, '0');
346
+ const updated = await mutateSessionState(session.dir, current => ({
347
+ ...current,
348
+ stepCount: current.stepCount + 1,
349
+ }));
350
+ const nn = String(updated.stepCount).padStart(2, '0');
121
351
  const sanitized = sanitizeLabel(label);
122
- const filename = `${nn}-${action}-${sanitized}.png`;
123
- const shotPath = path.join(session.dir, 'shots', filename);
124
-
125
- updateActiveSession({ stepCount: step });
126
- return shotPath;
352
+ return path.join(updated.dir, 'shots', `${nn}-${action}-${sanitized}.png`);
127
353
  }
354
+
355
+ export { type SnapMeta, type RecMeta };
@@ -1,13 +1,10 @@
1
1
  /**
2
- * vault/bundle — the import-driven exec engine.
2
+ * vault/bundle — the import-driven `page exec` engine.
3
3
  *
4
- * Ports the bundling mechanism of northlight's
5
- * `apps/core/src/tool-registry/lib/bundle-agent-code.ts`, DROPPING the telemetry
6
- * envelope (no `__vallumStartCall`, no `__snapshot`, no `borgEnabled`). When
7
- * `capture exec` is handed code containing leading static imports, this turns it
8
- * into a single self-contained IIFE: the user's `libs/<name>` imports are
9
- * resolved to the forked vault source, esbuild bundles the whole tree on the
10
- * fly, and the result is a string the exec path evals over CDP.
4
+ * When `capture page exec` receives code containing leading static imports,
5
+ * this turns it into a single self-contained IIFE: the user's `libs/<name>`
6
+ * imports are resolved to the forked vault source, esbuild bundles the whole
7
+ * tree on the fly, and the result is a string the exec path evaluates over CDP.
11
8
  *
12
9
  * No top-level `esbuild` import — it is loaded lazily via `loadEsbuild()` so the
13
10
  * published package (no esbuild) never crashes at startup.
@@ -23,6 +20,7 @@ import {
23
20
  vaultLibsDir,
24
21
  vaultRepoRoot,
25
22
  } from "./paths.js";
23
+ import { buildExecExpression } from "../cdp/exec-expression.js";
26
24
 
27
25
  // Sentinel the post-bundle return-insert anchors on. esbuild with minify:false
28
26
  // never renames top-level identifiers, and no first-party lib references
@@ -82,17 +80,24 @@ function preflightLibs(imports: string): void {
82
80
  }
83
81
  }
84
82
 
85
- // Hoist the imports to module top level (so esbuild resolves & tree-shakes them)
86
- // and wrap the body in a result-capturing async IIFE bound to the sentinel. The
87
- // trailing `.then()` is a preserved no-op call → DCE-safe anchor for the
88
- // post-bundle return insert (a bare `__CAPTURE_RESULT;` statement can be dropped
89
- // by tree-shaking; a call survives).
83
+ // Hoist the imports to module top level (so esbuild resolves & tree-shakes
84
+ // them) and bind the post-import body's VALUE to the sentinel. The body's
85
+ // semantics are owned by `buildExecExpression` — the same single authority
86
+ // plain (import-free) exec uses — so a natural final expression, explicit
87
+ // top-level return, top-level await, and a thrown exception behave
88
+ // identically with and without imports. `Promise.resolve` normalizes the
89
+ // built expression's value (a bare expression evaluates to a plain value,
90
+ // the wrapped forms to a promise) so the trailing `.then()` — a preserved
91
+ // no-op call → DCE-safe anchor for the post-bundle return insert (a bare
92
+ // `__CAPTURE_RESULT;` statement can be dropped by tree-shaking; a call
93
+ // survives) — always exists. The inner parens keep a comma expression one
94
+ // argument.
90
95
  function buildEntry(code: string): string {
91
96
  const { imports, body } = splitLeadingImports(code);
92
- const expr = body.trim().replace(/;+\s*$/, "");
97
+ const expression = buildExecExpression(body);
93
98
  return [
94
99
  imports,
95
- `const ${SENTINEL} = (async () => { ${expr} })();`,
100
+ `const ${SENTINEL} = Promise.resolve((${expression}));`,
96
101
  `${SENTINEL}.then();`,
97
102
  ].join("\n");
98
103
  }
@@ -163,10 +168,20 @@ async function runEsbuild(entry: string): Promise<BuildResult<{ write: false }>>
163
168
  // The only post-bundle edit: esbuild's iife wrapper `(() => { … })()` has no
164
169
  // return, so the script completion value is undefined. Insert `return ` before
165
170
  // the last `__CAPTURE_RESULT.then(` so the IIFE returns the user's promise.
166
- function applyReturnInsert(bundled: string): string {
171
+ // The anchor is an invariant `buildEntry` establishes (the preserved `.then()`
172
+ // call survives tree-shaking, and minify:false keeps the sentinel name), so a
173
+ // missing anchor is a broken bundle — evaluating it would silently complete
174
+ // with undefined instead of the code's value. Hard error, never a returnless
175
+ // bundle. Exported for its focused test (test/bundle.test.ts).
176
+ export function applyReturnInsert(bundled: string): string {
167
177
  const anchor = `${SENTINEL}.then(`;
168
178
  const i = bundled.lastIndexOf(anchor);
169
- return i === -1 ? bundled : bundled.slice(0, i) + "return " + bundled.slice(i);
179
+ if (i === -1) {
180
+ throw new Error(
181
+ `bundled exec output lost its ${SENTINEL}.then( return anchor — the bundle would complete with undefined instead of the code's value`,
182
+ );
183
+ }
184
+ return bundled.slice(0, i) + "return " + bundled.slice(i);
170
185
  }
171
186
 
172
187
  function formatBuildErrors(errors: Message[]): string {
@@ -1,105 +0,0 @@
1
- import * as fs from 'fs';
2
- import { withConnection } from '../connection.js';
3
- import { buildExecExpression } from '../exec-expression.js';
4
- import { HARRecorder } from '../har-recorder.js';
5
- import { writeHarAndPrintSummary } from '../har-output.js';
6
- import { type ParsedArgs } from '../types.js';
7
- import { hasImports, bundleExec } from '../../vault/bundle.js';
8
-
9
- export async function cmdExec(parsed: ParsedArgs, _args: string[]): Promise<void> {
10
- if (parsed.help) {
11
- console.log(
12
- 'Usage: capture exec <code> [--target <id>] [--record] [--file <path>] [--har <id>]\n\n' +
13
- 'Execute JavaScript in a browser tab. Plain expressions run directly; statement bodies can use top-level return/await and are wrapped in an async IIFE.\n\n' +
14
- 'Examples:\n' +
15
- ' capture exec "document.title" --target <id>\n' +
16
- ' capture exec "return document.title" --target <id>\n' +
17
- ' capture exec "return await fetch(\'/api/data\').then(r=>r.json())" --target <id>\n' +
18
- ' capture exec "document.querySelector(\'.btn\').click()" --target <id>\n' +
19
- ' capture exec --file /tmp/scrape.js --target <id> --record\n\n' +
20
- 'Import vault libs (dev checkout only): static imports first, then your code.\n' +
21
- ' capture exec "import {searchEmails} from \'libs/gmail\'; const ctx = await getContext(); return await searchEmails({ ...ctx, query: \'invoice\' })"',
22
- );
23
- process.exit(0);
24
- }
25
- let code: string;
26
- if (parsed.file) {
27
- code = fs.readFileSync(parsed.file, 'utf-8');
28
- } else {
29
- code = parsed.positional[0];
30
- if (!code) {
31
- console.error(
32
- 'ERROR: Missing code to execute.\n\n' +
33
- 'Usage: capture exec <code> [--target <id>] [--record] [--file <path>]\n\n' +
34
- 'Supported shapes:\n' +
35
- ' document.title\n' +
36
- ' return document.title\n' +
37
- ' const r = await fetch(\'/api/data\'); return await r.json()\n\n' +
38
- 'Examples:\n' +
39
- ' capture exec "document.title" --target <id>\n' +
40
- ' capture exec "return document.title" --target <id>\n' +
41
- ' capture exec "document.querySelector(\'.btn\').click()" --target <id>\n' +
42
- ' capture exec --file /tmp/scrape.js --target <id> --record',
43
- );
44
- process.exit(1);
45
- }
46
- }
47
-
48
- // Import-driven exec: if the code has leading static imports, bundle the
49
- // forked vault libs on the fly (esbuild) BEFORE opening a tab — fail fast,
50
- // no wasted CDP connection. Plain exec (no imports) skips this entirely.
51
- let prebuilt: string | undefined;
52
- if (hasImports(code)) {
53
- try {
54
- prebuilt = await bundleExec(code);
55
- } catch (e) {
56
- console.error(`ERROR: ${(e as Error).message}`);
57
- process.exit(1);
58
- }
59
- }
60
-
61
- await withConnection(
62
- parsed,
63
- async (client) => {
64
- await client.send('Emulation.setFocusEmulationEnabled', { enabled: true });
65
-
66
- // Standalone HAR recording (--record flag writes its own file)
67
- let standaloneRecorder: HARRecorder | undefined;
68
- if (parsed.record) {
69
- standaloneRecorder = new HARRecorder(client);
70
- await standaloneRecorder.start();
71
- }
72
-
73
- // A prebuilt bundle is already a complete IIFE returning the user's
74
- // promise. Otherwise fall back to the exec expression wrapper: bare
75
- // expressions run directly, while snippets containing top-level `return`
76
- // or `await` are wrapped in an async IIFE.
77
- const expression = buildExecExpression(code, prebuilt);
78
-
79
- const evalResult = (await client.send('Runtime.evaluate', {
80
- expression,
81
- awaitPromise: true,
82
- returnByValue: true,
83
- })) as {
84
- result?: { value?: unknown };
85
- exceptionDetails?: { exception?: { description?: string } };
86
- };
87
-
88
- if (standaloneRecorder) {
89
- const har = await standaloneRecorder.finish();
90
- writeHarAndPrintSummary(har, parsed.harOut);
91
- }
92
-
93
- if (evalResult.exceptionDetails) {
94
- console.error(
95
- `ERROR: ${evalResult.exceptionDetails.exception?.description ?? 'Unknown error'}`,
96
- );
97
- process.exit(1);
98
- }
99
-
100
- console.log(JSON.stringify(evalResult.result?.value));
101
- },
102
- { settle: 3000 },
103
- );
104
- return;
105
- }