@phnx-labs/agents-cli 1.18.3 → 1.18.5

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.
@@ -1,22 +1,64 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
- import { listProfiles, getProfile, createProfile, deleteProfile, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, } from '../lib/browser/profiles.js';
3
+ import { listProfiles, getProfile, createProfile, deleteProfile, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, } from '../lib/browser/profiles.js';
4
4
  import { findBrowserPath, getPortOccupant } from '../lib/browser/chrome.js';
5
+ import { listProfileCacheDirs, removeProfileCache, listAllProfileSnapshots, } from '../lib/browser/runtime-state.js';
6
+ import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
5
7
  import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
6
8
  import { parseTargetFilter } from '../lib/browser/service.js';
7
9
  import { sendIPCRequest } from '../lib/browser/ipc.js';
8
10
  import { browserTaskPicker } from './browser-picker.js';
9
11
  import { isInteractiveTerminal } from './utils.js';
12
+ import { registerCommandGroups } from '../lib/help.js';
13
+ /**
14
+ * Resolve which browser task a command targets. Order:
15
+ * 1. `--task <name>` flag (explicit per-command override)
16
+ * 2. `$AGENTS_BROWSER_TASK` (set once at the start of an agent run)
17
+ *
18
+ * Each agent process has its own environment, so the env-var path is safe for
19
+ * parallel agents — they can't see each other's value.
20
+ */
21
+ function resolveTaskName(opts) {
22
+ if (opts.task)
23
+ return opts.task;
24
+ const fromEnv = process.env.AGENTS_BROWSER_TASK;
25
+ if (fromEnv)
26
+ return fromEnv;
27
+ console.error('No task specified. Pass --task <name> or set AGENTS_BROWSER_TASK in your shell.');
28
+ console.error('Tip: T=$(agents browser start --profile <p>) && export AGENTS_BROWSER_TASK=$T');
29
+ process.exit(1);
30
+ }
31
+ // `-t` is taken by `--tab` on most commands, so `--task` is long-form only.
32
+ // Agents normally set $AGENTS_BROWSER_TASK once and never type this flag.
33
+ const TASK_OPTION_FLAG = '--task <name>';
34
+ const TASK_OPTION_DESC = 'Task name (defaults to $AGENTS_BROWSER_TASK)';
35
+ // Help groups — surfaces the actual mental model an agent follows
36
+ // ("open a session / drive the page / capture evidence / rare extras")
37
+ // instead of an alphabetical dump. Everything not listed falls into a
38
+ // trailing "Other commands" section automatically.
39
+ const BROWSER_HELP_GROUPS = [
40
+ { title: 'Session lifecycle', names: ['start', 'done', 'status'] },
41
+ {
42
+ title: 'Drive the page',
43
+ names: ['navigate', 'tabs', 'screenshot', 'evaluate', 'click', 'type', 'press', 'wait'],
44
+ },
45
+ {
46
+ title: 'Capture evidence',
47
+ names: ['console', 'errors', 'requests', 'responsebody', 'record', 'logs'],
48
+ },
49
+ ];
10
50
  export function registerBrowserCommand(program) {
11
51
  const browser = program
12
52
  .command('browser')
13
53
  .description('Browser automation via CDP');
14
54
  registerProfilesCommands(browser);
15
55
  registerTaskCommands(browser);
56
+ registerCommandGroups(browser, BROWSER_HELP_GROUPS);
16
57
  }
17
58
  export function registerBrowserSubcommands(program) {
18
59
  registerProfilesCommands(program);
19
60
  registerTaskCommands(program);
61
+ registerCommandGroups(program, BROWSER_HELP_GROUPS);
20
62
  }
