@phnx-labs/agents-cli 1.18.3 → 1.18.4

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,101 @@
1
+ import { Argument } from 'commander';
2
+ // Hidden trailing positional we use to capture the deprecated `<task>` slot
3
+ // without polluting `--help`. Required for backward compat during migration.
4
+ // commander v12 Argument lacks `hideHelp()`; we set the field our custom
5
+ // help formatter (src/lib/help.ts) already filters on.
6
+ function hiddenLegacyArg(name = 'legacyArg') {
7
+ const arg = new Argument(`[${name}]`, 'deprecated — legacy task positional');
8
+ arg.hidden = true;
9
+ return arg;
10
+ }
1
11
  import * as fs from 'fs';
2
12
  import * as path from 'path';
3
- import { listProfiles, getProfile, createProfile, deleteProfile, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, } from '../lib/browser/profiles.js';
13
+ import { listProfiles, getProfile, createProfile, deleteProfile, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, } from '../lib/browser/profiles.js';
4
14
  import { findBrowserPath, getPortOccupant } from '../lib/browser/chrome.js';
15
+ import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
5
16
  import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
6
17
  import { parseTargetFilter } from '../lib/browser/service.js';
7
18
  import { sendIPCRequest } from '../lib/browser/ipc.js';
8
19
  import { browserTaskPicker } from './browser-picker.js';
9
20
  import { isInteractiveTerminal } from './utils.js';
21
+ import { registerCommandGroups } from '../lib/help.js';
22
+ /**
23
+ * Resolve which browser task a command targets. Order:
24
+ * 1. `--task <name>` flag (explicit per-command override)
25
+ * 2. Legacy positional `<task>` arg (deprecated; emits a one-time warning)
26
+ * 3. `$AGENTS_BROWSER_TASK` (set once at the start of an agent run)
27
+ *
28
+ * Each agent process has its own environment, so the env-var path is safe for
29
+ * parallel agents — they can't see each other's value.
30
+ */
31
+ let legacyTaskWarned = false;
32
+ function warnLegacyTaskPositional() {
33
+ if (legacyTaskWarned)
34
+ return;
35
+ legacyTaskWarned = true;
36
+ console.error('warning: passing <task> as a positional argument is deprecated. ' +
37
+ 'Set AGENTS_BROWSER_TASK once per shell, or use --task <name> for a one-off override.');
38
+ }
39
+ function resolveTaskName(opts, legacyTaskArg) {
40
+ if (opts.task)
41
+ return opts.task;
42
+ if (legacyTaskArg !== undefined && legacyTaskArg !== '') {
43
+ warnLegacyTaskPositional();
44
+ return legacyTaskArg;
45
+ }
46
+ const fromEnv = process.env.AGENTS_BROWSER_TASK;
47
+ if (fromEnv)
48
+ return fromEnv;
49
+ console.error('No task specified. Pass --task <name> or set AGENTS_BROWSER_TASK in your shell.');
50
+ console.error('Tip: T=$(agents browser start --profile <p>) && export AGENTS_BROWSER_TASK=$T');
51
+ process.exit(1);
52
+ }
53
+ /**
54
+ * Split positional args into (deprecated leading task, new-shape positionals).
55
+ *
56
+ * If exactly one more positional than the new shape expects is supplied, treat
57
+ * the first one as the deprecated `<task>` positional and shift the rest. Used
58
+ * to keep old `agents browser navigate <task> <url>` style invocations working
59
+ * during the migration to `--task <name>` / `$AGENTS_BROWSER_TASK`.
60
+ */
61
+ function unpackPositionals(rawArgs, newPositionalCount, opts) {
62
+ const defined = rawArgs.filter((a) => a !== undefined);
63
+ if (defined.length === newPositionalCount + 1) {
64
+ return { task: resolveTaskName(opts, defined[0]), positionals: defined.slice(1) };
65
+ }
66
+ return { task: resolveTaskName(opts), positionals: defined };
67
+ }
68
+ // `-t` is taken by `--tab` on most commands, so `--task` is long-form only.
69
+ // Agents normally set $AGENTS_BROWSER_TASK once and never type this flag.
70
+ const TASK_OPTION_FLAG = '--task <name>';
71
+ const TASK_OPTION_DESC = 'Task name (defaults to $AGENTS_BROWSER_TASK)';
72
+ // Help groups — surfaces the actual mental model an agent follows
73
+ // ("open a session / drive the page / capture evidence / rare extras")
74
+ // instead of an alphabetical dump. Everything not listed falls into a
75
+ // trailing "Other commands" section automatically.
76
+ const BROWSER_HELP_GROUPS = [
77
+ { title: 'Session lifecycle', names: ['start', 'done', 'status'] },
78
+ {
79
+ title: 'Drive the page',
80
+ names: ['navigate', 'tabs', 'screenshot', 'evaluate', 'click', 'type', 'press', 'wait'],
81
+ },
82
+ {
83
+ title: 'Capture evidence',
84
+ names: ['console', 'errors', 'requests', 'responsebody', 'record'],
85
+ },
86
+ ];
10
87
  export function registerBrowserCommand(program) {
11
88
  const browser = program
12
89
  .command('browser')
13
90
  .description('Browser automation via CDP');
14
91
  registerProfilesCommands(browser);
15
92
  registerTaskCommands(browser);
93
+ registerCommandGroups(browser, BROWSER_HELP_GROUPS);
16
94
  }
