@phnx-labs/agents-cli 1.22.26 → 1.22.27

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 (131) hide show
  1. package/CHANGELOG.md +244 -0
  2. package/README.md +18 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/bench.d.ts +2 -0
  5. package/dist/commands/bench.js +101 -0
  6. package/dist/commands/doctor.js +24 -10
  7. package/dist/commands/exec.js +17 -16
  8. package/dist/commands/insights.js +39 -5
  9. package/dist/commands/output.js +100 -25
  10. package/dist/commands/resume.d.ts +11 -0
  11. package/dist/commands/resume.js +51 -0
  12. package/dist/commands/sessions-picker.js +12 -7
  13. package/dist/commands/sessions.d.ts +47 -1
  14. package/dist/commands/sessions.js +345 -37
  15. package/dist/commands/ssh.js +184 -11
  16. package/dist/commands/teams.js +3 -3
  17. package/dist/index.js +16 -20
  18. package/dist/lib/auth-health.d.ts +47 -3
  19. package/dist/lib/auth-health.js +74 -15
  20. package/dist/lib/bench/index.d.ts +4 -0
  21. package/dist/lib/bench/index.js +4 -0
  22. package/dist/lib/bench/runner.d.ts +16 -0
  23. package/dist/lib/bench/runner.js +111 -0
  24. package/dist/lib/bench/schema.d.ts +5 -0
  25. package/dist/lib/bench/schema.js +91 -0
  26. package/dist/lib/bench/storage.d.ts +5 -0
  27. package/dist/lib/bench/storage.js +32 -0
  28. package/dist/lib/bench/types.d.ts +40 -0
  29. package/dist/lib/bench/types.js +1 -0
  30. package/dist/lib/claude-account-token.d.ts +2 -0
  31. package/dist/lib/claude-account-token.js +27 -1
  32. package/dist/lib/crabbox/cli.d.ts +2 -0
  33. package/dist/lib/crabbox/cli.js +2 -0
  34. package/dist/lib/crabbox/lease.js +7 -1
  35. package/dist/lib/daemon.js +20 -0
  36. package/dist/lib/devices/connect.d.ts +2 -0
  37. package/dist/lib/devices/connect.js +7 -0
  38. package/dist/lib/devices/doctor-findings.d.ts +4 -1
  39. package/dist/lib/devices/doctor-findings.js +15 -1
  40. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  41. package/dist/lib/devices/harness-inventory.js +0 -0
  42. package/dist/lib/devices/registry.d.ts +2 -0
  43. package/dist/lib/devices/ssh-config.js +3 -0
  44. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  45. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  46. package/dist/lib/exec.bench.d.ts +1 -0
  47. package/dist/lib/exec.bench.js +186 -0
  48. package/dist/lib/exec.js +18 -8
  49. package/dist/lib/fleet/apply.js +9 -7
  50. package/dist/lib/fleet/remote-login.d.ts +4 -3
  51. package/dist/lib/fleet/remote-login.js +11 -9
  52. package/dist/lib/gemini-settings.d.ts +0 -1
  53. package/dist/lib/gemini-settings.js +12 -7
  54. package/dist/lib/hooks.d.ts +8 -5
  55. package/dist/lib/hooks.js +10 -6
  56. package/dist/lib/hosts/dispatch.js +7 -3
  57. package/dist/lib/hosts/passthrough.d.ts +22 -0
  58. package/dist/lib/hosts/passthrough.js +14 -8
  59. package/dist/lib/hosts/progress.d.ts +2 -1
  60. package/dist/lib/hosts/progress.js +4 -3
  61. package/dist/lib/hosts/providers/devices.js +1 -0
  62. package/dist/lib/hosts/ready.d.ts +4 -2
  63. package/dist/lib/hosts/ready.js +24 -8
  64. package/dist/lib/hosts/reconcile.d.ts +1 -1
  65. package/dist/lib/hosts/reconcile.js +16 -7
  66. package/dist/lib/hosts/reconnect.d.ts +45 -12
  67. package/dist/lib/hosts/reconnect.js +90 -34
  68. package/dist/lib/hosts/registry.d.ts +2 -2
  69. package/dist/lib/hosts/registry.js +3 -5
  70. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  71. package/dist/lib/hosts/remote-cmd.js +29 -0
  72. package/dist/lib/hosts/tasks.d.ts +2 -0
  73. package/dist/lib/hosts/types.d.ts +1 -0
  74. package/dist/lib/hosts/types.js +3 -0
  75. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  76. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  77. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  78. package/dist/lib/menubar/install-menubar.js +20 -9
  79. package/dist/lib/models.d.ts +30 -1
  80. package/dist/lib/models.js +42 -144
  81. package/dist/lib/pricing/cost.d.ts +9 -0
  82. package/dist/lib/pricing/cost.js +24 -0
  83. package/dist/lib/pricing/index.d.ts +1 -1
  84. package/dist/lib/pricing/index.js +1 -1
  85. package/dist/lib/redact.js +8 -3
  86. package/dist/lib/remote-agents-json.d.ts +32 -0
  87. package/dist/lib/remote-agents-json.js +47 -16
  88. package/dist/lib/resource-profiles.js +1 -2
  89. package/dist/lib/routine-notify-owner.d.ts +102 -0
  90. package/dist/lib/routine-notify-owner.js +232 -0
  91. package/dist/lib/routines.d.ts +10 -0
  92. package/dist/lib/runner.d.ts +9 -1
  93. package/dist/lib/runner.js +182 -17
  94. package/dist/lib/sandbox.d.ts +0 -2
  95. package/dist/lib/sandbox.js +2 -19
  96. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  97. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  98. package/dist/lib/secrets/agent.js +3 -2
  99. package/dist/lib/secrets/lease.d.ts +25 -0
  100. package/dist/lib/secrets/lease.js +44 -0
  101. package/dist/lib/self-update.d.ts +20 -5
  102. package/dist/lib/self-update.js +93 -16
  103. package/dist/lib/session/db.d.ts +19 -2
  104. package/dist/lib/session/db.js +54 -5
  105. package/dist/lib/session/discover.d.ts +21 -1
  106. package/dist/lib/session/discover.js +124 -32
  107. package/dist/lib/session/insights.d.ts +19 -0
  108. package/dist/lib/session/insights.js +78 -10
  109. package/dist/lib/session/recovery.d.ts +17 -1
  110. package/dist/lib/session/recovery.js +111 -4
  111. package/dist/lib/session/remote-list.d.ts +10 -9
  112. package/dist/lib/session/remote-list.js +25 -23
  113. package/dist/lib/session/resume-owner.d.ts +55 -0
  114. package/dist/lib/session/resume-owner.js +69 -0
  115. package/dist/lib/session/team-filter.d.ts +65 -0
  116. package/dist/lib/session/team-filter.js +98 -3
  117. package/dist/lib/session/types.d.ts +33 -0
  118. package/dist/lib/smart-launch.d.ts +31 -5
  119. package/dist/lib/smart-launch.js +43 -7
  120. package/dist/lib/ssh-exec.d.ts +27 -0
  121. package/dist/lib/ssh-exec.js +34 -1
  122. package/dist/lib/ssh-tunnel.d.ts +3 -2
  123. package/dist/lib/ssh-tunnel.js +25 -16
  124. package/dist/lib/startup/command-registry.d.ts +1 -0
  125. package/dist/lib/startup/command-registry.js +2 -0
  126. package/dist/lib/teams/placement-probe.d.ts +1 -1
  127. package/dist/lib/teams/placement-probe.js +24 -24
  128. package/dist/lib/tmux/session.d.ts +8 -0
  129. package/dist/lib/tmux/session.js +22 -0
  130. package/dist/lib/types.d.ts +2 -0
  131. package/package.json +1 -1
