@phnx-labs/agents-cli 1.20.69 → 1.20.71

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 (65) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +19 -6
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +36 -3
  5. package/dist/commands/check.js +3 -1
  6. package/dist/commands/doctor.js +5 -0
  7. package/dist/commands/exec.js +12 -0
  8. package/dist/commands/fleet-capture.d.ts +14 -0
  9. package/dist/commands/fleet-capture.js +107 -0
  10. package/dist/commands/fork.js +13 -7
  11. package/dist/commands/hosts.js +11 -0
  12. package/dist/commands/secrets.d.ts +1 -0
  13. package/dist/commands/secrets.js +24 -4
  14. package/dist/commands/sessions-tail.js +1 -2
  15. package/dist/commands/sessions.js +6 -6
  16. package/dist/commands/ssh.js +63 -16
  17. package/dist/commands/usage.d.ts +13 -0
  18. package/dist/commands/usage.js +50 -28
  19. package/dist/commands/view.d.ts +1 -0
  20. package/dist/commands/view.js +5 -2
  21. package/dist/lib/auth-health.d.ts +38 -0
  22. package/dist/lib/auth-health.js +48 -3
  23. package/dist/lib/commands.d.ts +10 -0
  24. package/dist/lib/commands.js +31 -7
  25. package/dist/lib/computer/download.d.ts +3 -0
  26. package/dist/lib/computer/download.js +13 -12
  27. package/dist/lib/daemon.js +38 -0
  28. package/dist/lib/devices/connect.d.ts +13 -0
  29. package/dist/lib/devices/connect.js +20 -0
  30. package/dist/lib/devices/fleet.d.ts +36 -3
  31. package/dist/lib/devices/fleet.js +42 -4
  32. package/dist/lib/devices/health-report.d.ts +11 -0
  33. package/dist/lib/devices/health-report.js +73 -4
  34. package/dist/lib/devices/stats-cache.d.ts +36 -0
  35. package/dist/lib/devices/stats-cache.js +115 -0
  36. package/dist/lib/devices/sync.d.ts +30 -0
  37. package/dist/lib/devices/sync.js +50 -0
  38. package/dist/lib/doctor-diff.js +38 -16
  39. package/dist/lib/fleet/apply.d.ts +4 -0
  40. package/dist/lib/fleet/apply.js +28 -5
  41. package/dist/lib/fleet/capture.d.ts +35 -0
  42. package/dist/lib/fleet/capture.js +51 -0
  43. package/dist/lib/fleet/manifest.d.ts +6 -0
  44. package/dist/lib/fleet/manifest.js +24 -1
  45. package/dist/lib/fleet/types.d.ts +24 -1
  46. package/dist/lib/git.d.ts +14 -0
  47. package/dist/lib/git.js +39 -1
  48. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  49. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  50. package/dist/lib/menubar/install-menubar.js +26 -13
  51. package/dist/lib/secrets/agent.d.ts +5 -0
  52. package/dist/lib/secrets/agent.js +35 -3
  53. package/dist/lib/secrets/bundles.js +28 -0
  54. package/dist/lib/secrets/index.d.ts +14 -0
  55. package/dist/lib/secrets/index.js +36 -5
  56. package/dist/lib/secrets/session-store.d.ts +90 -0
  57. package/dist/lib/secrets/session-store.js +221 -0
  58. package/dist/lib/session/render.d.ts +9 -1
  59. package/dist/lib/session/render.js +35 -4
  60. package/dist/lib/share/capture.d.ts +2 -0
  61. package/dist/lib/share/capture.js +12 -5
  62. package/dist/lib/skills.d.ts +8 -1
  63. package/dist/lib/skills.js +11 -1
  64. package/dist/lib/types.d.ts +5 -1
  65. package/package.json +1 -1
@@ -998,7 +998,7 @@ async function sessionsAction(query, options) {
998
998
  // When the user explicitly asks to render (via mode flag), resolve the
999
999
  // query globally so sessions outside the default cwd/30d window are found.
1000
1000
  if (wantsRender && searchQuery) {
1001
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, noRedact: options.noRedact });
1001
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
1002
1002
  return;
1003
1003
  }
1004
1004
  // Interactive picker loads a deep pool but shows only recent sessions
@@ -1068,7 +1068,7 @@ async function sessionsAction(query, options) {
1068
1068
  return;
1069
1069
  }
1070
1070
  if (idMatches.length === 0 && looksLikeSessionId(searchQuery)) {
1071
- await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, noRedact: options.noRedact });
1071
+ await renderOneSession(searchQuery, mode, { agent: options.agent, project: options.project, filter: filterOpts, redact: options.redact });
1072
1072
  return;
1073
1073
  }
1074
1074
  }
@@ -1475,7 +1475,7 @@ async function renderSession(session, mode, filters, options = {}) {
1475
1475
  chalk.gray(` ${formatRelativeTime(session.timestamp)}`) +
1476
1476
  (session.account ? chalk.gray(` (${session.account})`) : ''));
1477
1477
  console.log(chalk.gray('─'.repeat(60)));
