@phnx-labs/agents-cli 1.21.0 → 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 (102) hide show
  1. package/CHANGELOG.md +198 -0
  2. package/README.md +2 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.js +5 -2
  5. package/dist/commands/feed.js +63 -40
  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/monitors.js +8 -6
  10. package/dist/commands/perf.d.ts +13 -0
  11. package/dist/commands/perf.js +80 -23
  12. package/dist/commands/projects.d.ts +11 -0
  13. package/dist/commands/projects.js +153 -21
  14. package/dist/commands/routines.js +46 -1
  15. package/dist/commands/ssh.js +69 -0
  16. package/dist/commands/trends.d.ts +2 -0
  17. package/dist/commands/trends.js +158 -0
  18. package/dist/commands/usage.d.ts +4 -4
  19. package/dist/commands/view.d.ts +6 -0
  20. package/dist/commands/view.js +90 -45
  21. package/dist/index.js +14 -1
  22. package/dist/lib/activity.d.ts +6 -0
  23. package/dist/lib/activity.js +3 -0
  24. package/dist/lib/agents.js +2 -2
  25. package/dist/lib/analytics/dashboard.d.ts +11 -0
  26. package/dist/lib/analytics/dashboard.js +31 -0
  27. package/dist/lib/analytics/recipes.d.ts +32 -0
  28. package/dist/lib/analytics/recipes.js +316 -0
  29. package/dist/lib/analytics/usage-db.d.ts +84 -0
  30. package/dist/lib/analytics/usage-db.js +301 -0
  31. package/dist/lib/browser/service.js +18 -0
  32. package/dist/lib/channels/providers/openclaw-telegram.js +1 -1
  33. package/dist/lib/channels/resolve.d.ts +18 -1
  34. package/dist/lib/channels/resolve.js +15 -5
  35. package/dist/lib/cli-resources.d.ts +20 -0
  36. package/dist/lib/cli-resources.js +48 -1
  37. package/dist/lib/daemon.js +51 -14
  38. package/dist/lib/devices/health-report.d.ts +5 -0
  39. package/dist/lib/devices/health-report.js +3 -0
  40. package/dist/lib/feed-broadcast.d.ts +93 -12
  41. package/dist/lib/feed-broadcast.js +235 -30
  42. package/dist/lib/feed-post.d.ts +16 -2
  43. package/dist/lib/feed-post.js +35 -7
  44. package/dist/lib/feed.js +1 -1
  45. package/dist/lib/fleet-cache.d.ts +37 -0
  46. package/dist/lib/fleet-cache.js +40 -0
  47. package/dist/lib/fleet-status.d.ts +53 -0
  48. package/dist/lib/fleet-status.js +120 -0
  49. package/dist/lib/friction-heuristics.d.ts +32 -0
  50. package/dist/lib/friction-heuristics.js +47 -0
  51. package/dist/lib/hooks/cache.js +28 -6
  52. package/dist/lib/hooks/profile.d.ts +8 -0
  53. package/dist/lib/hooks/profile.js +14 -4
  54. package/dist/lib/hooks.d.ts +16 -1
  55. package/dist/lib/hooks.js +133 -18
  56. package/dist/lib/linear-cache.d.ts +63 -0
  57. package/dist/lib/linear-cache.js +146 -0
  58. package/dist/lib/linear-project-counts.d.ts +35 -5
  59. package/dist/lib/linear-project-counts.js +61 -16
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  64. package/dist/lib/menubar/install-menubar.js +36 -6
  65. package/dist/lib/monitors/config.d.ts +1 -1
  66. package/dist/lib/monitors/dispatch.d.ts +6 -2
  67. package/dist/lib/monitors/dispatch.js +10 -15
  68. package/dist/lib/notify.d.ts +42 -6
  69. package/dist/lib/notify.js +41 -32
  70. package/dist/lib/perf/db.d.ts +6 -1
  71. package/dist/lib/perf/db.js +35 -5
  72. package/dist/lib/perf/types.d.ts +10 -0
  73. package/dist/lib/project-doctor.d.ts +36 -0
  74. package/dist/lib/project-doctor.js +45 -0
  75. package/dist/lib/project-import.d.ts +11 -1
  76. package/dist/lib/project-import.js +17 -3
  77. package/dist/lib/project-status.d.ts +25 -5
  78. package/dist/lib/project-status.js +48 -6
  79. package/dist/lib/rotate.d.ts +27 -0
  80. package/dist/lib/rotate.js +44 -17
  81. package/dist/lib/routines.d.ts +16 -0
  82. package/dist/lib/routines.js +39 -0
  83. package/dist/lib/runner.js +34 -0
  84. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  85. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  86. package/dist/lib/secrets/usage-db.d.ts +3 -63
  87. package/dist/lib/secrets/usage-db.js +46 -186
  88. package/dist/lib/session/db.d.ts +2 -1
  89. package/dist/lib/session/db.js +14 -3
  90. package/dist/lib/session/discover.d.ts +3 -0
  91. package/dist/lib/session/discover.js +8 -0
  92. package/dist/lib/session/types.d.ts +1 -0
  93. package/dist/lib/startup/command-registry.d.ts +1 -0
  94. package/dist/lib/startup/command-registry.js +2 -0
  95. package/dist/lib/state.d.ts +31 -3
  96. package/dist/lib/state.js +53 -10
  97. package/dist/lib/types.d.ts +14 -4
  98. package/dist/lib/usage-refresh.d.ts +106 -0
  99. package/dist/lib/usage-refresh.js +238 -0
  100. package/dist/lib/usage.d.ts +152 -17
  101. package/dist/lib/usage.js +393 -79
  102. package/package.json +1 -1