@@ -31,7 +31,7 @@ import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-targe
31
31
  import { clearPendingSentinel } from '../lib/devices/pending.js';
32
32
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
33
33
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
34
- import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
34
+ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, deviceIdentityArgs, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
35
35
  import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
36
36
  import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
37
37
  import { fanOutDevices, fleetHealthSkip, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
@@ -44,6 +44,7 @@ import { checkAllClis } from '../lib/teams/agents.js';
44
44
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
45
45
  import { sshExecAsync } from '../lib/ssh-exec.js';
46
46
  import { ALL_AGENT_IDS } from '../lib/agents.js';
47
+ import { collectLocalHarnessInventory, groupByAccount, renderAccountsMatrix, renderHarnessMatrix, } from '../lib/devices/harness-inventory.js';
47
48
  import { crabboxList, crabboxFind, crabboxSshArgv } from '../lib/crabbox/cli.js';
48
49
  import { boxAddress, boxStatus, fmtIdleShort, fmtExpiresShort } from './lease.js';
49
50
  import { authCellColor, formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, readAuthHealthCache, summarizeHostAuth, summarizeVerdicts, verdictColor, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
@@ -351,7 +352,7 @@ async function probeRemoteFleetStatus(target) {
351
352
  const isWin = /^win/i.test((target.platform ?? '').trim());
352
353
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
353
354
  const cmd = buildRemoteAgentsInvocation(['devices', 'status', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
354
- const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true });
355
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs: target.extraSshArgs });
355
356
  if (res.code !== 0) {
356
357
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
357
358
  }
@@ -361,10 +362,10 @@ async function probeRemoteHealth(target) {
361
362
  const isWin = /^win/i.test((target.platform ?? '').trim());
362
363
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
363
364
  const versionCmd = buildRemoteAgentsInvocation(['--version'], undefined, isWin ? 'windows' : undefined, env);
364
- const versionRes = await sshExecAsync(target.dialTarget, versionCmd, { timeoutMs: 15000, multiplex: true });
365
+ const versionRes = await sshExecAsync(target.dialTarget, versionCmd, { timeoutMs: 15000, multiplex: true, extraSshArgs: target.extraSshArgs });
365
366
  const version = versionRes.code === 0 ? versionRes.stdout.trim().split(/\s+/)[0] || null : null;
366
367
  const doctorCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, env);
367
- const doctorRes = await sshExecAsync(target.dialTarget, doctorCmd, { timeoutMs: 30000, multiplex: true });
368
+ const doctorRes = await sshExecAsync(target.dialTarget, doctorCmd, { timeoutMs: 30000, multiplex: true, extraSshArgs: target.extraSshArgs });
368
369
  if (doctorRes.code !== 0) {
369
370
  throw new Error(doctorRes.timedOut ? 'timed out' : (doctorRes.stderr.trim() || `exit ${doctorRes.code ?? 'unknown'}`));
370
371
  }
@@ -425,6 +426,7 @@ async function runFleetStatus(opts) {
425
426
  // this is trusted on the default path, not just under `--refresh`.
426
427
  skip: fleetHealthSkip(t.skip, statsMap.get(t.device.name)),
427
428
  dialTarget: fleetDialTarget(t.device),
429
+ extraSshArgs: deviceIdentityArgs(t.device),
428
430
  }));