1478
- process.stdout.write(renderMarkdown(renderConversationMarkdown(events, { redact: options.noRedact !== true })));
1478
+ process.stdout.write(renderMarkdown(renderConversationMarkdown(events, { redact: options.redact !== false })));
1479
1479
  return;
1480
1480
  }
1481
1481
  // json — normalized events plus the durable session signals from the state
@@ -1486,7 +1486,7 @@ async function renderSession(session, mode, filters, options = {}) {
1486
1486
  // checklist reflects true session state regardless of any `--include` filter;
1487
1487
  // it lets the Factory panel read the CLI's checklist instead of re-parsing.
1488
1488
  const todos = inferSessionState(parsedEvents, { cwd: session.cwd }).todos;
1489
- process.stdout.write(renderJson(events, todos ? { ...session, todos } : session));
1489
+ process.stdout.write(renderJson(events, todos ? { ...session, todos } : session, { redact: options.redact !== false }));
1490
1490
  }
1491
1491
  function renderTopicCell(label, topic, query, visibleWidth, paddedWidth) {
1492
1492
  const lbl = (label ?? '').trim();
@@ -2162,7 +2162,7 @@ async function renderOneSession(query, mode, scope) {
2162
2162
  throw new Error('Session resolution failed');
2163
2163
  }
2164
2164
  spinner.stop();
2165
- await renderSession(session, mode, scope.filter, { noRedact: scope.noRedact });
2165
+ await renderSession(session, mode, scope.filter, { redact: scope.redact });
2166
2166
  }
2167
2167
  catch (err) {
2168
2168
  if (isPromptCancelled(err))
@@ -2195,7 +2195,7 @@ export function registerSessionsCommands(program) {
2195
2195
  .option('-n, --limit <n>', 'Maximum number of sessions to return', '50')
2196
2196
  .option('--sort <field>', 'Sort the list by: recent (default), cost, or duration')
2197
2197
  .option('--markdown', 'Render the session as markdown (user, assistant, thinking, tool calls)')
2198
- .option('--no-redact', 'Disable default secret redaction in markdown session output')
2198
+ .option('--no-redact', 'Disable default secret redaction in rendered session output (--markdown and --json)')
2199
2199
  .option('--json', 'Output JSON (session list when browsing, event array when rendering one session)')
2200
2200
  .option('--include <roles>', 'Only include these roles (comma-separated): user, assistant, thinking, tools')
2201
2201
  .option('--exclude <roles>', 'Exclude these roles (comma-separated): user, assistant, thinking, tools')
@@ -18,6 +18,7 @@ import ora from 'ora';
18
18
  import { getCliVersion } from '../lib/version.js';
19
19
  import { readAndResolveBundleEnv, isHeadlessSecretsContext } from '../lib/secrets/bundles.js';
20
20
  import { machineId } from '../lib/session/sync/config.js';
21
+ import { registerFleetCaptureCommand } from './fleet-capture.js';
21
22
  import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, } from '../lib/devices/registry.js';
22
23
  import { addControlToken } from '../lib/serve/token.js';
23
24
  import { DEFAULT_SERVE_PORT } from '../lib/serve/server.js';
@@ -27,18 +28,19 @@ import { resolveDeviceTarget, splitUserHost } from '../lib/devices/resolve-targe
27
28
  import { clearPendingSentinel } from '../lib/devices/pending.js';
28
29
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
29
30
  import { hostNameFor, renderSshConfig } from '../lib/devices/ssh-config.js';
30
- import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, writeAskpassShim, } from '../lib/devices/connect.js';
31
+ import { ASKPASS_BUNDLE_ENV, ASKPASS_KEY_ENV, buildSshInvocation, fleetDialTarget, writeAskpassShim, } from '../lib/devices/connect.js';
31
32
  import { ensureManagedKnownHostsDir, isHostPinned } from '../lib/devices/known-hosts.js';
32
33
  import { shouldSyncTerminfo, syncTerminfoToDevice, terminfoHostKey } from '../lib/devices/terminfo.js';
33
34
  import { fanOutDevices, planFleetTargets, remoteFleetTargets, runFleet, skipLabel, upgradeCommand, } from '../lib/devices/fleet.js';
34
- import { fleetCapacity, fmtBytes, headroom, probeLocalStats, probeFleetStats, } from '../lib/devices/health.js';
35
+ import { fleetCapacity, fmtBytes, headroom, } from '../lib/devices/health.js';
35
36
  import { buildFleetHealthReport, renderFleetMatrix, renderFleetWarnings, } from '../lib/devices/health-report.js';
37
+ import { loadFleetStats, readStatsCache } from '../lib/devices/stats-cache.js';
36
38
  import { checkSyncStatus, countOrphans } from '../lib/drift.js';
37
39
  import { checkAllClis } from '../lib/teams/agents.js';
38
40
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
39
41
  import { sshExecAsync } from '../lib/ssh-exec.js';
40
42
  import { ALL_AGENT_IDS } from '../lib/agents.js';