@@ -25,9 +25,10 @@ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
25
25
  import { factoryProjectsPath } from '../lib/auto-dispatch.js';
26
26
  import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, } from '../lib/project-probe.js';
27
27
  import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
28
- import { rollupSessionsByProject, planPct, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
28
+ import { rollupSessionsByProject, liveDeadSplit, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
29
29
  import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
30
30
  import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
31
+ import { checkRepoSlug } from '../lib/project-doctor.js';
31
32
  import { buildFactoryImportCandidates, buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
32
33
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
33
34
  export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
@@ -57,7 +58,7 @@ function parseContextFlag(raw) {
57
58
  return { path: raw.slice(0, i).trim(), purpose: raw.slice(i + 1).trim() };
58
59
  }
59
60
  /** Best-effort `owner/repo` from a repo's origin remote. */
60
- function originSlug(cwd) {
61
+ export function originSlug(cwd) {
61
62
  try {
62
63
  const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8' }).trim();
63
64
  return parseOwnerRepoFromRemote(url) ?? undefined;
@@ -94,7 +95,9 @@ function runFactoryImport(existing, opts) {
94
95
  : Array.isArray(parsed?.projects)
95
96
  ? parsed.projects
96
97
  : [];
97
- return buildFactoryImportCandidates(rows, existing, opts);
98
+ // The checkout's real `origin` overrides the registry's path-derived slug —
99
+ // see buildFactoryImportCandidates for why the path guess is not trustworthy.
100
+ return buildFactoryImportCandidates(rows, existing, opts, (root) => originSlug(expandLocalHome(root)));
98
101
  }
99
102
  /**
100
103
  * List the workspace's Linear projects and plan their import, binding each to a
@@ -183,6 +186,40 @@ export function formatMilestoneDue(targetDate, nowMs) {
183
186
  });
184
187
  return `due ${label}`;
185
188
  }
189
+ /**
190
+ * The milestone block. `status` prints one line — the next checkpoint — because
191
+ * a roll-up across projects has to stay scannable. `view` prints every declared
192
+ * milestone, because "how many are there and when are they due" is the shape of
193
+ * the plan and the reason to open one project.
194
+ *
195
+ * Pure (chalk only) so the layout is testable without a Linear account.
196
+ */
197
+ export function formatMilestoneLines(milestones, next, nowMs, limit) {
198
+ if (milestones.length === 0) {
199
+ // `next` without a list only happens on a cached answer written before the
200
+ // list existed; render what we have rather than dropping the row.
201
+ return next ? [` ${chalk.dim('next')} ${formatNextMilestone(next, nowMs)}`] : [];
202
+ }
203
+ // The next milestone leads, always. Linear can flag a LATER-dated milestone
204
+ // as next, and `milestones` is date-ordered — so slicing the front would show
205
+ // an earlier one and hide the actual next behind "+N more", which is the one
206
+ // thing this row exists to say. Identity is name+targetDate: two milestones
207
+ // can share a name, and matching on name alone labelled the wrong row.
208
+ const key = (m) => `${m.name}${m.targetDate ?? ''}`;
209
+ const lead = next ? milestones.filter((m) => key(m) === key(next)).slice(0, 1) : [];
210
+ const others = next ? milestones.filter((m) => key(m) !== key(next)) : milestones;
211
+ const ordered = [...lead, ...others];
212
+ const shown = ordered.slice(0, Math.max(1, limit));
213
+ const out = shown.map((m, i) => {
214
+ const label = i === 0 && lead.length > 0 ? 'next' : i === 0 ? 'plan' : '';
215
+ return ` ${chalk.dim(label.padEnd(4))} ${formatNextMilestone(m, nowMs)}`;
216
+ });
217
+ const rest = milestones.length - shown.length;
218
+ if (rest > 0) {
219
+ out.push(` ${' '.repeat(4)} ${chalk.dim(`+${rest} more milestone${rest === 1 ? '' : 's'} — agents projects view <name>`)}`);
220
+ }
221
+ return out;
222
+ }
186
223
  /** The `next` card line: what this project is due to hit, and how far along it is. */
187
224
  export function formatNextMilestone(ms, nowMs) {
188
225
  const parts = [chalk.bold(ms.name)];
@@ -206,25 +243,39 @@ function statusBar(r) {
206
243
  push(r.byStatus.input_required, 'need-input', chalk.yellow);
207
244
  push(r.byStatus.queued, 'queued', chalk.gray);
208
245
  const shown = (r.byStatus.running ?? 0) + (r.byStatus.idle ?? 0) + (r.byStatus.input_required ?? 0) + (r.byStatus.queued ?? 0);
209
- // Sessions in a non-live state (orphaned/crashed/unknown) count toward the
210
- // headline render the remainder so the bar never sums to less than it.
211
- if (r.agents > shown)
212
- parts.push(chalk.gray(`+${r.agents - shown} other`));
246
+ // The remainder is the LIVE sessions in a state without its own chip
247
+ // (orphaned, unknown). Dead ones are on their own row now, so counting them
248
+ // here made the line disagree with the headline — `19 live` beside
249
+ // `+24 other`, which invites the reader to trust neither.
250
+ const live = liveDeadSplit(r.byStatus).live;
251
+ if (live > shown)
252
+ parts.push(chalk.gray(`+${live - shown} other`));
213
253
  return parts.join(' · ') || chalk.gray('no live agents');
214
254
  }
215
- function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
216
- const agents = r?.agents ?? 0;
217
- const pct = r ? planPct(r.plan) : undefined;
218
- const planStr = pct === undefined ? '' : ` · ${chalk.cyan(`${pct}% plan`)}`;
219
- console.log(`${chalk.bold(def.name)} ${chalk.dim('·')} ${chalk.bold(`${agents} agents`)}${planStr}`);
255
+ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now(),
256
+ /** How many milestones to print. `status` shows the next one; `view` shows all. */
257
+ milestoneLimit = 1) {
258
+ // The headline counts LIVE agents. It used to be every matched session, which
259
+ // read `39 agents` on a project where 19 had crashed. `planPct` used to sit
260
+ // here too and is gone: it summed each session's latest checklist snapshot,
261
+ // so one agent opening a fresh 40-item plan rendered the whole project `0%`.
262
+ const split = r ? liveDeadSplit(r.byStatus) : { live: 0, dead: 0, deadByStatus: [] };
263
+ console.log(`${chalk.bold(def.name)} ${chalk.dim('·')} ${chalk.bold(`${split.live} live`)}`);
220
264
  if (def.description)
221
265
  console.log(` ${chalk.dim(def.description)}`);
222
266
  console.log(` ${chalk.dim('live')} ${r ? statusBar(r) : chalk.gray('no live agents')}`);
267
+ if (split.dead > 0) {
268
+ // Wreckage is worth a number of its own — 19 crashed sessions is a thing to
269
+ // go fix, not a throughput signal to fold into the headline.
270
+ const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
271
+ console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`);
272
+ }
223
273
  if (r && r.members.length)
224
274
  console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
225
275
  const ships = [];
226
- if (remote?.mergedPrs)
227
- ships.push(chalk.green(`${remote.mergedPrs} merged (${remote.windowDays}d)`));
276
+ if (remote?.mergedPrs) {
277
+ ships.push(chalk.green(`${remote.mergedPrs}${remote.mergedPrsTruncated ? '+' : ''} merged (${remote.windowDays}d)`));
278
+ }
228
279
  if (r?.openPrs.length)
229
280
  ships.push(`${r.openPrs.length} open PR${r.openPrs.length === 1 ? '' : 's'}`);
230
281
  if (r?.worktrees)
@@ -236,8 +287,8 @@ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
236
287
  if (linear) {
237
288
  console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
238
289
  }
239
- if (linear?.nextMilestone) {
240
- console.log(` ${chalk.dim('next')} ${formatNextMilestone(linear.nextMilestone, nowMs)}`);
290
+ for (const line of formatMilestoneLines(linear?.milestones ?? [], linear?.nextMilestone, nowMs, milestoneLimit)) {
291
+ console.log(line);
241
292
  }
242
293
  if (r && r.tickets.length) {
243
294
  console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
@@ -258,6 +309,14 @@ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
258
309
  const repos = [def.repo, ...(def.repos ?? []).map((x) => x.slug)].filter(Boolean);
259
310
  if (repos.length)
260
311
  console.log(` ${chalk.dim('repos')} ${[...new Set(repos)].join(' · ')}`);
312
+ // A def whose `repo` disagrees with the checkout's origin reads PR and release
313
+ // counts from a DIFFERENT repository — both slugs resolve, so nothing errors
314
+ // and the wrong numbers look right. Say it here, with the fix attached.
315
+ const mismatch = def.root ? checkRepoSlug(def, originSlug(expandLocalHome(def.root))) : undefined;
316
+ if (mismatch) {
317
+ console.log(` ${chalk.yellow('!')} ${chalk.yellow(mismatch.message)}`);
318
+ console.log(` ${' '.repeat(8)}${chalk.gray(mismatch.remediation)}`);
319
+ }
261
320
  if (def.contexts?.length) {
262
321
  console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
263
322
  }
@@ -381,17 +440,24 @@ export function registerProjectsCommands(program) {
381
440
  });
382
441
  // ---- show ----
383
442
  projects
384
- .command('show <name>')
385
- .description('Show a project definition, resolved paths, repos, contexts, and links.')
443
+ .command('view <name>')
444
+ .alias('show')
445
+ .description('Show a project definition, resolved paths, repos, contexts, links, and milestones.')
386
446
  .option('--json', 'Machine-readable output')
387
- .action((name, opts) => {
447
+ .option('--no-remote', 'Skip the Linear lookup; definition only')
448
+ .action(async (name, opts) => {
388
449
  const def = loadProjectDef(name);
389
450
  if (!def) {
390
451
  console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
391
452
  process.exit(1);
392
453
  }
454
+ // Every milestone the project declares, not just the next one — the whole
455
+ // point of opening ONE project is to see the shape of its plan.
456
+ const counts = opts.remote !== false && def.linear?.projectId
457
+ ? await fetchLinearProjectCounts(def.linear.projectId)
458
+ : undefined;
393
459
  if (opts.json) {
394
- console.log(JSON.stringify(def, null, 2));
460
+ console.log(JSON.stringify({ ...def, linear: { ...def.linear, ...(counts ?? {}) } }, null, 2));
395
461
  return;
396
462
  }
397
463
  console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
@@ -410,6 +476,24 @@ export function registerProjectsCommands(program) {
410
476
  console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
411
477
  for (const d of def.docs ?? [])
412
478
  console.log(` doc ${d}`);
479
+ if (counts) {
480
+ const staleNote = counts.stale ? chalk.dim(' (cached)') : '';
481
+ console.log(` issues ${counts.done}/${counts.total}${counts.truncated ? '+' : ''} done · ${counts.inProgress} in progress${staleNote}`);
482
+ }
483
+ const ms = counts?.milestones ?? [];
484
+ if (ms.length) {
485
+ console.log(` ${chalk.bold('milestones')}`);
486
+ for (const m of ms) {
487
+ const mark = m.name === counts?.nextMilestone?.name ? chalk.cyan('next') : '';
488
+ console.log(` ${mark.padEnd(6)} ${formatNextMilestone(m, Date.now())}`);
489
+ }
490
+ // A milestone nothing is filed against cannot report progress. Say that
491
+ // once, plainly, rather than printing a row of silent 0%s.
492
+ const unfiled = ms.filter((m) => m.total === 0).length;
493
+ if (unfiled === ms.length) {
494
+ console.log(` ${chalk.yellow('!')} ${chalk.yellow(`no issues are assigned to any milestone — progress against them cannot be measured`)}`);
495
+ }
496
+ }
413
497
  console.log(chalk.gray(` ${projectDefPath(name)}`));
414
498
  });
415
499
  // ---- edit ----
@@ -514,7 +598,8 @@ export function registerProjectsCommands(program) {
514
598
  byStatus: r?.byStatus ?? {},
515
599
  members: r?.members ?? [],
516
600
  plan: r?.plan ?? { done: 0, total: 0 },
517
- planPct: r ? planPct(r.plan) ?? null : null,
601
+ live: r ? liveDeadSplit(r.byStatus).live : 0,
602
+ dead: r ? liveDeadSplit(r.byStatus).dead : 0,
518
603
  openPrs: r?.openPrs ?? [],
519
604
  mergedPrs: rem?.mergedPrs ?? 0,
520
605
  latestRelease: rem?.latestRelease ?? null,
@@ -577,6 +662,53 @@ export function registerProjectsCommands(program) {
577
662
  console.log(chalk.gray(' (widen with --min-confidence medium or --all)'));
578
663
  }
579
664
  });
665
+ // ---- set ----
666
+ projects
667
+ .command('set <name>')
668
+ .description('Change one field on a project definition, preserving everything else.')
669
+ .option('--repo <owner/repo>', 'Primary GitHub slug')
670
+ .option('--root <path>', 'Repo / monorepo root')
671
+ .option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
672
+ .option('--description <text>', 'One-line description shown on the card')
673
+ .action((name, opts) => {
674
+ const def = loadProjectDef(name);
675
+ if (!def) {
676
+ console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
677
+ process.exit(1);
678
+ }
679
+ const fields = ['repo', 'root', 'path', 'description'].filter((k) => opts[k] !== undefined);
680
+ if (fields.length === 0) {
681
+ console.error(chalk.red('Nothing to set. Pass a field, e.g. --repo <owner/repo>.'));
682
+ process.exit(1);
683
+ }
684
+ // Load, mutate the named fields, write back — the `link` pattern. NEVER
685
+ // the `add --force` pattern, which rebuilds the def from flags alone and
686
+ // silently drops linear/contexts/integrations that were not re-passed.
687
+ if (opts.repo !== undefined)
688
+ def.repo = opts.repo;
689
+ if (opts.root !== undefined)
690
+ def.root = opts.root;
691
+ if (opts.description !== undefined)
692
+ def.description = opts.description;
693
+ if (opts.path !== undefined) {
694
+ // `--path` is a subdir OF the root, so without one there is nothing to
695
+ // hang it off. A def imported with `--from-linear` that found no local
696
+ // checkout carries name + linear and no root — joining against '' there
697
+ // would silently write `/apps/cli`, an absolute path at the filesystem
698
+ // root, and the def would resolve somewhere that does not exist.
699
+ const base = (opts.root ?? def.root ?? '').replace(/\/$/, '');
700
+ if (!base) {
701
+ console.error(chalk.red(`"${def.name}" has no root, so --path has nothing to resolve against.`));
702
+ console.error(chalk.gray(` Set one first: agents projects set ${def.name} --root <path> --path ${opts.path}`));
703
+ process.exit(1);
704
+ }
705
+ def.defaultPath = `${base}/${opts.path.replace(/^\//, '')}`;
706
+ }
707
+ writeProjectDef(def);
708
+ console.log(chalk.green(`Updated ${def.name}`));
709
+ for (const f of fields)
710
+ console.log(chalk.gray(` ${f} ${f === 'path' ? def.defaultPath : def[f]}`));
711
+ });
580
712
  // ---- link ----
581
713
  projects
582
714
  .command('link <name>')
@@ -14,7 +14,7 @@ import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemo
14
14
  import { assertSchedulerEnabled } from '../lib/device-config.js';
15
15
  import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
16
16
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
17
- import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
17
+ import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, routineStats, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
18
18
  import { discoverProjectRoutinesAt, enableProjectRoutines, disableProjectRoutines, syncProjectRoutines, syncAllProjectRoutines, listEnabledProjectRoots, resolveProjectRoot, displayProjectPath, listProjectRoutineFiles, } from '../lib/routines-project.js';
19
19
  import { fireWebhookJobs, matchJobsToWebhook } from '../lib/triggers/webhook.js';
20
20
  import { getRoutinesDir } from '../lib/state.js';
@@ -359,6 +359,7 @@ function runMetaJson(run) {
359
359
  completedAt: run.completedAt,
360
360
  exitCode: run.exitCode,
361
361
  errorMessage: run.errorMessage ?? null,
362
+ duration: run.duration ?? null,
362
363
  };
363
364
  }
364
365
  export function buildRunsJson(runs) {
@@ -1034,6 +1035,50 @@ export function registerRoutinesCommands(program) {
1034
1035
  console.log(` ${run.runId} ${status} ${run.startedAt}`);
1035
1036
  }
1036
1037
  });
1038
+ routinesCmd
1039
+ .command('stats [name]')
1040
+ .description('Duration + outcome rollup per job: run count, failed, missed, avg/p50/p95 duration')
1041
+ .option('--json', 'Emit machine-readable JSON')
1042
+ .action(async (name, options) => {
1043
+ // No name: summarize every job (like `routines list`). A name narrows to
1044
+ // one job's rollup — no interactive picker, since a bare `stats` already
1045
+ // has a useful all-jobs default.
1046
+ if (!name) {
1047
+ const jobs = listAllJobs();
1048
+ const rows = jobs.map((j) => ({ name: j.name, ...routineStats(j.name) }));
1049
+ if (options.json) {
1050
+ writeJson({ jobs: rows });
1051
+ return;
1052
+ }
1053
+ if (rows.length === 0) {
1054
+ console.log(chalk.gray('No jobs configured'));
1055
+ return;
1056
+ }
1057
+ console.log(chalk.bold('Routine stats\n'));
1058
+ const pad = (s, w) => (s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length));
1059
+ console.log(chalk.gray(` ${pad('JOB', 28)} ${pad('N', 5)} ${pad('FAILED', 7)} ${pad('MISSED', 7)} ${pad('AVG', 7)} ${pad('P50', 7)} P95`));
1060
+ for (const r of rows) {
1061
+ console.log(` ${pad(r.name, 28)} ${pad(String(r.count), 5)} ${pad(String(r.failed), 7)} ${pad(String(r.missed), 7)} ${pad(`${r.avgMs}ms`, 7)} ${pad(`${r.p50}ms`, 7)} ${r.p95}ms`);
1062
+ }
1063
+ return;
1064
+ }
1065
+ const stats = routineStats(name);
1066
+ if (options.json) {
1067
+ writeJson({ jobId: name, name, ...stats });
1068
+ return;
1069
+ }
1070
+ if (stats.count === 0) {
1071
+ console.log(chalk.yellow(`No runs found for job '${name}'`));
1072
+ return;
1073
+ }
1074
+ console.log(chalk.bold(`Stats: ${name}\n`));
1075
+ console.log(` Runs: ${stats.count}`);
1076
+ console.log(` Failed: ${stats.failed}`);
1077
+ console.log(` Missed: ${stats.missed}`);
1078
+ console.log(` Avg: ${stats.avgMs}ms`);
1079
+ console.log(` P50: ${stats.p50}ms`);
1080
+ console.log(` P95: ${stats.p95}ms`);
1081
+ });
1037
1082
  routinesCmd
1038
1083
  .command('run [name]')
1039
1084
  .description('Execute a routine right now in the foreground. Ignores the schedule; useful for testing before enabling.')
@@ -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 {