429
431
  const remote = await fanOutDevices(remoteTargets, probeRemoteHealth);
430
432
  for (const result of remote) {
@@ -533,7 +535,7 @@ async function probeRemoteAuth(target) {
533
535
  const isWin = /^win/i.test((target.platform ?? '').trim());
534
536
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
535
537
  const cmd = buildRemoteAgentsInvocation(['devices', 'ping', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
536
- const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true });
538
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs: target.extraSshArgs });
537
539
  if (res.code !== 0) {
538
540
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
539
541
  }
@@ -601,6 +603,7 @@ async function runFleetPing(opts) {
601
603
  platform: t.device.platform,
602
604
  skip: t.skip,
603
605
  dialTarget: fleetDialTarget(t.device),
606
+ extraSshArgs: deviceIdentityArgs(t.device),
604
607
  }));
605
608
  const probeable = remoteTargets.filter((t) => !t.skip).length;
606
609
  const spinner = isInteractiveTerminal() && !opts.json
@@ -645,6 +648,118 @@ async function runFleetPing(opts) {
645
648
  if (opts.strict && anyBad)
646
649
  process.exitCode = 1;
647
650
  }
651
+ /** SSH into a host and read its raw harness rows (the `--local --json` worker). */
652
+ async function probeRemoteHarnesses(target, refresh) {
653
+ const isWin = /^win/i.test((target.platform ?? '').trim());
654
+ const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
655
+ const args = ['devices', 'harnesses', '--local', '--json'];
656
+ if (refresh)
657
+ args.push('--refresh');
658
+ const cmd = buildRemoteAgentsInvocation(args, undefined, isWin ? 'windows' : undefined, env);
659
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true, extraSshArgs: target.extraSshArgs });
660
+ if (res.code !== 0) {
661
+ throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
662
+ }
663
+ const parsed = JSON.parse(res.stdout);
664
+ return parsed.rows ?? [];
665
+ }
666
+ /**
667
+ * Gather harness rows across the fleet: THIS host in process, every reachable
668
+ * peer over SSH. Shared by both `harnesses` and `accounts` (they differ only in
669
+ * how the rows are rendered). Honors an optional `--device` allowlist on both
670
+ * the local and remote rows. Bounded by the same per-device + overall deadlines
671
+ * as `fleet ping`, so one unreachable box can never stall the glance.
672
+ */
673
+ async function collectFleetHarnesses(opts) {
674
+ const self = machineId();
675
+ const want = opts.devices?.length ? new Set(opts.devices) : null;
676
+ const results = [];
677
+ if (!want || want.has(self)) {
678
+ const localRows = await collectLocalHarnessInventory({ agents: opts.agents, refresh: opts.refresh });
679
+ results.push({ host: self, rows: localRows });
680
+ }
681
+ const reg = await loadDevices();
682
+ const planned = planFleetTargets(reg);
683
+ let remoteTargets = remoteFleetTargets(planned, self).map((t) => ({
684
+ name: t.device.name,
685
+ platform: t.device.platform,
686
+ skip: t.skip,
687
+ dialTarget: fleetDialTarget(t.device),
688
+ extraSshArgs: deviceIdentityArgs(t.device),
689
+ }));
690
+ if (want)
691
+ remoteTargets = remoteTargets.filter((t) => want.has(t.name));
692
+ if (remoteTargets.length > 0) {
693
+ const probeable = remoteTargets.filter((t) => !t.skip).length;
694
+ const spinner = isInteractiveTerminal() && !opts.json
695
+ ? ora(`Probing ${probeable} device${probeable === 1 ? '' : 's'}…`).start()
696
+ : undefined;
697
+ let remote;
698
+ try {
699
+ const fanOut = fanOutDevices(remoteTargets, (t) => probeRemoteHarnesses(t, !!opts.refresh), { perDeviceTimeoutMs: 15_000 });
700
+ remote = await raceFleetPingDeadline(fanOut, remoteTargets, 30_000);
701
+ }
702
+ finally {
703
+ spinner?.stop();
704
+ }
705
+ for (const r of remote) {
706
+ if (r.status === 'ok' && r.value) {
707
+ results.push({ host: r.name, rows: r.value });
708
+ }
709
+ else {
710
+ results.push({
711
+ host: r.name,
712
+ rows: [],
713
+ error: r.error,
714
+ skipped: r.reason ? String(r.reason) : undefined,
715
+ });
716
+ }
717
+ }
718
+ }
719
+ return results;
720
+ }
721
+ async function runDevicesHarnesses(opts) {
722
+ if (opts.local) {
723
+ const rows = await collectLocalHarnessInventory({ agents: opts.agents, refresh: opts.refresh });
724
+ if (opts.json)
725
+ console.log(JSON.stringify({ host: machineId(), rows }));
726
+ else
727
+ for (const line of renderHarnessMatrix([{ host: machineId(), rows }]))
728
+ console.log(line);
729
+ return;
730
+ }
731
+ const results = await collectFleetHarnesses(opts);
732
+ if (opts.json)
733
+ console.log(JSON.stringify(results, null, 2));
734
+ else
735
+ for (const line of renderHarnessMatrix(results))
736
+ console.log(line);
737
+ }
738
+ async function runDevicesAccounts(opts) {
739
+ if (opts.local) {
740
+ const rows = await collectLocalHarnessInventory({ agents: opts.agents, refresh: opts.refresh });
741
+ if (opts.json)
742
+ console.log(JSON.stringify({ host: machineId(), accounts: groupByAccount(rows) }));
743
+ else
744
+ for (const line of renderAccountsMatrix([{ host: machineId(), rows }]))
745
+ console.log(line);
746
+ return;
747
+ }
748
+ const results = await collectFleetHarnesses(opts);
749
+ if (opts.json) {
750
+ const grouped = results.map((r) => ({
751
+ host: r.host,
752
+ error: r.error,
753
+ skipped: r.skipped,
754
+ accounts: groupByAccount(r.rows),
755
+ }));
756
+ console.log(JSON.stringify(grouped, null, 2));
757
+ }
758
+ else {
759
+ for (const line of renderAccountsMatrix(results))
760
+ console.log(line);
761
+ }
762
+ }
648
763
  /** Resolve an {@link AuthCellColor} to a chalk painter. Single map for cells + labels. */