41
- import { formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, summarizeVerdicts, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
43
+ import { formatCheckedAge, isDeadVerdict, probeLocalFleetAuth, readAuthHealthCache, summarizeHostAuth, summarizeVerdicts, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
42
44
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
43
45
  * command is running on so it stands out from the rest of the tailnet. */
44
46
  function deviceSummary(d, isSelf = false) {
@@ -243,10 +245,10 @@ async function probeRemoteHealth(target) {
243
245
  const isWin = /^win/i.test((target.platform ?? '').trim());
244
246
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
245
247
  const versionCmd = buildRemoteAgentsInvocation(['--version'], undefined, isWin ? 'windows' : undefined, env);
246
- const versionRes = await sshExecAsync(target.name, versionCmd, { timeoutMs: 15000, multiplex: true });
248
+ const versionRes = await sshExecAsync(target.dialTarget, versionCmd, { timeoutMs: 15000, multiplex: true });
247
249
  const version = versionRes.code === 0 ? versionRes.stdout.trim().split(/\s+/)[0] || null : null;
248
250
  const doctorCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, env);
249
- const doctorRes = await sshExecAsync(target.name, doctorCmd, { timeoutMs: 30000, multiplex: true });
251
+ const doctorRes = await sshExecAsync(target.dialTarget, doctorCmd, { timeoutMs: 30000, multiplex: true });
250
252
  if (doctorRes.code !== 0) {
251
253
  throw new Error(doctorRes.timedOut ? 'timed out' : (doctorRes.stderr.trim() || `exit ${doctorRes.code ?? 'unknown'}`));
252
254
  }
@@ -256,25 +258,43 @@ async function probeRemoteHealth(target) {
256
258
  clis: parsed.clis ?? {},
257
259
  sync: parsed.sync ?? [],
258
260
  orphans: parsed.orphans ?? [],
261
+ // The remote self-reports its own cached auth rollup (fresh via its daemon),
262
+ // so the Auth column is current without a prior fleet-wide `fleet ping`.
263
+ // Older remotes that don't emit it fall back to this host's cache below.
264
+ auth: parsed.auth,
259
265
  };
260
266
  }
261
267
  async function runFleetStatus(opts) {
262
268
  const reg = await loadDevices();
263
269
  const self = machineId();
270
+ const forceRefresh = Boolean(opts.refresh || opts.live);
264
271
  const planned = planFleetTargets(reg);
265
272
  const probeable = planned.filter((t) => !t.skip).map((t) => t.device);
273
+ // Cache-first: serve remote stats from the daemon-warmed cache (instant),
274
+ // probe this machine locally, and only ssh out for missing/forced rows.
266
275
  const statsMap = opts.stats === false
267
276
  ? new Map()
268
- : await probeFleetStats(probeable, { selfName: self });
269
- if (opts.stats !== false && !statsMap.has(self)) {
270
- statsMap.set(self, await probeLocalStats(self));
271
- }
277
+ : (await loadFleetStats(probeable, { forceRefresh, selfName: self })).stats;
272
278
  const rows = [localHealthRow(self, statsMap.get(self))];
273
279
  const remoteTargets = remoteFleetTargets(planned, self)
274
280
  .map((t) => ({
275
281
  name: t.device.name,
276
282
  platform: t.device.platform,
277
283
  skip: t.skip,
284
+ dialTarget: fleetDialTarget(t.device),
285
+ // Fail fast: the stats probe already tried this box with the same
286
+ // (registry) address. If it came back unreachable, don't spend another
287
+ // 15s+30s on version+doctor — skip straight to an unreachable row so one
288
+ // genuinely-offline device can't stall the whole matrix.
289
+ //
290
+ // Only trust this on a FRESH probe (`--refresh`/`--live`). loadFleetStats
291
+ // is cache-first (daemon-warmed ~3min), so a box that came back online
292
+ // since the last warm would otherwise be skipped to "unreachable" without
293
+ // a live attempt — a false negative. On a default run we still do the live
294
+ // probe (fast now that it dials the registry address).
295
+ ...(forceRefresh && statsMap.get(t.device.name)?.reachable === false && !t.skip
296
+ ? { skip: 'unreachable' }
297
+ : {}),
278
298
  }));
279
299
  const remote = await fanOutDevices(remoteTargets, probeRemoteHealth);
280
300
  for (const result of remote) {
@@ -300,6 +320,15 @@ async function runFleetStatus(opts) {
300
320
  });
301
321
  }
302
322
  }
323
+ // Auth column: remote rows already carry the host's self-reported rollup from
324
+ // its `doctor --json`. Fill the rest (this machine; older remotes that don't
325
+ // emit it) from this host's local cache — written by `agents fleet ping` and
326
+ // the daemon's local refresh. A never-probed host rolls up to "—". No network.
327
+ const authCache = readAuthHealthCache();
328
+ for (const row of rows) {
329
+ if (!row.auth)
330
+ row.auth = summarizeHostAuth(authCache, row.name);
331
+ }
303
332
  const report = buildFleetHealthReport(rows);
304
333
  if (opts.json) {
305
334
  console.log(JSON.stringify(report, null, 2));
@@ -319,7 +348,7 @@ async function probeRemoteAuth(target) {
319
348
  const isWin = /^win/i.test((target.platform ?? '').trim());
320
349
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
321
350
  const cmd = buildRemoteAgentsInvocation(['devices', 'ping', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
322
- const res = await sshExecAsync(target.name, cmd, { timeoutMs: 60000, multiplex: true });
351
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 60000, multiplex: true });
323
352
  if (res.code !== 0) {
324
353
  throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
325
354
  }
@@ -356,6 +385,7 @@ async function runFleetPing(opts) {
356
385
  name: t.device.name,
357
386
  platform: t.device.platform,
358
387
  skip: t.skip,
388
+ dialTarget: fleetDialTarget(t.device),
359
389
  }));
360
390
  const probeable = remoteTargets.filter((t) => !t.skip).length;
361
391
  const spinner = isInteractiveTerminal() && !opts.json
@@ -497,6 +527,8 @@ Typical workflow:
497
527
  process.exit(1);
498
528
  }
