@phnx-labs/agents-cli 1.22.46 → 1.22.47

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 (167) hide show
  1. package/CHANGELOG.md +279 -0
  2. package/LICENSE +102 -182
  3. package/README.md +31 -18
  4. package/dist/cli/command-registry.d.ts +1 -1
  5. package/dist/cli/command-registry.js +4 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.js +7 -5
  8. package/dist/commands/browser-sessions-picker.js +9 -7
  9. package/dist/commands/browser.js +266 -15
  10. package/dist/commands/config.js +26 -4
  11. package/dist/commands/cost.d.ts +1 -1
  12. package/dist/commands/detach.d.ts +13 -0
  13. package/dist/commands/detach.js +11 -8
  14. package/dist/commands/exec.js +55 -38
  15. package/dist/commands/feed-watch.d.ts +2 -0
  16. package/dist/commands/feed-watch.js +28 -0
  17. package/dist/commands/feed.js +27 -0
  18. package/dist/commands/feedback.js +3 -19
  19. package/dist/commands/insights.d.ts +1 -1
  20. package/dist/commands/insights.js +2 -2
  21. package/dist/commands/lease.js +3 -3
  22. package/dist/commands/routines.js +1 -1
  23. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  24. package/dist/commands/sessions-picker-factory.js +1 -1
  25. package/dist/commands/sessions-stop.d.ts +24 -0
  26. package/dist/commands/sessions-stop.js +66 -0
  27. package/dist/commands/sessions-trace.d.ts +82 -0
  28. package/dist/commands/sessions-trace.js +387 -0
  29. package/dist/commands/sessions.js +4 -0
  30. package/dist/commands/ssh.d.ts +15 -0
  31. package/dist/commands/ssh.js +194 -16
  32. package/dist/commands/teams.js +34 -2
  33. package/dist/commands/view.d.ts +2 -0
  34. package/dist/commands/view.js +18 -31
  35. package/dist/commands/watchdog.js +9 -14
  36. package/dist/lib/account-capabilities.d.ts +4 -0
  37. package/dist/lib/account-capabilities.js +13 -9
  38. package/dist/lib/account-registry.d.ts +2 -0
  39. package/dist/lib/account-registry.js +30 -2
  40. package/dist/lib/accounting/rotate.d.ts +9 -0
  41. package/dist/lib/accounting/rotate.js +12 -2
  42. package/dist/lib/accounting/usage.d.ts +70 -3
  43. package/dist/lib/accounting/usage.js +235 -63
  44. package/dist/lib/agent-modes.js +29 -1
  45. package/dist/lib/agent-spec/agents.d.ts +4 -3
  46. package/dist/lib/agent-spec/agents.js +14 -6
  47. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  48. package/dist/lib/analytics/mix-commands.js +2 -2
  49. package/dist/lib/auth-health.d.ts +13 -3
  50. package/dist/lib/auth-health.js +50 -8
  51. package/dist/lib/browser/devices.d.ts +19 -0
  52. package/dist/lib/browser/devices.js +23 -0
  53. package/dist/lib/browser/ipc.d.ts +1 -1
  54. package/dist/lib/browser/ipc.js +33 -7
  55. package/dist/lib/browser/profiles.d.ts +106 -1
  56. package/dist/lib/browser/profiles.js +300 -17
  57. package/dist/lib/browser/remote-control.d.ts +26 -2
  58. package/dist/lib/browser/remote-control.js +33 -3
  59. package/dist/lib/browser/runtime-state.d.ts +9 -0
  60. package/dist/lib/browser/runtime-state.js +24 -8
  61. package/dist/lib/browser/service.d.ts +37 -0
  62. package/dist/lib/browser/service.js +78 -20
  63. package/dist/lib/browser/sessions-list.d.ts +1 -3
  64. package/dist/lib/browser/sessions-list.js +3 -16
  65. package/dist/lib/browser/types.d.ts +12 -1
  66. package/dist/lib/codex-policy.d.ts +11 -1
  67. package/dist/lib/codex-policy.js +20 -5
  68. package/dist/lib/config-keys.d.ts +4 -3
  69. package/dist/lib/config-keys.js +17 -7
  70. package/dist/lib/daemon/runner.js +15 -2
  71. package/dist/lib/daemon-ticks.js +2 -2
  72. package/dist/lib/device-config.d.ts +25 -8
  73. package/dist/lib/device-config.js +90 -17
  74. package/dist/lib/devices/config-migration.d.ts +7 -0
  75. package/dist/lib/devices/config-migration.js +67 -3
  76. package/dist/lib/devices/health.d.ts +27 -2
  77. package/dist/lib/devices/health.js +39 -9
  78. package/dist/lib/devices/interactive-host.d.ts +15 -0
  79. package/dist/lib/devices/interactive-host.js +53 -0
  80. package/dist/lib/devices/registry.d.ts +61 -4
  81. package/dist/lib/devices/registry.js +94 -38
  82. package/dist/lib/devices/stats-cache.d.ts +6 -0
  83. package/dist/lib/devices/stats-cache.js +10 -2
  84. package/dist/lib/exec.d.ts +5 -3
  85. package/dist/lib/exec.js +32 -16
  86. package/dist/lib/feed/answer.d.ts +29 -0
  87. package/dist/lib/feed/answer.js +145 -0
  88. package/dist/lib/feed/attention.d.ts +83 -0
  89. package/dist/lib/feed/attention.js +0 -0
  90. package/dist/lib/feed/feed.d.ts +109 -0
  91. package/dist/lib/feed/feed.js +154 -3
  92. package/dist/lib/feed/pr-status.d.ts +14 -0
  93. package/dist/lib/feed/pr-status.js +47 -0
  94. package/dist/lib/feed/watch.d.ts +59 -0
  95. package/dist/lib/feed/watch.js +180 -0
  96. package/dist/lib/fleet/capture.js +6 -0
  97. package/dist/lib/fleet/remote-login.js +8 -3
  98. package/dist/lib/fleet/types.d.ts +17 -0
  99. package/dist/lib/harness/adapter.d.ts +11 -0
  100. package/dist/lib/harness/adapters/claude.js +38 -24
  101. package/dist/lib/harness/adapters/codex.js +2 -4
  102. package/dist/lib/hooks/cache.js +8 -0
  103. package/dist/lib/hooks/match.d.ts +3 -0
  104. package/dist/lib/hooks/match.js +25 -0
  105. package/dist/lib/hosts/passthrough.js +16 -0
  106. package/dist/lib/hosts/registry.js +7 -0
  107. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  108. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  109. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  110. package/dist/lib/menubar/install-menubar.js +26 -2
  111. package/dist/lib/open-url.d.ts +60 -2
  112. package/dist/lib/open-url.js +167 -12
  113. package/dist/lib/refresh-coordinator.d.ts +1 -1
  114. package/dist/lib/refresh-coordinator.js +1 -1
  115. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  116. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  117. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  118. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  119. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  120. package/dist/lib/session/active.d.ts +67 -0
  121. package/dist/lib/session/active.js +69 -1
  122. package/dist/lib/session/db.js +4 -2
  123. package/dist/lib/session/discover.d.ts +2 -0
  124. package/dist/lib/session/discover.js +12 -6
  125. package/dist/lib/session/prompt.d.ts +35 -0
  126. package/dist/lib/session/prompt.js +56 -0
  127. package/dist/lib/session/remote/watch.d.ts +1 -1
  128. package/dist/lib/session/remote/watch.js +6 -1
  129. package/dist/lib/session/render.js +7 -3
  130. package/dist/lib/session/session-cache.js +5 -1
  131. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  132. package/dist/lib/session/trajectory-compare.js +116 -0
  133. package/dist/lib/session/trajectory-html.d.ts +15 -0
  134. package/dist/lib/session/trajectory-html.js +750 -0
  135. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  136. package/dist/lib/session/trajectory-lineage.js +195 -0
  137. package/dist/lib/session/trajectory-text.d.ts +37 -0
  138. package/dist/lib/session/trajectory-text.js +277 -0
  139. package/dist/lib/session/trajectory.d.ts +102 -0
  140. package/dist/lib/session/trajectory.js +335 -0
  141. package/dist/lib/startup/command-registry.d.ts +3 -1
  142. package/dist/lib/startup/command-registry.js +5 -2
  143. package/dist/lib/state.d.ts +1 -1
  144. package/dist/lib/state.js +4 -2
  145. package/dist/lib/teams/agents.js +9 -0
  146. package/dist/lib/terminal/inject.d.ts +19 -0
  147. package/dist/lib/terminal/inject.js +19 -9
  148. package/dist/lib/tmux/session.d.ts +46 -13
  149. package/dist/lib/tmux/session.js +173 -27
  150. package/dist/lib/types.d.ts +16 -0
  151. package/dist/lib/usage-backoff.d.ts +2 -1
  152. package/dist/lib/usage-backoff.js +60 -19
  153. package/dist/lib/usage-refresh.d.ts +17 -3
  154. package/dist/lib/usage-refresh.js +57 -11
  155. package/dist/lib/watchdog/log.d.ts +1 -1
  156. package/dist/lib/watchdog/log.js +1 -1
  157. package/dist/lib/watchdog/runner.d.ts +36 -45
  158. package/dist/lib/watchdog/runner.js +150 -119
  159. package/dist/lib/watchdog/service.d.ts +0 -1
  160. package/dist/lib/watchdog/service.js +0 -1
  161. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  162. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  163. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  164. package/dist/lib/watchdog/watchdog.js +37 -104
  165. package/package.json +2 -2
  166. package/dist/commands/usage.d.ts +0 -27
  167. package/dist/commands/usage.js +0 -96
