@phnx-labs/agents-cli 1.21.1 → 1.21.3

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 (111) hide show
  1. package/CHANGELOG.md +206 -0
  2. package/README.md +1 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.d.ts +4 -0
  5. package/dist/commands/computer-actions.js +34 -0
  6. package/dist/commands/computer.js +4 -2
  7. package/dist/commands/doctor.js +5 -2
  8. package/dist/commands/feed.js +28 -19
  9. package/dist/commands/hooks.js +9 -45
  10. package/dist/commands/menubar.js +24 -24
  11. package/dist/commands/message.js +23 -3
  12. package/dist/commands/perf.d.ts +29 -0
  13. package/dist/commands/perf.js +90 -23
  14. package/dist/commands/projects.d.ts +21 -0
  15. package/dist/commands/projects.js +169 -23
  16. package/dist/commands/routines.js +46 -1
  17. package/dist/commands/sessions-picker.js +17 -2
  18. package/dist/commands/sessions.d.ts +5 -0
  19. package/dist/commands/sessions.js +14 -0
  20. package/dist/commands/ssh.js +69 -0
  21. package/dist/commands/trends.d.ts +2 -0
  22. package/dist/commands/trends.js +158 -0
  23. package/dist/commands/usage.d.ts +4 -4
  24. package/dist/commands/view.d.ts +6 -0
  25. package/dist/commands/view.js +90 -45
  26. package/dist/index.js +22 -1
  27. package/dist/lib/agents.js +2 -2
  28. package/dist/lib/analytics/dashboard.d.ts +11 -0
  29. package/dist/lib/analytics/dashboard.js +31 -0
  30. package/dist/lib/analytics/recipes.d.ts +32 -0
  31. package/dist/lib/analytics/recipes.js +316 -0
  32. package/dist/lib/analytics/usage-db.d.ts +84 -0
  33. package/dist/lib/analytics/usage-db.js +301 -0
  34. package/dist/lib/browser/service.js +31 -0
  35. package/dist/lib/cli-resources.d.ts +20 -0
  36. package/dist/lib/cli-resources.js +48 -1
  37. package/dist/lib/computer/dispatch.d.ts +3 -1
  38. package/dist/lib/computer/dispatch.js +10 -2
  39. package/dist/lib/daemon.js +51 -14
  40. package/dist/lib/devices/health-report.d.ts +5 -0
  41. package/dist/lib/devices/health-report.js +3 -0
  42. package/dist/lib/event-stream.d.ts +2 -0
  43. package/dist/lib/event-stream.js +3 -0
  44. package/dist/lib/events.d.ts +3 -1
  45. package/dist/lib/events.js +4 -2
  46. package/dist/lib/feed-broadcast.d.ts +52 -7
  47. package/dist/lib/feed-broadcast.js +125 -18
  48. package/dist/lib/fleet-cache.d.ts +37 -0
  49. package/dist/lib/fleet-cache.js +40 -0
  50. package/dist/lib/fleet-status.d.ts +53 -0
  51. package/dist/lib/fleet-status.js +120 -0
  52. package/dist/lib/friction-heuristics.d.ts +32 -0
  53. package/dist/lib/friction-heuristics.js +47 -0
  54. package/dist/lib/git.d.ts +14 -0
  55. package/dist/lib/git.js +36 -0
  56. package/dist/lib/hooks/cache.js +28 -6
  57. package/dist/lib/hooks/profile.d.ts +8 -0
  58. package/dist/lib/hooks/profile.js +15 -18
  59. package/dist/lib/hooks.js +72 -17
  60. package/dist/lib/linear-cache.d.ts +63 -0
  61. package/dist/lib/linear-cache.js +146 -0
  62. package/dist/lib/linear-project-counts.d.ts +35 -5
  63. package/dist/lib/linear-project-counts.js +61 -16
  64. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  65. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +3 -1
  66. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  67. package/dist/lib/menubar/install-menubar.d.ts +7 -0
  68. package/dist/lib/menubar/install-menubar.js +36 -6
  69. package/dist/lib/percentile.d.ts +12 -0
  70. package/dist/lib/percentile.js +24 -0
  71. package/dist/lib/perf/db.d.ts +7 -3
  72. package/dist/lib/perf/db.js +37 -19
  73. package/dist/lib/perf/types.d.ts +10 -0
  74. package/dist/lib/plugins.js +12 -1
  75. package/dist/lib/project-doctor.d.ts +36 -0
  76. package/dist/lib/project-doctor.js +45 -0
  77. package/dist/lib/project-import.d.ts +11 -1
  78. package/dist/lib/project-import.js +17 -3
  79. package/dist/lib/project-status.d.ts +25 -5
  80. package/dist/lib/project-status.js +48 -6
  81. package/dist/lib/resources.d.ts +16 -0
  82. package/dist/lib/resources.js +25 -14
  83. package/dist/lib/rotate.d.ts +27 -0
  84. package/dist/lib/rotate.js +44 -17
  85. package/dist/lib/routines.d.ts +16 -0
  86. package/dist/lib/routines.js +26 -0
  87. package/dist/lib/runner.js +41 -0
  88. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  89. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  90. package/dist/lib/secrets/usage-db.d.ts +3 -63
  91. package/dist/lib/secrets/usage-db.js +46 -186
  92. package/dist/lib/session/db.d.ts +17 -1
  93. package/dist/lib/session/db.js +240 -6
  94. package/dist/lib/session/discover.d.ts +31 -0
  95. package/dist/lib/session/discover.js +50 -1
  96. package/dist/lib/session/highlights.d.ts +24 -4
  97. package/dist/lib/session/highlights.js +52 -7
  98. package/dist/lib/session/parse.js +19 -3
  99. package/dist/lib/session/prompt.d.ts +19 -0
  100. package/dist/lib/session/prompt.js +43 -0
  101. package/dist/lib/session/types.d.ts +43 -0
  102. package/dist/lib/startup/command-registry.d.ts +1 -0
  103. package/dist/lib/startup/command-registry.js +2 -0
  104. package/dist/lib/state.d.ts +31 -3
  105. package/dist/lib/state.js +53 -10
  106. package/dist/lib/types.d.ts +22 -4
  107. package/dist/lib/usage-refresh.d.ts +106 -0
  108. package/dist/lib/usage-refresh.js +238 -0
  109. package/dist/lib/usage.d.ts +152 -17
  110. package/dist/lib/usage.js +393 -79
  111. 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';