17
95
  export function registerBrowserSubcommands(program) {
18
96
  registerProfilesCommands(program);
19
97
  registerTaskCommands(program);
98
+ registerCommandGroups(program, BROWSER_HELP_GROUPS);
20
99
  }
21
100
  function registerProfilesCommands(browser) {
22
101
  const profiles = browser
@@ -38,7 +117,10 @@ function registerProfilesCommands(browser) {
38
117
  console.log('NAME'.padEnd(20) + 'BROWSER'.padEnd(12) + 'DESCRIPTION'.padEnd(38) + 'ENDPOINTS');
39
118
  console.log('-'.repeat(92));
40
119
  for (const p of allProfiles) {
41
- const endpoints = p.endpoints.join(', ');
120
+ const presets = getEndpointPresets(p);
121
+ const endpoints = Object.entries(presets)
122
+ .map(([name, ep]) => (name.startsWith('endpoint-') ? ep.target : `${name}=${ep.target}`))
123
+ .join(', ');
42
124
  const desc = (p.description ?? '').slice(0, 36).padEnd(38);
43
125
  console.log(p.name.padEnd(20) + (p.browser || '-').padEnd(12) + desc + endpoints);
44
126
  }
@@ -47,7 +129,10 @@ function registerProfilesCommands(browser) {
47
129
  console.log('NAME'.padEnd(20) + 'BROWSER'.padEnd(12) + 'ENDPOINTS');
48
130
  console.log('-'.repeat(72));
49
131
  for (const p of allProfiles) {
50
- const endpoints = p.endpoints.join(', ');
132
+ const presets = getEndpointPresets(p);
133
+ const endpoints = Object.entries(presets)
134
+ .map(([name, ep]) => (name.startsWith('endpoint-') ? ep.target : `${name}=${ep.target}`))
135
+ .join(', ');
51
136
  console.log(p.name.padEnd(20) + (p.browser || '-').padEnd(12) + endpoints);
52
137
  }
53
138
  }
@@ -61,7 +146,7 @@ function registerProfilesCommands(browser) {
61
146
  .option('-s, --secrets <bundle>', 'Secrets bundle to inject')
62
147
  .option('-d, --description <text>', 'Profile description')
63
148
  .option('--headless', 'Run in headless mode')
64
- .option('--window <WxH>', 'Window size, e.g. 1512x982')
149
+ .option('--window <WxH>', `Window size in CSS pixels (default: ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}, MacBook Pro 14")`)
65
150
  .option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
66
151
  .option('--binary <path>', 'Absolute path to the browser/app binary (required with --browser custom)')
67
152
  .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 +185,16 @@ function registerProfilesCommands(browser) {
100
185
  const freePort = await findFreeProfilePort();
101
186
  endpoints = [`cdp://127.0.0.1:${freePort}`];
102
187
  }
103
- // Viewport is mandatory — default to 1512x982 if --window is not provided
188
+ // Viewport is mandatory — default to MacBook Pro 14" (1512x982) if
189
+ // --window is not provided. See lib/browser/devices.ts DEFAULT_VIEWPORT.
104
190
  let viewport = {
105
- width: 1512,
106
- height: 982,
191
+ width: DEFAULT_VIEWPORT.width,
192
+ height: DEFAULT_VIEWPORT.height,
107
193
  };
108
194
  if (opts.window) {
109
195
  const m = String(opts.window).match(/^(\d+)x(\d+)$/);
110
196
  if (!m) {
111
- console.error('--window must be WxH, e.g. 1512x982');
197
+ console.error(`--window must be WxH, e.g. ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}`);
112
198
  process.exit(1);
113
199
  }
114
200
  viewport.width = parseInt(m[1], 10);
@@ -167,9 +253,24 @@ function registerProfilesCommands(browser) {
167
253
  console.log(`Target filter: ${profile.targetFilter}`);
168
254
  if (profile.description)
169
255
  console.log(`Description: ${profile.description}`);
170
- console.log(`Endpoints:`);
171
- for (const e of profile.endpoints) {
172
- console.log(` - ${e}`);
256
+ const presets = getEndpointPresets(profile);
257
+ const defaultName = profile.defaultEndpoint && presets[profile.defaultEndpoint]
258
+ ? profile.defaultEndpoint
259
+ : Object.keys(presets)[0];
260
+ console.log('Endpoints:');
261
+ for (const [presetName, preset] of Object.entries(presets)) {
262
+ const marker = presetName === defaultName ? ' (default)' : '';
263
+ const isLegacy = presetName.startsWith('endpoint-');
264
+ console.log(` - ${isLegacy ? preset.target : `${presetName}: ${preset.target}`}${marker}`);
265
+ if (preset.binary)
266
+ console.log(` binary: ${preset.binary}`);
267
+ if (preset.targetFilter)
268
+ console.log(` targetFilter: ${preset.targetFilter}`);
269
+ }
270
+ if (profile.viewport) {
271
+ const v = profile.viewport;
272
+ const pos = v.x !== undefined && v.y !== undefined ? ` @ ${v.x},${v.y}` : '';
273
+ console.log(`Viewport: ${v.width}×${v.height}${pos}`);
173
274
  }
174
275
  if (profile.secrets)
175
276
  console.log(`Secrets: ${profile.secrets}`);
@@ -183,27 +284,6 @@ function registerProfilesCommands(browser) {
183
284
  await deleteProfile(name);
184
285
  console.log(`Deleted profile: ${name}`);
185
286
  });
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);
202
- }
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>`);
206
- });
207
287
  profiles
208
288
  .command('doctor <name>')
209
289
  .description('Diagnose a browser profile: binary, port, user-data-dir, onboarding state')
@@ -302,7 +382,9 @@ function registerProfilesCommands(browser) {
302
382
  checks.push({
303
383
  label: 'onboarding',
304
384
  ok: false,
305
- detail: 'Local State is empty — run `agents browser profiles prime ' + name + '`',
385
+ detail: 'Local State is empty — run `agents browser start --profile ' +
386
+ name +
387
+ '` and finish any first-run screens before automating',
306
388
  });
307
389
  }