@@ -1,14 +1,14 @@
1
1
  import chalk from 'chalk';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
- import { listProfiles, getProfile, createProfile, deleteProfile, getConfiguredDefaultProfileName, resolveProfileRef, resolveProfileRefForStart, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, listProfilesWithScope, formatProfilesTable, } from '../lib/browser/profiles.js';
4
+ import { listProfiles, getProfile, createProfile, deleteProfile, getConfiguredDefaultProfileName, resolveProfileRef, resolveProfileRefForStart, getProfileRuntimeDir, extractConfiguredPort, findFreeProfilePort, getEndpointPresets, listProfilesWithScope, formatProfilesTable, editProfile, moveProfileScope, renameProfile, assertRegistrableProfileName, misfiledFleetProfile, } from '../lib/browser/profiles.js';
5
5
  import { resolveActor } from '../lib/actor.js';
6
6
  import { loginsForProfile, profilesLoggedInto, serviceForUrl, loginsWithAccountsForProfile, accountsForProfile, credKeysForService, AUTH_SIGNATURES, } from '../lib/browser/login-detection.js';
7
7
  import { parseSecretRef } from '../lib/browser/secret-ref.js';
8
8
  import { readAndResolveBundleEnv, bundleExists, readBundle, describeBundle } from '../lib/secrets/bundles.js';
