@phnx-labs/agents-cli 1.21.1 → 1.21.2

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 (87) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +1 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +28 -19
  6. package/dist/commands/hooks.js +9 -45
  7. package/dist/commands/menubar.js +24 -24
  8. package/dist/commands/message.js +23 -3
  9. package/dist/commands/perf.d.ts +13 -0
  10. package/dist/commands/perf.js +80 -23
  11. package/dist/commands/projects.d.ts +11 -0
  12. package/dist/commands/projects.js +153 -21
  13. package/dist/commands/routines.js +46 -1
  14. package/dist/commands/ssh.js +69 -0
  15. package/dist/commands/trends.d.ts +2 -0
  16. package/dist/commands/trends.js +158 -0
  17. package/dist/commands/usage.d.ts +4 -4
  18. package/dist/commands/view.d.ts +6 -0
  19. package/dist/commands/view.js +90 -45
  20. package/dist/index.js +14 -1
  21. package/dist/lib/agents.js +2 -2
  22. package/dist/lib/analytics/dashboard.d.ts +11 -0
  23. package/dist/lib/analytics/dashboard.js +31 -0
  24. package/dist/lib/analytics/recipes.d.ts +32 -0
  25. package/dist/lib/analytics/recipes.js +316 -0
  26. package/dist/lib/analytics/usage-db.d.ts +84 -0
  27. package/dist/lib/analytics/usage-db.js +301 -0
  28. package/dist/lib/browser/service.js +18 -0
  29. package/dist/lib/cli-resources.d.ts +20 -0
  30. package/dist/lib/cli-resources.js +48 -1
  31. package/dist/lib/daemon.js +51 -14
  32. package/dist/lib/devices/health-report.d.ts +5 -0
  33. package/dist/lib/devices/health-report.js +3 -0
  34. package/dist/lib/feed-broadcast.d.ts +52 -7
  35. package/dist/lib/feed-broadcast.js +125 -18
  36. package/dist/lib/fleet-cache.d.ts +37 -0
  37. package/dist/lib/fleet-cache.js +40 -0
  38. package/dist/lib/fleet-status.d.ts +53 -0
  39. package/dist/lib/fleet-status.js +120 -0
  40. package/dist/lib/friction-heuristics.d.ts +32 -0
  41. package/dist/lib/friction-heuristics.js +47 -0
  42. package/dist/lib/hooks/cache.js +28 -6
  43. package/dist/lib/hooks/profile.d.ts +8 -0
  44. package/dist/lib/hooks/profile.js +14 -4
  45. package/dist/lib/hooks.js +72 -17
  46. package/dist/lib/linear-cache.d.ts +63 -0
  47. package/dist/lib/linear-cache.js +146 -0
  48. package/dist/lib/linear-project-counts.d.ts +35 -5
  49. package/dist/lib/linear-project-counts.js +61 -16
  50. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  54. package/dist/lib/menubar/install-menubar.js +36 -6
  55. package/dist/lib/perf/db.d.ts +6 -1
  56. package/dist/lib/perf/db.js +35 -5
  57. package/dist/lib/perf/types.d.ts +10 -0
  58. package/dist/lib/project-doctor.d.ts +36 -0
  59. package/dist/lib/project-doctor.js +45 -0
  60. package/dist/lib/project-import.d.ts +11 -1
  61. package/dist/lib/project-import.js +17 -3
  62. package/dist/lib/project-status.d.ts +25 -5
  63. package/dist/lib/project-status.js +48 -6
  64. package/dist/lib/rotate.d.ts +27 -0
  65. package/dist/lib/rotate.js +44 -17
  66. package/dist/lib/routines.d.ts +16 -0
  67. package/dist/lib/routines.js +39 -0
  68. package/dist/lib/runner.js +34 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  70. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  71. package/dist/lib/secrets/usage-db.d.ts +3 -63
  72. package/dist/lib/secrets/usage-db.js +46 -186
  73. package/dist/lib/session/db.d.ts +2 -1
  74. package/dist/lib/session/db.js +14 -3
  75. package/dist/lib/session/discover.d.ts +3 -0
  76. package/dist/lib/session/discover.js +8 -0
  77. package/dist/lib/session/types.d.ts +1 -0
  78. package/dist/lib/startup/command-registry.d.ts +1 -0
  79. package/dist/lib/startup/command-registry.js +2 -0
  80. package/dist/lib/state.d.ts +31 -3
  81. package/dist/lib/state.js +53 -10
  82. package/dist/lib/types.d.ts +8 -4
  83. package/dist/lib/usage-refresh.d.ts +106 -0
  84. package/dist/lib/usage-refresh.js +238 -0
  85. package/dist/lib/usage.d.ts +152 -17
  86. package/dist/lib/usage.js +393 -79
  87. package/package.json +1 -1
@@ -332,6 +332,20 @@ async function localHealthRow(self, stats) {
332
332
  inventory: await collectLocalFleetInventory(process.cwd()),
333
333
  };
334
334
  }