21
63
  function registerProfilesCommands(browser) {
22
64
  const profiles = browser
@@ -38,7 +80,10 @@ function registerProfilesCommands(browser) {
38
80
  console.log('NAME'.padEnd(20) + 'BROWSER'.padEnd(12) + 'DESCRIPTION'.padEnd(38) + 'ENDPOINTS');
39
81
  console.log('-'.repeat(92));
40
82
  for (const p of allProfiles) {
41
- const endpoints = p.endpoints.join(', ');
83
+ const presets = getEndpointPresets(p);
84
+ const endpoints = Object.entries(presets)
85
+ .map(([name, ep]) => (name.startsWith('endpoint-') ? ep.target : `${name}=${ep.target}`))
86
+ .join(', ');
42
87
  const desc = (p.description ?? '').slice(0, 36).padEnd(38);
43
88
  console.log(p.name.padEnd(20) + (p.browser || '-').padEnd(12) + desc + endpoints);
44
89
  }
@@ -47,7 +92,10 @@ function registerProfilesCommands(browser) {
47
92
  console.log('NAME'.padEnd(20) + 'BROWSER'.padEnd(12) + 'ENDPOINTS');
48
93
  console.log('-'.repeat(72));
49
94
  for (const p of allProfiles) {
50
- const endpoints = p.endpoints.join(', ');
95
+ const presets = getEndpointPresets(p);
96
+ const endpoints = Object.entries(presets)
97
+ .map(([name, ep]) => (name.startsWith('endpoint-') ? ep.target : `${name}=${ep.target}`))
98
+ .join(', ');
51
99
  console.log(p.name.padEnd(20) + (p.browser || '-').padEnd(12) + endpoints);
52
100
  }
53
101
  }
@@ -61,7 +109,7 @@ function registerProfilesCommands(browser) {
61
109
  .option('-s, --secrets <bundle>', 'Secrets bundle to inject')
62
110
  .option('-d, --description <text>', 'Profile description')
63
111
  .option('--headless', 'Run in headless mode')
64
- .option('--window <WxH>', 'Window size, e.g. 1512x982')
112
+ .option('--window <WxH>', `Window size in CSS pixels (default: ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}, MacBook Pro 14")`)
65
113
  .option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
66
114
  .option('--binary <path>', 'Absolute path to the browser/app binary (required with --browser custom)')
67
115
  .option('--electron', 'Treat this profile as an Electron desktop app: never call Target.createTarget; bind to the visible window using --target-filter or the skip-invisible heuristic')
@@ -100,15 +148,16 @@ function registerProfilesCommands(browser) {
100
148
  const freePort = await findFreeProfilePort();
101
149
  endpoints = [`cdp://127.0.0.1:${freePort}`];
102
150
  }
103
- // Viewport is mandatory — default to 1512x982 if --window is not provided
151
+ // Viewport is mandatory — default to MacBook Pro 14" (1512x982) if
152
+ // --window is not provided. See lib/browser/devices.ts DEFAULT_VIEWPORT.
104
153
  let viewport = {
105
- width: 1512,
106
- height: 982,
154
+ width: DEFAULT_VIEWPORT.width,
155
+ height: DEFAULT_VIEWPORT.height,
107
156
  };
108
157
  if (opts.window) {
109
158
  const m = String(opts.window).match(/^(\d+)x(\d+)$/);
110
159
  if (!m) {
111
- console.error('--window must be WxH, e.g. 1512x982');
160
+ console.error(`--window must be WxH, e.g. ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}`);
112
161
  process.exit(1);
113
162
  }
114
163
  viewport.width = parseInt(m[1], 10);
@@ -167,9 +216,24 @@ function registerProfilesCommands(browser) {
167
216
  console.log(`Target filter: ${profile.targetFilter}`);
168
217
  if (profile.description)
169
218
  console.log(`Description: ${profile.description}`);
170
- console.log(`Endpoints:`);
171
- for (const e of profile.endpoints) {
172
- console.log(` - ${e}`);
219
+ const presets = getEndpointPresets(profile);
220
+ const defaultName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
221
+ ? profile.defaultEndpoint
222
+ : Object.keys(presets)[0];
223
+ console.log('Endpoints:');
224
+ for (const [presetName, preset] of Object.entries(presets)) {
225
+ const marker = presetName === defaultName ? ' (default)' : '';
226
+ const isLegacy = presetName.startsWith('endpoint-');
227
+ console.log(` - ${isLegacy ? preset.target : `${presetName}: ${preset.target}`}${marker}`);
228
+ if (preset.binary)
229
+ console.log(` binary: ${preset.binary}`);
230
+ if (preset.targetFilter)
231
+ console.log(` targetFilter: ${preset.targetFilter}`);
232
+ }
233
+ if (profile.viewport) {
234
+ const v = profile.viewport;
235
+ const pos = v.x !== undefined && v.y !== undefined ? ` @ ${v.x},${v.y}` : '';
236
+ console.log(`Viewport: ${v.width}×${v.height}${pos}`);
173
237
  }
174
238
  if (profile.secrets)
175
239
  console.log(`Secrets: ${profile.secrets}`);
@@ -178,31 +242,32 @@ function registerProfilesCommands(browser) {
178
242
  });
179
243
  profiles
180
244
  .command('delete <name>')
181
- .description('Delete a browser profile')
182
- .action(async (name) => {
245
+ .description('Delete a browser profile (drops YAML config + all cached runtime dirs)')
246
+ .option('--keep-cache', "Leave ~/.agents/.cache/browser/<name>* dirs in place (don't wipe chrome-data)")
247
+ .action(async (name, opts) => {
183
248
  await deleteProfile(name);
184
- console.log(`Deleted profile: ${name}`);
185
- });
186
- profiles
187
- .command('launch <name>')
188
- .description('Start (or attach to) the profile\'s browser without creating a task')
189
- .action(async (name) => {
190
- const profile = await getProfile(name);
191
- if (!profile) {
192
- console.error(`Profile "${name}" not found`);
193
- process.exit(1);
194
- }
195
- const response = await sendIPCRequest({
196
- action: 'launch-profile',
197
- profile: name,
198
- });
199
- if (!response.ok) {
200
- console.error(response.error);
201
- process.exit(1);
249
+ // The composite naming change introduced multiple cache dirs per
250
+ // profile (`<name>`, `<name>@endpoint-0`, …). Sweep them all unless
251
+ // the user explicitly wants the chrome-data preserved (e.g. for
252
+ // re-import into a freshly-created profile of the same name).
253
+ let removed = 0;
254
+ if (!opts.keepCache) {
255
+ const cacheDirs = listProfileCacheDirs(name);
256
+ removed = cacheDirs.length;
257
+ for (const dir of cacheDirs) {
258
+ // `removeProfileCache` operates by profile-name; for the
259
+ // composite dirs we already have the absolute path. Use rmSync
260
+ // directly so we don't depend on naming round-trips.
261
+ try {
262
+ fs.rmSync(dir, { recursive: true, force: true });
263
+ }
264
+ catch { /* ignore */ }
265
+ }
266
+ // The canonical wipe also covers the legacy dir if present.
267
+ removeProfileCache(name);
202
268
  }
203
- const pidLabel = response.pid ? `pid ${response.pid}` : 'attached';
204
- console.log(`Launched "${name}" on port ${response.port} (${pidLabel})`);
205
- console.log(`Next: agents browser start --profile ${name} --url <url>`);
269
+ console.log(`Deleted profile: ${name}` +
270
+ (removed > 0 ? ` (and ${removed} cache dir${removed === 1 ? '' : 's'})` : ''));
206
271
  });
207
272
  profiles
208
273
  .command('doctor <name>')
@@ -226,12 +291,31 @@ function registerProfilesCommands(browser) {
226
291
  detail: err instanceof Error ? err.message : String(err),
227
292
  });
228
293
  }
229
- // 2. Configured port: free, or already serving the expected browser?
294
+ // 2. Configured port. For local cdp:// we check the local port. For
295
+ // ssh:// the port lives on a remote host — doctor's previous
296
+ // behavior was to lsof the LOCAL port number, which was both
297
+ // misleading and arbitrary (after the SSH-binds-locally change
298
+ // the local port now matches the remote, so a positive answer
299
+ // is plausible; but doctor still shouldn't report on remote
300
+ // state without an --remote-probe explicitly).
230
301
  const port = extractConfiguredPort(profile);
231
302
  let attachingToExistingBrowser = false;
303
+ const firstEndpointTarget = (() => {
304
+ const presets = getEndpointPresets(profile);
305
+ const first = Object.keys(presets)[0];
306
+ return first ? presets[first].target : undefined;
307
+ })();
308
+ const isSshEndpoint = firstEndpointTarget?.startsWith('ssh://') ?? false;
232
309
  if (port === undefined) {
233
310
  checks.push({ label: 'port', ok: true, detail: 'no port in endpoint' });
234
311
  }
312
+ else if (isSshEndpoint) {
313
+ checks.push({
314
+ label: 'port',
315
+ ok: true,
316
+ detail: `${port} (remote on ${firstEndpointTarget}) — skipping local check`,
317
+ });
318
+ }
235
319
  else {
236
320
  const occupant = getPortOccupant(port);
237
321
  if (!occupant) {
@@ -302,7 +386,9 @@ function registerProfilesCommands(browser) {
302
386
  checks.push({
303
387
  label: 'onboarding',
304
388
  ok: false,
305
- detail: 'Local State is empty — run `agents browser profiles prime ' + name + '`',
389
+ detail: 'Local State is empty — run `agents browser start --profile ' +
390
+ name +
391
+ '` and finish any first-run screens before automating',
306
392
  });
307
393
  }
308
394
  }
@@ -310,7 +396,9 @@ function registerProfilesCommands(browser) {
310
396
  checks.push({
311
397
  label: 'onboarding',
312
398
  ok: false,
313
- detail: 'Not primed yet — run `agents browser profiles prime ' + name + '`',
399
+ detail: 'Not initialized yet — run `agents browser start --profile ' +
400
+ name +
401
+ '` and finish any first-run screens before automating',
314
402
  });
315
403
  }
316
404
  }
@@ -322,63 +410,68 @@ function registerProfilesCommands(browser) {
322
410
  if (!allOk)
323
411
  process.exit(1);
324
412
  });
325
- profiles
326
- .command('prime <name>')
327
- .description('Launch the profile so you can complete first-run onboarding interactively')
328
- .action(async (name) => {
329
- const profile = await getProfile(name);
330
- if (!profile) {
331
- console.error(`Profile "${name}" not found`);
332
- process.exit(1);
333
- }
334
- const response = await sendIPCRequest({
335
- action: 'launch-profile',
336
- profile: name,
337
- });
338
- if (!response.ok) {
339
- console.error(response.error);
340
- process.exit(1);
341
- }
342
- const pidLabel = response.pid ? `pid ${response.pid}` : 'attached';
343
- console.log(`Launched "${name}" on port ${response.port} (${pidLabel}).`);
344
- console.log('');
345
- console.log('Finish any first-run / onboarding screens in the browser window');
346
- console.log('(welcome, profile setup, default-browser prompt, sign-in, etc.).');
347
- console.log('Once you reach a normal browsing surface, this profile is primed');
348
- console.log('— its user-data-dir persists across runs, so you only do this once.');
349
- console.log('');
350
- console.log(`Next: agents browser start --profile ${name} --url <url>`);
351
- });
352
413
  }
353
414
  function registerTaskCommands(browser) {
354
415
  browser
355
416
  .command('start')
356
417
  .description('Start a browser task with a profile')
357
418
  .requiredOption('-p, --profile <name>', 'Browser profile to use')
358
- .option('-t, --task <name>', 'Task name (auto-generated if omitted)')
419
+ .option(TASK_OPTION_FLAG, 'Task name (auto-generated if omitted)')
420
+ .option('-e, --endpoint <name>', 'Endpoint preset (defaults to the profile\'s default)')
359
421
  .option('-u, --url <url>', 'Open URL in first tab')
360
422
  .action(async (opts) => {
423
+ // Pre-check the profile locally so we fail fast with a helpful error
424
+ // instead of round-tripping a generic "Profile not found" through the daemon.
425
+ const profile = await getProfile(opts.profile);
426
+ if (!profile) {
427
+ console.error(`Profile "${opts.profile}" not found.`);
428
+ const all = await listProfiles();
429
+ if (all.length > 0) {
430
+ console.error(`Available profiles: ${all.map((p) => p.name).join(', ')}`);
431
+ }
432
+ console.error(`Create one with: agents browser profiles create ${opts.profile} --browser <chrome|comet|chromium|brave|edge|custom>`);
433
+ process.exit(1);
434
+ }
435
+ // Pre-check the endpoint name too — same fail-fast rationale.
436
+ if (opts.endpoint) {
437
+ const presets = getEndpointPresets(profile);
438
+ if (!presets[opts.endpoint]) {
439
+ console.error(`Endpoint "${opts.endpoint}" not found on profile "${opts.profile}". ` +
440
+ `Available: ${Object.keys(presets).join(', ')}`);
441
+ process.exit(1);
442
+ }
443
+ }
361
444
  const response = await sendIPCRequest({
362
445
  action: 'start',
363
446
  profile: opts.profile,
364
447
  taskName: opts.task,
365
448
  url: opts.url,
449
+ endpoint: opts.endpoint,
366
450
  });
367
451
  if (!response.ok) {
368
452
  console.error(response.error);
369
453
  process.exit(1);
370
454
  }
455
+ // stdout: just the resolved name, one line, no decoration. Lets callers do:
456
+ // export AGENTS_BROWSER_TASK=$(agents browser start --profile work)
457
+ console.log(response.task);
458
+ // stderr: human-friendly commentary so a TTY user still sees what happened.
459
+ // Shell substitution captures stdout only, so $(...) stays clean.
371
460
  if (opts.url && response.tabId) {
372
- console.log(`Started task "${response.task}" with tab ${response.tabId}`);
461
+ console.error(`Started task "${response.task}" with tab ${response.tabId}`);
373
462
  }
374
463
  else {
375
- console.log(`Started task "${response.task}"`);
464
+ console.error(`Started task "${response.task}"`);
376
465
  }
466
+ console.error(`Tip: export AGENTS_BROWSER_TASK=${response.task}`);
467
+ console.error('Try: agents browser screenshot | agents browser console --level error');
377
468
  });
378
469
  browser
379
- .command('done <task>')
470
+ .command('done')
380
471
  .description('Complete a task and close its tabs')
381
- .action(async (task) => {
472
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
473
+ .action(async (opts) => {
474
+ const task = resolveTaskName(opts);
382
475
  const response = await sendIPCRequest({
383
476
  action: 'done',
384
477
  task,
@@ -390,9 +483,24 @@ function registerTaskCommands(browser) {
390
483
  console.log(`Completed task: ${task}`);
391
484
  });
392
485
  browser
393
- .command('stop <task>')
394
- .description('Stop a browser task and close its tabs')
395
- .action(async (task) => {
486
+ .command('stop')
487
+ .description('Stop a browser task and close its tabs; with --profile, detach the whole profile (close browser + drop cached connection)')
488
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
489
+ .option('-p, --profile <name>', 'Detach the whole profile (incl. composite "name@endpoint") instead of stopping a single task')
490
+ .action(async (opts) => {
491
+ if (opts.profile) {
492
+ const response = await sendIPCRequest({
493
+ action: 'stop',
494
+ profile: opts.profile,
495
+ });
496
+ if (!response.ok) {
497
+ console.error(response.error);
498
+ process.exit(1);
499
+ }
500
+ console.log(`Stopped profile: ${opts.profile}`);
501
+ return;
502
+ }
503
+ const task = resolveTaskName(opts);
396
504
  const response = await sendIPCRequest({
397
505
  action: 'stop',
398
506
  task,
@@ -404,45 +512,53 @@ function registerTaskCommands(browser) {
404
512
  console.log(`Stopped task: ${task}`);
405
513
  });
406
514
  browser
407
- .command('navigate <task> <url>')
515
+ .command('navigate')
408
516
  .description('Navigate current tab to URL (creates tab if none exist)')
517
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
518
+ .requiredOption('--url <url>', 'URL to navigate to')
409
519
  .option('-p, --profile <name>', 'Browser profile (optional if task is unique)')
410
- .action(async (task, url, opts) => {
520
+ .action(async (opts) => {
521
+ const task = resolveTaskName(opts);
411
522
  const response = await sendIPCRequest({
412
523
  action: 'navigate',
413
524
  task,
414
- url,
525
+ url: opts.url,
415
526
  profile: opts.profile,
416
527
  });
417
528
  if (!response.ok) {
418
529
  console.error(response.error);
419
530
  process.exit(1);
420
531
  }
421
- console.log(`Navigated ${response.tabId} to ${url}`);
532
+ console.log(`Navigated ${response.tabId} to ${opts.url}`);
422
533
  });
423
534
  // Tab subcommand group
424
535
  const tab = browser.command('tab').description('Manage tabs');
425
536
  tab
426
- .command('add <task> <url>')
537
+ .command('add')
427
538
  .description('Open URL in new tab (becomes current)')
539
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
540
+ .requiredOption('--url <url>', 'URL to open in the new tab')
428
541
  .option('-p, --profile <name>', 'Browser profile')
429
- .action(async (task, url, opts) => {
542
+ .action(async (opts) => {
543
+ const task = resolveTaskName(opts);
430
544
  const response = await sendIPCRequest({
431
545
  action: 'tab-add',
432
546
  task,
433
- url,
547
+ url: opts.url,
434
548
  profile: opts.profile,
435
549
  });
436
550
  if (!response.ok) {
437
551
  console.error(response.error);
438
552
  process.exit(1);
439
553
  }
440
- console.log(`Opened tab ${response.tabId}: ${url}`);
554
+ console.log(`Opened tab ${response.tabId}: ${opts.url}`);
441
555
  });
442
556
  tab
443
- .command('focus <task> <tabId>')
557
+ .command('focus <tabId>')
444
558
  .description('Switch to tab (by ID, prefix, or URL substring)')
445
- .action(async (task, tabId) => {
559
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
560
+ .action(async (tabId, opts) => {
561
+ const task = resolveTaskName(opts);
446
562
  const response = await sendIPCRequest({
447
563
  action: 'tab-focus',
448
564
  task,
@@ -455,9 +571,11 @@ function registerTaskCommands(browser) {
455
571
  console.log(`Focused tab ${response.tabId}`);
456
572
  });
457
573
  tab
458
- .command('close <task> [tabId]')
574
+ .command('close [tabId]')
459
575
  .description('Close tab(s) — omit tabId to close all')
460
- .action(async (task, tabId) => {
576
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
577
+ .action(async (tabId, opts) => {
578
+ const task = resolveTaskName(opts);
461
579
  const response = await sendIPCRequest({
462
580
  action: 'tab-close',
463
581
  task,
@@ -469,11 +587,13 @@ function registerTaskCommands(browser) {
469
587
  }
470
588
  console.log(tabId ? `Closed tab ${tabId}` : `Closed all tabs for ${task}`);
471
589
  });
472
- tab
473
- .command('list <task>')
474
- .description('List tabs for a task')
590
+ browser
591
+ .command('tabs')
592
+ .description('List tabs open for the current task')
593
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
475
594
  .option('--json', 'Output machine-readable JSON')
476
- .action(async (task, opts) => {
595
+ .action(async (opts) => {
596
+ const task = resolveTaskName(opts);
477
597
  const response = await sendIPCRequest({
478
598
  action: 'tab-list',
479
599
  task,
@@ -503,27 +623,73 @@ function registerTaskCommands(browser) {
503
623
  }
504
624
  });
505
625
  browser
506
- .command('screenshot <task> [tabId]')
507
- .description('Take a screenshot')
508
- .option('-o, --output <path>', 'Output path')
509
- .action(async (task, tabId, opts) => {
626
+ .command('screenshot')
627
+ .description('Take a screenshot — auto-saved per task; --output only needed when you want a specific path')
628
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
629
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
630
+ .option('-o, --output <path>', 'Specific output path (otherwise auto-saved under sessions/<task>/)')
631
+ .option('-q, --quality <mode>', 'compressed (JPEG, capped at ~100 KB — default) or raw (PNG, pixel-faithful)', 'compressed')
632
+ .action(async (opts) => {
633
+ const task = resolveTaskName(opts);
634
+ if (opts.quality !== 'compressed' && opts.quality !== 'raw') {
635
+ console.error('--quality must be "compressed" or "raw"');
636
+ process.exit(1);
637
+ }
510
638
  const response = await sendIPCRequest({
511
639
  action: 'screenshot',
512
640
  task,
513
- tabId,
641
+ tabId: opts.tab,
514
642
  path: opts.output,
643
+ quality: opts.quality,
515
644
  });
516
645
  if (!response.ok) {
517
646
  console.error(response.error);
518
647
  process.exit(1);
519
648
  }
649
+ // stdout: just the path, so `P=$(agents browser screenshot)` works.
520
650
  console.log(response.path);
651
+ // stderr: human commentary with size + dimensions, so an agent can
652
+ // see at a glance what was captured without `ls -l && file` round-trips.
653
+ const size = humanizeBytes(response.bytes);
654
+ const dims = response.width && response.height ? `${response.width}×${response.height}` : 'unknown size';
655
+ console.error(`Saved screenshot to ${response.path} (${size}, ${dims})`);
656
+ // When auto-saving (no --output), surface the directory once so the
657
+ // agent doesn't have to dirname() the path or guess where files land.
658
+ if (!opts.output && response.path) {
659
+ const dir = path.dirname(response.path);
660
+ console.error(`Tip: auto-saving to ${dir}. Pass --output <path> to choose a path.`);
661
+ }
521
662
  });
522
663
  browser
523
- .command('evaluate <task> <expression>')
664
+ .command('evaluate')
524
665
  .description('Evaluate JavaScript in current tab')
666
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
525
667
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
526
- .action(async (task, expression, opts) => {
668
+ .option('-e, --expression <js>', 'JavaScript expression to evaluate')
669
+ .option('-f, --file <path>', 'Path to a .js file whose contents will be evaluated')
670
+ .action(async (opts) => {
671
+ const task = resolveTaskName(opts);
672
+ if (opts.expression && opts.file) {
673
+ console.error('Pass exactly one of --expression or --file');
674
+ process.exit(1);
675
+ }
676
+ let expression;
677
+ if (opts.file) {
678
+ try {
679
+ expression = fs.readFileSync(opts.file, 'utf8');
680
+ }
681
+ catch (err) {
682
+ console.error(`Cannot read --file ${opts.file}: ${err.message}`);
683
+ process.exit(1);
684
+ }
685
+ }
686
+ else if (opts.expression) {
687
+ expression = opts.expression;
688
+ }
689
+ else {
690
+ console.error('Pass --expression <js> or --file <path>');
691
+ process.exit(1);
692
+ }
527
693
  const response = await sendIPCRequest({
528
694
  action: 'evaluate',
529
695
  task,
@@ -536,6 +702,73 @@ function registerTaskCommands(browser) {
536
702
  }
537
703
  console.log(JSON.stringify(response.result, null, 2));
538
704
  });
705
+ browser
706
+ .command('ps')
707
+ .description('List every browser/electron/tunnel process agents has tracked (alive or stale) — works without the daemon')
708
+ .option('--json', 'Output machine-readable JSON')
709
+ .action((opts) => {
710
+ const snapshots = listAllProfileSnapshots();
711
+ // Cross-check against what's actually listening locally so we can
712
+ // surface "port claimed by us but nothing is listening" (= leaked
713
+ // cache file) and "port listening but not in our records" (= someone
714
+ // else owns it; a new profile pointing here would collide).
715
+ const portOwners = new Map();
716
+ const conflicts = [];
717
+ for (const s of snapshots) {
718
+ const port = s.meta?.port;
719
+ if (!port)
720
+ continue;
721
+ const occupant = getPortOccupant(port);
722
+ if (!occupant) {
723
+ if (s.pidAlive || s.tunnelAlive) {
724
+ conflicts.push(`${s.name}: port ${port} marked active but nothing is listening`);
725
+ }
726
+ continue;
727
+ }
728
+ const ourPid = s.meta?.tunnelPid && s.meta.kind === 'tunnel'
729
+ ? s.meta.tunnelPid
730
+ : s.meta?.pid;
731
+ if (ourPid && occupant.pid !== ourPid) {
732
+ conflicts.push(`${s.name}: port ${port} listened on by ${occupant.command} (pid ${occupant.pid}) but our record says pid ${ourPid}`);
733
+ }
734
+ portOwners.set(port, occupant);
735
+ }
736
+ if (opts.json) {
737
+ console.log(JSON.stringify({ snapshots, conflicts }, null, 2));
738
+ return;
739
+ }
740
+ if (snapshots.length === 0) {
741
+ console.log('No tracked browser state. Run `agents browser start --profile <name>` to spawn one.');
742
+ return;
743
+ }
744
+ console.log('PROFILE KIND PID TUNNEL PORT ALIVE TASKS OWNER');
745
+ console.log('-----------------------------------------------------------------------------------------------');
746
+ for (const s of snapshots) {
747
+ const kind = s.meta?.kind ?? '-';
748
+ const pid = s.meta?.pid ?? '-';
749
+ const tunnelPid = s.meta?.tunnelPid ?? '-';
750
+ const port = s.meta?.port ?? '-';
751
+ const alive = aliveLabel(s);
752
+ const owner = s.meta?.daemonPid
753
+ ? `daemon${s.daemonAlive ? '' : '(dead)'}=${s.meta.daemonPid}`
754
+ : '-';
755
+ console.log(`${s.name.padEnd(40)} ${String(kind).padEnd(9)} ${String(pid).padEnd(6)} ${String(tunnelPid).padEnd(7)} ${String(port).padEnd(6)} ${alive.padEnd(6)} ${String(s.taskCount).padEnd(6)} ${owner}`);
756
+ }
757
+ if (conflicts.length > 0) {
758
+ console.log('');
759
+ console.log('Conflicts / leaks detected:');
760
+ for (const c of conflicts)
761
+ console.log(` - ${c}`);
762
+ console.log('');
763
+ console.log('Run `agents browser stop --profile <name>` to clean up a specific profile, or restart the daemon to trigger the orphan reaper.');
764
+ }
765
+ });
766
+ function aliveLabel(s) {
767
+ const k = s.meta?.kind;
768
+ if (k === 'tunnel')
769
+ return s.tunnelAlive ? 'yes' : 'stale';
770
+ return s.pidAlive ? 'yes' : 'stale';
771
+ }
539
772
  browser
540
773
  .command('status')
541
774
  .description('Show running browser tasks')
@@ -749,13 +982,15 @@ function registerTaskCommands(browser) {
749
982
  }
750
983
  });
751
984
  browser
752
- .command('refs <task>')
985
+ .command('refs')
753
986
  .description('Get DOM refs for interactive elements')
987
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
754
988
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
755
989
  .option('--all', 'Include non-interactive elements')
756
990
  .option('-l, --limit <n>', 'Max elements (default 500)', '500')
757
991
  .option('--json', 'Output machine-readable JSON')
758
- .action(async (task, opts) => {
992
+ .action(async (opts) => {
993
+ const task = resolveTaskName(opts);
759
994
  const response = await sendIPCRequest({
760
995
  action: 'refs',
761
996
  task,
@@ -779,10 +1014,12 @@ function registerTaskCommands(browser) {
779
1014
  console.log(response.refs);
780
1015
  });
781
1016
  browser
782
- .command('click <task> <ref>')
1017
+ .command('click <ref>')
783
1018
  .description('Click an element by ref')
1019
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
784
1020
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
785
- .action(async (task, ref, opts) => {
1021
+ .action(async (ref, opts) => {
1022
+ const task = resolveTaskName(opts);
786
1023
  const response = await sendIPCRequest({
787
1024
  action: 'click',
788
1025
  task,
@@ -796,17 +1033,25 @@ function registerTaskCommands(browser) {
796
1033
  console.log('Clicked');
797
1034
  });
798
1035
  browser
799
- .command('type <task> <ref> <text>')
1036
+ .command('type <ref>')
800
1037
  .description('Type text into an element by ref')
1038
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
801
1039
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1040
+ .requiredOption('--text <text>', 'Text to type (use quotes for spaces/special chars)')
802
1041
  .option('--clear', 'Clear editor content before typing')
803
- .action(async (task, ref, text, opts) => {
1042
+ .action(async (ref, opts) => {
1043
+ const task = resolveTaskName(opts);
1044
+ const refNum = parseInt(ref, 10);
1045
+ if (!Number.isFinite(refNum)) {
1046
+ console.error(`<ref> must be an integer, got: ${ref}`);
1047
+ process.exit(1);
1048
+ }
804
1049
  const response = await sendIPCRequest({
805
1050
  action: 'type',
806
1051
  task,
807
1052
  tabId: opts.tab,
808
- ref: parseInt(ref, 10),
809
- text,
1053
+ ref: refNum,
1054
+ text: opts.text,
810
1055
  clear: opts.clear,
811
1056
  });
812
1057
  if (!response.ok) {
@@ -816,10 +1061,12 @@ function registerTaskCommands(browser) {
816
1061
  console.log('Typed');
817
1062
  });
818
1063
  browser
819
- .command('press <task> <key>')
1064
+ .command('press <key>')
820
1065
  .description('Press a key (Enter, Tab, Escape, etc)')
1066
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
821
1067
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
822
- .action(async (task, key, opts) => {
1068
+ .action(async (key, opts) => {
1069
+ const task = resolveTaskName(opts);
823
1070
  const response = await sendIPCRequest({
824
1071
  action: 'press',
825
1072
  task,
@@ -833,10 +1080,12 @@ function registerTaskCommands(browser) {
833
1080
  console.log('Pressed');
834
1081
  });
835
1082
  browser
836
- .command('hover <task> <ref>')
1083
+ .command('hover <ref>')
837
1084
  .description('Hover over an element by ref')
1085
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
838
1086
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
839
- .action(async (task, ref, opts) => {
1087
+ .action(async (ref, opts) => {
1088
+ const task = resolveTaskName(opts);
840
1089
  const response = await sendIPCRequest({
841
1090
  action: 'hover',
842
1091
  task,
@@ -850,18 +1099,30 @@ function registerTaskCommands(browser) {
850
1099
  console.log('Hovered');
851
1100
  });
852
1101
  browser
853
- .command('scroll <task> <deltaX> <deltaY>')
854
- .description('Scroll the page by pixel amount')
1102
+ .command('scroll')
1103
+ .description('Scroll the page by pixel amount (negatives scroll up/left)')
1104
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
855
1105
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1106
+ .option('--dx <n>', 'Horizontal pixels (negative = left)', (v) => parseInt(v, 10), 0)
1107
+ .option('--dy <n>', 'Vertical pixels (negative = up)', (v) => parseInt(v, 10), 0)
856
1108
  .option('-x, --at-x <x>', 'X coordinate to dispatch scroll from (default 0)', parseInt)
857
1109
  .option('-y, --at-y <y>', 'Y coordinate to dispatch scroll from (default 0)', parseInt)
858
- .action(async (task, deltaX, deltaY, opts) => {
1110
+ .action(async (opts) => {
1111
+ const task = resolveTaskName(opts);
1112
+ if (!Number.isFinite(opts.dx) || !Number.isFinite(opts.dy)) {
1113
+ console.error('--dx and --dy must be integers');
1114
+ process.exit(1);
1115
+ }
1116
+ if (opts.dx === 0 && opts.dy === 0) {
1117
+ console.error('Pass --dx and/or --dy (at least one must be non-zero)');
1118
+ process.exit(1);
1119
+ }
859
1120
  const response = await sendIPCRequest({
860
1121
  action: 'scroll',
861
1122
  task,
862
1123
  tabId: opts.tab,
863
- scrollX: parseInt(deltaX, 10),
864
- scrollY: parseInt(deltaY, 10),
1124
+ scrollX: opts.dx,
1125
+ scrollY: opts.dy,
865
1126
  scrollAtX: opts.atX,
866
1127
  scrollAtY: opts.atY,
867
1128
  });
@@ -872,8 +1133,9 @@ function registerTaskCommands(browser) {
872
1133
  console.log('Scrolled');
873
1134
  });
874
1135
  browser
875
- .command('upload <task>')
1136
+ .command('upload')
876
1137
  .description('Upload file(s) — supports hidden file inputs, drag-drop targets, and OS chooser interception')
1138
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
877
1139
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
878
1140
  .option('-r, --ref <n>', 'Ref of the upload target element (file input or drop zone)', (v) => parseInt(v, 10))
879
1141
  .option('--trigger <n>', 'Ref of a button that opens the OS file chooser (Pattern C)', (v) => parseInt(v, 10))
@@ -881,7 +1143,8 @@ function registerTaskCommands(browser) {
881
1143
  .option('--drop', 'Force drag-drop pattern even if ref is an <input type=file>')
882
1144
  .option('--input', 'Force file-input pattern (DOM.setFileInputFiles)')
883
1145
  .option('--timeout <ms>', 'Timeout for chooser interception (Pattern C)', (v) => parseInt(v, 10))
884
- .action(async (task, opts) => {
1146
+ .action(async (opts) => {
1147
+ const task = resolveTaskName(opts);
885
1148
  const files = opts.file ?? [];
886
1149
  if (files.length === 0) {
887
1150
  console.error('--file <path> is required (repeat for multiple files)');
@@ -921,12 +1184,14 @@ function registerTaskCommands(browser) {
921
1184
  // ─── Viewport & Device ───────────────────────────────────────────────────────
922
1185
  const setCmd = browser.command('set').description('Set browser emulation options');
923
1186
  setCmd
924
- .command('viewport <task> <width> <height>')
1187
+ .command('viewport <width> <height>')
925
1188
  .description('Set viewport size')
1189
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
926
1190
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
927
1191
  .option('-m, --mobile', 'Enable mobile emulation')
928
1192
  .option('-s, --scale <factor>', 'Device scale factor', parseFloat)
929
- .action(async (task, width, height, opts) => {
1193
+ .action(async (width, height, opts) => {
1194
+ const task = resolveTaskName(opts);
930
1195
  const response = await sendIPCRequest({
931
1196
  action: 'set-viewport',
932
1197
  task,
@@ -943,10 +1208,12 @@ function registerTaskCommands(browser) {
943
1208
  console.log(`Viewport set to ${width}x${height}${opts.mobile ? ' (mobile)' : ''}`);
944
1209
  });
945
1210
  setCmd
946
- .command('device <task> <device-name>')
1211
+ .command('device <device-name>')
947
1212
  .description('Emulate a device (iPhone 14, iPad, MacBook Pro)')
1213
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
948
1214
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
949
- .action(async (task, deviceName, opts) => {
1215
+ .action(async (deviceName, opts) => {
1216
+ const task = resolveTaskName(opts);
950
1217
  const response = await sendIPCRequest({
951
1218
  action: 'set-device',
952
1219
  task,
@@ -971,12 +1238,14 @@ function registerTaskCommands(browser) {
971
1238
  });
972
1239
  // ─── Console & Errors ────────────────────────────────────────────────────────
973
1240
  browser
974
- .command('console <task>')
1241
+ .command('console')
975
1242
  .description('Read console logs from a tab')
1243
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
976
1244
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
977
1245
  .option('-l, --level <level>', 'Filter by level (log, info, warn, error)')
978
1246
  .option('--clear', 'Clear logs after reading')
979
- .action(async (task, opts) => {
1247
+ .action(async (opts) => {
1248
+ const task = resolveTaskName(opts);
980
1249
  const response = await sendIPCRequest({
981
1250
  action: 'console',
982
1251
  task,
@@ -999,11 +1268,13 @@ function registerTaskCommands(browser) {
999
1268
  }
1000
1269
  });
1001
1270
  browser
1002
- .command('errors <task>')
1271
+ .command('errors')
1003
1272
  .description('Read page errors from a tab')
1273
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1004
1274
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1005
1275
  .option('--clear', 'Clear errors after reading')
1006
- .action(async (task, opts) => {
1276
+ .action(async (opts) => {
1277
+ const task = resolveTaskName(opts);
1007
1278
  const response = await sendIPCRequest({
1008
1279
  action: 'errors',
1009
1280
  task,
@@ -1029,12 +1300,14 @@ function registerTaskCommands(browser) {
1029
1300
  });
1030
1301
  // ─── Network ─────────────────────────────────────────────────────────────────
1031
1302
  browser
1032
- .command('requests <task>')
1303
+ .command('requests')
1033
1304
  .description('Read captured network requests')
1305
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1034
1306
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1035
1307
  .option('-f, --filter <text>', 'Filter URLs containing text')
1036
1308
  .option('--clear', 'Clear requests after reading')
1037
- .action(async (task, opts) => {
1309
+ .action(async (opts) => {
1310
+ const task = resolveTaskName(opts);
1038
1311
  const response = await sendIPCRequest({
1039
1312
  action: 'requests',
1040
1313
  task,
@@ -1058,12 +1331,50 @@ function registerTaskCommands(browser) {
1058
1331
  }
1059
1332
  });
1060
1333
  browser
1061
- .command('responsebody <task> <url-pattern>')
1334
+ .command('logs <task>')
1335
+ .description('Read merged rush-app + rush-cli JSONL logs for a task')
1336
+ .option('--source <name>', 'Source to scope to: rush-app or rush-cli (default both)')
1337
+ .option('--lines <n>', 'Tail N entries (default 200; ignored when --since)', (v) => parseInt(v, 10))
1338
+ .option('--since <when>', 'Absolute timestamp or relative offset (e.g. 5m, 2h, 1d)')
1339
+ .option('--until <when>', 'Absolute timestamp or relative offset (e.g. 5m, 2h, 1d)')
1340
+ .option('--level <level>', 'Filter entries by level field')
1341
+ .option('--message <name>', 'Filter entries by exact message field')
1342
+ .option('--filter <text>', 'Filter entries whose JSON contains this substring')
1343
+ .option('-f, --follow', 'Follow mode (not yet implemented)')
1344
+ .action(async (task, opts) => {
1345
+ if (opts.follow) {
1346
+ process.stderr.write('follow mode not yet implemented; coming next pass\n');
1347
+ process.exit(1);
1348
+ }
1349
+ const response = await sendIPCRequest({
1350
+ action: 'getAppLogs',
1351
+ task,
1352
+ source: opts.source,
1353
+ lines: opts.lines,
1354
+ since: opts.since,
1355
+ until: opts.until,
1356
+ appLevel: opts.level,
1357
+ message: opts.message,
1358
+ filter: opts.filter,
1359
+ });
1360
+ if (!response.ok) {
1361
+ console.error(response.error);
1362
+ process.exit(1);
1363
+ }
1364
+ const entries = response.appLogs ?? [];
1365
+ for (const entry of entries) {
1366
+ console.log(JSON.stringify(entry));
1367
+ }
1368
+ });
1369
+ browser
1370
+ .command('responsebody <url-pattern>')
1062
1371
  .description('Wait for and read a response body by URL pattern')
1372
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1063
1373
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1064
1374
  .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
1065
1375
  .option('--max-chars <n>', 'Max characters to return', parseInt)
1066
- .action(async (task, urlPattern, opts) => {
1376
+ .action(async (urlPattern, opts) => {
1377
+ const task = resolveTaskName(opts);
1067
1378
  const response = await sendIPCRequest({
1068
1379
  action: 'response-body',
1069
1380
  task,
@@ -1080,8 +1391,9 @@ function registerTaskCommands(browser) {
1080
1391
  });
1081
1392
  // ─── Wait ────────────────────────────────────────────────────────────────────
1082
1393
  browser
1083
- .command('wait <task>')
1394
+ .command('wait')
1084
1395
  .description('Wait for a condition')
1396
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1085
1397
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1086
1398
  .option('--time <ms>', 'Wait for milliseconds')
1087
1399
  .option('--selector <css>', 'Wait for CSS selector to appear')
@@ -1089,7 +1401,8 @@ function registerTaskCommands(browser) {
1089
1401
  .option('--fn <js>', 'Wait for JS expression to return truthy')
1090
1402
  .option('--state <state>', 'Wait for load state (domcontentloaded, load, networkidle)')
1091
1403
  .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
1092
- .action(async (task, opts) => {
1404
+ .action(async (opts) => {
1405
+ const task = resolveTaskName(opts);
1093
1406
  let waitType;
1094
1407
  let waitValue;
1095
1408
  if (opts.time) {
@@ -1132,11 +1445,13 @@ function registerTaskCommands(browser) {
1132
1445
  });
1133
1446
  // ─── Downloads ───────────────────────────────────────────────────────────────
1134
1447
  browser
1135
- .command('download <task>')
1448
+ .command('download')
1136
1449
  .description('Set download directory for a task')
1450
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1137
1451
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1138
1452
  .requiredOption('-p, --path <dir>', 'Download directory path')
1139
- .action(async (task, opts) => {
1453
+ .action(async (opts) => {
1454
+ const task = resolveTaskName(opts);
1140
1455
  const response = await sendIPCRequest({
1141
1456
  action: 'set-download-path',
1142
1457
  task,
@@ -1149,11 +1464,58 @@ function registerTaskCommands(browser) {
1149
1464
  }
1150
1465
  console.log(`Download path set to ${opts.path}`);
1151
1466
  });
1467
+ // ─── Recording ─────────────────────────────────────────────────────────────
1468
+ const record = browser.command('record').description('Record a video of the page');
1469
+ record
1470
+ .command('start')
1471
+ .description('Start recording — auto-saved under sessions/<task>/recordings/. Bounded by --fps, --duration, --max-mb.')
1472
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1473
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1474
+ .option('--fps <n>', 'Frames per second (1–30, default 5)', (v) => parseInt(v, 10))
1475
+ .option('--duration <sec>', 'Hard duration cap in seconds (default 60)', (v) => parseInt(v, 10))
1476
+ .option('--max-mb <mb>', 'Stop when output exceeds this many MB (default 25)', (v) => parseInt(v, 10))
1477
+ .action(async (opts) => {
1478
+ const task = resolveTaskName(opts);
1479
+ const response = await sendIPCRequest({
1480
+ action: 'record-start',
1481
+ task,
1482
+ tabId: opts.tab,
1483
+ fps: opts.fps,
1484
+ duration: opts.duration,
1485
+ maxMb: opts.maxMb,
1486
+ });
1487
+ if (!response.ok) {
1488
+ console.error(response.error);
1489
+ process.exit(1);
1490
+ }
1491
+ // stdout: path (for capture into a variable). stderr: human commentary.
1492
+ console.log(response.path);
1493
+ console.error(`Recording task "${task}" at ${response.fps} fps (cap ${response.durationCapSec}s / ${response.maxMb} MB) → ${response.path}`);
1494
+ console.error('Stop with: agents browser record stop');
1495
+ });
1496
+ record
1497
+ .command('stop')
1498
+ .description('Stop an in-progress recording')
1499
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1500
+ .action(async (opts) => {
1501
+ const task = resolveTaskName(opts);
1502
+ const response = await sendIPCRequest({ action: 'record-stop', task });
1503
+ if (!response.ok) {
1504
+ console.error(response.error);
1505
+ process.exit(1);
1506
+ }
1507
+ console.log(response.path);
1508
+ const size = humanizeBytes(response.bytes);
1509
+ const seconds = ((response.durationMs ?? 0) / 1000).toFixed(1);
1510
+ console.error(`Saved recording to ${response.path} (${size}, ${seconds}s, stopped: ${response.stopReason})`);
1511
+ });
1152
1512
  browser
1153
- .command('waitdownload <task>')
1513
+ .command('waitdownload')
1154
1514
  .description('Wait for a download to complete')
1515
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1155
1516
  .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
1156
- .action(async (task, opts) => {
1517
+ .action(async (opts) => {
1518
+ const task = resolveTaskName(opts);
1157
1519
  const response = await sendIPCRequest({
1158
1520
  action: 'wait-download',
1159
1521
  task,
@@ -1180,6 +1542,15 @@ function formatAge(timestamp) {
1180
1542
  const hours = Math.floor(minutes / 60);
1181
1543
  return `${hours}h ago`;
1182
1544
  }
1545
+ function humanizeBytes(n) {
1546
+ if (n === undefined)
1547
+ return 'unknown size';
1548
+ if (n < 1024)
1549
+ return `${n} B`;
1550
+ if (n < 1024 * 1024)
1551
+ return `${(n / 1024).toFixed(0)} KB`;
1552
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
1553
+ }
1183
1554
  function formatDuration(ms) {
1184
1555
  const seconds = Math.floor(ms / 1000);
1185
1556
  if (seconds < 60)