649
764
  const CELL_PAINT = {
650
765
  green: chalk.green,
@@ -777,6 +892,7 @@ Typical workflow:
777
892
  agents devices configure mac-mini --max-agents 4 --scheduler off
778
893
  agents devices note mac-mini "runs the releases — don't reboot"
779
894
  agents devices set win-mini --auth password --bundle muqsit
895
+ agents devices set worker --auth key --identity-file ~/.ssh/worker_ed25519
780
896
  agents devices render --write # write ~/.ssh/config.d/agents include
781
897
  agents fleet update # roll out latest agents-cli to every online device
782
898
  agents fleet run uname -a # run a command on every online device
@@ -1219,6 +1335,59 @@ Typical workflow:
1219
1335
  const verbose = opts.verbose ?? Boolean(cmd.optsWithGlobals().verbose);
1220
1336
  await runFleetPing({ ...opts, verbose });
1221
1337
  });
1338
+ const csvList = (s) => s ? s.split(',').map((x) => x.trim()).filter(Boolean) : undefined;
1339
+ const harnessInvOpts = (opts) => ({
1340
+ agents: csvList(opts.agents),
1341
+ devices: csvList(opts.device ?? opts.devices),
1342
+ refresh: opts.refresh || opts.live,
1343
+ json: opts.json,
1344
+ local: opts.local,
1345
+ });
1346
+ const harnessesCmd = devicesCmd
1347
+ .command('harnesses')
1348
+ .description('Per device, one row per installed agent@version: account, signed-in, quota, and a single ready verdict. SSH-probes each online box.')
1349
+ .option('--json', 'output machine-readable JSON (per-host rows)')
1350
+ .option('--agents <csv>', 'only these agents (comma-separated)')
1351
+ .option('--device <csv>', 'only these devices (comma-separated); default: every online box')
1352
+ .option('--refresh', 'fetch live quota instead of the cached snapshot (slower)')
1353
+ .option('--live', 'alias of --refresh')
1354
+ .option('--local', "this host only: emit THIS box's rows (the per-host worker the fan-out reads over ssh)")
1355
+ .action(async (opts) => {
1356
+ await runDevicesHarnesses(harnessInvOpts(opts));
1357
+ });
1358
+ setHelpSections(harnessesCmd, {
1359
+ examples: `
1360
+ agents devices harnesses # every box: agent@version · account · signed · quota · ready
1361
+ agents devices harnesses --agents claude,codex # just these harnesses
1362
+ agents devices harnesses --device zion # one box
1363
+ agents devices harnesses --refresh # live quota (bypass the cached snapshot)
1364
+ agents devices harnesses --json # machine-readable, per-host rows`,
1365
+ notes: `
1366
+ "ready" = signed in AND not rate-limited — usable for a run right now.
1367
+ Quota is the cached usage snapshot (the daemon warms it); --refresh fetches live.
1368
+ Use \`agents devices accounts\` for the same data grouped by account.`,
1369
+ });
1370
+ const accountsCmd = devicesCmd
1371
+ .command('accounts')
1372
+ .description('Per device, one row per account: which harnesses share it, signed-in, quota, and ready. The identity lens on `agents devices harnesses`.')
1373
+ .option('--json', 'output machine-readable JSON (per-host account groups)')
1374
+ .option('--agents <csv>', 'only these agents (comma-separated)')
1375
+ .option('--device <csv>', 'only these devices (comma-separated); default: every online box')
1376
+ .option('--refresh', 'fetch live quota instead of the cached snapshot (slower)')
1377
+ .option('--live', 'alias of --refresh')
1378
+ .option('--local', "this host only: emit THIS box's account groups")
1379
+ .action(async (opts) => {
1380
+ await runDevicesAccounts(harnessInvOpts(opts));
1381
+ });
1382
+ setHelpSections(accountsCmd, {
1383
+ examples: `
1384
+ agents devices accounts # every box: account · agents · signed · quota · ready
1385
+ agents devices accounts --device mac-mini
1386
+ agents devices accounts --json # machine-readable, per-host account groups`,
1387
+ notes: `
1388
+ Collapses the installs that share one account (e.g. five claude versions on one
1389
+ email) into a single row. Use \`agents devices harnesses\` for the per-install view.`,
1390
+ });
1222
1391
  devicesCmd