9
9
  import { findBrowserPath, getPortOccupant, isLauncherScript, listInstalledBrowsers } from '../lib/browser/chrome.js';
10
10
  import { listProfileCacheDirs, removeProfileCache, listAllProfileSnapshots, buildProfilePrunePlan, pruneProfiles, PRUNE_REASON_TEXT, } from '../lib/browser/runtime-state.js';
11
- import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
11
+ import { DEFAULT_VIEWPORT, parseWindowSize, parseWindowPosition } from '../lib/browser/devices.js';
12
12
  import { runBrowserSessionsCommand } from './browser-sessions-picker.js';
13
13
  import { discoverBrowserWsUrl, verifyBrowserIdentity } from '../lib/browser/cdp.js';
14
14
  import { parseTargetFilter } from '../lib/browser/service.js';
@@ -330,8 +330,11 @@ function registerProfilesCommands(browser) {
330
330
  .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')
331
331
  .option('--target-filter <expr>', 'Pick the visible CDP page target when the app exposes more than one. Format: url:<substring> or title:<substring>')
332
332
  .action(async (name, opts) => {
333
- if (!/^[a-z][a-z0-9-]*$/.test(name)) {
334
- console.error('Profile name must be lowercase alphanumeric with hyphens');
333
+ try {
334
+ assertRegistrableProfileName(name);
335
+ }
336
+ catch (err) {
337
+ console.error(err instanceof Error ? err.message : String(err));
335
338
  process.exit(1);
336
339
  }
337
340
  if (!VALID_BROWSERS.includes(opts.browser)) {
@@ -370,22 +373,22 @@ function registerProfilesCommands(browser) {
370
373
  height: DEFAULT_VIEWPORT.height,
371
374
  };
372
375
  if (opts.window) {
373
- const m = String(opts.window).match(/^(\d+)x(\d+)$/);
374
- if (!m) {
376
+ const size = parseWindowSize(String(opts.window));
377
+ if (!size) {
375
378
  console.error(`--window must be WxH, e.g. ${DEFAULT_VIEWPORT.width}x${DEFAULT_VIEWPORT.height}`);
376
379
  process.exit(1);
377
380
  }
378
- viewport.width = parseInt(m[1], 10);
379
- viewport.height = parseInt(m[2], 10);
381
+ viewport.width = size.width;
382
+ viewport.height = size.height;
380
383
  }
381
384
  if (opts.position) {
382
- const m = String(opts.position).match(/^(-?\d+),(-?\d+)$/);
383
- if (!m) {
385
+ const pos = parseWindowPosition(String(opts.position));
386
+ if (!pos) {
384
387
  console.error('--position must be X,Y, e.g. 80,80');
385
388
  process.exit(1);
386
389
  }
387
- viewport.x = parseInt(m[1], 10);
388
- viewport.y = parseInt(m[2], 10);
390
+ viewport.x = pos.x;
391
+ viewport.y = pos.y;
389
392
  }
390
393
  const profile = {
391
394
  name,
@@ -407,6 +410,193 @@ function registerProfilesCommands(browser) {
407
410
  console.error(`warning: secrets bundle "${opts.secrets}" does not exist yet. Create it with: agents secrets create ${opts.secrets}`);
408
411
  }
409
412
  });
413
+ profiles
414
+ .command('edit <name>')
415
+ .description('Edit an existing profile in place (stays in the store it already lives in)')
416
+ // Declared only so the action can explain WHY the browser type is not
417
+ // editable. Without it commander rejects `-b` as an unknown option before
418
+ // the action runs, and the delete-and-recreate guidance never reaches anyone.
419
+ .option('-b, --browser <type>', 'Not editable — see the error for the delete-and-recreate path')
420
+ .option('-d, --description <text>', "Profile description (pass '' to clear)")
421
+ .option('-e, --endpoint <url>', 'Replace the endpoint list (repeatable)', collect, [])
422
+ .option('-s, --secrets <bundle>', 'Secrets bundle to inject')
423
+ .option('--headless', 'Run in headless mode')
424
+ .option('--no-headless', 'Run headed')
425
+ .option('--window <WxH>', 'Window size in CSS pixels')
426
+ .option('--position <X,Y>', 'Window position on screen, e.g. 80,80')
427
+ .option('--binary <path>', 'Absolute path to the browser/app binary')
428
+ .option('--electron', 'Treat this profile as an Electron desktop app')
429
+ .option('--no-electron', 'Stop treating it as an Electron app')
430
+ .option('--target-filter <expr>', "url:<substring> or title:<substring>; requires --electron (pass '' to clear)")
431
+ .option('--json', 'Output machine-readable JSON')
432
+ .action(async (name, opts) => {
433
+ // The browser type and the name are identity, not settings: both key the
434
+ // on-disk runtime dir and any live `<name>@<endpoint>` connection, so
435
+ // changing either orphans the cached browser data (and its logins).
436
+ if (opts.browser) {
437
+ console.error('browser type is not editable — it keys the on-disk profile cache. ' +
438
+ `Delete and recreate instead: agents browser profiles delete ${name} && agents browser profiles create ${name} -b <type>`);
439
+ process.exit(1);
440
+ }
441
+ if (opts.targetFilter) {
442
+ const parsed = parseTargetFilter(String(opts.targetFilter));
443
+ if (!parsed) {
444
+ console.error('--target-filter must be url:<substring> or title:<substring> (non-empty value, no leading whitespace)');
445
+ process.exit(1);
446
+ }
447
+ }
448
+ const patch = {};
449
+ if (opts.description !== undefined)
450
+ patch.description = opts.description || undefined;
451
+ if (opts.endpoint && opts.endpoint.length > 0)
452
+ patch.endpoints = opts.endpoint;
453
+ if (opts.secrets !== undefined)
454
+ patch.secrets = opts.secrets;
455
+ if (opts.binary !== undefined)
456
+ patch.binary = opts.binary;
457
+ if (opts.targetFilter !== undefined)
458
+ patch.targetFilter = opts.targetFilter || undefined;
459
+ // commander maps --electron/--no-electron and --headless/--no-headless onto
460
+ // one boolean each, and leaves it undefined when neither was passed.
461
+ if (opts.electron !== undefined)
462
+ patch.electron = opts.electron || undefined;
463
+ if (opts.headless !== undefined || opts.window || opts.position) {
464
+ const current = await getProfile(name);
465
+ if (!current) {
466
+ console.error(`Profile "${name}" does not exist`);
467
+ process.exit(1);
468
+ }
469
+ if (opts.headless !== undefined) {
470
+ const chrome = { ...current.chrome };
471
+ if (opts.headless)
472
+ chrome.headless = true;
473
+ else
474
+ delete chrome.headless;
475
+ // `{ headless: undefined }` is truthy, so profileToConfig would persist
476
+ // a bare `chrome: {}` into the YAML and the change-detector would report
477
+ // a phantom edit on an already-headed profile. Drop the key entirely.
478
+ patch.chrome = Object.keys(chrome).length > 0 ? chrome : undefined;
479
+ }
480
+ if (opts.window || opts.position) {
481
+ const viewport = { ...(current.viewport ?? { width: DEFAULT_VIEWPORT.width, height: DEFAULT_VIEWPORT.height }) };
482
+ if (opts.window) {
483
+ const size = parseWindowSize(String(opts.window));
484
+ if (!size) {
485
+ console.error('--window must be WxH, e.g. 1512x982');
486
+ process.exit(1);
487
+ }
488
+ viewport.width = size.width;
489
+ viewport.height = size.height;
490
+ }
491
+ if (opts.position) {
492
+ const pos = parseWindowPosition(String(opts.position));
493
+ if (!pos) {
494
+ console.error('--position must be X,Y, e.g. 80,80');
495
+ process.exit(1);
496
+ }
497
+ viewport.x = pos.x;
498
+ viewport.y = pos.y;
499
+ }
500
+ patch.viewport = viewport;
501
+ }
502
+ }
503
+ if (Object.keys(patch).length === 0) {
504
+ console.error('Nothing to edit. Pass at least one field, e.g. -d "<description>".');
505
+ process.exit(1);
506
+ }
507
+ let result;
508
+ try {
509
+ result = await editProfile(name, patch);
510
+ }
511
+ catch (err) {
512
+ console.error(err instanceof Error ? err.message : String(err));
513
+ process.exit(1);
514
+ }
515
+ if (opts.json) {
516
+ console.log(JSON.stringify({ ...result.profile, scope: result.scope, changed: result.changed }, null, 2));
517
+ }
518
+ else if (result.changed.length === 0) {
519
+ console.log(`No change: ${name} already had those values.`);
520
+ }
521
+ else {
522
+ console.log(`Updated ${name} (${result.scope}): ${result.changed.join(', ')}`);
523
+ }
524
+ if (result.fleetCopyLeftStale) {
525
+ console.error(`warning: "${name}" is machine-local by rule, so the edit was written to this machine only. ` +
526
+ `The fleet-synced copy still holds the old values on every other box.`);
527
+ }
528
+ if (patch.secrets && !bundleExists(patch.secrets)) {
529
+ console.error(`warning: secrets bundle "${patch.secrets}" does not exist yet. Create it with: agents secrets create ${patch.secrets}`);
530
+ }
531
+ });
532
+ profiles
533
+ .command('rename <from> <to>')
534
+ .description("Rename a profile, moving its browser data with it (logins survive)")
535
+ .option('--json', 'Output machine-readable JSON')
536
+ .action(async (from, to, opts) => {
537
+ let res;
538
+ try {
539
+ res = await renameProfile(from, to);
540
+ }
541
+ catch (err) {
542
+ console.error(err instanceof Error ? err.message : String(err));
543
+ process.exit(1);
544
+ }
545
+ if (opts.json) {
546
+ console.log(JSON.stringify({ from, to, ...res }, null, 2));
547
+ return;
548
+ }
549
+ console.log(`Renamed ${from} -> ${to} (${res.scope})`);
550
+ if (res.movedDirs.length > 0) {
551
+ console.log(` moved ${res.movedDirs.length} browser data dir${res.movedDirs.length === 1 ? '' : 's'} — logins preserved`);
552
+ }
553
+ if (res.repointedDefault) {
554
+ console.log(` browser.profile now points at ${to}`);
555
+ }
556
+ if (res.repointedViewer) {
557
+ console.log(` browser.viewer now points at ${to}`);
558
+ }
559
+ if (res.stalePins.length > 0) {
560
+ const devices = [...new Set(res.stalePins.map((p) => p.device))];
561
+ const one = devices.length === 1;
562
+ console.error(`warning: ${devices.join(', ')} still ${one ? 'pins' : 'pin'} "${from}" in ` +
563
+ `${one ? 'its' : 'their'} own device config. Fix with:`);
564
+ for (const pin of res.stalePins) {
565
+ console.error(` agents config set ${pin.key} ${to} --device ${pin.device}`);
566
+ }
567
+ }
568
+ });
569
+ profiles
570
+ .command('scope <name> <scope>')
571
+ .description('Move a profile between the fleet-synced store and this machine (local|fleet)')
572
+ .option('--json', 'Output machine-readable JSON')
573
+ .action(async (name, scope, opts) => {
574
+ if (scope !== 'local' && scope !== 'fleet') {
575
+ console.error('scope must be `local` or `fleet`');
576
+ process.exit(1);
577
+ }
578
+ let moved;
579
+ try {
580
+ moved = await moveProfileScope(name, scope);
581
+ }
582
+ catch (err) {
583
+ console.error(err instanceof Error ? err.message : String(err));
584
+ process.exit(1);
585
+ }
586
+ if (opts.json) {
587
+ console.log(JSON.stringify({ profile: name, ...moved }, null, 2));
588
+ return;
589
+ }
590
+ if (moved.from === moved.to) {
591
+ console.log(`${name} is already ${scope}.`);
592
+ return;
593
+ }
594
+ console.log(`Moved ${name}: ${moved.from} -> ${moved.to}`);
595
+ if (scope === 'local') {
596
+ console.error(`note: ${name} is no longer visible on any other machine. ` +
597
+ `Run this ON the machine that owns the browser — the surviving copy is this one.`);
598
+ }
599
+ });
410
600
  profiles
411
601
  .command('show <name>')
412
602
  .description('Show profile details')
@@ -531,12 +721,29 @@ function registerProfilesCommands(browser) {
531
721
  console.log(JSON.stringify({ dryRun: !!opts.dryRun, ...plan }, null, 2));
532
722
  return;
533
723
  }
724
+ // A misfiled fleet profile is never a prune candidate (deleting a fleet
725
+ // entry deletes it everywhere), so without this it would only ever surface
726
+ // in --json. It is the one kept-reason a user has to act on.
727
+ const misfiled = plan.kept.filter((k) => k.misfiled);
728
+ const reportMisfiled = () => {
729
+ if (misfiled.length === 0)
730
+ return;
731
+ console.log('');
732
+ console.log(`${misfiled.length} fleet profile${misfiled.length === 1 ? '' : 's'} ${misfiled.length === 1 ? 'is' : 'are'} misfiled — ` +
733
+ `fleet-synced but bound to a local port, so the name means a different browser on every machine:`);
734
+ for (const k of misfiled) {
735
+ console.log(` ${k.name} — agents browser profiles scope ${k.name} local`);
736
+ }
737
+ console.log('Run that on the machine that owns each browser. Nothing was moved for you.');
738
+ console.log('(Prune never deletes these — removing a fleet entry removes it everywhere.)');
739
+ };
534
740
  if (plan.candidates.length === 0) {
535
741
  console.log('Nothing to prune — every profile is in use, healthy, or protected.');
536
742
  if (plan.kept.length > 0) {
537
743
  for (const k of plan.kept)
538
744
  console.log(` kept ${k.name} (${k.scope}) — ${k.why}`);
539
745
  }
746
+ reportMisfiled();
540
747
  return;
541
748
  }
542
749
  const verb = opts.dryRun ? 'Would remove' : 'Removed';
@@ -547,6 +754,7 @@ function registerProfilesCommands(browser) {
547
754
  : '';
548
755
  console.log(` ${c.name} (${c.scope}) — ${PRUNE_REASON_TEXT[c.reason]}${cache}`);
549
756
  }
757
+ reportMisfiled();
550
758
  if (opts.dryRun) {
551
759
  console.log('');
552
760
  console.log('Nothing was changed. Re-run without --dry-run to apply.');
@@ -594,6 +802,15 @@ function registerProfilesCommands(browser) {
594
802
  process.exit(1);
595
803
  }
596
804
  const checks = [];
805
+ // 0. Store scope. A fleet-synced profile whose endpoint is loopback cdp://
806
+ // means a DIFFERENT browser on every machine — the name lies everywhere
807
+ // but here. Checked first because it invalidates the reading of every
808
+ // check below on any other box.
809
+ const scoped = (await listProfilesWithScope()).find((p) => p.profile.name === name);
810
+ const misfiled = scoped ? misfiledFleetProfile(profile, scoped.scope) : { misfiled: false };
811
+ checks.push(misfiled.misfiled
812
+ ? { label: 'scope', ok: false, detail: misfiled.why }
813
+ : { label: 'scope', ok: true, detail: scoped ? `stored ${scoped.scope}` : 'stored local' });
597
814
  // 1. Binary exists for declared browser type, and is a real executable we
598
815
  // can drive — not a distro launcher script. findBrowserPath already
599
816
  // unwraps the known Chromium wrappers to their ELF; if it still hands
@@ -836,9 +1053,11 @@ function registerTaskCommands(browser) {
836
1053
  .option('--duration <sec>', 'Recording duration cap in seconds (with --record; default 60)', (v) => parseInt(v, 10))
837
1054
  .option('--max-mb <mb>', 'Recording size cap in MB (with --record; default 25)', (v) => parseInt(v, 10))
838
1055
  .action(async (opts) => {
839
- // Consent gate: a fleet-remote `browser --device <this-machine> start` may
840
- // only open a browser here if the owner opted in. Refuse before we resolve
841
- // or auto-create any profile. Local starts are never gated.
1056
+ // Fast-fail copy of the consent gate, so a refused start never resolves or
1057
+ // auto-creates a profile. The AUTHORITATIVE gate is in the daemon
1058
+ // (BrowserService.start / resolveOrCreateTask, via
1059
+ // assertRemoteControlAllowedForRequest) — it has to be, because the page
1060
+ // verbs create a browser implicitly and never reach this command.
842
1061
  try {
843
1062
  assertRemoteControlAllowed();
844
1063
  }
@@ -1053,6 +1272,38 @@ function registerTaskCommands(browser) {
1053
1272
  console.log(` ${c.task} (${c.profile}, ${c.reason})`);
1054
1273
  }
1055
1274
  });
1275
+ browser
1276
+ .command('show <url>')
1277
+ .description('Open a URL for a human to read: goes to browser.viewer (default: browser.profile), and binds no task')
1278
+ .option('--os-browser', 'Use the OS default handler instead of the configured viewer')
1279
+ .option('--json', 'Output machine-readable JSON')
1280
+ .action(async (url, opts) => {
1281
+ // The entry point external tools need. `navigate` binds a task, which the
1282
+ // abandoned-task reaper closes when the calling session ends — wrong for a
1283
+ // page a person is reading. This does not.
1284
+ const { showUrl, showFile } = await import('../lib/open-url.js');
1285
+ // `+` not `*` on the scheme body: `[a-z][a-z0-9+.-]*:` matches a Windows
1286
+ // drive letter (`C:\Users\me\plan.html`), so a real path was treated as a
1287
+ // URL. A scheme is at least two characters.
1288
+ const isLocalFile = !/^[a-z][a-z0-9+.-]+:/i.test(url);
1289
+ const outcome = isLocalFile
1290
+ ? await showFile(path.resolve(url), { osBrowser: opts.osBrowser })
1291
+ : await showUrl(url, { osBrowser: opts.osBrowser });
1292
+ if (opts.json) {
1293
+ console.log(JSON.stringify(outcome, null, 2));
1294
+ }
1295
+ else if (outcome.via === 'none') {
1296
+ console.error(`Could not open a browser — open this yourself:\n ${url}`);
1297
+ }
1298
+ else if (outcome.via === 'profile') {
1299
+ console.log(`Shown in ${outcome.profile}: ${url}`);
1300
+ }
1301
+ else {
1302
+ console.log(`Opened in the OS default browser: ${url}`);
1303
+ }
1304
+ if (outcome.via === 'none')
1305
+ process.exit(1);
1306
+ });
1056
1307
  browser
1057
1308
  .command('navigate [url]')
1058
1309
  .alias('goto')
@@ -72,6 +72,7 @@ function parseValue(key, parsed, raw) {
72
72
  case 'notes':
73
73
  return raw.trim();
74
74
  case 'browser.profile':
75
+ case 'browser.viewer':
75
76
  return raw.trim();
76
77
  }
77
78
  // A device property with no arm above used to fall out of the switch and
@@ -121,7 +122,7 @@ function setConfig(parsed, value) {
121
122
  // (same store `agents devices config` / getConfigValue use). Bare
122
123
  // browser.profile targets this machine; devices.<name>.browser.profile
123
124
  // targets a peer.
124
- setConfigValue('browser.profile', value, parsed.device ? { device: parsed.device } : undefined);
125
+ setConfigValue(parsed.property === 'viewer' ? 'browser.viewer' : 'browser.profile', value, parsed.device ? { device: parsed.device } : undefined);
125
126
  return;
126
127
  }
127
128
  case 'project':
@@ -167,8 +168,12 @@ function unsetConfig(parsed) {
167
168
  }
168
169
  case 'browser': {
169
170
  const target = parsed.device ? { device: parsed.device } : undefined;
170
- const had = getConfigValue('browser.profile', target).value !== undefined;
171
- unsetConfigValue('browser.profile', target);
171
+ // Must follow parsed.property. Hardcoding 'browser.profile' here meant
172
+ // `config unset browser.viewer` deleted the user's browser.profile while
173
+ // printing success, and left browserViewer in place.
174
+ const name = parsed.property === 'viewer' ? 'browser.viewer' : 'browser.profile';
175
+ const had = getConfigValue(name, target).value !== undefined;
176
+ unsetConfigValue(name, target);
172
177
  return had;
173
178
  }
174
179
  case 'project': {
@@ -204,7 +209,7 @@ function getConfig(parsed) {
204
209
  case 'auto':
205
210
  return getConfigValue('auto.pool').value;
206
211
  case 'browser': {
207
- return getConfigValue('browser.profile', parsed.device ? { device: parsed.device } : undefined).value;
212
+ return getConfigValue(parsed.property === 'viewer' ? 'browser.viewer' : 'browser.profile', parsed.device ? { device: parsed.device } : undefined).value;
208
213
  }
209
214
  case 'project':
210
215
  return getProjectRoot();
@@ -274,6 +279,11 @@ function* listCentralConfigEntries() {
274
279
  const key = 'browser.profile';
275
280
  yield { key, value: browserProfile, hint: configKeyStorageHint(parseConfigKey(key)) };
276
281
  }
282
+ const browserViewer = getConfigValue('browser.viewer').value;
283
+ if (browserViewer !== undefined) {
284
+ const key = 'browser.viewer';
285
+ yield { key, value: browserViewer, hint: configKeyStorageHint(parseConfigKey(key)) };
286
+ }
277
287
  }
278
288
  /** Collect device-scope config entries. */
279
289
  function* listDeviceConfigEntries(device) {
@@ -314,7 +324,19 @@ function* listDeviceConfigEntries(device) {
314
324
  continue;
315
325
  key = `${prefix}browser.profile`;
316
326
  break;
327
+ case 'browser.viewer':
328
+ // Same duplication rule as browser.profile above.
329
+ if (device === machineId())
330
+ continue;
331
+ key = `${prefix}browser.viewer`;
332
+ break;
317
333
  default:
334
+ // A `default: continue` here silently drops any device property with no
335
+ // arm — which is how browser.viewer was invisible to `config list` after
336
+ // being added everywhere else. This is the fourth switch enumerating
337
+ // DeviceConfigProperty; unlike parseValue's it cannot use a `never`
338
+ // binding (it must keep skipping properties that are deliberately not
339
+ // listed), so the completeness test in config.test.ts is the guard.
318
340
  continue;
319
341
  }
320
342
  yield { key, value: entry.value, hint: configKeyStorageHint(parseConfigKey(key)) };
@@ -5,7 +5,7 @@
5
5
  * This is the read side of issue #323. Cost and duration are computed and
6
6
  * persisted at scan time (see src/lib/session/discover.ts + db.ts); this
7
7
  * command only queries and renders them — a pure SQLite/CLI win, no server,
8
- * no telemetry. Distinct from `agents usage`, which reports live rate-limit /
8
+ * no telemetry. Distinct from `agents view`, which reports live rate-limit /
9
9
  * quota status per agent and is left untouched.
10
10
  */
11
11
  import type { Command } from 'commander';
@@ -1,5 +1,18 @@
1
1
  import type { Command } from 'commander';
2
+ import type { ActiveSession } from '../lib/session/active.js';
2
3
  export declare function registerDetachCommand(program: Command): void;
4
+ /**
5
+ * Stop a live agent's interactive process and tear down its mux. tmux-hosted
6
+ * sessions are killed by session (closes the pane, ends the agent, reaps its
7
+ * helper processes); a plain process is SIGTERM'd (then SIGKILLed) by pid. Either
8
+ * way we wait for the process to actually exit before returning.
9
+ *
10
+ * Shared by two callers: `detach` (stop the terminal so the headless resume it
11
+ * spawns next doesn't fight the old process over the same transcript) and `stop`
12
+ * (end the session outright — no resume). `socket` defaults to the shared agents
13
+ * socket; it is a parameter only so a test can drive an isolated tmux server.
14
+ */
15
+ export declare function stopInteractive(s: ActiveSession, socket?: string): Promise<void>;
3
16
  export declare function detachAction(id: string, opts?: {
4
17
  local?: boolean;
5
18
  }): Promise<void>;
@@ -34,17 +34,20 @@ export function registerDetachCommand(program) {
34
34
  });
35
35
  }
36
36
  /**
37
- * Stop the interactive process so it can't fight the headless resume over the
38
- * same transcript. tmux-hosted sessions are killed by session (closes the pane
39
- * and ends the agent); a plain process is SIGTERM'd by pid. Either way we then
40
- * wait for the process to actually exit before returning, so the headless
41
- * continuation never starts writing the transcript while the old process is
42
- * still flushing it.
37
+ * Stop a live agent's interactive process and tear down its mux. tmux-hosted
38
+ * sessions are killed by session (closes the pane, ends the agent, reaps its
39
+ * helper processes); a plain process is SIGTERM'd (then SIGKILLed) by pid. Either
40
+ * way we wait for the process to actually exit before returning.
41
+ *
42
+ * Shared by two callers: `detach` (stop the terminal so the headless resume it
43
+ * spawns next doesn't fight the old process over the same transcript) and `stop`
44
+ * (end the session outright — no resume). `socket` defaults to the shared agents
45
+ * socket; it is a parameter only so a test can drive an isolated tmux server.
43
46
  */
44
- async function stopInteractive(s) {
47
+ export async function stopInteractive(s, socket = getDefaultSocketPath()) {
45
48
  if (s.tmuxTarget) {
46
49
  const name = s.tmuxTarget.split(':')[0];
47
- await killSession(name, getDefaultSocketPath());
50
+ await killSession(name, socket);
48
51
  }
49
52
  else if (s.pid && s.pid > 0) {
50
53
  try {