499
529
  });
530
+ // `agents fleet capture` — snapshot live state into agents.yaml fleet:.
531
+ registerFleetCaptureCommand(devicesCmd);
500
532
  devicesCmd
501
533
  .command('register <name>')
502
534
  .description('Register a discovered (pending) node by name — used by the menu-bar "NEW DEVICES → Register" action.')
@@ -550,6 +582,8 @@ Typical workflow:
550
582
  .description('List registered devices with platform, address, reachability, and live resource headroom.')
551
583
  .option('--json', 'output the registry as a JSON array (for scripts and hooks)')
552
584
  .option('--no-stats', 'skip the live resource probe (instant; names/addresses only)')
585
+ .option('--refresh', 'force a live probe of every device, bypassing the cache')
586
+ .option('--live', 'alias of --refresh (shorter to type)')
553
587
  .option('-f, --full', 'full mode: add per-device core count and free/total memory')
554
588
  .action(async (opts) => {
555
589
  const reg = await loadDevices();
@@ -564,18 +598,26 @@ Typical workflow:
564
598
  return;
565
599
  }
566
600
  const self = machineId();
601
+ const forceRefresh = Boolean(opts.refresh || opts.live);
567
602
  let statsMap;
603
+ let freshness;
568
604
  if (opts.stats !== false) {
569
- // Probe only reachable devices, in parallel, bounded by the per-probe
570
- // timeout a slow box degrades to "—", it never hangs the table.
605
+ // Cache-first: serve remote devices from the daemon-warmed cache
606
+ // (instant), probe this machine locally, and only ssh out for missing or
607
+ // forced (--refresh/--live) rows — so a warm read never hangs on a box.
571
608
  const probeable = planFleetTargets(reg)
572
609
  .filter((t) => !t.skip)
573
610
  .map((t) => t.device);
574
- const spinner = isInteractiveTerminal()
611
+ // Only spin when we'll actually ssh (forced, or a cold/partial cache).
612
+ const cache = readStatsCache();
613
+ const willSsh = forceRefresh || probeable.some((d) => d.name !== self && !cache[d.name]);
614
+ const spinner = willSsh && isInteractiveTerminal()
575
615
  ? ora(`Probing ${probeable.length} device${probeable.length === 1 ? '' : 's'}…`).start()
576
616
  : undefined;
577
617
  try {
578
- statsMap = await probeFleetStats(probeable, { selfName: self });
618
+ const res = await loadFleetStats(probeable, { forceRefresh, selfName: self });
619
+ statsMap = res.stats;
620
+ freshness = res;
579
621
  }
580
622
  finally {
581
623
  spinner?.stop();
@@ -584,6 +626,9 @@ Typical workflow:
584
626
  console.log(chalk.bold(`Devices (${names.length})`));
585
627
  for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full))
586
628
  console.log(line);
629
+ if (freshness?.servedFromCache && freshness.oldestFetchedAt != null) {
630
+ console.log(chalk.gray(` updated ${formatCheckedAge(freshness.oldestFetchedAt)} — pass --refresh (--live) for a live probe`));
631
+ }
587
632
  });
588
633
  devicesCmd
589
634
  .command('status')
@@ -591,6 +636,8 @@ Typical workflow:
591
636
  .option('--json', 'output machine-readable JSON')
592
637
  .option('--strict', 'exit non-zero when any device has drift or is unreachable')
593
638
  .option('--no-stats', 'skip the live resource probe')
639
+ .option('--refresh', 'force a live probe of every device, bypassing the cache')
640
+ .option('--live', 'alias of --refresh (shorter to type)')
594
641
  .action(async (opts) => {
595
642
  await runFleetStatus(opts);
596
643
  });
@@ -752,7 +799,7 @@ Typical workflow:
752
799
  return;
753
800
  }
754
801
  console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
755
- const results = runFleet(targets, cmd);
802
+ const results = runFleet(targets, cmd, { self: machineId() });
756
803
  printFleetResults(results);
757
804
  });
758
805
  devicesCmd
@@ -771,7 +818,7 @@ Typical workflow:
771
818
  return;
772
819
  }
773
820
  console.log(chalk.gray(`Running \`${cmd.join(' ')}\` on ${targets.filter((t) => !t.skip).length} online device(s)…`));