308
390
  }
@@ -310,7 +392,9 @@ function registerProfilesCommands(browser) {
310
392
  checks.push({
311
393
  label: 'onboarding',
312
394
  ok: false,
313
- detail: 'Not primed yet — run `agents browser profiles prime ' + name + '`',
395
+ detail: 'Not initialized yet — run `agents browser start --profile ' +
396
+ name +
397
+ '` and finish any first-run screens before automating',
314
398
  });
315
399
  }
316
400
  }
@@ -322,63 +406,69 @@ function registerProfilesCommands(browser) {
322
406
  if (!allOk)
323
407
  process.exit(1);
324
408
  });
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
409
  }
353
410
  function registerTaskCommands(browser) {
354
411
  browser
355
412
  .command('start')
356
413
  .description('Start a browser task with a profile')
357
414
  .requiredOption('-p, --profile <name>', 'Browser profile to use')
358
- .option('-t, --task <name>', 'Task name (auto-generated if omitted)')
415
+ .option(TASK_OPTION_FLAG, 'Task name (auto-generated if omitted)')
416
+ .option('-e, --endpoint <name>', 'Endpoint preset (defaults to the profile\'s default)')
359
417
  .option('-u, --url <url>', 'Open URL in first tab')
360
418
  .action(async (opts) => {
419
+ // Pre-check the profile locally so we fail fast with a helpful error
420
+ // instead of round-tripping a generic "Profile not found" through the daemon.
421
+ const profile = await getProfile(opts.profile);
422
+ if (!profile) {
423
+ console.error(`Profile "${opts.profile}" not found.`);
424
+ const all = await listProfiles();
425
+ if (all.length > 0) {
426
+ console.error(`Available profiles: ${all.map((p) => p.name).join(', ')}`);
427
+ }
428
+ console.error(`Create one with: agents browser profiles create ${opts.profile} --browser <chrome|comet|chromium|brave|edge|custom>`);
429
+ process.exit(1);
430
+ }
431
+ // Pre-check the endpoint name too — same fail-fast rationale.
432
+ if (opts.endpoint) {
433
+ const presets = getEndpointPresets(profile);
434
+ if (!presets[opts.endpoint]) {
435
+ console.error(`Endpoint "${opts.endpoint}" not found on profile "${opts.profile}". ` +
436
+ `Available: ${Object.keys(presets).join(', ')}`);
437
+ process.exit(1);
438
+ }
439
+ }
361
440
  const response = await sendIPCRequest({
362
441
  action: 'start',
363
442
  profile: opts.profile,
364
443
  taskName: opts.task,
365
444
  url: opts.url,
445
+ endpoint: opts.endpoint,
366
446
  });
367
447
  if (!response.ok) {
368
448
  console.error(response.error);
369
449
  process.exit(1);
370
450
  }
451
+ // stdout: just the resolved name, one line, no decoration. Lets callers do:
452
+ // export AGENTS_BROWSER_TASK=$(agents browser start --profile work)
453
+ console.log(response.task);
454
+ // stderr: human-friendly commentary so a TTY user still sees what happened.
455
+ // Shell substitution captures stdout only, so $(...) stays clean.
371
456
  if (opts.url && response.tabId) {
372
- console.log(`Started task "${response.task}" with tab ${response.tabId}`);
457
+ console.error(`Started task "${response.task}" with tab ${response.tabId}`);
373
458
  }
374
459
  else {
375
- console.log(`Started task "${response.task}"`);
460
+ console.error(`Started task "${response.task}"`);
376
461
  }
462
+ console.error(`Tip: export AGENTS_BROWSER_TASK=${response.task}`);
463
+ console.error('Try: agents browser screenshot | agents browser console --level error');
377
464
  });
378
465
  browser
379
- .command('done <task>')
466
+ .command('done')
467
+ .addArgument(hiddenLegacyArg('legacyTask'))
380
468
  .description('Complete a task and close its tabs')
381
- .action(async (task) => {
469
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
470
+ .action(async (legacyTask, opts) => {
471
+ const { task } = unpackPositionals([legacyTask], 0, opts);
382
472
  const response = await sendIPCRequest({
383
473
  action: 'done',
384
474
  task,
@@ -390,9 +480,12 @@ function registerTaskCommands(browser) {
390
480
  console.log(`Completed task: ${task}`);
391
481
  });
392
482
  browser
393
- .command('stop <task>')
483
+ .command('stop')
484
+ .addArgument(hiddenLegacyArg('legacyTask'))
394
485
  .description('Stop a browser task and close its tabs')
395
- .action(async (task) => {
486
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
487
+ .action(async (legacyTask, opts) => {
488
+ const { task } = unpackPositionals([legacyTask], 0, opts);
396
489
  const response = await sendIPCRequest({
397
490
  action: 'stop',
398
491
  task,
@@ -404,10 +497,14 @@ function registerTaskCommands(browser) {
404
497
  console.log(`Stopped task: ${task}`);
405
498
  });
406
499
  browser
407
- .command('navigate <task> <url>')
500
+ .command('navigate <url>')
501
+ .addArgument(hiddenLegacyArg('legacyUrl'))
408
502
  .description('Navigate current tab to URL (creates tab if none exist)')
503
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
409
504
  .option('-p, --profile <name>', 'Browser profile (optional if task is unique)')
410
- .action(async (task, url, opts) => {
505
+ .action(async (arg1, arg2, opts) => {
506
+ const { task, positionals } = unpackPositionals([arg1, arg2], 1, opts);
507
+ const [url] = positionals;
411
508
  const response = await sendIPCRequest({
412
509
  action: 'navigate',
413
510
  task,
@@ -423,10 +520,14 @@ function registerTaskCommands(browser) {
423
520
  // Tab subcommand group
424
521
  const tab = browser.command('tab').description('Manage tabs');
425
522
  tab
426
- .command('add <task> <url>')
523
+ .command('add <url>')
524
+ .addArgument(hiddenLegacyArg('legacyUrl'))
427
525
  .description('Open URL in new tab (becomes current)')
526
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
428
527
  .option('-p, --profile <name>', 'Browser profile')
429
- .action(async (task, url, opts) => {
528
+ .action(async (arg1, arg2, opts) => {
529
+ const { task, positionals } = unpackPositionals([arg1, arg2], 1, opts);
530
+ const [url] = positionals;
430
531
  const response = await sendIPCRequest({
431
532
  action: 'tab-add',
432
533
  task,
@@ -440,9 +541,13 @@ function registerTaskCommands(browser) {
440
541
  console.log(`Opened tab ${response.tabId}: ${url}`);
441
542
  });
442
543
  tab
443
- .command('focus <task> <tabId>')
544
+ .command('focus <tabId>')
545
+ .addArgument(hiddenLegacyArg('legacyTabId'))
444
546
  .description('Switch to tab (by ID, prefix, or URL substring)')
445
- .action(async (task, tabId) => {
547
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
548
+ .action(async (arg1, arg2, opts) => {
549
+ const { task, positionals } = unpackPositionals([arg1, arg2], 1, opts);
550
+ const [tabId] = positionals;
446
551
  const response = await sendIPCRequest({
447
552
  action: 'tab-focus',
448
553
  task,
@@ -455,9 +560,11 @@ function registerTaskCommands(browser) {
455
560
  console.log(`Focused tab ${response.tabId}`);
456
561
  });
457
562
  tab
458
- .command('close <task> [tabId]')
563
+ .command('close [tabId]')
459
564
  .description('Close tab(s) — omit tabId to close all')
460
- .action(async (task, tabId) => {
565
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
566
+ .action(async (tabId, opts) => {
567
+ const task = resolveTaskName(opts);
461
568
  const response = await sendIPCRequest({
462
569
  action: 'tab-close',
463
570
  task,
@@ -469,11 +576,13 @@ function registerTaskCommands(browser) {
469
576
  }
470
577
  console.log(tabId ? `Closed tab ${tabId}` : `Closed all tabs for ${task}`);
471
578
  });
472
- tab
473
- .command('list <task>')
474
- .description('List tabs for a task')
579
+ browser
580
+ .command('tabs')
581
+ .description('List tabs open for the current task')
582
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
475
583
  .option('--json', 'Output machine-readable JSON')
476
- .action(async (task, opts) => {
584
+ .action(async (opts) => {
585
+ const task = resolveTaskName(opts);
477
586
  const response = await sendIPCRequest({
478
587
  action: 'tab-list',
479
588
  task,
@@ -503,27 +612,45 @@ function registerTaskCommands(browser) {
503
612
  }
504
613
  });
505
614
  browser
506
- .command('screenshot <task> [tabId]')
507
- .description('Take a screenshot')
508
- .option('-o, --output <path>', 'Output path')
509
- .action(async (task, tabId, opts) => {
615
+ .command('screenshot [tabId]')
616
+ .description('Take a screenshot — auto-saved per task; --output only needed when you want a specific path')
617
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
618
+ .option('-o, --output <path>', 'Specific output path (otherwise auto-saved under sessions/<task>/)')
619
+ .option('-q, --quality <mode>', 'compressed (JPEG, capped at ~100 KB — default) or raw (PNG, pixel-faithful)', 'compressed')
620
+ .action(async (tabId, opts) => {
621
+ const task = resolveTaskName(opts);
622
+ if (opts.quality !== 'compressed' && opts.quality !== 'raw') {
623
+ console.error('--quality must be "compressed" or "raw"');
624
+ process.exit(1);
625
+ }
510
626
  const response = await sendIPCRequest({
511
627
  action: 'screenshot',
512
628
  task,
513
629
  tabId,
514
630
  path: opts.output,
631
+ quality: opts.quality,
515
632
  });
516
633
  if (!response.ok) {
517
634
  console.error(response.error);
518
635
  process.exit(1);
519
636
  }
637
+ // stdout: just the path, so `P=$(agents browser screenshot)` works.
520
638
  console.log(response.path);
639
+ // stderr: human commentary with size + dimensions, so an agent can
640
+ // see at a glance what was captured without `ls -l && file` round-trips.
641
+ const size = humanizeBytes(response.bytes);
642
+ const dims = response.width && response.height ? `${response.width}×${response.height}` : 'unknown size';
643
+ console.error(`Saved screenshot to ${response.path} (${size}, ${dims})`);
521
644
  });
522
645
  browser
523
- .command('evaluate <task> <expression>')
646
+ .command('evaluate <expression>')
647
+ .addArgument(hiddenLegacyArg('legacyExpression'))
524
648
  .description('Evaluate JavaScript in current tab')
649
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
525
650
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
526
- .action(async (task, expression, opts) => {
651
+ .action(async (arg1, arg2, opts) => {
652
+ const { task, positionals } = unpackPositionals([arg1, arg2], 1, opts);
653
+ const [expression] = positionals;
527
654
  const response = await sendIPCRequest({
528
655
  action: 'evaluate',
529
656
  task,
@@ -749,13 +876,16 @@ function registerTaskCommands(browser) {
749
876
  }
750
877
  });
751
878
  browser
752
- .command('refs <task>')
879
+ .command('refs')
880
+ .addArgument(hiddenLegacyArg('legacyTask'))
753
881
  .description('Get DOM refs for interactive elements')
882
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
754
883
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
755
884
  .option('--all', 'Include non-interactive elements')
756
885
  .option('-l, --limit <n>', 'Max elements (default 500)', '500')
757
886
  .option('--json', 'Output machine-readable JSON')
758
- .action(async (task, opts) => {
887
+ .action(async (legacyTask, opts) => {
888
+ const { task } = unpackPositionals([legacyTask], 0, opts);
759
889
  const response = await sendIPCRequest({
760
890
  action: 'refs',
761
891
  task,
@@ -779,10 +909,14 @@ function registerTaskCommands(browser) {
779
909
  console.log(response.refs);
780
910
  });
781
911
  browser
782
- .command('click <task> <ref>')
912
+ .command('click <ref>')
913
+ .addArgument(hiddenLegacyArg('legacyRef'))
783
914
  .description('Click an element by ref')
915
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
784
916
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
785
- .action(async (task, ref, opts) => {
917
+ .action(async (arg1, arg2, opts) => {
918
+ const { task, positionals } = unpackPositionals([arg1, arg2], 1, opts);
919
+ const [ref] = positionals;
786
920
  const response = await sendIPCRequest({
787
921
  action: 'click',
788
922
  task,
@@ -796,11 +930,15 @@ function registerTaskCommands(browser) {
796
930
  console.log('Clicked');
797
931
  });
798
932
  browser
799
- .command('type <task> <ref> <text>')
933
+ .command('type <ref> <text>')
934
+ .addArgument(hiddenLegacyArg('legacyText'))
800
935
  .description('Type text into an element by ref')
936
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
801
937
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
802
938
  .option('--clear', 'Clear editor content before typing')
803
- .action(async (task, ref, text, opts) => {
939
+ .action(async (arg1, arg2, arg3, opts) => {
940
+ const { task, positionals } = unpackPositionals([arg1, arg2, arg3], 2, opts);
941
+ const [ref, text] = positionals;
804
942
  const response = await sendIPCRequest({
805
943
  action: 'type',
806
944
  task,
@@ -816,10 +954,14 @@ function registerTaskCommands(browser) {
816
954
  console.log('Typed');
817
955
  });
818
956
  browser
819
- .command('press <task> <key>')
957
+ .command('press <key>')
958
+ .addArgument(hiddenLegacyArg('legacyKey'))
820
959
  .description('Press a key (Enter, Tab, Escape, etc)')
960
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
821
961
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
822
- .action(async (task, key, opts) => {
962
+ .action(async (arg1, arg2, opts) => {
963
+ const { task, positionals } = unpackPositionals([arg1, arg2], 1, opts);
964
+ const [key] = positionals;
823
965
  const response = await sendIPCRequest({
824
966
  action: 'press',
825
967
  task,
@@ -833,10 +975,14 @@ function registerTaskCommands(browser) {
833
975
  console.log('Pressed');
834
976
  });
835
977
  browser
836
- .command('hover <task> <ref>')
978
+ .command('hover <ref>')
979
+ .addArgument(hiddenLegacyArg('legacyRef'))
837
980
  .description('Hover over an element by ref')
981
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
838
982
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
839
- .action(async (task, ref, opts) => {
983
+ .action(async (arg1, arg2, opts) => {
984
+ const { task, positionals } = unpackPositionals([arg1, arg2], 1, opts);
985
+ const [ref] = positionals;
840
986
  const response = await sendIPCRequest({
841
987
  action: 'hover',
842
988
  task,
@@ -850,12 +996,16 @@ function registerTaskCommands(browser) {
850
996
  console.log('Hovered');
851
997
  });
852
998
  browser
853
- .command('scroll <task> <deltaX> <deltaY>')
999
+ .command('scroll <deltaX> <deltaY>')
1000
+ .addArgument(hiddenLegacyArg('legacyDeltaY'))
854
1001
  .description('Scroll the page by pixel amount')
1002
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
855
1003
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
856
1004
  .option('-x, --at-x <x>', 'X coordinate to dispatch scroll from (default 0)', parseInt)
857
1005
  .option('-y, --at-y <y>', 'Y coordinate to dispatch scroll from (default 0)', parseInt)
858
- .action(async (task, deltaX, deltaY, opts) => {
1006
+ .action(async (arg1, arg2, arg3, opts) => {
1007
+ const { task, positionals } = unpackPositionals([arg1, arg2, arg3], 2, opts);
1008
+ const [deltaX, deltaY] = positionals;
859
1009
  const response = await sendIPCRequest({
860
1010
  action: 'scroll',
861
1011
  task,
@@ -872,8 +1022,10 @@ function registerTaskCommands(browser) {
872
1022
  console.log('Scrolled');
873
1023
  });
874
1024
  browser
875
- .command('upload <task>')
1025
+ .command('upload')
1026
+ .addArgument(hiddenLegacyArg('legacyTask'))
876
1027
  .description('Upload file(s) — supports hidden file inputs, drag-drop targets, and OS chooser interception')
1028
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
877
1029
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
878
1030
  .option('-r, --ref <n>', 'Ref of the upload target element (file input or drop zone)', (v) => parseInt(v, 10))
879
1031
  .option('--trigger <n>', 'Ref of a button that opens the OS file chooser (Pattern C)', (v) => parseInt(v, 10))
@@ -881,7 +1033,8 @@ function registerTaskCommands(browser) {
881
1033
  .option('--drop', 'Force drag-drop pattern even if ref is an <input type=file>')
882
1034
  .option('--input', 'Force file-input pattern (DOM.setFileInputFiles)')
883
1035
  .option('--timeout <ms>', 'Timeout for chooser interception (Pattern C)', (v) => parseInt(v, 10))
884
- .action(async (task, opts) => {
1036
+ .action(async (legacyTask, opts) => {
1037
+ const { task } = unpackPositionals([legacyTask], 0, opts);
885
1038
  const files = opts.file ?? [];
886
1039
  if (files.length === 0) {
887
1040
  console.error('--file <path> is required (repeat for multiple files)');
@@ -921,12 +1074,16 @@ function registerTaskCommands(browser) {
921
1074
  // ─── Viewport & Device ───────────────────────────────────────────────────────
922
1075
  const setCmd = browser.command('set').description('Set browser emulation options');
923
1076
  setCmd
924
- .command('viewport <task> <width> <height>')
1077
+ .command('viewport <width> <height>')
1078
+ .addArgument(hiddenLegacyArg('legacyHeight'))
925
1079
  .description('Set viewport size')
1080
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
926
1081
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
927
1082
  .option('-m, --mobile', 'Enable mobile emulation')
928
1083
  .option('-s, --scale <factor>', 'Device scale factor', parseFloat)
929
- .action(async (task, width, height, opts) => {
1084
+ .action(async (arg1, arg2, arg3, opts) => {
1085
+ const { task, positionals } = unpackPositionals([arg1, arg2, arg3], 2, opts);
1086
+ const [width, height] = positionals;
930
1087
  const response = await sendIPCRequest({
931
1088
  action: 'set-viewport',
932
1089
  task,
@@ -943,10 +1100,14 @@ function registerTaskCommands(browser) {
943
1100
  console.log(`Viewport set to ${width}x${height}${opts.mobile ? ' (mobile)' : ''}`);
944
1101
  });
945
1102
  setCmd
946
- .command('device <task> <device-name>')
1103
+ .command('device <device-name>')
1104
+ .addArgument(hiddenLegacyArg('legacyDeviceName'))
947
1105
  .description('Emulate a device (iPhone 14, iPad, MacBook Pro)')
1106
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
948
1107
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
949
- .action(async (task, deviceName, opts) => {
1108
+ .action(async (arg1, arg2, opts) => {
1109
+ const { task, positionals } = unpackPositionals([arg1, arg2], 1, opts);
1110
+ const [deviceName] = positionals;
950
1111
  const response = await sendIPCRequest({
951
1112
  action: 'set-device',
952
1113
  task,
@@ -971,12 +1132,15 @@ function registerTaskCommands(browser) {
971
1132
  });
972
1133
  // ─── Console & Errors ────────────────────────────────────────────────────────
973
1134
  browser
974
- .command('console <task>')
1135
+ .command('console')
1136
+ .addArgument(hiddenLegacyArg('legacyTask'))
975
1137
  .description('Read console logs from a tab')
1138
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
976
1139
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
977
1140
  .option('-l, --level <level>', 'Filter by level (log, info, warn, error)')
978
1141
  .option('--clear', 'Clear logs after reading')
979
- .action(async (task, opts) => {
1142
+ .action(async (legacyTask, opts) => {
1143
+ const { task } = unpackPositionals([legacyTask], 0, opts);
980
1144
  const response = await sendIPCRequest({
981
1145
  action: 'console',
982
1146
  task,
@@ -999,11 +1163,14 @@ function registerTaskCommands(browser) {
999
1163
  }
1000
1164
  });
1001
1165
  browser
1002
- .command('errors <task>')
1166
+ .command('errors')
1167
+ .addArgument(hiddenLegacyArg('legacyTask'))
1003
1168
  .description('Read page errors from a tab')
1169
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1004
1170
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1005
1171
  .option('--clear', 'Clear errors after reading')
1006
- .action(async (task, opts) => {
1172
+ .action(async (legacyTask, opts) => {
1173
+ const { task } = unpackPositionals([legacyTask], 0, opts);
1007
1174
  const response = await sendIPCRequest({
1008
1175
  action: 'errors',
1009
1176
  task,
@@ -1029,12 +1196,15 @@ function registerTaskCommands(browser) {
1029
1196
  });
1030
1197
  // ─── Network ─────────────────────────────────────────────────────────────────
1031
1198
  browser
1032
- .command('requests <task>')
1199
+ .command('requests')
1200
+ .addArgument(hiddenLegacyArg('legacyTask'))
1033
1201
  .description('Read captured network requests')
1202
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1034
1203
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1035
1204
  .option('-f, --filter <text>', 'Filter URLs containing text')
1036
1205
  .option('--clear', 'Clear requests after reading')
1037
- .action(async (task, opts) => {
1206
+ .action(async (legacyTask, opts) => {
1207
+ const { task } = unpackPositionals([legacyTask], 0, opts);
1038
1208
  const response = await sendIPCRequest({
1039
1209
  action: 'requests',
1040
1210
  task,
@@ -1058,12 +1228,16 @@ function registerTaskCommands(browser) {
1058
1228
  }
1059
1229
  });
1060
1230
  browser
1061
- .command('responsebody <task> <url-pattern>')
1231
+ .command('responsebody <url-pattern>')
1232
+ .addArgument(hiddenLegacyArg('legacyUrlPattern'))
1062
1233
  .description('Wait for and read a response body by URL pattern')
1234
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1063
1235
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1064
1236
  .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
1065
1237
  .option('--max-chars <n>', 'Max characters to return', parseInt)
1066
- .action(async (task, urlPattern, opts) => {
1238
+ .action(async (arg1, arg2, opts) => {
1239
+ const { task, positionals } = unpackPositionals([arg1, arg2], 1, opts);
1240
+ const [urlPattern] = positionals;
1067
1241
  const response = await sendIPCRequest({
1068
1242
  action: 'response-body',
1069
1243
  task,
@@ -1080,8 +1254,10 @@ function registerTaskCommands(browser) {
1080
1254
  });
1081
1255
  // ─── Wait ────────────────────────────────────────────────────────────────────
1082
1256
  browser
1083
- .command('wait <task>')
1257
+ .command('wait')
1258
+ .addArgument(hiddenLegacyArg('legacyTask'))
1084
1259
  .description('Wait for a condition')
1260
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1085
1261
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1086
1262
  .option('--time <ms>', 'Wait for milliseconds')
1087
1263
  .option('--selector <css>', 'Wait for CSS selector to appear')
@@ -1089,7 +1265,8 @@ function registerTaskCommands(browser) {
1089
1265
  .option('--fn <js>', 'Wait for JS expression to return truthy')
1090
1266
  .option('--state <state>', 'Wait for load state (domcontentloaded, load, networkidle)')
1091
1267
  .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
1092
- .action(async (task, opts) => {
1268
+ .action(async (legacyTask, opts) => {
1269
+ const { task } = unpackPositionals([legacyTask], 0, opts);
1093
1270
  let waitType;
1094
1271
  let waitValue;
1095
1272
  if (opts.time) {
@@ -1132,11 +1309,14 @@ function registerTaskCommands(browser) {
1132
1309
  });
1133
1310
  // ─── Downloads ───────────────────────────────────────────────────────────────
1134
1311
  browser
1135
- .command('download <task>')
1312
+ .command('download')
1313
+ .addArgument(hiddenLegacyArg('legacyTask'))
1136
1314
  .description('Set download directory for a task')
1315
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1137
1316
  .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1138
1317
  .requiredOption('-p, --path <dir>', 'Download directory path')
1139
- .action(async (task, opts) => {
1318
+ .action(async (legacyTask, opts) => {
1319
+ const { task } = unpackPositionals([legacyTask], 0, opts);
1140
1320
  const response = await sendIPCRequest({
1141
1321
  action: 'set-download-path',
1142
1322
  task,
@@ -1149,11 +1329,59 @@ function registerTaskCommands(browser) {
1149
1329
  }
1150
1330
  console.log(`Download path set to ${opts.path}`);
1151
1331
  });
1332
+ // ─── Recording ─────────────────────────────────────────────────────────────
1333
+ const record = browser.command('record').description('Record a video of the page');
1334
+ record
1335
+ .command('start')
1336
+ .description('Start recording — auto-saved under sessions/<task>/recordings/. Bounded by --fps, --duration, --max-mb.')
1337
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1338
+ .option('-t, --tab <tabId>', 'Tab ID (defaults to current)')
1339
+ .option('--fps <n>', 'Frames per second (1–30, default 5)', (v) => parseInt(v, 10))
1340
+ .option('--duration <sec>', 'Hard duration cap in seconds (default 60)', (v) => parseInt(v, 10))
1341
+ .option('--max-mb <mb>', 'Stop when output exceeds this many MB (default 25)', (v) => parseInt(v, 10))
1342
+ .action(async (opts) => {
1343
+ const task = resolveTaskName(opts);
1344
+ const response = await sendIPCRequest({
1345
+ action: 'record-start',
1346
+ task,
1347
+ tabId: opts.tab,
1348
+ fps: opts.fps,
1349
+ duration: opts.duration,
1350
+ maxMb: opts.maxMb,
1351
+ });
1352
+ if (!response.ok) {
1353
+ console.error(response.error);
1354
+ process.exit(1);
1355
+ }
1356
+ // stdout: path (for capture into a variable). stderr: human commentary.
1357
+ console.log(response.path);
1358
+ console.error(`Recording task "${task}" at ${response.fps} fps (cap ${response.durationCapSec}s / ${response.maxMb} MB) → ${response.path}`);
1359
+ console.error('Stop with: agents browser record stop');
1360
+ });
1361
+ record
1362
+ .command('stop')
1363
+ .description('Stop an in-progress recording')
1364
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1365
+ .action(async (opts) => {
1366
+ const task = resolveTaskName(opts);
1367
+ const response = await sendIPCRequest({ action: 'record-stop', task });
1368
+ if (!response.ok) {
1369
+ console.error(response.error);
1370
+ process.exit(1);
1371
+ }
1372
+ console.log(response.path);
1373
+ const size = humanizeBytes(response.bytes);
1374
+ const seconds = ((response.durationMs ?? 0) / 1000).toFixed(1);
1375
+ console.error(`Saved recording to ${response.path} (${size}, ${seconds}s, stopped: ${response.stopReason})`);
1376
+ });
1152
1377
  browser
1153
- .command('waitdownload <task>')
1378
+ .command('waitdownload')
1379
+ .addArgument(hiddenLegacyArg('legacyTask'))
1154
1380
  .description('Wait for a download to complete')
1381
+ .option(TASK_OPTION_FLAG, TASK_OPTION_DESC)
1155
1382
  .option('--timeout <ms>', 'Timeout in milliseconds', parseInt)
1156
- .action(async (task, opts) => {
1383
+ .action(async (legacyTask, opts) => {
1384
+ const { task } = unpackPositionals([legacyTask], 0, opts);
1157
1385
  const response = await sendIPCRequest({
1158
1386
  action: 'wait-download',
1159
1387
  task,
@@ -1180,6 +1408,15 @@ function formatAge(timestamp) {
1180
1408
  const hours = Math.floor(minutes / 60);
1181
1409
  return `${hours}h ago`;
1182
1410
  }
1411
+ function humanizeBytes(n) {
1412
+ if (n === undefined)
1413
+ return 'unknown size';
1414
+ if (n < 1024)
1415
+ return `${n} B`;
1416
+ if (n < 1024 * 1024)
1417
+ return `${(n / 1024).toFixed(0)} KB`;
1418
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
1419
+ }
1183
1420
  function formatDuration(ms) {
1184
1421
  const seconds = Math.floor(ms / 1000);
1185
1422
  if (seconds < 60)