cbrowser 18.70.0 → 18.71.0

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 (67) hide show
  1. package/dist/browser.d.ts +32 -0
  2. package/dist/browser.d.ts.map +1 -1
  3. package/dist/browser.js +46 -2
  4. package/dist/browser.js.map +1 -1
  5. package/dist/cli.js +1465 -24
  6. package/dist/cli.js.map +1 -1
  7. package/dist/cognitive/index.d.ts +10 -0
  8. package/dist/cognitive/index.d.ts.map +1 -1
  9. package/dist/cognitive/index.js +8 -0
  10. package/dist/cognitive/index.js.map +1 -1
  11. package/dist/daemon.d.ts.map +1 -1
  12. package/dist/daemon.js +151 -3
  13. package/dist/daemon.js.map +1 -1
  14. package/dist/mcp-server.d.ts.map +1 -1
  15. package/dist/mcp-server.js +67 -0
  16. package/dist/mcp-server.js.map +1 -1
  17. package/dist/mcp-tools/base/capture-tools.d.ts +189 -0
  18. package/dist/mcp-tools/base/capture-tools.d.ts.map +1 -0
  19. package/dist/mcp-tools/base/capture-tools.js +667 -0
  20. package/dist/mcp-tools/base/capture-tools.js.map +1 -0
  21. package/dist/mcp-tools/base/gif-tools.d.ts +19 -0
  22. package/dist/mcp-tools/base/gif-tools.d.ts.map +1 -1
  23. package/dist/mcp-tools/base/gif-tools.js +45 -27
  24. package/dist/mcp-tools/base/gif-tools.js.map +1 -1
  25. package/dist/mcp-tools/base/index.d.ts +2 -1
  26. package/dist/mcp-tools/base/index.d.ts.map +1 -1
  27. package/dist/mcp-tools/base/index.js +5 -1
  28. package/dist/mcp-tools/base/index.js.map +1 -1
  29. package/dist/mcp-tools/tool-categories.d.ts.map +1 -1
  30. package/dist/mcp-tools/tool-categories.js +11 -0
  31. package/dist/mcp-tools/tool-categories.js.map +1 -1
  32. package/dist/recording/auto-capture.d.ts +72 -0
  33. package/dist/recording/auto-capture.d.ts.map +1 -0
  34. package/dist/recording/auto-capture.js +155 -0
  35. package/dist/recording/auto-capture.js.map +1 -0
  36. package/dist/recording/contact-sheet.d.ts +65 -0
  37. package/dist/recording/contact-sheet.d.ts.map +1 -0
  38. package/dist/recording/contact-sheet.js +204 -0
  39. package/dist/recording/contact-sheet.js.map +1 -0
  40. package/dist/recording/encoders.d.ts +101 -0
  41. package/dist/recording/encoders.d.ts.map +1 -0
  42. package/dist/recording/encoders.js +304 -0
  43. package/dist/recording/encoders.js.map +1 -0
  44. package/dist/recording/engine.d.ts +263 -0
  45. package/dist/recording/engine.d.ts.map +1 -0
  46. package/dist/recording/engine.js +890 -0
  47. package/dist/recording/engine.js.map +1 -0
  48. package/dist/recording/ssim.d.ts +35 -0
  49. package/dist/recording/ssim.d.ts.map +1 -0
  50. package/dist/recording/ssim.js +113 -0
  51. package/dist/recording/ssim.js.map +1 -0
  52. package/dist/recording/timing.d.ts +124 -0
  53. package/dist/recording/timing.d.ts.map +1 -0
  54. package/dist/recording/timing.js +251 -0
  55. package/dist/recording/timing.js.map +1 -0
  56. package/dist/recording/types.d.ts +213 -0
  57. package/dist/recording/types.d.ts.map +1 -0
  58. package/dist/recording/types.js +171 -0
  59. package/dist/recording/types.js.map +1 -0
  60. package/dist/testing/nl-test-suite.d.ts +10 -0
  61. package/dist/testing/nl-test-suite.d.ts.map +1 -1
  62. package/dist/testing/nl-test-suite.js +13 -1
  63. package/dist/testing/nl-test-suite.js.map +1 -1
  64. package/dist/types.d.ts +22 -0
  65. package/dist/types.d.ts.map +1 -1
  66. package/dist/types.js.map +1 -1
  67. package/package.json +1 -1