335
+ /** SSH into a host and read its already-computed fleet-status row (a cheap
336
+ * `fleet status --local --json` on the peer — NOT a fresh remote resource probe;
337
+ * the peer's daemon keeps that row warm). Bounded + reaped via sshExecAsync's
338
+ * timeout (RUSH-2114). */
339
+ async function probeRemoteFleetStatus(target) {
340
+ const isWin = /^win/i.test((target.platform ?? '').trim());
341
+ const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
342
+ const cmd = buildRemoteAgentsInvocation(['devices', 'status', '--local', '--json'], undefined, isWin ? 'windows' : undefined, env);
343
+ const res = await sshExecAsync(target.dialTarget, cmd, { timeoutMs: 15000, multiplex: true });
344
+ if (res.code !== 0) {
345
+ throw new Error(res.timedOut ? 'timed out' : (res.stderr.trim() || `exit ${res.code ?? 'unknown'}`));
346
+ }
347
+ return JSON.parse(res.stdout);
348
+ }
335
349
  async function probeRemoteHealth(target) {
336
350
  const isWin = /^win/i.test((target.platform ?? '').trim());
337
351
  const env = isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' };
@@ -363,6 +377,19 @@ async function runFleetStatus(opts) {
363
377
  const reg = await loadDevices();
364
378
  const self = machineId();
365
379
  const forceRefresh = Boolean(opts.refresh || opts.live);
380
+ // `--local`: the publish endpoint the read-union reads over ssh. Probe THIS
381
+ // host only (resource stats + live-agent workload, no ssh) and print its row.
382
+ // Publishes into the local mirror as a side effect so a same-host reader is
383
+ // instantly warm too.
384
+ if (opts.local) {
385
+ const { publishLocalFleetStatus } = await import('../lib/fleet-status.js');
386
+ const row = await publishLocalFleetStatus(self);
387
+ if (opts.json)
388
+ console.log(JSON.stringify(row, null, 2));
389
+ else
390
+ console.log(`${self}: ${row.agents.running} running agent(s), ${row.agents.live} live`);
391
+ return;
392
+ }
366
393
  const planned = planFleetTargets(reg);
367
394
  const probeable = planned.filter((t) => !t.skip).map((t) => t.device);
368
395
  // Cache-first: serve remote stats from the daemon-warmed cache (instant),
@@ -429,6 +456,47 @@ async function runFleetStatus(opts) {
429
456
  row.lastSeen = profile.tailscale?.lastSeen ?? profile.reachability?.checkedAt;
430
457
  }
431
458
  }
459
+ // Live-agent workload (RUSH-2061): publish THIS host's row, then union peers'
460
+ // rows cache-first. The daemon no longer probes the fleet (publish-own /
461
+ // read-union), so cross-host counts are gathered HERE, on demand — a mirror row
462
+ // younger than the freshness window is served without ssh; a missing/stale one
463
+ // is read over ssh via `fleet status --local --json` (bounded + kill-on-timeout
464
+ // through sshExecAsync/fanOutDevices, RUSH-2114). Best-effort: agent counts are
465
+ // additive, so a failed gather never breaks the status render.
466
+ try {
467
+ const { publishLocalFleetStatus, readFleetStatus, writeFleetStatusRows } = await import('../lib/fleet-status.js');
468
+ const selfRow = await publishLocalFleetStatus(self);
469
+ const mirror = readFleetStatus();
470
+ const now = Date.now();
471
+ const AGENT_STATUS_STALE_MS = 3 * 60_000;
472
+ const toRead = remoteTargets.filter((t) => {
473
+ if (t.skip)
474
+ return false;
475
+ if (forceRefresh)
476
+ return true;
477
+ const row = mirror[t.name];
478
+ return !row || now - row.capturedAt > AGENT_STATUS_STALE_MS;
479
+ });
480
+ if (toRead.length > 0) {
481
+ const gathered = await fanOutDevices(toRead, probeRemoteFleetStatus, { perDeviceTimeoutMs: 20_000 });
482
+ const updates = {};
483
+ for (const g of gathered) {
484
+ if (g.status === 'ok' && g.value)
485
+ updates[g.name] = { ...g.value, host: g.name };
486
+ }
487
+ if (Object.keys(updates).length > 0)
488
+ writeFleetStatusRows(updates);
489
+ }
490
+ const union = readFleetStatus();
491
+ for (const row of rows) {
492
+ const r = row.name === self ? selfRow : union[row.name];
493
+ if (r)
494
+ row.agents = r.agents;
495
+ }
496
+ }
497
+ catch {
498
+ // best-effort — agent counts are additive to the health view
499
+ }
432
500
  const report = buildFleetHealthReport(rows, new Date(), { self });
433
501
  if (opts.json) {
434
502
  console.log(JSON.stringify(report, null, 2));
@@ -1110,6 +1178,7 @@ Typical workflow:
1110
1178
  .option('--no-stats', 'skip the live resource probe')
1111
1179
  .option('--refresh', 'force a live probe of every device, bypassing the cache')
1112
1180
  .option('--live', 'alias of --refresh (shorter to type)')
1181
+ .option('--local', "this machine only: print THIS host's status row (resource stats + live-agent workload). The publish endpoint the fleet-status read-union reads over ssh.")
1113
1182
  .option('--verbose', 'show the full per-device auth/CLI/sync/version grid instead of the summary')
1114
1183
  .action(async (opts, cmd) => {
1115
1184
  // The root program also defines a global `--verbose`; commander binds a
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerTrendsCommand(program: Command): void;
@@ -0,0 +1,158 @@
1
+ import chalk from 'chalk';
2
+ import { setHelpSections } from '../lib/help.js';
3
+ import { buildTrendsDashboard, trendsWindow, runRecipe, RECIPE_IDS } from '../lib/analytics/dashboard.js';
4
+ import { listRecipes } from '../lib/analytics/recipes.js';
5
+ import { queryUsage, usageDbPath, USAGE_KINDS } from '../lib/analytics/usage-db.js';
6
+ function parseDays(raw) {
7
+ const n = parseInt(raw ?? '7', 10);
8
+ return Number.isFinite(n) && n > 0 ? n : 7;
9
+ }
10
+ function parseLimit(raw, fallback) {
11
+ const n = parseInt(raw ?? String(fallback), 10);
12
+ return Number.isFinite(n) && n > 0 ? n : fallback;
13
+ }
14
+ function printSection(section) {
15
+ console.log(chalk.bold(section.title));
16
+ if (section.rows.length === 0) {
17
+ console.log(chalk.gray(' (no data)'));
18
+ console.log();
19
+ return;
20
+ }
21
+ const keys = Object.keys(section.rows[0]);
22
+ const widths = keys.map((k) => Math.max(k.length, ...section.rows.map((r) => String(r[k] ?? '').length), 4));
23
+ const pad = (s, w) => (s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length));
24
+ console.log(chalk.gray(keys.map((k, i) => pad(k.toUpperCase(), widths[i])).join(' ')));
25
+ for (const row of section.rows) {
26
+ console.log(keys.map((k, i) => pad(String(row[k] ?? ''), widths[i])).join(' '));
27
+ }
28
+ console.log();
29
+ }
30
+ function renderDashboard(days, asJson) {
31
+ const dash = buildTrendsDashboard({ days });
32
+ if (asJson) {
33
+ console.log(JSON.stringify(dash, null, 2));
34
+ return;
35
+ }
36
+ console.log(chalk.bold(`agents trends — last ${dash.window.days} days`));
37
+ console.log(chalk.gray(`compute ${dash.durationMs}ms · usage ${usageDbPath()}`));
38
+ console.log();
39
+ if (dash.sections.length === 0) {
40
+ console.log(chalk.gray('No session or usage data in this window yet.'));
41
+ return;
42
+ }
43
+ for (const section of dash.sections)
44
+ printSection(section);
45
+ }
46
+ export function registerTrendsCommand(program) {
47
+ const trends = program
48
+ .command('trends')
49
+ .description('Usage analytics — harness/model mix, token ratios, resource frequency')
50
+ .option('--days <n>', 'Days of history to include', '7')
51
+ .option('--json', 'Emit JSON instead of tables')
52
+ .action(function summary() {
53
+ const opts = this.opts();
54
+ renderDashboard(parseDays(opts.days), Boolean(opts.json));
55
+ });
56
+ setHelpSections(trends, {
57
+ examples: `
58
+ # Auto recipe dashboard (7d)
59
+ agents trends
60
+
61
+ # Last 30 days
62
+ agents trends --days 30
63
+
64
+ # One recipe as JSON
65
+ agents trends harness-mix --json
66
+
67
+ # Raw usage events
68
+ agents trends query --kind secret --days 7
69
+
70
+ # List baked recipe ids
71
+ agents trends recipes
72
+ `,
73
+ notes: `
74
+ Session recipes read sessions.db; resource recipes read ~/.agents/.history/analytics/usage.db.
75
+ Empty recipes are skipped on the default dashboard.
76
+ Quota / rate-limits remain on \`agents usage\`; latency on \`agents perf\`.
77
+ `,
78
+ });
79
+ trends.command('recipes')
80
+ .description('List baked recipe ids')
81
+ .option('--json', 'Emit JSON')
82
+ .action((opts) => {
83
+ const list = listRecipes();
84
+ if (opts.json) {
85
+ console.log(JSON.stringify(list, null, 2));
86
+ return;
87
+ }
88
+ for (const r of list) {
89
+ console.log(`${r.id.padEnd(22)} ${r.store.padEnd(10)} ${r.title}`);
90
+ }
91
+ });
92
+ trends.command('query')
93
+ .description('Raw usage-event query')
94
+ .option('--kind <kind>', `One of: ${USAGE_KINDS.join(', ')}`)
95
+ .option('--name <name>', 'Resource name filter')
96
+ .option('--event <event>', 'Event name filter')
97
+ .option('--days <n>', 'Days of history', '7')
98
+ .option('--limit <n>', 'Max rows', '40')
99
+ .option('--json', 'Emit JSON')
100
+ .action((opts) => {
101
+ const win = trendsWindow(parseDays(opts.days));
102
+ const kind = opts.kind && USAGE_KINDS.includes(opts.kind)
103
+ ? opts.kind
104
+ : undefined;
105
+ if (opts.kind && !kind) {
106
+ console.error(`Unknown kind '${opts.kind}'. Expected: ${USAGE_KINDS.join(', ')}`);
107
+ process.exitCode = 1;
108
+ return;
109
+ }
110
+ const rows = queryUsage({
111
+ kind,
112
+ name: opts.name,
113
+ event: opts.event,
114
+ sinceIso: win.sinceIso,
115
+ limit: parseLimit(opts.limit, 40),
116
+ });
117
+ if (opts.json) {
118
+ console.log(JSON.stringify({ window: win, rows }, null, 2));
119
+ return;
120
+ }
121
+ if (rows.length === 0) {
122
+ console.log(chalk.gray('No usage events.'));
123
+ return;
124
+ }
125
+ printSection({
126
+ id: 'query',
127
+ title: 'Usage events',
128
+ rows: rows.map((r) => ({
129
+ ts: r.ts,
130
+ kind: r.kind,
131
+ name: r.name,
132
+ event: r.event,
133
+ agent: r.agent,
134
+ })),
135
+ });
136
+ });
137
+ for (const id of RECIPE_IDS) {
138
+ trends.command(id)
139
+ .description(`Recipe: ${id}`)
140
+ .option('--days <n>', 'Days of history', '7')
141
+ .option('--json', 'Emit JSON')
142
+ .action(function recipeAction() {
143
+ const parent = this.parent?.opts?.();
144
+ const opts = { ...parent, ...this.opts() };
145
+ const win = trendsWindow(parseDays(opts.days));
146
+ const section = runRecipe(id, win);
147
+ if (opts.json) {
148
+ console.log(JSON.stringify({ window: win, section }, null, 2));
149
+ return;
150
+ }
151
+ if (section.empty) {
152
+ console.log(chalk.gray(`No data for recipe '${id}' in the last ${win.days} days.`));
153
+ return;
154
+ }
155
+ printSection(section);
156
+ });
157
+ }
158
+ }
@@ -2,12 +2,12 @@
2
2
  * Usage command -- show rate-limit / quota status for each installed agent.
3
3
  *
4
4
  * Lists every installed agent with the best available usage snapshot:
5
- * - claude: live OAuth API call (cached for 2 minutes)
5
+ * - claude: live OAuth API call (cached for 5 minutes)
6
6
  * - codex: parsed from latest session log's rate_limits event
7
- * - kimi: live Kimi Code /usages API call (cached for 2 minutes)
8
- * - droid: live Factory billing/limits API call (cached for 2 minutes)
7
+ * - kimi: live Kimi Code /usages API call (cached for 5 minutes)
8
+ * - droid: live Factory billing/limits API call (cached for 5 minutes)
9
9
  * - grok: parsed from the latest local usage event
10
- * - cursor: live Cursor usage API call (cached for 2 minutes)
10
+ * - cursor: live Cursor usage API call (cached for 5 minutes)
11
11
  * - others: marked as "not exposed by CLI"
12
12
  */
13
13
  import type { Command } from 'commander';
@@ -14,6 +14,12 @@ import { type ConfiguredModelSource } from '../lib/models.js';
14
14
  import { type ProfileSummary } from '../lib/profiles.js';
15
15
  /** Shared account identity formatter, re-exported for the view-specific tests. */
16
16
  export declare const accountColumnLabel: typeof accountDisplayLabel;
17
+ /**
18
+ * Join fixed view columns with a consistent two-space gutter. Empty trailing
19
+ * columns are dropped so a row without an auth chip does not grow a dangling
20
+ * gutter, but interior empties stay padded so later columns stay aligned.
21
+ */
22
+ export declare function joinViewColumns(cols: string[]): string;
17
23
  type SyncState = 'synced' | 'new' | 'modified' | 'deleted';
18
24
  /** Per-section filter flags. When any are true, only those sections render. */
19
25
  export interface ViewSectionFilter {
@@ -1,6 +1,6 @@
1
1
  import { addHostOption } from '../lib/hosts/option.js';
2
2
  import chalk from 'chalk';
3
- import { visibleWidth, termLink } from '../lib/format.js';
3
+ import { termLink } from '../lib/format.js';
4
4
  import ora from 'ora';
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
@@ -31,9 +31,31 @@ import { renderHarnessDetail } from './harness.js';
31
31
  import { loadManifest, isStale } from '../lib/staleness/index.js';
32
32
  import { confirm } from '@inquirer/prompts';
33
33
  import { formatPath, isInteractiveTerminal, isPromptCancelled } from './utils.js';
34
- import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
34
+ import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
35
35
  /** Shared account identity formatter, re-exported for the view-specific tests. */
36
36
  export const accountColumnLabel = accountDisplayLabel;
37
+ /**
38
+ * Overview (`agents view` with no agent filter) caps compact usage windows so
39
+ * multi-meter agents (Antigravity's four model quotas, Droid's three buckets)
40
+ * cannot force every row to pad past the terminal width and wrap. Single-agent
41
+ * views leave the cap unset and show every blocking window.
42
+ */
43
+ const OVERVIEW_MAX_USAGE_WINDOWS = 2;
44
+ /** Fixed width for the last-active column ("just now", "8h ago", "2d ago"). */
45
+ const LAST_ACTIVE_COL_WIDTH = 10;
46
+ /**
47
+ * Join fixed view columns with a consistent two-space gutter. Empty trailing
48
+ * columns are dropped so a row without an auth chip does not grow a dangling
49
+ * gutter, but interior empties stay padded so later columns stay aligned.
50
+ */
51
+ export function joinViewColumns(cols) {
52
+ // Trim only pure-trailing empty strings so auth/status can be absent without
53
+ // shifting earlier columns for rows that do carry them.
54
+ let end = cols.length;
55
+ while (end > 0 && cols[end - 1] === '')
56
+ end--;
57
+ return cols.slice(0, end).join(' ');
58
+ }
37
59
  /**
38
60
  * Custom harnesses (the `~/.agents/profiles/*.yml` bundles), sorted by name and
39
61
  * optionally narrowed to the ones that run on one host agent. YAMLs that fail
@@ -246,6 +268,8 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
246
268
  : 'Checking installed agents...';
247
269
  const spinner = ora({ text: spinnerText, isSilent: !process.stdout.isTTY }).start();
248
270
  const agentsToShow = filterAgentId ? [filterAgentId] : ALL_AGENT_IDS;
271
+ // Overview caps meter count; single-agent view shows every blocking window.
272
+ const usageWindowCap = filterAgentId ? undefined : OVERVIEW_MAX_USAGE_WINDOWS;
249
273
  // A globally-installed CLI is superseded only by a NORMAL managed version — that
250
274
  // is when agents-cli owns the launcher and a "global" row would just be our own
251
275
  // shim reported back. `--isolated` promises the opposite: no default, no bare
@@ -265,7 +289,6 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
265
289
  const cliStates = Object.fromEntries(await Promise.all(agentsToShow
266
290
  .filter((agentId) => !hasNonIsolatedVersion(agentId))
267
291
  .map(async (agentId) => [agentId, await getUnmanagedCliState(agentId)])));
268
- spinner.stop();
269
292
  const showPaths = !!filterAgentId;
270
293
  const harnesses = getHarnesses(filterAgentId);
271
294
  // Auto-heal stale versioned aliases. Pre-v2 aliases (e.g. pre-CLAUDE_CONFIG_DIR
@@ -287,11 +310,15 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
287
310
  }
288
311
  }
289
312
  // Shim healing is silent — users don't need to know about internal repairs
290
- console.log(chalk.bold('Installed Agent CLIs\n'));
291
313
  const selfHost = machineId();
292
314
  // Read the auth-health cache once (not per version row — see the batching note above).
293
315
  const authCache = readAuthHealthCache();
294
- // Pre-fetch account info for all versions in parallel
316
+ // Pre-fetch account info for all versions in parallel. Spinner stays up through
317
+ // account + usage so a multi-account cold path doesn't leave a blank terminal
318
+ // after "Checking…" vanishes (the hang the screenshots caught).
319
+ spinner.text = filterAgentId
320
+ ? `Loading ${agentLabel(filterAgentId)} accounts...`
321
+ : 'Loading accounts and usage...';
295
322
  const infoFetches = [];
296
323
  const globalInfoFetches = [];
297
324
  for (const agentId of agentsToShow) {
@@ -329,6 +356,7 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
329
356
  // or org scope, not a specific installed version. Version homes cache those
330
357
  // values independently, so older installs can show stale values. Reuse the
331
358
  // freshest cache entry per stable usage identity and keep lastActive per version.
359
+ // Goes through the unified usage core (SWR cache + concurrency cap + timeout).
332
360
  const { canonicalByUsageKey, usageByKey } = await getUsageInfoByIdentity([
333
361
  ...infoResults.map(({ agentId, home, version, info }) => ({
334
362
  agentId,
@@ -342,6 +370,8 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
342
370
  info,
343
371
  })),
344
372
  ], { forceRefresh: viewOpts?.forceRefresh });
373
+ spinner.stop();
374
+ console.log(chalk.bold('Installed Agent CLIs\n'));
345
375
  const mergeCanonical = (info) => {
346
376
  const key = getUsageLookupKey(info);
347
377
  if (!key)
@@ -441,7 +471,8 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
441
471
  }
442
472
  }
443
473
  }
444
- // Second pass: compute max visible usage + status widths (now that maxPlanWidth is settled)
474
+ // Second pass: compute max visible usage + status widths (now that maxPlanWidth is settled).
475
+ // stringWidth (not String.length) so chalk + block-bar glyphs pad correctly.
445
476
  for (const agentId of versionManaged) {
446
477
  const versions = listInstalledVersions(agentId);
447
478
  for (const v of versions) {
@@ -451,10 +482,14 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
451
482
  const usageInfo = usageKey ? usageByKey.get(usageKey) : undefined;
452
483
  const usageUnavailable = agentReportsUsage(agentId) && !!info?.signedIn && !usageInfo?.snapshot;
453
484
  const usageUnverified = !!usageInfo?.snapshot && !!usageInfo.error;
454
- const usageStr = formatUsageSummary(info?.plan || null, usageInfo?.snapshot || null, maxPlanWidth, { unavailable: usageUnavailable, unverified: usageUnverified });
455
- maxUsageWidth = Math.max(maxUsageWidth, visibleWidth(usageStr));
485
+ const usageStr = formatUsageSummary(info?.plan || null, usageInfo?.snapshot || null, maxPlanWidth, {
486
+ unavailable: usageUnavailable,
487
+ unverified: usageUnverified,
488
+ maxWindows: usageWindowCap,
489
+ });
490
+ maxUsageWidth = Math.max(maxUsageWidth, stringWidth(usageStr));
456
491
  const statusStr = formatUsageStatusBadge(info?.usageStatus);
457
- maxStatusWidth = Math.max(maxStatusWidth, visibleWidth(statusStr));
492
+ maxStatusWidth = Math.max(maxStatusWidth, stringWidth(statusStr));
458
493
  }
459
494
  }
460
495
  for (const agentId of versionManaged) {
@@ -496,18 +531,26 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
496
531
  const vInfo = rawInfo ? mergeCanonical(rawInfo) : undefined;
497
532
  const usageKey = getUsageLookupKey(vInfo);
498
533
  const usageInfo = usageKey ? usageByKey.get(usageKey) : undefined;
499
- // Build columns, trimming trailing whitespace when columns are empty
534
+ // Fixed columns for every signed-in row so status / lastActive / auth
535
+ // stay vertically aligned across agents — even when this row has no
536
+ // usage bars or no rate-limit badge. Skipping empty columns mid-table
537
+ // was what made the multi-agent view look unjustified next to
538
+ // `agents view claude`.
500
539
  const parts = [` ${label}`];
501
540
  // Configured model — same priority as the version, right beside it.
502
541
  if (maxModelWidth > 0) {
503
542
  const model = modelByKey.get(`${agentId}:${version}`) ?? '';
504
- parts.push(chalk.yellow(model.padEnd(maxModelWidth)));
543
+ parts.push(chalk.yellow(padToWidth(model, maxModelWidth)));
505
544
  }
506
545
  const hasEmail = !!vInfo?.email;
507
546
  const signedIn = !!vInfo?.signedIn;
508
547
  const usageUnavailable = agentReportsUsage(agentId) && signedIn && !usageInfo?.snapshot;
509
548
  const usageUnverified = !!usageInfo?.snapshot && !!usageInfo.error;
510
- const usageStr = formatUsageSummary(vInfo?.plan || null, usageInfo?.snapshot || null, maxPlanWidth, { unavailable: usageUnavailable, unverified: usageUnverified });
549
+ const usageStr = formatUsageSummary(vInfo?.plan || null, usageInfo?.snapshot || null, maxPlanWidth, {
550
+ unavailable: usageUnavailable,
551
+ unverified: usageUnverified,
552
+ maxWindows: usageWindowCap,
553
+ });
511
554
  const hasUsage = usageStr.length > 0;
512
555
  // Only show lastActive for versions with an actual logged-in account.
513
556
  // Otherwise it reflects install time (misleading "just now" for fresh installs).
@@ -534,25 +577,21 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
534
577
  : '(logged out — log in with: ' + loginHint(agentId) + ')'));
535
578
  }
536
579
  else {
537
- if (hasEmail || hasUsage || hasActive || signedIn) {
538
- // Signed-in agents without a local email show their account id
539
- // (Kimi's user_id) or a "signed in" placeholder (Antigravity) so they
540
- // read as logged in, not blank.
541
- const display = accountColumnLabel(vInfo);
542
- const emailCol = display.padEnd(maxEmail);
543
- parts.push(display ? chalk.cyan(emailCol) : ' '.repeat(maxEmail));
544
- }
545
- if (hasUsage || hasActive) {
546
- const usagePad = ' '.repeat(Math.max(0, maxUsageWidth - visibleWidth(usageStr)));
547
- parts.push(usageStr + usagePad);
580
+ // Always emit account / usage / status / lastActive columns once any
581
+ // signed-in row exists in the table (widths are global). Empty cells
582
+ // are space-padded so later columns do not drift left.
583
+ const display = accountColumnLabel(vInfo);
584
+ parts.push(display ? chalk.cyan(padToWidth(display, maxEmail)) : ' '.repeat(maxEmail));
585
+ if (maxUsageWidth > 0) {
586
+ parts.push(padToWidth(usageStr, maxUsageWidth));
548
587
  }
549
- const statusStr = formatUsageStatusBadge(vInfo?.usageStatus);
550
588
  if (maxStatusWidth > 0) {
551
- const statusPad = ' '.repeat(Math.max(0, maxStatusWidth - visibleWidth(statusStr)));
552
- parts.push(statusStr + statusPad);
589
+ const statusStr = formatUsageStatusBadge(vInfo?.usageStatus);
590
+ parts.push(padToWidth(statusStr, maxStatusWidth));
553
591
  }
554
- if (hasActive)
555
- parts.push(activeStr);
592
+ // Fixed-width lastActive so the auth chip (●/○/◐) lines up even when
593
+ // some rows have no email-derived lastActive.
594
+ parts.push(hasActive ? padToWidth(activeStr, LAST_ACTIVE_COL_WIDTH) : ' '.repeat(LAST_ACTIVE_COL_WIDTH));
556
595
  }
557
596
  if (runDefaultBits.length > 0) {
558
597
  parts.push(chalk.gray(`run ${runDefaultBits.join(' ')}`));
@@ -560,7 +599,7 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
560
599
  const authChip = liveAuthChip(authCache, selfHost, agentId, version);
561
600
  if (authChip)
562
601
  parts.push(authChip);
563
- console.log(parts.join(' '));
602
+ console.log(joinViewColumns(parts));
564
603
  if (showPaths) {
565
604
  const versionDir = getVersionDir(agentId, version);
566
605
  console.log(chalk.gray(` ${versionDir}`));
@@ -586,18 +625,25 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
586
625
  const cliState = cliStates[agentId];
587
626
  return `${cliState?.version || 'installed'} (global)`.length;
588
627
  }));
589
- // Pre-pass: max badge width so rows with `lastActive` line up whether or
590
- // not THIS row carries a throttle badge. Without this, the row that DOES
591
- // have "out of credits" shifts every other row's `lastActive` left by
592
- // ~16 chars, exactly what the version-managed block at maxStatusWidth
593
- // already solves above.
628
+ // Pre-pass: max badge/usage/email widths so columns line up the same way
629
+ // the version-managed block does (stringWidth for chalk-aware padding).
594
630
  let gMaxStatusWidth = 0;
631
+ let gMaxUsageWidth = 0;
632
+ let gMaxEmail = 0;
595
633
  for (const agentId of globallyInstalled) {
596
634
  const gInfoRaw = globalInfoMap.get(agentId);
597
635
  const gInfo = gInfoRaw ? mergeCanonical(gInfoRaw) : undefined;
598
- const w = visibleWidth(formatUsageStatusBadge(gInfo?.usageStatus));
599
- if (w > gMaxStatusWidth)
600
- gMaxStatusWidth = w;
636
+ gMaxStatusWidth = Math.max(gMaxStatusWidth, stringWidth(formatUsageStatusBadge(gInfo?.usageStatus)));
637
+ const gUsageKey = getUsageLookupKey(gInfo);
638
+ const gUsage = gUsageKey ? usageByKey.get(gUsageKey) : undefined;
639
+ const gUsageStr = formatUsageSummary(gInfo?.plan || null, gUsage?.snapshot || null, 3, {
640
+ unverified: !!gUsage?.snapshot && !!gUsage.error,
641
+ maxWindows: usageWindowCap,
642
+ });
643
+ gMaxUsageWidth = Math.max(gMaxUsageWidth, stringWidth(gUsageStr));
644
+ const gDisplay = accountColumnLabel(gInfo);
645
+ if (gDisplay)
646
+ gMaxEmail = Math.max(gMaxEmail, gDisplay.length);
601
647
  }
602
648
  for (const agentId of globallyInstalled) {
603
649
  const agent = AGENTS[agentId];
@@ -613,22 +659,21 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
613
659
  const gUsage = gUsageKey ? usageByKey.get(gUsageKey) : undefined;
614
660
  const gUsageStr = formatUsageSummary(gInfo?.plan || null, gUsage?.snapshot || null, 3, {
615
661
  unverified: !!gUsage?.snapshot && !!gUsage.error,
662
+ maxWindows: usageWindowCap,
616
663
  });
617
664
  const gActiveStr = gInfo ? formatLastActive(gInfo.lastActive) : '';
618
665
  if (gInfo?.email || gUsageStr || gActiveStr || gInfo?.signedIn) {
619
666
  const gDisplay = accountColumnLabel(gInfo);
620
- parts.push(gDisplay ? chalk.cyan(gDisplay) : '');
667
+ parts.push(gDisplay ? chalk.cyan(padToWidth(gDisplay, gMaxEmail)) : ' '.repeat(gMaxEmail));
621
668
  }
622
- if (gUsageStr || gActiveStr)
623
- parts.push(gUsageStr);
624
- const gStatusStr = formatUsageStatusBadge(gInfo?.usageStatus);
669
+ if (gMaxUsageWidth > 0)
670
+ parts.push(padToWidth(gUsageStr, gMaxUsageWidth));
625
671
  if (gMaxStatusWidth > 0) {
626
- const statusPad = ' '.repeat(Math.max(0, gMaxStatusWidth - visibleWidth(gStatusStr)));
627
- parts.push(gStatusStr + statusPad);
672
+ parts.push(padToWidth(formatUsageStatusBadge(gInfo?.usageStatus), gMaxStatusWidth));
628
673
  }
629
674
  if (gActiveStr)
630
- parts.push(gActiveStr);
631
- console.log(parts.join(' '));
675
+ parts.push(padToWidth(gActiveStr, LAST_ACTIVE_COL_WIDTH));
676
+ console.log(joinViewColumns(parts));
632
677
  if (showPaths && cliState?.path) {
633
678
  console.log(chalk.gray(` ${cliState.path}`));
634
679
  }
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, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadPerf, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadActivity, 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, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadSend, loadFeed, loadActivity, 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';
@@ -197,6 +197,18 @@ program.hook('postAction', (_thisCommand, actionCommand) => {
197
197
  command,
198
198
  ...(durationMs !== undefined ? { durationMs } : {}),
199
199
  });
200
+ if (parts[0] === 'run') {
201
+ const agentName = actionCommand.args?.[0] ? String(actionCommand.args[0]).split('@')[0] : 'run';
202
+ void import('./lib/analytics/usage-db.js').then(({ recordUsage }) => {
203
+ recordUsage({
204
+ kind: 'agent',
205
+ name: agentName || 'run',
206
+ event: 'invoke',
207
+ source: 'cli',
208
+ meta: durationMs !== undefined ? { durationMs } : undefined,
209
+ });
210
+ }).catch(() => { });
211
+ }
200
212
  // Disposable perf warehouse — fail-soft spool append (no SQLite on this path).
201
213
  if (durationMs !== undefined && parts[0] !== 'perf') {
202
214
  void import('./lib/perf/spool.js').then(({ recordSample }) => {
@@ -933,6 +945,7 @@ async function registerAllEagerCommands() {
933
945
  await reg(loadUsage);
934
946
  await reg(loadCost);
935
947
  await reg(loadPerf);
948
+ await reg(loadTrends);
936
949
  await reg(loadOutput);
937
950
  await reg(loadBudget);
938
951
  await reg(loadAlias);
@@ -1754,8 +1754,8 @@ export async function getAccountInfo(agentId, home) {
1754
1754
  }
1755
1755
  }
1756
1756
  // Fresh window for the cached session walk. Matches USAGE_CACHE_FRESH_MS in
1757
- // usage.ts so a launch storm reuses both probes for the same period.
1758
- const LAST_ACTIVE_CACHE_FRESH_MS = 2 * 60 * 1000;
1757
+ // usage.ts (5 minutes) so a launch storm reuses both probes for the same period.
1758
+ const LAST_ACTIVE_CACHE_FRESH_MS = 5 * 60 * 1000;
1759
1759
  const getLastActiveCachePath = () => path.join(getCacheDir(), 'last-active.json');
1760
1760
  /**
1761
1761
  * Determine when the agent was last used by checking session file mtimes,
@@ -0,0 +1,11 @@
1
+ import { RECIPE_IDS, runRecipe, trendsWindow, type RecipeId, type RecipeSection, type TrendsWindow } from './recipes.js';
2
+ export interface TrendsDashboard {
3
+ window: TrendsWindow;
4
+ durationMs: number;
5
+ sections: RecipeSection[];
6
+ }
7
+ export declare function buildTrendsDashboard(opts?: {
8
+ days?: number;
9
+ ids?: RecipeId[];
10
+ }): TrendsDashboard;
11
+ export { trendsWindow, runRecipe, RECIPE_IDS, type RecipeId, type RecipeSection };
@@ -0,0 +1,31 @@
1
+ import { RECIPE_IDS, runRecipe, trendsWindow, } from './recipes.js';
2
+ const DEFAULT_ORDER = [
3
+ 'harness-mix',
4
+ 'model-mix',
5
+ 'session-volume',
6
+ 'token-ratio',
7
+ 'tools-per-session',
8
+ 'secrets-hot',
9
+ 'browser-activity',
10
+ 'resource-mix',
11
+ ];
12
+ export function buildTrendsDashboard(opts = {}) {
13
+ const t0 = Date.now();
14
+ const win = trendsWindow(opts.days ?? 7);
15
+ const ids = opts.ids ?? DEFAULT_ORDER;
16
+ const sections = [];
17
+ for (const id of ids) {
18
+ if (!RECIPE_IDS.includes(id))
19
+ continue;
20
+ const section = runRecipe(id, win);
21
+ if (section.empty)
22
+ continue;
23
+ sections.push(section);
24
+ }
25
+ return {
26
+ window: win,
27
+ durationMs: Date.now() - t0,
28
+ sections,
29
+ };
30
+ }
31
+ export { trendsWindow, runRecipe, RECIPE_IDS };