@@ -56,10 +57,24 @@ function parseContextFlag(raw) {
56
57
  return { path: raw.trim(), purpose: '' };
57
58
  return { path: raw.slice(0, i).trim(), purpose: raw.slice(i + 1).trim() };
58
59
  }
59
- /** Best-effort `owner/repo` from a repo's origin remote. */
60
- function originSlug(cwd) {
60
+ /**
61
+ * Best-effort `owner/repo` from a repo's origin remote.
62
+ *
63
+ * `stderr: 'ignore'` is load-bearing, not tidiness. A checkout with no origin
64
+ * makes git print `error: No such remote 'origin'` on ITS stderr, which is the
65
+ * terminal's — the catch below never sees it. That was invisible while this was
66
+ * called once from `add` inside a real repo, and became noise the moment
67
+ * `import --from-factory` started calling it per row: importing 12 registry
68
+ * rows printed two raw git errors between the progress lines. Absence of a
69
+ * remote is an expected answer here (`undefined`), not something to report.
70
+ */
71
+ export function originSlug(cwd) {
61
72
  try {
62
- const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, encoding: 'utf8' }).trim();
73
+ const url = execFileSync('git', ['remote', 'get-url', 'origin'], {
74
+ cwd,
75
+ encoding: 'utf8',
76
+ stdio: ['ignore', 'pipe', 'ignore'],
77
+ }).trim();
63
78
  return parseOwnerRepoFromRemote(url) ?? undefined;
64
79
  }