@@ -0,0 +1,667 @@
1
+ /**
2
+ * CBrowser MCP Tools - Screen Capture Tools
3
+ *
4
+ * Screen capture (pixels -> GIF/WebP/video), exposed as `capture_start`,
5
+ * `capture_stop`, `capture_status`.
6
+ *
7
+ * Distinct from the `record` feature, which records user ACTIONS for test
8
+ * generation. Nothing here touches that.
9
+ *
10
+ * The response discipline is the point of this module: a recording is hundreds
11
+ * of frames and tens of megabytes of video, none of which can cross an MCP
12
+ * boundary. Frames and artifacts are returned as PATHS; the only inlined pixels
13
+ * are a contact sheet sized to whatever is left of the response budget after
14
+ * the JSON payload is measured.
15
+ *
16
+ * @copyright 2026 Alexandria Eden alexandria.shai.eden@gmail.com https://cbrowser.ai
17
+ * @license MIT
18
+ */
19
+ import { existsSync, readFileSync } from "node:fs";
20
+ import { isAbsolute, join, resolve } from "node:path";
21
+ import { z } from "zod";
22
+ import { getPaths } from "../../config.js";
23
+ import { buildContactSheet } from "../../recording/contact-sheet.js";
24
+ import { VideoCaptureSession } from "../../recording/engine.js";
25
+ import { VIEWPORT_PRESETS } from "../../types.js";
26
+ import { buildContentWithScreenshots, MAX_RESPONSE_SIZE } from "../screenshot-utils.js";
27
+ /** Formats the engine can encode. */
28
+ const CAPTURE_FORMATS = ["gif", "webp", "mp4", "webm"];
29
+ /** Change points and gaps are truncated at these counts before serialising. */
30
+ const MAX_CHANGE_POINTS = 200;
31
+ const MAX_FRAME_GAPS = 50;
32
+ const MAX_CONSOLE_ERRORS = 20;
33
+ /**
34
+ * Base64 inflates a payload by 4/3; the same 1.37 fudge `screenshot` uses in
35
+ * src/browser.ts, which also covers the JSON envelope around the block.
36
+ */
37
+ const BASE64_OVERHEAD = 1.37;
38
+ /**
39
+ * A contact sheet smaller than this is not worth inlining — below roughly this
40
+ * size the JPEG is too degraded to read, so the path alone is more useful.
41
+ */
42
+ const MIN_USEFUL_SHEET_BYTES = 12000;
43
+ // ===========================================================================
44
+ // Session registry
45
+ // ===========================================================================
46
+ /**
47
+ * Live capture sessions, keyed by browser token.
48
+ *
49
+ * Capture is stateful across tool calls — `capture_start` returns before the
50
+ * recording ends — so the session has to outlive the handler. Keying by the
51
+ * same `_browserToken` that carries page continuity means a caller driving two
52
+ * browsers gets two independent captures.
53
+ */
54
+ const sessions = new Map();
55
+ /**
56
+ * The most recent finished capture per key.
57
+ *
58
+ * Without this, `capture_status` after a stop reports "no capture has been
59
+ * started" — the session is gone from the live map and the completed run
60
+ * vanishes with it, which reads as though the capture never happened.
61
+ */
62
+ const finished = new Map();
63
+ /** Sessions are keyed by token; an untokened caller shares the default slot. */
64
+ function sessionKey(token) {
65
+ return token ?? "__default__";
66
+ }
67
+ /** Exposed for tests and for `reset_browser`-style teardown. */
68
+ export function clearCaptureSessions() {
69
+ sessions.clear();
70
+ finished.clear();
71
+ }
72
+ /**
73
+ * A capture started by another process (the CLI or its daemon), discovered
74
+ * through the pointer file the CLI maintains at `videosDir/active.json`.
75
+ *
76
+ * Read-only on purpose: this surface does not write the pointer, because the
77
+ * CLI's `capture stop` signals the pid it finds there, and pointing that at the
78
+ * long-lived MCP server would let a CLI command kill an unrelated process.
79
+ * Cross-surface visibility is one-directional until the daemon owns the pointer.
80
+ */
81
+ function foreignActiveCapture() {
82
+ try {
83
+ const pointer = join(getPaths().videosDir, "active.json");
84
+ if (!existsSync(pointer))
85
+ return null;
86
+ const active = JSON.parse(readFileSync(pointer, "utf-8"));
87
+ if (typeof active.pid !== "number")
88
+ return null;
89
+ // A stale pointer outlives its process; signal 0 tests liveness without
90
+ // touching the process.
91
+ try {
92
+ process.kill(active.pid, 0);
93
+ }
94
+ catch {
95
+ return null;
96
+ }
97
+ return active;
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ }
103
+ // ===========================================================================
104
+ // Argument parsing
105
+ // ===========================================================================
106
+ /** Parse "5s", "2m", "1500ms" or a bare millisecond count. */
107
+ export function parseDurationMs(input) {
108
+ const match = /^(\d+(?:\.\d+)?)\s*(ms|s|m)?$/i.exec(input.trim());
109
+ if (!match) {
110
+ throw new Error(`Invalid duration "${input}" - use 5s, 2m, 1500ms or a millisecond count`);
111
+ }
112
+ const value = parseFloat(match[1]);
113
+ const unit = (match[2] || "ms").toLowerCase();
114
+ const ms = unit === "s" ? value * 1000 : unit === "m" ? value * 60_000 : value;
115
+ if (ms <= 0)
116
+ throw new Error(`Invalid duration "${input}" - must be greater than zero`);
117
+ return Math.round(ms);
118
+ }
119
+ /** Parse "gif,webp,mp4" into validated formats. */
120
+ export function parseFormats(input) {
121
+ const parsed = input.split(",").map((f) => f.trim().toLowerCase()).filter(Boolean);
122
+ const bad = parsed.filter((f) => !CAPTURE_FORMATS.includes(f));
123
+ if (bad.length > 0) {
124
+ throw new Error(`Unknown format(s): ${bad.join(", ")}. Supported: ${CAPTURE_FORMATS.join(", ")}`);
125
+ }
126
+ if (parsed.length === 0)
127
+ throw new Error("format requires at least one format");
128
+ return parsed;
129
+ }
130
+ /**
131
+ * Resolve a viewport argument: either `WxH` or the name of a built-in preset
132
+ * (`mobile`, `tablet`, `desktop-lg`, ...).
133
+ */
134
+ export function parseViewport(input) {
135
+ const trimmed = input.trim();
136
+ const match = /^(\d+)\s*x\s*(\d+)$/i.exec(trimmed);
137
+ if (match) {
138
+ const width = parseInt(match[1], 10);
139
+ const height = parseInt(match[2], 10);
140
+ if (width < 1 || height < 1) {
141
+ throw new Error(`Invalid viewport "${input}" - width and height must be positive`);
142
+ }
143
+ return { width, height };
144
+ }
145
+ const preset = VIEWPORT_PRESETS.find((p) => p.name.toLowerCase() === trimmed.toLowerCase());
146
+ if (preset)
147
+ return { width: preset.width, height: preset.height };
148
+ throw new Error(`Invalid viewport "${input}" - use WIDTHxHEIGHT (e.g. 1280x720) or a preset: ` +
149
+ VIEWPORT_PRESETS.map((p) => p.name).join(", "));
150
+ }
151
+ /** Parse "x,y,width,height" into a rect. */
152
+ export function parseRegion(input) {
153
+ const parts = input.split(",").map((p) => Number(p.trim()));
154
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
155
+ throw new Error(`Invalid region "${input}" - use x,y,width,height (e.g. 0,0,800,600)`);
156
+ }
157
+ const [x, y, width, height] = parts;
158
+ if (width <= 0 || height <= 0) {
159
+ throw new Error(`Invalid region "${input}" - width and height must be positive`);
160
+ }
161
+ return { x, y, width, height };
162
+ }
163
+ /**
164
+ * Build the `capture_stop` JSON payload from a manifest.
165
+ *
166
+ * Deliberately excludes the frame array: 500 frames of paths, crops, SSIM
167
+ * scores and per-frame console/network records is megabytes of JSON that would
168
+ * blow the response budget and tell the model nothing it can act on. The frames
169
+ * live on disk and the directory is named instead. Unbounded lists
170
+ * (change points, gaps, console errors) are truncated with their true totals
171
+ * reported alongside, so the model can tell "3 change points" from
172
+ * "200 shown of 4,812".
173
+ *
174
+ * Pure and exported so the budget can be tested without running a browser.
175
+ */
176
+ export function buildCaptureStopPayload(manifest, extra) {
177
+ const consoleErrors = [];
178
+ let networkTotal = 0;
179
+ let consoleErrorTotal = 0;
180
+ for (const frame of manifest.frames) {
181
+ networkTotal += frame.network.length;
182
+ for (const entry of frame.console) {
183
+ if (entry.type !== "error")
184
+ continue;
185
+ consoleErrorTotal++;
186
+ if (consoleErrors.length < MAX_CONSOLE_ERRORS) {
187
+ // Long stack-trace-bearing messages are the usual budget offender.
188
+ consoleErrors.push(entry.text.length > 300 ? `${entry.text.slice(0, 300)}...` : entry.text);
189
+ }
190
+ }
191
+ }
192
+ const encodeErrors = extra.encodeErrors && Object.keys(extra.encodeErrors).length > 0
193
+ ? extra.encodeErrors
194
+ : undefined;
195
+ return {
196
+ success: true,
197
+ slug: manifest.slug,
198
+ manifest_path: extra.manifestPath,
199
+ out_dir: extra.outDir,
200
+ frames: manifest.frames.length,
201
+ target_fps: manifest.target_fps,
202
+ actual_fps: manifest.actual_fps,
203
+ duration_ms: manifest.duration_ms,
204
+ capture_method: manifest.capture_method,
205
+ engine: manifest.engine,
206
+ output_dims: manifest.output_dims,
207
+ target: manifest.target,
208
+ ...(manifest.region_clamped !== undefined ? { region_clamped: manifest.region_clamped } : {}),
209
+ artifacts: manifest.artifacts,
210
+ ...(encodeErrors ? { encode_errors: encodeErrors } : {}),
211
+ change_points: manifest.change_points.slice(0, MAX_CHANGE_POINTS),
212
+ change_points_total: manifest.change_points.length,
213
+ frame_gaps: manifest.frame_gaps.slice(0, MAX_FRAME_GAPS),
214
+ frame_gaps_total: manifest.frame_gaps.length,
215
+ console_errors: consoleErrors,
216
+ console_errors_total: consoleErrorTotal,
217
+ network_requests_total: networkTotal,
218
+ frames_dir: join(extra.outDir, "frames"),
219
+ ...(extra.contactSheet ? { contact_sheet: extra.contactSheet } : {}),
220
+ ...(extra.contactSheetNote ? { contact_sheet_note: extra.contactSheetNote } : {}),
221
+ note: "Individual frames and video artifacts are on disk, not inlined — read them by path. " +
222
+ "The contact sheet is an evenly spaced visual sample of the recording.",
223
+ };
224
+ }
225
+ /**
226
+ * Last line of defence on the response budget.
227
+ *
228
+ * The payload builder and the contact-sheet budget already keep the response
229
+ * small, but "small by construction" is a claim about code that can drift. This
230
+ * measures the actual serialized content blocks and sheds until they fit:
231
+ * image blocks first (the sheet is a convenience, the paths are the payload),
232
+ * then the text block itself, replaced by a minimal pointer to the manifest.
233
+ *
234
+ * An over-budget response is therefore not merely unlikely, it cannot be
235
+ * returned — the tool would have to skip this function to emit one.
236
+ */
237
+ export function enforceResponseBudget(content, maxBytes = MAX_RESPONSE_SIZE, fallbackText = '{"success":true,"note":"Response exceeded the size budget; read the manifest from disk."}') {
238
+ const size = (blocks) => Buffer.byteLength(JSON.stringify(blocks));
239
+ if (size(content) <= maxBytes)
240
+ return content;
241
+ // Shed pixels before prose: the paths are what the caller acts on.
242
+ const withoutImages = content.filter((block) => block.type !== "image");
243
+ if (size(withoutImages) <= maxBytes)
244
+ return withoutImages;
245
+ const minimal = withoutImages
246
+ .filter((block) => block.type === "text")
247
+ .slice(0, 1)
248
+ .map((block) => ({ ...block, text: fallbackText }));
249
+ return (size(minimal) <= maxBytes ? minimal : []);
250
+ }
251
+ /**
252
+ * Byte budget left for an inlined contact sheet once the JSON payload is
253
+ * accounted for. Returns 0 when there is no room worth using.
254
+ */
255
+ export function contactSheetBudget(payloadBytes, totalBudget = MAX_RESPONSE_SIZE) {
256
+ const remaining = totalBudget - payloadBytes;
257
+ if (remaining <= 0)
258
+ return 0;
259
+ const budget = Math.floor(remaining / BASE64_OVERHEAD);
260
+ return budget >= MIN_USEFUL_SHEET_BYTES ? budget : 0;
261
+ }
262
+ /**
263
+ * Resolve a selector to a fixed capture region.
264
+ *
265
+ * The engine has no element target, so an element capture is resolved to the
266
+ * element's bounding box ONCE, at start. The region does not follow the element
267
+ * if it later moves — that limitation is reported back to the caller rather
268
+ * than hidden.
269
+ */
270
+ async function resolveElementRegion(page, selector, padding) {
271
+ const box = await page.locator(selector).first().boundingBox();
272
+ if (!box) {
273
+ throw new Error(`Element "${selector}" is not visible, so it has no region to capture`);
274
+ }
275
+ return {
276
+ x: box.x - padding,
277
+ y: box.y - padding,
278
+ width: box.width + padding * 2,
279
+ height: box.height + padding * 2,
280
+ };
281
+ }
282
+ /** Start a capture on the given browser. Returns the payload object. */
283
+ export async function startCapture(browser, args, token) {
284
+ const key = sessionKey(token);
285
+ const existing = sessions.get(key);
286
+ if (existing && existing.session.status().state === "recording") {
287
+ throw new Error(`A capture is already running (${existing.session.status().slug}). ` +
288
+ "Call capture_stop before starting another.");
289
+ }
290
+ if (args.viewport) {
291
+ const size = parseViewport(args.viewport);
292
+ const page = await browser.getPage();
293
+ await page.setViewportSize(size);
294
+ }
295
+ if (args.url)
296
+ await browser.navigate(args.url);
297
+ const page = await browser.getPage();
298
+ if (args.region && args.element) {
299
+ throw new Error("Pass region or element, not both");
300
+ }
301
+ let target;
302
+ let elementNote;
303
+ if (args.region) {
304
+ target = { kind: "region", rect: parseRegion(args.region) };
305
+ }
306
+ else if (args.element) {
307
+ const padding = args.padding ?? 0;
308
+ target = { kind: "region", rect: await resolveElementRegion(page, args.element, padding) };
309
+ elementNote =
310
+ `Region resolved from selector "${args.element}" at capture start. ` +
311
+ "It is fixed for the run and does not follow the element if it moves.";
312
+ }
313
+ // The browser's own config is private, so the engine family is read off the
314
+ // live Playwright context rather than duplicated from CBrowser config.
315
+ const engineName = page.context().browser()?.browserType().name();
316
+ const engine = engineName === "firefox" ? "firefox" : engineName === "webkit" ? "webkit" : "chromium";
317
+ const options = {
318
+ ...(args.fps !== undefined ? { fps: args.fps } : {}),
319
+ ...(args.duration !== undefined ? { durationMs: parseDurationMs(args.duration) } : {}),
320
+ ...(target ? { target } : {}),
321
+ ...(args.format !== undefined ? { format: parseFormats(args.format) } : {}),
322
+ ...(args.quality !== undefined ? { quality: args.quality } : {}),
323
+ ...(args.max_frames !== undefined ? { maxFrames: args.max_frames } : {}),
324
+ ...(args.name !== undefined ? { slug: args.name } : {}),
325
+ ...(args.out_dir !== undefined ? { outDir: resolve(args.out_dir) } : {}),
326
+ };
327
+ // videosDir, not recordingsDir — the latter is the ACTION-recording feature's
328
+ // .json store, and screenshotsDir is swept by the 1-hour retention cleanup,
329
+ // which would delete users' GIFs. Session-scoped to match the CLI layout
330
+ // (~/.cbrowser/videos/<sessionId>/<slug>/): without the session segment, two
331
+ // concurrent captures that pick the same slug interleave frames into one dir.
332
+ const paths = getPaths();
333
+ const session = new VideoCaptureSession(page, engine, join(paths.videosDir, paths.sessionId));
334
+ const status = await session.start(options);
335
+ sessions.set(key, { session, ...(args.url ? { url: args.url } : {}) });
336
+ return {
337
+ success: true,
338
+ state: status.state,
339
+ slug: status.slug,
340
+ out_dir: status.outDir,
341
+ capture_method: status.captureMethod,
342
+ engine,
343
+ ...(elementNote ? { element_note: elementNote } : {}),
344
+ note: "Capture is event-driven: a page that never repaints emits no frames, and still " +
345
+ "stretches are reported as frame_gaps rather than padded out. Interact with the page " +
346
+ "(navigate, click, scroll) to produce frames, then call capture_stop.",
347
+ };
348
+ }
349
+ /**
350
+ * Stop the capture and build the response payload plus a budget-fitted contact
351
+ * sheet. Returns the payload and the sheet path (if one was produced).
352
+ */
353
+ export async function stopCapture(token) {
354
+ const key = sessionKey(token);
355
+ const entry = sessions.get(key);
356
+ if (!entry) {
357
+ throw new Error("No capture has been started on this browser session. Call capture_start first.");
358
+ }
359
+ const result = await entry.session.stop();
360
+ const status = entry.session.status();
361
+ const manifest = result.manifest;
362
+ // Frame paths are relative to the manifest, so they resolve against outDir.
363
+ const framePaths = manifest.frames.map((f) => isAbsolute(f.path) ? f.path : join(status.outDir, f.path));
364
+ // Size the JSON first, then spend what is left of the budget on pixels.
365
+ const provisional = buildCaptureStopPayload(manifest, {
366
+ manifestPath: result.manifestPath,
367
+ outDir: status.outDir,
368
+ encodeErrors: result.encodeErrors,
369
+ contactSheet: join(status.outDir, "contact-sheet.jpg"),
370
+ });
371
+ const budget = contactSheetBudget(Buffer.byteLength(JSON.stringify(provisional, null, 2)));
372
+ let contactSheet;
373
+ let contactSheetNote;
374
+ const usable = framePaths.filter((p) => existsSync(p));
375
+ if (usable.length === 0) {
376
+ contactSheetNote = "No frames were captured, so there is no contact sheet.";
377
+ }
378
+ else if (budget === 0) {
379
+ contactSheetNote = "Response budget left no room to inline a contact sheet.";
380
+ }
381
+ else {
382
+ try {
383
+ const sheet = await buildContactSheet({
384
+ framePaths: usable,
385
+ outPath: join(status.outDir, "contact-sheet.jpg"),
386
+ maxBytes: budget,
387
+ });
388
+ contactSheet = sheet.path;
389
+ }
390
+ catch (e) {
391
+ // A capture that succeeded is not failed by an unrenderable summary.
392
+ contactSheetNote = `Contact sheet could not be built: ${e.message}`;
393
+ }
394
+ }
395
+ const payload = buildCaptureStopPayload(manifest, {
396
+ manifestPath: result.manifestPath,
397
+ outDir: status.outDir,
398
+ encodeErrors: result.encodeErrors,
399
+ ...(contactSheet ? { contactSheet } : {}),
400
+ ...(contactSheetNote ? { contactSheetNote } : {}),
401
+ });
402
+ finished.set(key, {
403
+ slug: manifest.slug,
404
+ outDir: status.outDir,
405
+ manifestPath: result.manifestPath,
406
+ frames: manifest.frames.length,
407
+ durationMs: manifest.duration_ms,
408
+ actualFps: manifest.actual_fps,
409
+ stoppedAt: new Date().toISOString(),
410
+ });
411
+ sessions.delete(key);
412
+ return { payload, ...(contactSheet ? { contactSheet } : {}) };
413
+ }
414
+ /**
415
+ * Describe a capture running somewhere other than this browser session, if any:
416
+ * another token on this server, or another process (CLI / daemon).
417
+ */
418
+ function runningElsewhere() {
419
+ for (const [otherKey, other] of sessions) {
420
+ const status = other.session.status();
421
+ if (status.state !== "recording")
422
+ continue;
423
+ return {
424
+ scope: "another browser token on this server",
425
+ slug: status.slug,
426
+ out_dir: status.outDir,
427
+ frames: status.frames,
428
+ elapsed_ms: status.elapsedMs,
429
+ ...(other.url ? { url: other.url } : {}),
430
+ note: "Pass that call's _browserToken to capture_stop; this session's " +
431
+ "capture_stop will not find it." +
432
+ (otherKey === "__default__" ? " It was started without a token." : ""),
433
+ };
434
+ }
435
+ const foreign = foreignActiveCapture();
436
+ if (foreign) {
437
+ return {
438
+ scope: `another process (pid ${foreign.pid})`,
439
+ slug: foreign.slug,
440
+ out_dir: foreign.outDir,
441
+ started_at: foreign.startedAt,
442
+ ...(foreign.url ? { url: foreign.url } : {}),
443
+ note: "Started outside the MCP server (CLI or daemon). It is stopped from the " +
444
+ "process that started it; capture_stop here will not find it.",
445
+ };
446
+ }
447
+ return null;
448
+ }
449
+ /**
450
+ * Report the running or most recent capture for this browser session.
451
+ *
452
+ * Two rules, in tension, both honoured:
453
+ * - A running capture is never invisible. If one is live on another token or
454
+ * in another process, it is always surfaced.
455
+ * - The caller's own capture is never masked by someone else's. A session
456
+ * that just finished a recording is told about its own run first, with the
457
+ * other capture attached alongside rather than in place of it.
458
+ *
459
+ * The failure this replaces: after `capture_stop`, status reported
460
+ * "No capture has been started on this browser session" — the completed run
461
+ * vanished with the session object, reading as though it never happened.
462
+ */
463
+ export function captureStatus(token) {
464
+ const key = sessionKey(token);
465
+ const elsewhere = runningElsewhere();
466
+ const withElsewhere = (base) => elsewhere ? { ...base, also_running_elsewhere: elsewhere } : base;
467
+ // 1. This session's own capture, running or just stopped.
468
+ const entry = sessions.get(key);
469
+ if (entry) {
470
+ const status = entry.session.status();
471
+ return withElsewhere({
472
+ success: true,
473
+ state: status.state,
474
+ slug: status.slug,
475
+ out_dir: status.outDir,
476
+ frames: status.frames,
477
+ elapsed_ms: status.elapsedMs,
478
+ capture_method: status.captureMethod,
479
+ ...(status.manifestPath ? { manifest_path: status.manifestPath } : {}),
480
+ ...(entry.url ? { url: entry.url } : {}),
481
+ });
482
+ }
483
+ // 2. This session's most recent completed capture.
484
+ const own = finished.get(key);
485
+ if (own)
486
+ return withElsewhere(finishedPayload(own));
487
+ // 3. Nothing of this session's own — surface whatever is running elsewhere.
488
+ if (elsewhere) {
489
+ return {
490
+ success: true,
491
+ state: "recording",
492
+ started_by: elsewhere.scope,
493
+ ...elsewhere,
494
+ };
495
+ }
496
+ // 4. Any other session's completed capture.
497
+ const recent = mostRecentFinished();
498
+ if (recent)
499
+ return finishedPayload(recent);
500
+ return {
501
+ success: true,
502
+ state: "idle",
503
+ note: "No capture has been started on this browser session.",
504
+ };
505
+ }
506
+ function finishedPayload(capture) {
507
+ return {
508
+ success: true,
509
+ state: "stopped",
510
+ slug: capture.slug,
511
+ out_dir: capture.outDir,
512
+ manifest_path: capture.manifestPath,
513
+ frames: capture.frames,
514
+ duration_ms: capture.durationMs,
515
+ actual_fps: capture.actualFps,
516
+ stopped_at: capture.stoppedAt,
517
+ note: "No capture is running on this session. This is its most recent completed capture.",
518
+ };
519
+ }
520
+ /** Most recently stopped capture across all keys. */
521
+ function mostRecentFinished() {
522
+ let best;
523
+ for (const candidate of finished.values()) {
524
+ if (!best || candidate.stoppedAt > best.stoppedAt)
525
+ best = candidate;
526
+ }
527
+ return best;
528
+ }
529
+ /** Shared error envelope, matching the house shape for tool failures. */
530
+ function errorContent(error) {
531
+ return {
532
+ content: [
533
+ {
534
+ type: "text",
535
+ text: JSON.stringify({ success: false, error: error instanceof Error ? error.message : String(error) }, null, 2),
536
+ },
537
+ ],
538
+ };
539
+ }
540
+ // ===========================================================================
541
+ // Registration
542
+ // ===========================================================================
543
+ /** Input schema shared by both servers, so the two cannot drift. */
544
+ export const captureStartSchema = {
545
+ url: z.string().optional().describe("URL to navigate to before capturing. Omit to capture the current page."),
546
+ fps: z.number().optional().describe("Target frames per second (default: 10)"),
547
+ duration: z.string().optional().describe("Auto-stop after this long: 5s, 2m, 1500ms. Omit for an open-ended capture stopped by capture_stop."),
548
+ viewport: z.string().optional().describe("Viewport as WIDTHxHEIGHT (e.g. 1280x720) or a preset name (mobile, tablet, desktop, desktop-lg)"),
549
+ region: z.string().optional().describe("Capture a fixed region as x,y,width,height (e.g. 0,0,800,600). Clamped to the viewport."),
550
+ element: z.string().optional().describe("CSS selector to capture. Resolved to the element's bounding box once at start; the region does not follow the element if it moves."),
551
+ padding: z.number().optional().describe("Padding in pixels around the element region (default: 0)"),
552
+ format: z.string().optional().describe("Comma-separated output formats: gif,webp,mp4,webm (default: gif). mp4 needs a full ffmpeg; the bundled one does webm."),
553
+ quality: z.number().optional().describe("JPEG quality for captured frames, 1-100 (default: 80)"),
554
+ max_frames: z.number().optional().describe("Hard cap on retained frames (default: 3000)"),
555
+ name: z.string().optional().describe("Capture name / slug"),
556
+ out_dir: z.string().optional().describe("Output directory (default: a slug directory under the recordings dir)"),
557
+ _browserToken: z.string().optional().describe("Browser session token from a previous tool call"),
558
+ };
559
+ export const captureStopSchema = {
560
+ _browserToken: z.string().optional().describe("Browser session token from a previous tool call"),
561
+ };
562
+ export const captureStatusSchema = {
563
+ _browserToken: z.string().optional().describe("Browser session token from a previous tool call"),
564
+ };
565
+ const CAPTURE_START_DESCRIPTION = "Start capturing the screen to GIF/WebP/video. Records PIXELS over time — distinct from the " +
566
+ "`record` tool family, which records user actions for test generation. Capture runs in the " +
567
+ "background: call capture_start, interact with the page, then capture_stop. " +
568
+ "IMPORTANT: If you received a _browserToken from a previous tool call, you MUST pass it here " +
569
+ "to capture the same page. Without it, you get a blank new browser.";
570
+ const CAPTURE_STOP_DESCRIPTION = "Stop the running screen capture, encode the requested formats and return the manifest path, " +
571
+ "artifact paths, change points and an inlined contact sheet (an evenly spaced visual sample). " +
572
+ "Frames and video are returned as paths, never inlined — read them from disk if you need them.";
573
+ const CAPTURE_STATUS_DESCRIPTION = "Report the running or most recent screen capture for this browser session: state, frame count, " +
574
+ "elapsed time and output directory.";
575
+ /**
576
+ * Register capture tools (3 tools: capture_start, capture_stop, capture_status)
577
+ */
578
+ export function registerCaptureTools(server, { getBrowser, getBrowserByToken }) {
579
+ /** Resolve the browser + token exactly as the other tool modules do. */
580
+ const resolveBrowser = async (_browserToken) => {
581
+ if (getBrowserByToken) {
582
+ const result = await getBrowserByToken(_browserToken);
583
+ return { browser: result.browser, token: result.token };
584
+ }
585
+ return { browser: await getBrowser() };
586
+ };
587
+ server.registerTool("capture_start", {
588
+ title: "Start Screen Capture",
589
+ description: CAPTURE_START_DESCRIPTION,
590
+ inputSchema: captureStartSchema,
591
+ annotations: {
592
+ title: "Start Screen Capture",
593
+ readOnlyHint: false,
594
+ destructiveHint: false,
595
+ idempotentHint: false,
596
+ openWorldHint: true,
597
+ },
598
+ }, async ({ _browserToken, ...args }) => {
599
+ try {
600
+ const { browser, token } = await resolveBrowser(_browserToken);
601
+ const payload = await startCapture(browser, args, token);
602
+ return {
603
+ content: [
604
+ {
605
+ type: "text",
606
+ text: JSON.stringify({ ...payload, ...(token ? { _browserToken: token } : {}) }, null, 2),
607
+ },
608
+ ],
609
+ };
610
+ }
611
+ catch (error) {
612
+ return errorContent(error);
613
+ }
614
+ });
615
+ server.registerTool("capture_stop", {
616
+ title: "Stop Screen Capture",
617
+ description: CAPTURE_STOP_DESCRIPTION,
618
+ inputSchema: captureStopSchema,
619
+ annotations: {
620
+ title: "Stop Screen Capture",
621
+ readOnlyHint: false,
622
+ destructiveHint: false,
623
+ idempotentHint: true,
624
+ openWorldHint: false,
625
+ },
626
+ }, async ({ _browserToken }) => {
627
+ try {
628
+ const { token } = await resolveBrowser(_browserToken);
629
+ const { payload, contactSheet } = await stopCapture(token);
630
+ return {
631
+ content: enforceResponseBudget(buildContentWithScreenshots({ ...payload, ...(token ? { _browserToken: token } : {}) }, contactSheet)),
632
+ };
633
+ }
634
+ catch (error) {
635
+ return errorContent(error);
636
+ }
637
+ });
638
+ server.registerTool("capture_status", {
639
+ title: "Screen Capture Status",
640
+ description: CAPTURE_STATUS_DESCRIPTION,
641
+ inputSchema: captureStatusSchema,
642
+ annotations: {
643
+ title: "Screen Capture Status",
644
+ readOnlyHint: true,
645
+ destructiveHint: false,
646
+ idempotentHint: true,
647
+ openWorldHint: false,
648
+ },
649
+ }, async ({ _browserToken }) => {
650
+ try {
651
+ const { token } = await resolveBrowser(_browserToken);
652
+ return {
653
+ content: [
654
+ {
655
+ type: "text",
656
+ text: JSON.stringify({ ...captureStatus(token), ...(token ? { _browserToken: token } : {}) }, null, 2),
657
+ },
658
+ ],
659
+ };
660
+ }
661
+ catch (error) {
662
+ return errorContent(error);
663
+ }
664
+ });
665
+ }
666
+ export { CAPTURE_START_DESCRIPTION, CAPTURE_STOP_DESCRIPTION, CAPTURE_STATUS_DESCRIPTION };
667
+ //# sourceMappingURL=capture-tools.js.map