774
- const results = runFleet(targets, cmd);
821
+ const results = runFleet(targets, cmd, { self: machineId() });
775
822
  printFleetResults(results);
776
823
  });
777
824
  }
@@ -10,4 +10,17 @@
10
10
  * don't publish per-account usage today)
11
11
  */
12
12
  import type { Command } from 'commander';
13
+ import type { AgentId } from '../lib/types.js';
14
+ import { getUsageInfoForIdentity } from '../lib/usage.js';
15
+ /** One agent's usage snapshot — the unit the text and --json renderers share. */
16
+ export interface AgentUsageRecord {
17
+ agent: AgentId;
18
+ /** Plain agent name (no ANSI) — safe to emit in --json; the text table colorizes it. */
19
+ label: string;
20
+ status: 'unsupported' | 'no-version' | 'not-signed-in' | 'ok';
21
+ email?: string;
22
+ usage?: Awaited<ReturnType<typeof getUsageInfoForIdentity>>;
23
+ }
13
24
  export declare function registerUsageCommand(program: Command): void;
25
+ /** Render one usage record as the human table section. */
26
+ export declare function formatAgentUsage(rec: AgentUsageRecord): string;
@@ -13,13 +13,15 @@ const USAGE_SUPPORTED = new Set(['claude', 'codex', 'kimi', 'droid']);
13
13
  export function registerUsageCommand(program) {
14
14
  addHostOption(program.command('usage [agent]'))
15
15
  .description('Show rate-limit / quota usage per agent')
16
+ .option('--json', 'Emit machine-readable JSON (per-agent usage snapshot) instead of the table')
16
17
  .addHelpText('after', `
17
18
  Examples:
18
19
  agents usage Show usage for all installed agents
19
20
  agents usage claude Show usage for Claude only
20
21
  agents usage codex Show usage for Codex only
22
+ agents usage --json Machine-readable snapshot for scripts
21
23
  `)
22
- .action(async (agentFilter) => {
24
+ .action(async (agentFilter, options) => {
23
25
  let filter;
24
26
  if (agentFilter) {
25
27
  const resolved = resolveAgentName(agentFilter);
@@ -33,47 +35,67 @@ Examples:
33
35
  ? [filter]
34
36
  : ALL_AGENT_IDS.filter((id) => listInstalledVersions(id).length > 0);
35
37
  if (targets.length === 0) {
38
+ if (options.json) {
39
+ console.log('[]');
40
+ return;
41
+ }
36
42
  console.log(chalk.gray('No agents installed. Run `agents add <agent>` first.'));
37
43
  return;
38
44
  }
39
- const sections = await Promise.all(targets.map(async (agentId) => renderAgentUsage(agentId)));
40
- console.log(sections.filter(Boolean).join('\n\n'));
45
+ const records = await Promise.all(targets.map((agentId) => collectAgentUsage(agentId)));
46
+ if (options.json) {
47
+ console.log(JSON.stringify(records, null, 2));
48
+ return;
49
+ }
50
+ console.log(records.map(formatAgentUsage).filter(Boolean).join('\n\n'));
41
51
  });
42
52
  }
43
- async function renderAgentUsage(agentId) {
44
- const cfg = AGENTS[agentId];
45
- const heading = agentLabel(agentId);
53
+ /** Gather one agent's usage snapshot as structured data (shared by both renderers). */
54
+ async function collectAgentUsage(agentId) {
55
+ // Plain name — color is applied only at text-render time (formatAgentUsage), so
56
+ // `--json` never emits ANSI escapes in `label` (e.g. under FORCE_COLOR=1).
57
+ const label = AGENTS[agentId].name;
46
58
  if (!USAGE_SUPPORTED.has(agentId)) {
47
- return [
48
- `${heading}`,
49
- ` ${chalk.dim(`${cfg.name} CLI does not publish usage data.`)}`,
50
- ].join('\n');
59
+ return { agent: agentId, label, status: 'unsupported' };
51
60
  }
52
61
  const versions = listInstalledVersions(agentId);
53
62
  const version = getGlobalDefault(agentId) || versions[0];
54
63
  if (!version) {
55
- return [`${heading}`, ` ${chalk.dim('No version installed.')}`].join('\n');
64
+ return { agent: agentId, label, status: 'no-version' };
56
65
  }
57
66
  const home = getVersionHomePath(agentId, version);
58
67
  const info = await getAccountInfo(agentId, home);
59
68
  if (!info.usageKey && !info.accountKey) {
60
- return [`${heading}`, ` ${chalk.dim('Not signed in.')}`].join('\n');
61
- }
62
- const usage = await getUsageInfoForIdentity({
63
- agentId,
64
- home,
65
- info,
66
- cliVersion: null,
67
- });
68
- const lines = [heading];
69
- if (info.email)
70
- lines.push(` ${chalk.dim(info.email)}`);
71
- const section = formatUsageSection(usage);
72
- if (section.length === 0) {
73
- lines.push(` ${chalk.dim('No usage data available right now.')}`);
69
+ return { agent: agentId, label, status: 'not-signed-in' };
74
70
  }
75
- else {
76
- lines.push(...section);
71
+ const usage = await getUsageInfoForIdentity({ agentId, home, info, cliVersion: null });
72
+ return { agent: agentId, label, status: 'ok', email: info.email ?? undefined, usage };
73
+ }
74
+ /** Render one usage record as the human table section. */
75
+ export function formatAgentUsage(rec) {
76
+ const cfg = AGENTS[rec.agent];
77
+ // Colorize the heading here (not in the record) so the plain `label` stays
78
+ // clean for --json while the text table keeps its per-agent color.
79
+ const heading = agentLabel(rec.agent);
80
+ switch (rec.status) {
81
+ case 'unsupported':
82
+ return [heading, ` ${chalk.dim(`${cfg.name} CLI does not publish usage data.`)}`].join('\n');
83
+ case 'no-version':
84
+ return [heading, ` ${chalk.dim('No version installed.')}`].join('\n');
85
+ case 'not-signed-in':
86
+ return [heading, ` ${chalk.dim('Not signed in.')}`].join('\n');
87
+ case 'ok': {
88
+ const lines = [heading];
89
+ if (rec.email)
90
+ lines.push(` ${chalk.dim(rec.email)}`);
91
+ const section = formatUsageSection(rec.usage);
92
+ if (section.length === 0) {
93
+ lines.push(` ${chalk.dim('No usage data available right now.')}`);
94
+ }
95
+ else {
96
+ lines.push(...section);
97
+ }
98
+ return lines.join('\n');
99
+ }
77
100
  }
78
- return lines.join('\n');
79
101
  }
@@ -123,6 +123,7 @@ export declare function viewAction(agentArg?: string, options?: {
123
123
  resources?: string | boolean;
124
124
  detailed?: boolean;
125
125
  refresh?: boolean;
126
+ live?: boolean;
126
127
  } & ViewSectionFilter): Promise<void>;
127
128
  /** Register the `agents view` command. */
128
129
  export declare function registerViewCommand(program: Command): void;
@@ -1400,6 +1400,8 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
1400
1400
  * Exported for use by deprecated aliases.
1401
1401
  */
1402
1402
  export async function viewAction(agentArg, options) {
1403
+ // --live is a shorter-to-type alias of --refresh; both force a live probe.
1404
+ const forceRefresh = options?.refresh === true || options?.live === true;
1403
1405
  // --resources / --detailed imply --json (they only shape structured output).
1404
1406
  const explicitResources = options?.detailed === true || options?.resources !== undefined;
1405
1407
  const json = options?.json === true || explicitResources;
@@ -1440,7 +1442,7 @@ export async function viewAction(agentArg, options) {
1440
1442
  return;
1441
1443
  }
1442
1444
  // No argument: show all installed versions
1443
- await showInstalledVersions(undefined, { forceRefresh: options?.refresh === true });
1445
+ await showInstalledVersions(undefined, { forceRefresh });
1444
1446
  return;
1445
1447
  }
1446
1448
  // Parse agent@version syntax
@@ -1501,7 +1503,7 @@ export async function viewAction(agentArg, options) {
1501
1503
  }
1502
1504
  else {
1503
1505
  // Just agent name: show versions for that agent
1504
- await showInstalledVersions(agentId, { forceRefresh: options?.refresh === true });
1506
+ await showInstalledVersions(agentId, { forceRefresh });
1505
1507
  }
1506
1508
  }
1507
1509
  /** Register the `agents view` command. */
@@ -1512,6 +1514,7 @@ export function registerViewCommand(program) {
1512
1514
  .option('--resources [sections]', 'In --json mode, include each version\'s resources: "all" (default) or a comma list (skills,plugins,mcp,commands,workflows,memory,hooks). Implies --json.')
1513
1515
  .option('--detailed', 'Include all resources in --json output (alias for --resources all). Implies --json.')
1514
1516
  .option('-r, --refresh', 'Force a live usage refresh, bypassing the cache (slower). Repopulates the S:/W: limit bars for every account whose token is reachable.')
1517
+ .option('--live', 'Alias of --refresh (shorter to type).')
1515
1518
  .option('--prune', 'Remove older installed versions that share an account with a newer installed version. Skips the global default.')
1516
1519
  .option('--dry-run', 'With --prune, show duplicate versions without deleting')
1517
1520
  .option('-y, --yes', 'Skip the prune confirmation prompt.')
@@ -49,6 +49,44 @@ export interface VerdictSummary {
49
49
  export declare function summarizeVerdicts(verdicts: AuthVerdict[]): VerdictSummary;
50
50
  /** Verdicts that mean "this token was rejected by the server — re-login required". */
51
51
  export declare function isDeadVerdict(verdict: AuthVerdict): boolean;
52
+ /**
53
+ * A host's rolled-up auth state for the `fleet status` Auth column.
54
+ *
55
+ * The four display buckets are deliberately finer-grained than
56
+ * {@link VerdictSummary}'s live/bad/warn: they separate "present but this agent
57
+ * has no live probe" (`unverified`) and "soft, self-healing expiry"
58
+ * (`expired`/`rate_limited`) from a genuine server rejection (`revoked`). The
59
+ * old three-bucket rollup lumped all of those into `warn` and the column painted
60
+ * them one alarming yellow — so a fleet of perfectly logged-in accounts on
61
+ * codex/grok/etc (which can NEVER be probed live) read as half-degraded. These
62
+ * buckets let the renderer show `unverified` as neutral and reserve red for the
63
+ * only verdict that actually means "re-login now" ({@link isDeadVerdict}).
64
+ */
65
+ export interface HostAuthSummary {
66
+ /** Live-verified accounts (a real 2xx). */
67
+ live: number;
68
+ /** Signed in but this agent has no live-probe endpoint — benign, neutral. */
69
+ present: number;
70
+ /** Soft/degraded: expired (self-healing) / rate_limited / error. Mild warning. */
71
+ degraded: number;
72
+ /** Server rejected the token — genuinely needs re-login. */
73
+ revoked: number;
74
+ /** Total cached rows for this host (0 → the renderer shows "—"). */
75
+ total: number;
76
+ /** Oldest `checkedAt` (epoch ms) among this host's cached rows, or null when none. */
77
+ oldestCheckedAt: number | null;
78
+ }
79
+ /**
80
+ * Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
81
+ * plus the age of its stalest entry. Pure — reads the map the caller already
82
+ * loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
83
+ * column without any network probe. A host with no cached rows yields an empty
84
+ * summary (total 0), which the renderer shows as "—".
85
+ *
86
+ * Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
87
+ * prefix so agent/version segments can never be mistaken for a host.
88
+ */
89
+ export declare function summarizeHostAuth(cache: Record<string, AuthHealth>, host: string): HostAuthSummary;
52
90
  /** Human "3m ago" style age for a checkedAt timestamp. */
53
91
  export declare function formatCheckedAge(checkedAt: number, now?: number): string;
54
92
  /**
@@ -5,9 +5,12 @@
5
5
  * The rest of the CLI reports "signed in" from a local heuristic — a credential
6
6
  * file is present and its email decodes — which cannot distinguish a good token
7
7
  * from a revoked-but-unexpired one. This module completes a real request per
8
- * (agent, account) and records the verdict in a small cache that `agents view`,
9
- * `agents fleet status`, and the run rotation all read. The daemon and
10
- * `agents fleet ping` are the writers; everyone else reads.
8
+ * (agent, account) and records the verdict in a small cache that `agents view`
9
+ * (per-version chip), `agents fleet status` (the per-host Auth column, via
10
+ * {@link summarizeHostAuth}), and the run rotation all read. The writers are
11
+ * the daemon (a periodic local refresh) and `agents fleet ping` (which also
12
+ * fans out to write remote hosts' rows into the local cache); everyone else
13
+ * reads.
11
14
  *
12
15
  * The network probes themselves live in lib/usage.ts (where the per-provider
13
16
  * token loaders + endpoints already are); this module classifies their result,
@@ -95,6 +98,48 @@ export function summarizeVerdicts(verdicts) {
95
98
  export function isDeadVerdict(verdict) {
96
99
  return verdict === 'revoked';
97
100
  }
101
+ /**
102
+ * Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
103
+ * plus the age of its stalest entry. Pure — reads the map the caller already
104
+ * loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
105
+ * column without any network probe. A host with no cached rows yields an empty
106
+ * summary (total 0), which the renderer shows as "—".
107
+ *
108
+ * Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
109
+ * prefix so agent/version segments can never be mistaken for a host.
110
+ */
111
+ export function summarizeHostAuth(cache, host) {
112
+ const prefix = `${host}:`;
113
+ let live = 0, present = 0, degraded = 0, revoked = 0, total = 0;
114
+ let oldest = null;
115
+ for (const [key, health] of Object.entries(cache)) {
116
+ if (!key.startsWith(prefix))
117
+ continue;
118
+ // `unconfigured` = no credential at all — not a probed account. Writers
119
+ // already drop these before they reach the cache; skip here too so a stray
120
+ // one never counts toward total or the freshness age (belt-and-suspenders).
121
+ if (health.verdict === 'unconfigured')
122
+ continue;
123
+ total++;
124
+ switch (health.verdict) {
125
+ case 'live':
126
+ live++;
127
+ break;
128
+ case 'unverified':
129
+ present++;
130
+ break; // signed in, no probe — benign
131
+ case 'revoked':
132
+ revoked++;
133
+ break; // server said no — re-login
134
+ default:
135
+ degraded++;
136
+ break; // expired / rate_limited / error — soft
137
+ }
138
+ if (oldest === null || health.checkedAt < oldest)
139
+ oldest = health.checkedAt;
140
+ }
141
+ return { live, present, degraded, revoked, total, oldestCheckedAt: oldest };
142
+ }
98
143
  /** Human "3m ago" style age for a checkedAt timestamp. */
99
144
  export function formatCheckedAge(checkedAt, now = Date.now()) {
100
145
  const secs = Math.max(0, Math.round((now - checkedAt) / 1000));
@@ -89,6 +89,16 @@ export interface VersionCommandDiff {
89
89
  /**
90
90
  * Compare a version home's commands against central. Returns the reconciliation diff.
91
91
  */
92
+ /**
93
+ * Flattened names of plugin-bundled commands (`<plugin>-<command>`), matching
94
+ * exactly how `syncPluginToVersion` installs a plugin's `commands/<cmd>.md` as a
95
+ * command-skill (plugins.ts → `installCommandSkillToVersion(agentDir,
96
+ * `${plugin.name}-${cmd}`, …)`). These are source-managed by their plugin, so
97
+ * the orphan detector must NOT flag them — else `prune cleanup` proposes
98
+ * deleting live plugin commands (swarm-plan, code-review, …). Scans user +
99
+ * system + extra marketplaces (no project layer), matching the trusted sources.
100
+ */
101
+ export declare function listPluginCommandNames(): Set<string>;
92
102
  export declare function diffVersionCommands(agent: AgentId, version: string): VersionCommandDiff;
93
103
  /**
94
104
  * Install a single command from central into a specific version home.
@@ -14,6 +14,7 @@ import { capableAgents, isCapable, supports } from './capabilities.js';
14
14
  import { markdownToToml } from './convert.js';
15
15
  import { getCommandsDir, getUserCommandsDir, getEnabledExtraRepos, getProjectAgentsDir, getSkillsDir, getTrashCommandsDir } from './state.js';
16
16
  import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
17
+ import { discoverPlugins } from './plugins.js';
17
18
  import { commandSkillMatches, installCommandSkillToVersion, listCommandSkillsInVersion, removeCommandSkillFromVersion, shouldInstallCommandAsSkill, } from './command-skills.js';
18
19
  import { installGooseCommandToVersion, listGooseCommandsInVersion, gooseCommandMatches, removeGooseCommandFromVersion, } from './goose-commands.js';
19
20
  function compareVersions(a, b) {
@@ -311,8 +312,26 @@ function versionCommandMatches(agent, version, commandName) {
311
312
  /**
312
313
  * Compare a version home's commands against central. Returns the reconciliation diff.
313
314
  */
315
+ /**
316
+ * Flattened names of plugin-bundled commands (`<plugin>-<command>`), matching
317
+ * exactly how `syncPluginToVersion` installs a plugin's `commands/<cmd>.md` as a
318
+ * command-skill (plugins.ts → `installCommandSkillToVersion(agentDir,
319
+ * `${plugin.name}-${cmd}`, …)`). These are source-managed by their plugin, so
320
+ * the orphan detector must NOT flag them — else `prune cleanup` proposes
321
+ * deleting live plugin commands (swarm-plan, code-review, …). Scans user +
322
+ * system + extra marketplaces (no project layer), matching the trusted sources.
323
+ */
324
+ export function listPluginCommandNames() {
325
+ const names = new Set();
326
+ for (const plugin of discoverPlugins()) {
327
+ for (const cmd of plugin.commands)
328
+ names.add(`${plugin.name}-${cmd}`);
329
+ }
330
+ return names;
331
+ }
314
332
  export function diffVersionCommands(agent, version) {
315
333
  const central = new Set(listCentralCommands());
334
+ const pluginCommands = listPluginCommandNames();
316
335
  const installed = new Set(listCommandsInVersionHome(agent, version));
317
336
  const toAdd = [];
318
337
  const toUpdate = [];
@@ -335,15 +354,20 @@ export function diffVersionCommands(agent, version) {
335
354
  }
336
355
  }
337
356
  for (const name of installed) {
338
- if (!central.has(name)) {
339
- orphans.push(name);
357
+ if (central.has(name)) {
358
+ const sourcePath = path.join(getCommandsDir(), `${name}.md`);
359
+ const metadata = parseCommandMetadata(sourcePath);
360
+ if (!commandAppliesTo(agent, version, metadata).ok) {
361
+ toRemove.push(name);
362
+ }
340
363
  continue;
341
364
  }
342
- const sourcePath = path.join(getCommandsDir(), `${name}.md`);
343
- const metadata = parseCommandMetadata(sourcePath);
344
- if (!commandAppliesTo(agent, version, metadata).ok) {
345
- toRemove.push(name);
346
- }
365
+ // A plugin-bundled command (installed as `<plugin>-<cmd>`) is source-managed
366
+ // by its plugin — not an orphan. Only a name with no central AND no plugin
367
+ // source is genuinely unmanaged.
368
+ if (pluginCommands.has(name))
369
+ continue;
370
+ orphans.push(name);
347
371
  }
348
372
  return {
349
373
  agent,
@@ -29,6 +29,9 @@ export declare function macHelperAssetUrls(version: string): {
29
29
  zip: string;
30
30
  sha256: string;
31
31
  };
32
+ /** Extract `TeamIdentifier=XXXX` from `codesign -dv --verbose=4` output (which is
33
+ * emitted on stderr). Returns null when absent (ad-hoc / unsigned). */
34
+ export declare function parseTeamId(codesignInfo: string): string | null;
32
35
  /**
33
36
  * Verify a helper `.app` bundle is intact, signed by the expected Developer ID
34
37
  * Team, and notarized (Gatekeeper-accepted). Throws with an actionable message