65
80
  catch {
@@ -94,7 +109,9 @@ function runFactoryImport(existing, opts) {
94
109
  : Array.isArray(parsed?.projects)
95
110
  ? parsed.projects
96
111
  : [];
97
- return buildFactoryImportCandidates(rows, existing, opts);
112
+ // The checkout's real `origin` overrides the registry's path-derived slug —
113
+ // see buildFactoryImportCandidates for why the path guess is not trustworthy.
114
+ return buildFactoryImportCandidates(rows, existing, opts, (root) => originSlug(expandLocalHome(root)));
98
115
  }
99
116
  /**
100
117
  * List the workspace's Linear projects and plan their import, binding each to a
@@ -183,6 +200,40 @@ export function formatMilestoneDue(targetDate, nowMs) {
183
200
  });
184
201
  return `due ${label}`;
185
202
  }
203
+ /**
204
+ * The milestone block. `status` prints one line — the next checkpoint — because
205
+ * a roll-up across projects has to stay scannable. `view` prints every declared
206
+ * milestone, because "how many are there and when are they due" is the shape of
207
+ * the plan and the reason to open one project.
208
+ *
209
+ * Pure (chalk only) so the layout is testable without a Linear account.
210
+ */
211
+ export function formatMilestoneLines(milestones, next, nowMs, limit) {
212
+ if (milestones.length === 0) {
213
+ // `next` without a list only happens on a cached answer written before the
214
+ // list existed; render what we have rather than dropping the row.
215
+ return next ? [` ${chalk.dim('next')} ${formatNextMilestone(next, nowMs)}`] : [];
216
+ }
217
+ // The next milestone leads, always. Linear can flag a LATER-dated milestone
218
+ // as next, and `milestones` is date-ordered — so slicing the front would show
219
+ // an earlier one and hide the actual next behind "+N more", which is the one
220
+ // thing this row exists to say. Identity is name+targetDate: two milestones
221
+ // can share a name, and matching on name alone labelled the wrong row.
222
+ const key = (m) => `${m.name}${m.targetDate ?? ''}`;
223
+ const lead = next ? milestones.filter((m) => key(m) === key(next)).slice(0, 1) : [];
224
+ const others = next ? milestones.filter((m) => key(m) !== key(next)) : milestones;
225
+ const ordered = [...lead, ...others];
226
+ const shown = ordered.slice(0, Math.max(1, limit));
227
+ const out = shown.map((m, i) => {
228
+ const label = i === 0 && lead.length > 0 ? 'next' : i === 0 ? 'plan' : '';
229
+ return ` ${chalk.dim(label.padEnd(4))} ${formatNextMilestone(m, nowMs)}`;
230
+ });
231
+ const rest = milestones.length - shown.length;
232
+ if (rest > 0) {
233
+ out.push(` ${' '.repeat(4)} ${chalk.dim(`+${rest} more milestone${rest === 1 ? '' : 's'} — agents projects view <name>`)}`);
234
+ }
235
+ return out;
236
+ }
186
237
  /** The `next` card line: what this project is due to hit, and how far along it is. */
187
238
  export function formatNextMilestone(ms, nowMs) {
188
239
  const parts = [chalk.bold(ms.name)];
@@ -206,25 +257,39 @@ function statusBar(r) {
206
257
  push(r.byStatus.input_required, 'need-input', chalk.yellow);
207
258
  push(r.byStatus.queued, 'queued', chalk.gray);
208
259
  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`));
260
+ // The remainder is the LIVE sessions in a state without its own chip
261
+ // (orphaned, unknown). Dead ones are on their own row now, so counting them
262
+ // here made the line disagree with the headline — `19 live` beside
263
+ // `+24 other`, which invites the reader to trust neither.
264
+ const live = liveDeadSplit(r.byStatus).live;
265
+ if (live > shown)
266
+ parts.push(chalk.gray(`+${live - shown} other`));
213
267
  return parts.join(' · ') || chalk.gray('no live agents');
214
268
  }
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}`);
269
+ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now(),
270
+ /** How many milestones to print. `status` shows the next one; `view` shows all. */
271
+ milestoneLimit = 1) {
272
+ // The headline counts LIVE agents. It used to be every matched session, which
273
+ // read `39 agents` on a project where 19 had crashed. `planPct` used to sit
274
+ // here too and is gone: it summed each session's latest checklist snapshot,
275
+ // so one agent opening a fresh 40-item plan rendered the whole project `0%`.
276
+ const split = r ? liveDeadSplit(r.byStatus) : { live: 0, dead: 0, deadByStatus: [] };
277
+ console.log(`${chalk.bold(def.name)} ${chalk.dim('·')} ${chalk.bold(`${split.live} live`)}`);
220
278
  if (def.description)
221
279
  console.log(` ${chalk.dim(def.description)}`);
222
280
  console.log(` ${chalk.dim('live')} ${r ? statusBar(r) : chalk.gray('no live agents')}`);
281
+ if (split.dead > 0) {
282
+ // Wreckage is worth a number of its own — 19 crashed sessions is a thing to
283
+ // go fix, not a throughput signal to fold into the headline.
284
+ const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
285
+ console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`);
286
+ }
223
287
  if (r && r.members.length)
224
288
  console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
225
289
  const ships = [];
226
- if (remote?.mergedPrs)
227
- ships.push(chalk.green(`${remote.mergedPrs} merged (${remote.windowDays}d)`));
290
+ if (remote?.mergedPrs) {
291
+ ships.push(chalk.green(`${remote.mergedPrs}${remote.mergedPrsTruncated ? '+' : ''} merged (${remote.windowDays}d)`));
292
+ }
228
293
  if (r?.openPrs.length)
229
294
  ships.push(`${r.openPrs.length} open PR${r.openPrs.length === 1 ? '' : 's'}`);
230
295
  if (r?.worktrees)
@@ -236,8 +301,8 @@ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
236
301
  if (linear) {
237
302
  console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
238
303
  }
239
- if (linear?.nextMilestone) {
240
- console.log(` ${chalk.dim('next')} ${formatNextMilestone(linear.nextMilestone, nowMs)}`);
304
+ for (const line of formatMilestoneLines(linear?.milestones ?? [], linear?.nextMilestone, nowMs, milestoneLimit)) {
305
+ console.log(line);
241
306
  }
242
307
  if (r && r.tickets.length) {
243
308
  console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
@@ -258,6 +323,14 @@ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
258
323
  const repos = [def.repo, ...(def.repos ?? []).map((x) => x.slug)].filter(Boolean);
259
324
  if (repos.length)
260
325
  console.log(` ${chalk.dim('repos')} ${[...new Set(repos)].join(' · ')}`);
326
+ // A def whose `repo` disagrees with the checkout's origin reads PR and release
327
+ // counts from a DIFFERENT repository — both slugs resolve, so nothing errors
328
+ // and the wrong numbers look right. Say it here, with the fix attached.
329
+ const mismatch = def.root ? checkRepoSlug(def, originSlug(expandLocalHome(def.root))) : undefined;
330
+ if (mismatch) {
331
+ console.log(` ${chalk.yellow('!')} ${chalk.yellow(mismatch.message)}`);
332
+ console.log(` ${' '.repeat(8)}${chalk.gray(mismatch.remediation)}`);
333
+ }
261
334
  if (def.contexts?.length) {
262
335
  console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
263
336
  }
@@ -381,17 +454,24 @@ export function registerProjectsCommands(program) {
381
454
  });
382
455
  // ---- show ----
383
456
  projects
384
- .command('show <name>')
385
- .description('Show a project definition, resolved paths, repos, contexts, and links.')
457
+ .command('view <name>')
458
+ .alias('show')
459
+ .description('Show a project definition, resolved paths, repos, contexts, links, and milestones.')
386
460
  .option('--json', 'Machine-readable output')
387
- .action((name, opts) => {
461
+ .option('--no-remote', 'Skip the Linear lookup; definition only')
462
+ .action(async (name, opts) => {
388
463
  const def = loadProjectDef(name);
389
464
  if (!def) {
390
465
  console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
391
466
  process.exit(1);
392
467
  }
468
+ // Every milestone the project declares, not just the next one — the whole
469
+ // point of opening ONE project is to see the shape of its plan.
470
+ const counts = opts.remote !== false && def.linear?.projectId
471
+ ? await fetchLinearProjectCounts(def.linear.projectId)
472
+ : undefined;
393
473
  if (opts.json) {
394
- console.log(JSON.stringify(def, null, 2));
474
+ console.log(JSON.stringify({ ...def, linear: { ...def.linear, ...(counts ?? {}) } }, null, 2));
395
475
  return;
396
476
  }
397
477
  console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
@@ -410,6 +490,24 @@ export function registerProjectsCommands(program) {
410
490
  console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
411
491
  for (const d of def.docs ?? [])
412
492
  console.log(` doc ${d}`);
493
+ if (counts) {
494
+ const staleNote = counts.stale ? chalk.dim(' (cached)') : '';
495
+ console.log(` issues ${counts.done}/${counts.total}${counts.truncated ? '+' : ''} done · ${counts.inProgress} in progress${staleNote}`);
496
+ }
497
+ const ms = counts?.milestones ?? [];
498
+ if (ms.length) {
499
+ console.log(` ${chalk.bold('milestones')}`);
500
+ for (const m of ms) {
501
+ const mark = m.name === counts?.nextMilestone?.name ? chalk.cyan('next') : '';
502
+ console.log(` ${mark.padEnd(6)} ${formatNextMilestone(m, Date.now())}`);
503
+ }
504
+ // A milestone nothing is filed against cannot report progress. Say that
505
+ // once, plainly, rather than printing a row of silent 0%s.
506
+ const unfiled = ms.filter((m) => m.total === 0).length;
507
+ if (unfiled === ms.length) {
508
+ console.log(` ${chalk.yellow('!')} ${chalk.yellow(`no issues are assigned to any milestone — progress against them cannot be measured`)}`);
509
+ }
510
+ }
413
511
  console.log(chalk.gray(` ${projectDefPath(name)}`));
414
512
  });
415
513
  // ---- edit ----
@@ -514,7 +612,8 @@ export function registerProjectsCommands(program) {
514
612
  byStatus: r?.byStatus ?? {},
515
613
  members: r?.members ?? [],
516
614
  plan: r?.plan ?? { done: 0, total: 0 },
517
- planPct: r ? planPct(r.plan) ?? null : null,
615
+ live: r ? liveDeadSplit(r.byStatus).live : 0,
616
+ dead: r ? liveDeadSplit(r.byStatus).dead : 0,
518
617
  openPrs: r?.openPrs ?? [],
519
618
  mergedPrs: rem?.mergedPrs ?? 0,
520
619
  latestRelease: rem?.latestRelease ?? null,
@@ -577,6 +676,53 @@ export function registerProjectsCommands(program) {
577
676
  console.log(chalk.gray(' (widen with --min-confidence medium or --all)'));
578
677
  }
579
678
  });
679
+ // ---- set ----
680
+ projects
681
+ .command('set <name>')
682
+ .description('Change one field on a project definition, preserving everything else.')
683
+ .option('--repo <owner/repo>', 'Primary GitHub slug')
684
+ .option('--root <path>', 'Repo / monorepo root')
685
+ .option('--path <subdir>', 'Default cwd for agents (a monorepo subdir)')
686
+ .option('--description <text>', 'One-line description shown on the card')
687
+ .action((name, opts) => {
688
+ const def = loadProjectDef(name);
689
+ if (!def) {
690
+ console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
691
+ process.exit(1);
692
+ }
693
+ const fields = ['repo', 'root', 'path', 'description'].filter((k) => opts[k] !== undefined);
694
+ if (fields.length === 0) {
695
+ console.error(chalk.red('Nothing to set. Pass a field, e.g. --repo <owner/repo>.'));
696
+ process.exit(1);
697
+ }
698
+ // Load, mutate the named fields, write back — the `link` pattern. NEVER
699
+ // the `add --force` pattern, which rebuilds the def from flags alone and
700
+ // silently drops linear/contexts/integrations that were not re-passed.
701
+ if (opts.repo !== undefined)
702
+ def.repo = opts.repo;
703
+ if (opts.root !== undefined)
704
+ def.root = opts.root;
705
+ if (opts.description !== undefined)
706
+ def.description = opts.description;
707
+ if (opts.path !== undefined) {
708
+ // `--path` is a subdir OF the root, so without one there is nothing to
709
+ // hang it off. A def imported with `--from-linear` that found no local
710
+ // checkout carries name + linear and no root — joining against '' there
711
+ // would silently write `/apps/cli`, an absolute path at the filesystem
712
+ // root, and the def would resolve somewhere that does not exist.
713
+ const base = (opts.root ?? def.root ?? '').replace(/\/$/, '');
714
+ if (!base) {
715
+ console.error(chalk.red(`"${def.name}" has no root, so --path has nothing to resolve against.`));
716
+ console.error(chalk.gray(` Set one first: agents projects set ${def.name} --root <path> --path ${opts.path}`));
717
+ process.exit(1);
718
+ }
719
+ def.defaultPath = `${base}/${opts.path.replace(/^\//, '')}`;
720
+ }
721
+ writeProjectDef(def);
722
+ console.log(chalk.green(`Updated ${def.name}`));
723
+ for (const f of fields)
724
+ console.log(chalk.gray(` ${f} ${f === 'path' ? def.defaultPath : def[f]}`));
725
+ });
580
726
  // ---- link ----
581
727
  projects
582
728
  .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.')
@@ -374,6 +374,13 @@ function formatCompactPreview(events, session) {
374
374
  let latestTodos;
375
375
  let subAgentCount = 0;
376
376
  const toolTags = new Set();
377
+ // usedBrowser/usedComputer are computed at scan time from a sessionId-scoped
378
+ // events-log read (session/db.ts detectToolUsage), NOT a transcript regex —
379
+ // undefined means a legacy row this scanner hasn't computed the field for
380
+ // yet, so only THEN does classifySessionTool's transcript-derived guess run
381
+ // below (mirrors directoriesTouched's prefer-persisted/fall-back-to-derived
382
+ // pattern for recentDirectoriesTouched).
383
+ const knownToolUsage = session.usedBrowser !== undefined;
377
384
  for (const event of events) {
378
385
  if (event.type === 'message') {
379
386
  if (event.role === 'user' && !firstUser && event.content) {
@@ -388,8 +395,10 @@ function formatCompactPreview(events, session) {
388
395
  else if (event.type === 'tool_use' && !event._local) {
389
396
  const tool = event.tool || '';
390
397
  const command = event.command || '';
391
- for (const tag of classifySessionTool(tool, command))
392
- toolTags.add(tag);
398
+ if (!knownToolUsage) {
399
+ for (const tag of classifySessionTool(tool, command))
400
+ toolTags.add(tag);
401
+ }
393
402
  if (isSubAgentTool(tool, command))
394
403
  subAgentCount++;
395
404
  const p = event.path || event.args?.file_path || event.args?.path || '';
@@ -412,6 +421,12 @@ function formatCompactPreview(events, session) {
412
421
  toolCalls++;
413
422
  }
414
423
  }
424
+ // Persisted field wins when computed — it comes from real browser.navigate/
425
+ // browser.screenshot/computer.action events, not a fuzzy tool-name regex.
426
+ if (session.usedBrowser)
427
+ toolTags.add('browser');
428
+ if (session.usedComputer)
429
+ toolTags.add('computer');
415
430
  // Prefer the transcript-derived list when we just re-parsed the file (freshest
416
431
  // checklist write); fall back to SessionMeta.todos for rows where the scan/
417
432
  // fan-out attached progress but the event stream has no TodoWrite yet.
@@ -70,6 +70,11 @@ interface SessionsOptions extends SessionFilterOptions {
70
70
  printCmd?: boolean;
71
71
  /** Print a compact preview of the matched session and exit (no pager). */
72
72
  preview?: boolean;
73
+ /** Only sessions that invoked this skill (#12) — matches a bare name or a
74
+ * namespaced plugin skill's short name (`--skill design` finds `rush:design`). */
75
+ skill?: string;
76
+ /** Only sessions that used a skill/command owned by this plugin (#12). */
77
+ plugin?: string;
73
78
  }
74
79
  /**
75
80
  * Strip terminal/harness noise from a preview so the column stays a single line
@@ -1172,6 +1172,12 @@ export function hasNoBrowserDisqualifyingFlags(options, query) {
1172
1172
  !options.markdown &&
1173
1173
  !options.until &&
1174
1174
  !options.project &&
1175
+ // The interactive browser picker is a fuzzy-search TUI over the discovered
1176
+ // pool, not a SQL-filtered listing — it cannot represent a skill/plugin
1177
+ // scope. Falling through to it here would silently drop the filter and
1178
+ // show the unfiltered pool instead (same reasoning as --project/--sort).
1179
+ !options.skill &&
1180
+ !options.plugin &&
1175
1181
  !options.sort &&
1176
1182
  !options.artifacts &&
1177
1183
  options.artifact === undefined &&
@@ -1202,6 +1208,10 @@ function canonicalSessionsCommand(query, options) {
1202
1208
  a.push('--device', h);
1203
1209
  if (options.project)
1204
1210
  a.push('--project', options.project);
1211
+ if (options.skill)
1212
+ a.push('--skill', options.skill);
1213
+ if (options.plugin)
1214
+ a.push('--plugin', options.plugin);
1205
1215
  if (options.all)
1206
1216
  a.push('--all');
1207
1217
  if (options.since)
@@ -1557,6 +1567,8 @@ limitSource) {
1557
1567
  until: options.until,
1558
1568
  sortBy,
1559
1569
  origin: options.routine ? 'routine' : undefined,
1570
+ skill: options.skill,
1571
+ plugin: options.plugin,
1560
1572
  };
1561
1573
  let hiddenUnmanaged = 0;
1562
1574
  let sessions = await discoverSessions({
@@ -3306,6 +3318,8 @@ export function registerSessionsCommands(program) {
3306
3318
  .option('--in-team <name>', "Only this team: the session that spawned it plus (with --teams) its teammates. Spans every directory and all time, since a team's worktrees and history sit outside the default window.")
3307
3319
  .option('--routine', 'Show only sessions archived from routine runs')
3308
3320
  .option('-p, --project <name>', 'Filter by project name (searches across all directories)')
3321
+ .option('--skill <name>', 'Only sessions that invoked this skill (matches a bare name or a namespaced plugin skill\'s short name, e.g. --skill design finds rush:design)')
3322
+ .option('--plugin <name>', 'Only sessions that used a skill/command owned by this plugin')
3309
3323
  .option('--since <time>', 'Only sessions newer than this (e.g., 2h, 7d, 4w, or ISO date)')
3310
3324
  .option('--until <time>', 'Only sessions older than this (ISO timestamp)')
3311
3325
  .option('-n, --limit <n>', 'Maximum number of sessions to return', DEFAULT_LIMIT)
@@ -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;