1223
1392
  .command('login')
1224
1393
  .description('Log agent CLIs into fleet boxes over SSH: drive each box\'s device-code OAuth, scrape the URL + code, and surface every pending login in one local browser page. Default drives all codes at once; --interactive walks one box at a time (codes requested just-in-time so they don\'t expire).')
@@ -1281,15 +1450,19 @@ Typical workflow:
1281
1450
  .option('--auth <method>', 'key | password')
1282
1451
  .option('--bundle <bundle>', 'secrets bundle holding the password (for --auth password)')
1283
1452
  .option('--bundle-key <key>', "key within the bundle (default 'password')")
1453
+ .option('--identity-file <path>', 'private-key path for --auth key')
1454
+ .option('--clear-identity-file', 'return key auth to ssh-agent/default-key discovery')
1284
1455
  .action(async (name, opts) => {
1285
1456
  try {
1286
1457
  const existing = await mustGetDevice(name);
1287
- const auth = opts.auth || opts.bundle || opts.bundleKey
1288
- ? {
1289
- method: opts.auth ?? existing.auth.method,
1290
- bundle: opts.bundle ?? existing.auth.bundle,
1291
- bundleKey: opts.bundleKey ?? existing.auth.bundleKey,
1292
- }
1458
+ const nextMethod = opts.auth ?? existing.auth.method;
1459
+ if (opts.identityFile && nextMethod !== 'key') {
1460
+ throw new Error('--identity-file requires key auth; pass --auth key in the same command.');
1461
+ }
1462
+ const auth = opts.auth || opts.bundle || opts.bundleKey || opts.identityFile || opts.clearIdentityFile
1463
+ ? nextMethod === 'key'
1464
+ ? { method: nextMethod, identityFile: opts.clearIdentityFile ? undefined : (opts.identityFile ?? existing.auth.identityFile) }
1465
+ : { method: nextMethod, bundle: opts.bundle ?? existing.auth.bundle, bundleKey: opts.bundleKey ?? existing.auth.bundleKey }
1293
1466
  : undefined;
1294
1467
  const d = await upsertDevice(name, {
1295
1468
  platform: opts.platform ?? undefined,
@@ -17,7 +17,7 @@ import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } f
17
17
  import { setHelpSections } from '../lib/help.js';
18
18
  import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, } from '../lib/teams/worktree.js';
19
19
  import { resolveHost } from '../lib/hosts/registry.js';
20
- import { isDeviceAuto, resolveDeviceAffinity } from '../lib/smart-launch.js';
20
+ import { isDeviceAuto, resolveDeviceAuto } from '../lib/smart-launch.js';
21
21
  import { sshTargetFor } from '../lib/hosts/types.js';
22
22
  import { ensureHostReady } from '../lib/hosts/ready.js';
23
23
  import { remoteShellFor } from '../lib/hosts/remote-cmd.js';
@@ -1354,12 +1354,12 @@ export function registerTeamsCommands(program) {
1354
1354
  }
1355
1355
  return h ?? d ?? null;
1356
1356
  })();
1357
- // `auto` is the same affinity sentinel `agents run --device auto` resolves
1357
+ // `auto` is the same live fleet sentinel `agents run --device auto` resolves
1358
1358
  // (RUSH-2185) — pick the concrete device name up front so the local-machine
1359
1359
  // check right below (and every dieFriction message further down) sees the
1360
1360
  // real target instead of the literal string "auto".
1361
1361
  if (explicitDevice && isDeviceAuto(explicitDevice)) {
1362
- const plan = resolveDeviceAffinity({});
1362
+ const plan = await resolveDeviceAuto(parseTeammate(teammate).agent);
1363
1363
  const picked = plan.host ?? machineId();
1364
1364
  process.stderr.write(chalk.gray(`[teams] device=auto → ${picked === machineId() ? 'local' : picked}\n`));
1365
1365
  explicitDevice = picked;
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadBench, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
@@ -392,15 +392,15 @@ async function showWhatsNew(fromVersion, toVersion) {
392
392
  const UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
393
393
  import { getUpdateCheckPath, getMigratedSentinelPath, getUserAgentsDir, getRuntimeStateDir } from './lib/state.js';
394
394
  import { resolveBrandName, disabledCommandsForActiveBrand } from './lib/brand.js';
395
- import { readUpdateCache, saveUpdateCheck, dismissUpdateVersion, shouldPromptUpgrade, findAgentsCliInstalls, resolveRunningPackageRoot, } from './lib/self-update.js';
395
+ import { readUpdateCache, saveUpdateCheck, dismissUpdateVersion, shouldPromptUpgrade, buildMultiInstallInventory, findAgentsCliInstalls, resolveRunningPackageRoot, } from './lib/self-update.js';
396
396
  const UPDATE_CHECK_FILE = getUpdateCheckPath();
397
397
  /**
398
- * Warn once when PATH resolves `agents` to a different agents-cli install
399
- * than the copy that is currently running (or to several). Divergent installs
398
+ * Warn once when this machine contains a different agents-cli install than the
399
+ * copy that is currently running (or several). Divergent installs
400
400
  * are how self-updates "succeed" without changing the command the user types.
401
- * The warning re-fires only when the set of install roots changes; dev builds
402
- * (0.0.0-dev) are ignored because side-by-side dev installs are a supported
403
- * workflow.
401
+ * The warning re-fires only when the set of install roots or their helper-copy
402
+ * safety changes. Dev builds are included because old dev copies can still
403
+ * overwrite the shared macOS helper bundle non-atomically.
404
404
  */
405
405
  function maybeWarnMultiInstall() {
406
406
  const sentinel = path.join(getRuntimeStateDir(), 'multi-install-warned');
@@ -414,31 +414,26 @@ function maybeWarnMultiInstall() {
414
414
  // "/$bunfs" install. This warning is advisory — stay silent instead.
415
415
  return;
416
416
  }
417
- const byRoot = new Map();
418
- byRoot.set(runningRoot, { version: VERSION, note: 'running' });
419
- for (const install of findAgentsCliInstalls(process.env.PATH || '')) {
420
- if (install.version.startsWith('0.0.0-dev'))
421
- continue;
422
- if (!byRoot.has(install.packageRoot)) {
423
- byRoot.set(install.packageRoot, { version: install.version, note: `agents on PATH: ${install.binPath}` });
424
- }
425
- }
426
- if (byRoot.size < 2) {
417
+ const inventory = buildMultiInstallInventory(runningRoot, VERSION, findAgentsCliInstalls(process.env.PATH || ''));
418
+ if (inventory.length < 2) {
427
419
  try {
428
420
  fs.unlinkSync(sentinel);
429
421
  }
430
422
  catch { /* nothing recorded */ }
431
423
  return;
432
424
  }
433
- const key = [...byRoot.keys()].sort().join('\n');
425
+ const key = inventory
426
+ .map((info) => `${info.packageRoot}\t${info.version}\t${info.note}`)
427
+ .sort()
428
+ .join('\n');
434
429
  try {
435
430
  if (fs.readFileSync(sentinel, 'utf-8') === key)
436
431
  return;
437
432
  }
438
433
  catch { /* not warned for this set yet */ }
439
434
  console.error(chalk.yellow('Multiple agents-cli installs detected:'));
440
- for (const [root, info] of byRoot) {
441
- console.error(chalk.gray(` ${root} ${info.version} (${info.note})`));
435
+ for (const info of inventory) {
436
+ console.error(chalk.gray(` ${info.packageRoot} ${info.version} (${info.note})`));
442
437
  }
443
438
  console.error(chalk.gray('Upgrades apply to the running copy. Remove a stale copy with: npm uninstall -g --prefix <prefix> @phnx-labs/agents-cli'));
444
439
  try {
@@ -967,6 +962,7 @@ async function registerAllEagerCommands() {
967
962
  await reg(loadCost);
968
963
  await reg(loadInsights);
969
964
  await reg(loadPerf);
965
+ await reg(loadBench);
970
966
  await reg(loadTrends);
971
967
  await reg(loadOutput);
972
968
  await reg(loadBudget);
@@ -156,10 +156,54 @@ export interface AuthProbeRow {
156
156
  account?: string;
157
157
  health: AuthHealth;
158
158
  }
159
+ /** One installed (agent, version) home, tagged with its resolved account label. */
160
+ export interface FleetAuthInstall {
161
+ agent: AgentId;
162
+ version: string;
163
+ /** Human account label from {@link authAccountLabel}, or undefined when none resolves. */
164
+ account: string | undefined;
165
+ }
166
+ /**
167
+ * A set of installs that share one provider account and MUST be probed once.
168
+ * The live probe runs against `probe` (the representative home); every entry in
169
+ * `members` — the representative included — then receives that one verdict.
170
+ */
171
+ export interface FleetAuthProbeGroup<T extends FleetAuthInstall> {
172
+ probe: T;
173
+ members: T[];
174
+ }
159
175
  /**
160
- * Enumerate every installed (agent, version) on THIS host, probe each in
161
- * parallel, and return the rows (installs with no credential at all are
162
- * dropped). Shared by `agents fleet ping --local` and the daemon refresh.
176
+ * Collapse installs so a live auth probe fires ONCE per (agent, account) rather
177
+ * than once per version home.
178
+ *
179
+ * Several version homes signed into the same provider account share one OAuth
180
+ * rate limit, so probing each of them concurrently — which the daemon did every
181
+ * three minutes across every installed home (RUSH-2111) — raced that limit into
182
+ * a 429 storm that then parked the whole box behind a `Retry-After` penalty (the
183
+ * incident {@link file:./usage-backoff.ts} was written to survive; this removes
184
+ * its cause). Grouping by account means N homes on one account issue ONE request.
185
+ *
186
+ * `isMergeable` scopes the dedup to the installs it actually helps: only agents
187
+ * that make a network probe ({@link LIVE_PROBE_AGENTS}) can 429, so only they are
188
+ * merged. A best-effort agent (its verdict is a cheap local file read, no rate
189
+ * limit) is left per-install — collapsing it would gain nothing and could
190
+ * silently override one home's local `signedIn` verdict with another's. Installs
191
+ * with no resolvable account label, or that `isMergeable` rejects, are never
192
+ * merged: each becomes its own group keyed by version, preserving the old
193
+ * per-install probe (also the `unconfigured` case dropped downstream). Pure: no
194
+ * fs, no network, so the dedup decision is unit-tested directly.
195
+ */
196
+ export declare function groupFleetAuthInstalls<T extends FleetAuthInstall>(installs: readonly T[], isMergeable?: (install: T) => boolean): FleetAuthProbeGroup<T>[];
197
+ /**
198
+ * Enumerate every installed (agent, version) on THIS host and return one row per
199
+ * install (installs with no credential at all are dropped). Shared by
200
+ * `agents fleet ping --local` and the daemon refresh.
201
+ *
202
+ * The live network probe is deduped by account: homes sharing one provider
203
+ * account are probed ONCE and the verdict is fanned out to each home's row, so
204
+ * the daemon's every-3-minute refresh can no longer fire concurrent same-account
205
+ * requests and self-inflict a 429 (RUSH-2111). Each home still gets its own cache
206
+ * row (the cache key is per-version by design — see {@link authCacheKey}).
163
207
  */
164
208
  export declare function probeLocalFleetAuth(opts?: {
165
209
  cliVersion?: string | null;
@@ -283,28 +283,87 @@ export async function probeAuthHealth(agent, home, opts) {
283
283
  return { verdict: info?.signedIn ? 'unverified' : 'unconfigured', checkedAt };
284
284
  }
285
285
  /**
286
- * Enumerate every installed (agent, version) on THIS host, probe each in
287
- * parallel, and return the rows (installs with no credential at all are
288
- * dropped). Shared by `agents fleet ping --local` and the daemon refresh.
286
+ * Collapse installs so a live auth probe fires ONCE per (agent, account) rather
287
+ * than once per version home.
288
+ *
289
+ * Several version homes signed into the same provider account share one OAuth
290
+ * rate limit, so probing each of them concurrently — which the daemon did every
291
+ * three minutes across every installed home (RUSH-2111) — raced that limit into
292
+ * a 429 storm that then parked the whole box behind a `Retry-After` penalty (the
293
+ * incident {@link file:./usage-backoff.ts} was written to survive; this removes
294
+ * its cause). Grouping by account means N homes on one account issue ONE request.
295
+ *
296
+ * `isMergeable` scopes the dedup to the installs it actually helps: only agents
297
+ * that make a network probe ({@link LIVE_PROBE_AGENTS}) can 429, so only they are
298
+ * merged. A best-effort agent (its verdict is a cheap local file read, no rate
299
+ * limit) is left per-install — collapsing it would gain nothing and could
300
+ * silently override one home's local `signedIn` verdict with another's. Installs
301
+ * with no resolvable account label, or that `isMergeable` rejects, are never
302
+ * merged: each becomes its own group keyed by version, preserving the old
303
+ * per-install probe (also the `unconfigured` case dropped downstream). Pure: no
304
+ * fs, no network, so the dedup decision is unit-tested directly.
305
+ */
306
+ export function groupFleetAuthInstalls(installs, isMergeable = () => true) {
307
+ const groups = new Map();
308
+ for (const inst of installs) {
309
+ // The `acct:` / `ver:` tokens make the two branches disjoint, so an account
310
+ // label can never collide with a version fallback key no matter its content.
311
+ const key = inst.account && isMergeable(inst)
312
+ ? `${inst.agent} acct:${inst.account}`
313
+ : `${inst.agent} ver:${inst.version}`;
314
+ const existing = groups.get(key);
315
+ if (existing)
316
+ existing.members.push(inst);
317
+ else
318
+ groups.set(key, { probe: inst, members: [inst] });
319
+ }
320
+ return [...groups.values()];
321
+ }
322
+ /**
323
+ * Enumerate every installed (agent, version) on THIS host and return one row per
324
+ * install (installs with no credential at all are dropped). Shared by
325
+ * `agents fleet ping --local` and the daemon refresh.
326
+ *
327
+ * The live network probe is deduped by account: homes sharing one provider
328
+ * account are probed ONCE and the verdict is fanned out to each home's row, so
329
+ * the daemon's every-3-minute refresh can no longer fire concurrent same-account
330
+ * requests and self-inflict a 429 (RUSH-2111). Each home still gets its own cache
331
+ * row (the cache key is per-version by design — see {@link authCacheKey}).
289
332
  */
290
333
  export async function probeLocalFleetAuth(opts) {
291
334
  const agentIds = opts?.agents ?? ALL_AGENT_IDS;
292
- const tasks = [];
335
+ // Enumerate every install, then resolve its account label. getAccountInfo is a
336
+ // local credential-file read (no network), so this fan-out is cheap and cannot
337
+ // contribute to the rate limit the probe grouping below exists to avoid.
338
+ const installs = [];
293
339
  for (const agent of agentIds) {
294
340
  for (const version of listInstalledVersions(agent)) {
295
- const home = getVersionHomePath(agent, version);
296
- tasks.push((async () => {
297
- const info = await getAccountInfo(agent, home).catch(() => null);
298
- const health = await probeAuthHealth(agent, home, { cliVersion: opts?.cliVersion, info });
299
- health.account = authAccountLabel(info);
300
- if (health.verdict === 'unconfigured')
301
- return null;
302
- return { agent, version, account: health.account, health };
303
- })());
341
+ installs.push({ agent, version, home: getVersionHomePath(agent, version), info: null, account: undefined });
304
342
  }
305
343
  }
306
- const settled = await Promise.all(tasks);
307
- return settled.filter((r) => r !== null);
344
+ await Promise.all(installs.map(async (inst) => {
345
+ inst.info = await getAccountInfo(inst.agent, inst.home).catch(() => null);
346
+ inst.account = authAccountLabel(inst.info);
347
+ }));
348
+ // Probe once per (agent, account) — but only for the network-probing agents
349
+ // that can actually 429; best-effort agents stay per-install (see
350
+ // groupFleetAuthInstalls). Groups run in parallel: they target distinct
351
+ // accounts, so no same-account concurrency is left to trip the throttle.
352
+ const perGroup = await Promise.all(groupFleetAuthInstalls(installs, (inst) => LIVE_PROBE_AGENTS.has(inst.agent)).map(async (group) => {
353
+ const rep = group.probe;
354
+ const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info });
355
+ health.account = authAccountLabel(rep.info);
356
+ if (health.verdict === 'unconfigured')
357
+ return [];
358
+ return group.members.map((inst) => ({
359
+ agent: inst.agent,
360
+ version: inst.version,
361
+ account: health.account,
362
+ // A distinct object per row so a later mutation of one can't bleed across.
363
+ health: { ...health },
364
+ }));
365
+ }));
366
+ return perGroup.flat();
308
367
  }
309
368
  /** Persist a host's probed rows into the cache (keyed by host+agent+version). */
310
369
  export function writeFleetAuthRows(host, rows) {
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./schema.js";
3
+ export * from "./storage.js";
4
+ export * from "./runner.js";
@@ -0,0 +1,4 @@
1
+ export * from "./types.js";
2
+ export * from "./schema.js";
3
+ export * from "./storage.js";
4
+ export * from "./runner.js";
@@ -0,0 +1,16 @@
1
+ import type { BenchCell, BenchCellResult, BenchRunResult, BenchTask } from "./types.js";
2
+ export interface CellExecutionRequest extends BenchCell {
3
+ prompt: string;
4
+ cwd: string;
5
+ }
6
+ export type CellExecutor = (request: CellExecutionRequest) => Promise<Omit<BenchCellResult, keyof BenchCell | "wall_ms" | "status">>;
7
+ export declare function extractTokenCount(stdout: string, stderr: string): number | undefined;
8
+ export declare function executeCellViaAgentsRun(request: CellExecutionRequest): ReturnType<CellExecutor>;
9
+ export declare function runCells(options: {
10
+ task?: BenchTask;
11
+ prompt: string;
12
+ cells: BenchCell[];
13
+ concurrency: number;
14
+ execute?: CellExecutor;
15
+ tempRoot?: string;
16
+ }): Promise<BenchRunResult>;