@phnx-labs/agents-cli 1.22.6 → 1.22.7

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 (77) hide show
  1. package/CHANGELOG.md +87 -1
  2. package/README.md +7 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +35 -4
  5. package/dist/commands/feed.d.ts +2 -1
  6. package/dist/commands/feed.js +47 -20
  7. package/dist/commands/focus.js +22 -1
  8. package/dist/commands/models.js +2 -2
  9. package/dist/commands/projects.js +122 -107
  10. package/dist/commands/secrets.js +1 -1
  11. package/dist/commands/sessions-backfill.d.ts +33 -0
  12. package/dist/commands/sessions-backfill.js +83 -1
  13. package/dist/commands/sessions-stats.d.ts +36 -0
  14. package/dist/commands/sessions-stats.js +263 -0
  15. package/dist/commands/sessions.d.ts +1 -1
  16. package/dist/commands/sessions.js +21 -1
  17. package/dist/commands/view.d.ts +2 -1
  18. package/dist/commands/view.js +62 -11
  19. package/dist/index.js +1 -2
  20. package/dist/lib/activity.js +2 -2
  21. package/dist/lib/agents.js +68 -11
  22. package/dist/lib/analytics/recipes.js +11 -5
  23. package/dist/lib/browser/profiles.d.ts +15 -7
  24. package/dist/lib/browser/profiles.js +53 -12
  25. package/dist/lib/byok-usage.d.ts +38 -0
  26. package/dist/lib/byok-usage.js +117 -0
  27. package/dist/lib/capabilities.js +1 -1
  28. package/dist/lib/exec.d.ts +20 -0
  29. package/dist/lib/exec.js +73 -8
  30. package/dist/lib/feed-outcome.d.ts +1 -0
  31. package/dist/lib/feed-outcome.js +2 -0
  32. package/dist/lib/feed-post.js +1 -1
  33. package/dist/lib/feed-ranking.d.ts +1 -0
  34. package/dist/lib/feed-ranking.js +4 -0
  35. package/dist/lib/feed.d.ts +4 -1
  36. package/dist/lib/feed.js +25 -0
  37. package/dist/lib/hosts/passthrough.js +0 -1
  38. package/dist/lib/mcp.js +6 -1
  39. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  40. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  41. package/dist/lib/model-tiers.js +4 -1
  42. package/dist/lib/models.js +63 -0
  43. package/dist/lib/profiles.d.ts +42 -1
  44. package/dist/lib/profiles.js +50 -2
  45. package/dist/lib/project-focus.d.ts +9 -0
  46. package/dist/lib/project-focus.js +23 -0
  47. package/dist/lib/project-key.d.ts +1 -1
  48. package/dist/lib/project-key.js +1 -1
  49. package/dist/lib/project-probe.d.ts +18 -0
  50. package/dist/lib/project-probe.js +46 -0
  51. package/dist/lib/project-status.d.ts +56 -0
  52. package/dist/lib/project-status.js +125 -18
  53. package/dist/lib/resources/mcp.js +3 -0
  54. package/dist/lib/resources/types.d.ts +1 -1
  55. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  56. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  57. package/dist/lib/secrets/audit.js +1 -1
  58. package/dist/lib/secrets/index.d.ts +1 -0
  59. package/dist/lib/secrets/index.js +29 -15
  60. package/dist/lib/secrets/remote.d.ts +1 -1
  61. package/dist/lib/secrets/remote.js +1 -1
  62. package/dist/lib/session/active.d.ts +2 -0
  63. package/dist/lib/session/bash-command.d.ts +2 -3
  64. package/dist/lib/session/db.d.ts +92 -1
  65. package/dist/lib/session/db.js +230 -1
  66. package/dist/lib/session/digest.d.ts +1 -1
  67. package/dist/lib/session/digest.js +1 -1
  68. package/dist/lib/share/publish.js +24 -0
  69. package/dist/lib/startup/command-registry.d.ts +0 -1
  70. package/dist/lib/startup/command-registry.js +0 -2
  71. package/dist/lib/subagents-registry.js +3 -0
  72. package/dist/lib/types.d.ts +11 -2
  73. package/dist/lib/usage.d.ts +5 -0
  74. package/dist/lib/usage.js +3 -3
  75. package/package.json +1 -1
  76. package/dist/commands/activity.d.ts +0 -87
  77. package/dist/commands/activity.js +0 -346
@@ -23,13 +23,13 @@ import { getActiveSessions } from '../lib/session/active.js';
23
23
  import { gatherRemoteActive } from '../lib/session/remote-active.js';
24
24
  import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
25
25
  import { factoryProjectsPath } from '../lib/auto-dispatch.js';
26
- import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, } from '../lib/project-probe.js';
26
+ import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, workspaceWarnings, } from '../lib/project-probe.js';
27
27
  import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
28
- import { rollupSessionsByProject, isDeadStatus, liveDeadSplit, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
28
+ import { rollupSessionsByProject, withDefaultMachine, isDeadStatus, liveDeadSplit, enrichProjectSignals, formatProjectMembersByHost, formatProjectWarnings, } 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
31
  import { checkRepoSlug } from '../lib/project-doctor.js';
32
- import { readFocusAreas } from '../lib/project-focus.js';
32
+ import { formatFocusAreas, readFocusAreas } from '../lib/project-focus.js';
33
33
  import { formatVerdict, scheduleVerdict } from '../lib/project-schedule.js';
34
34
  import { buildFactoryImportCandidates, buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
35
35
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
@@ -232,7 +232,7 @@ export function formatMilestoneLines(milestones, next, nowMs, limit) {
232
232
  // an earlier one and hide the actual next behind "+N more", which is the one
233
233
  // thing this row exists to say. Identity is name+targetDate: two milestones
234
234
  // can share a name, and matching on name alone labelled the wrong row.
235
- const key = (m) => `${m.name}${m.targetDate ?? ''}`;
235
+ const key = (m) => `${m.name}${m.targetDate ?? ''}`;
236
236
  const lead = next ? milestones.filter((m) => key(m) === key(next)).slice(0, 1) : [];
237
237
  const others = next ? milestones.filter((m) => key(m) !== key(next)) : milestones;
238
238
  const ordered = [...lead, ...others];
@@ -291,7 +291,12 @@ function statusBar(r) {
291
291
  * artifact log and git focus, and skips just the network calls (`gh`, Linear).
292
292
  */
293
293
  async function enrichProjectsForRender(defs, all, opts) {
294
- const roll = rollupSessionsByProject(all, [...(await getActiveSessions()), ...(opts.extraSessions ?? [])]);
294
+ // Local getActiveSessions() leaves `machine` unset (the sessions renderer
295
+ // falls back to this box). Host-grouped agents need an explicit stamp so
296
+ // under `--fleet` this box does not render as `@local` next to peers that
297
+ // carry real device ids.
298
+ const local = withDefaultMachine(await getActiveSessions(), machineId());
299
+ const roll = rollupSessionsByProject(all, [...local, ...(opts.extraSessions ?? [])]);
295
300
  const remote = new Map();
296
301
  const linear = new Map();
297
302
  // Local git, no API, no rate limit — measured 0.23s over a 897-commit week.
@@ -317,7 +322,12 @@ milestoneLimit = 1,
317
322
  /** Directories the window's work landed in, from local git. */
318
323
  focus = [],
319
324
  /** `view` mode: the caller prints the stored definition in full afterwards. */
320
- detail = false) {
325
+ detail = false,
326
+ /**
327
+ * Workspace probe rows used only for the warnings footer. May be the full
328
+ * `--fleet` set or a local-only probe so drift is never silent by default.
329
+ */
330
+ warnWorkspaces = []) {
321
331
  // The headline counts LIVE agents. It used to be every matched session, which
322
332
  // read `39 agents` on a project where 19 had crashed. `planPct` used to sit
323
333
  // here too and is gone: it summed each session's latest checklist snapshot,
@@ -330,15 +340,18 @@ detail = false) {
330
340
  if (split.dead > 0) {
331
341
  // Wreckage is worth a number of its own — 19 crashed sessions is a thing to
332
342
  // go fix, not a throughput signal to fold into the headline.
333
- const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
334
- console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`);
343
+ const deadDetail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
344
+ console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${deadDetail})`)}`);
335
345
  }
336
- // Live only. The roster used to list every matched session, so a card headed
337
- // `23 live` went on to show `crashed ×25` — the corpses the `dead` row above
338
- // already accounts for, counted twice and contradicting the headline.
346
+ // Live only, grouped by host when machine stamps exist so "who is on which
347
+ // box" is visible. Flat collapse hid that when harness×status matched across hosts.
339
348
  const liveMembers = r?.members.filter((m) => !isDeadStatus(m.status)) ?? [];
340
- if (liveMembers.length)
341
- console.log(` ${chalk.dim('agents')} ${formatProjectMembers(liveMembers)}`);
349
+ if (liveMembers.length) {
350
+ const agentLines = formatProjectMembersByHost(liveMembers);
351
+ agentLines.forEach((line, i) => {
352
+ console.log(` ${chalk.dim((i === 0 ? 'agents' : '').padEnd(7))} ${line}`);
353
+ });
354
+ }
342
355
  const ships = [];
343
356
  if (remote?.mergedPrs) {
344
357
  ships.push(chalk.green(`${remote.mergedPrs}${remote.mergedPrsTruncated ? '+' : ''} merged (${remote.windowDays}d)`));
@@ -357,13 +370,15 @@ detail = false) {
357
370
  for (const line of formatMilestoneLines(linear?.milestones ?? [], linear?.nextMilestone, nowMs, milestoneLimit)) {
358
371
  console.log(line);
359
372
  }
360
- // What the dates prove — never an invented "on track". See project-schedule.ts.
373
+ // Informational schedule only. Warn-level verdicts land in the footer so the
374
+ // bottom of the card is the one place you look for "what needs attention".
361
375
  const verdict = linear?.milestones?.length ? formatVerdict(scheduleVerdict(linear.milestones, nowMs)) : undefined;
362
- if (verdict) {
363
- console.log(` ${chalk.dim('schedule')} ${verdict.warn ? chalk.yellow(verdict.text) : verdict.text}`);
376
+ if (verdict && !verdict.warn) {
377
+ console.log(` ${chalk.dim('schedule')} ${verdict.text}`);
364
378
  }
365
379
  if (focus.length) {
366
- console.log(` ${chalk.dim('focus')} ${focus.map((f) => `${f.path} ${chalk.dim(String(f.touches))}`).join(chalk.dim(' · '))}`);
380
+ const windowDays = remote?.windowDays ?? 7;
381
+ console.log(` ${chalk.dim('focus')} ${formatFocusAreas(focus, windowDays)}`);
367
382
  }
368
383
  if (r && r.tickets.length) {
369
384
  console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
@@ -384,14 +399,29 @@ detail = false) {
384
399
  const repos = [def.repo, ...(def.repos ?? []).map((x) => x.slug)].filter(Boolean);
385
400
  if (repos.length)
386
401
  console.log(` ${chalk.dim('repos')} ${[...new Set(repos)].join(' · ')}`);
387
- // A def whose `repo` disagrees with the checkout's origin reads PR and release
388
- // counts from a DIFFERENT repository — both slugs resolve, so nothing errors
389
- // and the wrong numbers look right. Say it here, with the fix attached.
402
+ // Warnings footer — critical (🔴) then continue (⚠️). Sources: repo slug
403
+ // mismatch, workspace drift/dirty/missing, schedule that cannot measure.
404
+ const warnings = [];
390
405
  const mismatch = def.root ? checkRepoSlug(def, originSlug(expandLocalHome(def.root))) : undefined;
391
406
  if (mismatch) {
392
- console.log(` ${chalk.yellow('!')} ${chalk.yellow(mismatch.message)}`);
393
- console.log(` ${' '.repeat(8)}${chalk.gray(mismatch.remediation)}`);
407
+ warnings.push({ severity: 'critical', text: mismatch.message, remediation: mismatch.remediation });
408
+ }
409
+ for (const w of workspaceWarnings(warnWorkspaces)) {
410
+ warnings.push(w);
394
411
  }
412
+ if (verdict?.warn) {
413
+ warnings.push({ severity: 'continue', text: verdict.text });
414
+ }
415
+ if (split.dead > 0 && (split.deadByStatus.find((d) => d.status === 'crashed')?.n ?? 0) > 0) {
416
+ const crashed = split.deadByStatus.find((d) => d.status === 'crashed').n;
417
+ warnings.push({
418
+ severity: crashed >= 10 ? 'critical' : 'continue',
419
+ text: `${crashed} crashed session${crashed === 1 ? '' : 's'} on this project`,
420
+ remediation: 'inspect with agents sessions --active / clean up stuck worktrees',
421
+ });
422
+ }
423
+ for (const line of formatProjectWarnings(warnings))
424
+ console.log(line);
395
425
  // `view` prints these in full (path + purpose, label + URL) right below, so
396
426
  // the compact one-line summaries would just say the same thing twice.
397
427
  if (!detail && def.goals?.length) {
@@ -417,7 +447,8 @@ export function registerProjectsCommands(program) {
417
447
  agents projects add rush --repo phnx-labs/rush --path apps/web
418
448
  agents projects list
419
449
  agents projects status # progress card for every project
420
- agents projects status rush --json # one project, machine-readable
450
+ agents projects status rush # one project (same body as view/show)
451
+ agents projects view rush # alias of status <name>
421
452
  agents projects status --fleet # + per-device workspace drift over SSH
422
453
  agents projects link rush --linear # bind the Linear project (auto-suggest)
423
454
  agents run --project rush # land an agent in the project
@@ -522,56 +553,8 @@ export function registerProjectsCommands(program) {
522
553
  console.log(chalk.gray(` ${target}`));
523
554
  console.log(chalk.gray(` root ${def.root}${def.repo ? ` · repo ${def.repo}` : ''}`));
524
555
  });
525
- // ---- show ----
526
- projects
527
- .command('view <name>')
528
- .alias('show')
529
- .description('Everything about one project: the full status card, every milestone, and the stored definition.')
530
- .option('--json', 'Machine-readable output')
531
- .option('--window <days>', 'Window for merged PRs, artifacts, and focus areas', '7')
532
- .option('--no-remote', 'Skip the GitHub and Linear lookups; definition only')
533
- .action(async (name, opts) => {
534
- const def = loadProjectDef(name);
535
- if (!def) {
536
- console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
537
- process.exit(1);
538
- }
539
- // `view` is `status` for one project PLUS the stored definition and every
540
- // milestone. It used to render its own short list — root, repos, a raw
541
- // Linear id, issues — so opening a single project told you LESS than the
542
- // roll-up did: no agents, no ships, no focus, no schedule verdict.
543
- const windowDays = Math.max(1, Number.parseInt(opts.window ?? '7', 10) || 7);
544
- const nowMs = Date.now();
545
- const all = listProjectDefs();
546
- const { roll, remote, linear, focus } = await enrichProjectsForRender([def], all, {
547
- windowDays,
548
- nowMs,
549
- skipRemote: opts.remote === false,
550
- });
551
- const r = roll.get(def.name);
552
- const sig = remote.get(def.name);
553
- const counts = linear.get(def.name);
554
- if (opts.json) {
555
- console.log(JSON.stringify({
556
- ...def,
557
- linear: { ...def.linear, ...(counts ?? {}) },
558
- schedule: counts?.milestones?.length ? scheduleVerdict(counts.milestones, nowMs) : null,
559
- live: r ? liveDeadSplit(r.byStatus).live : 0,
560
- dead: r ? liveDeadSplit(r.byStatus).dead : 0,
561
- byStatus: r?.byStatus ?? {},
562
- members: r?.members ?? [],
563
- openPrs: r?.openPrs ?? [],
564
- tickets: r?.tickets ?? [],
565
- mergedPrs: sig?.mergedPrs ?? 0,
566
- latestRelease: sig?.latestRelease ?? null,
567
- artifacts: sig?.artifacts ?? 0,
568
- focus: focus.get(def.name) ?? [],
569
- }, null, 2));
570
- return;
571
- }
572
- // The same card `status` prints, with every milestone instead of the next.
573
- renderCard(def, r, sig, undefined, counts, nowMs, Number.POSITIVE_INFINITY, focus.get(def.name) ?? [], true);
574
- // Then what only `view` shows: the stored definition, in full.
556
+ /** Print the YAML-side fields that sit under the shared card in `view` mode. */
557
+ function printProjectDefinition(def, name) {
575
558
  console.log();
576
559
  if (def.root)
577
560
  console.log(` ${chalk.dim('root')} ${def.root}`);
@@ -593,38 +576,17 @@ export function registerProjectsCommands(program) {
593
576
  for (const d of def.docs ?? [])
594
577
  console.log(` ${chalk.dim('doc')} ${d}`);
595
578
  console.log(chalk.gray(` ${projectDefPath(name)}`));
596
- });
597
- // ---- edit ----
598
- projects
599
- .command('edit <name>')
600
- .description('Open the project YAML in $EDITOR (it is hand-editable regardless).')
601
- .action((name) => {
602
- const target = projectDefPath(name);
603
- if (!fs.existsSync(target)) {
604
- console.error(chalk.red(`No project named "${name}". Create it: agents projects add ${name}`));
605
- process.exit(1);
606
- }
607
- const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
608
- // $EDITOR commonly carries args ("code --wait") — split like monitors/routines do.
609
- const parts = editor.split(/\s+/).filter(Boolean);
610
- const res = spawnSync(parts[0], [...parts.slice(1), target], { stdio: 'inherit' });
611
- process.exit(res.status ?? 0);
612
- });
613
- // ---- status ----
614
- projects
615
- .command('status [name]')
616
- .description('Progress rollup: agents, plan %, merged/open PRs, tickets, and artifacts per project.')
617
- .option('--json', 'Machine-readable output')
618
- .option('--window <days>', 'Window for merged PRs and artifacts', '7')
619
- .option('--no-remote', 'Skip the GitHub lookup (merged-PR count); faster, offline')
620
- .option('--fleet', 'Also dial every fleet device for workspace presence, branch, and drift (one SSH per peer)')
621
- .action(async (name, opts) => {
579
+ }
580
+ async function runProjectCard(name, opts, mode) {
581
+ const detail = mode === 'view';
622
582
  const all = listProjectDefs();
623
583
  // Named lookup goes through the strict single-def loader so a broken
624
584
  // <name>.yaml surfaces its validation error instead of "No project named".
625
585
  const defs = name ? [loadProjectDef(name)].filter((d) => d !== undefined) : all;
626
586
  if (name && !defs.length) {
627
- console.error(chalk.red(`No project named "${name}".`));
587
+ console.error(chalk.red(detail
588
+ ? `No project named "${name}". List them: agents projects list`
589
+ : `No project named "${name}".`));
628
590
  process.exit(1);
629
591
  }
630
592
  if (!defs.length) {
@@ -639,7 +601,8 @@ export function registerProjectsCommands(program) {
639
601
  // --fleet: probe each shown def's workspace paths (root + repos[].path)
640
602
  // locally and on every peer in one parallel SSH round, and widen the
641
603
  // live-session rollup to the whole fleet via the existing sessions
642
- // fan-out. Both are opt-in — they dial the fleet.
604
+ // fan-out. Both are opt-in — they dial the fleet. `view` accepts the flag
605
+ // too so the two verbs stay interchangeable once a name is given.
643
606
  const fleetTargets = opts.fleet ? [...new Set(defs.flatMap(workspaceTargetsForDef))] : [];
644
607
  let fleetWs = [];
645
608
  let fleetSkipped = [];
@@ -673,28 +636,41 @@ export function registerProjectsCommands(program) {
673
636
  const targets = new Set(workspaceTargetsForDef(d));
674
637
  return fleetWs.filter((s) => targets.has(s.path));
675
638
  };
639
+ /** Local-only workspace probe for the warnings footer when --fleet is off. */
640
+ const localWsCache = new Map();
641
+ const localWsFor = (d) => {
642
+ const cached = localWsCache.get(d.name);
643
+ if (cached)
644
+ return cached;
645
+ const self = machineId();
646
+ const rows = probeProjectWorkspaces(workspaceTargetsForDef(d)).map((s) => ({ ...s, host: self }));
647
+ localWsCache.set(d.name, rows);
648
+ return rows;
649
+ };
676
650
  if (opts.json) {
677
651
  if (fleetSkipped.length > 0)
678
652
  process.stderr.write(formatFleetSkippedNote(fleetSkipped));
653
+ // `view` used to nest the def fields at the top level and omit plan /
654
+ // worktrees / windowDays; keep one machine shape for both verbs so a
655
+ // consumer that switches `status`↔`view` does not re-learn the schema.
679
656
  console.log(JSON.stringify(defs.map((d) => {
680
657
  const r = roll.get(d.name);
681
658
  const rem = remote.get(d.name);
659
+ const counts = linear.get(d.name);
682
660
  return {
683
- name: d.name,
661
+ ...(detail ? d : { name: d.name }),
684
662
  agents: r?.agents ?? 0,
685
663
  byStatus: r?.byStatus ?? {},
686
664
  members: r?.members ?? [],
687
665
  plan: r?.plan ?? { done: 0, total: 0 },
688
- schedule: linear.get(d.name)?.milestones?.length
689
- ? scheduleVerdict(linear.get(d.name).milestones, nowMs)
690
- : null,
666
+ schedule: counts?.milestones?.length ? scheduleVerdict(counts.milestones, nowMs) : null,
691
667
  focus: focus.get(d.name) ?? [],
692
668
  live: r ? liveDeadSplit(r.byStatus).live : 0,
693
669
  dead: r ? liveDeadSplit(r.byStatus).dead : 0,
694
670
  openPrs: r?.openPrs ?? [],
695
671
  mergedPrs: rem?.mergedPrs ?? 0,
696
672
  latestRelease: rem?.latestRelease ?? null,
697
- linear: linear.get(d.name) ?? null,
673
+ linear: detail ? { ...d.linear, ...(counts ?? {}) } : (counts ?? null),
698
674
  tickets: r?.tickets ?? [],
699
675
  worktrees: r?.worktrees ?? 0,
700
676
  artifacts: rem?.artifacts ?? 0,
@@ -706,11 +682,50 @@ export function registerProjectsCommands(program) {
706
682
  }), null, 2));
707
683
  return;
708
684
  }
685
+ // Compact rollup shows the next milestone; `view` shows every declared one.
686
+ const milestoneLimit = detail ? Number.POSITIVE_INFINITY : 1;
709
687
  for (const d of defs) {
710
- renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name), nowMs, 1, focus.get(d.name) ?? []);
688
+ renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name), nowMs, milestoneLimit, focus.get(d.name) ?? [], detail,
689
+ // Always feed workspace rows into the warnings footer — full fleet when
690
+ // --fleet, otherwise a local-only probe so behind/dirty is never silent.
691
+ opts.fleet ? fleetFor(d) : localWsFor(d));
692
+ if (detail)
693
+ printProjectDefinition(d, d.name);
711
694
  }
712
695
  if (fleetSkipped.length > 0)
713
696
  process.stdout.write(formatFleetSkippedNote(fleetSkipped));
697
+ }
698
+ projects
699
+ .command('status [name]')
700
+ .alias('view')
701
+ .alias('show')
702
+ .description('Progress card for every project, or one named project (aliases: view, show). Named form also prints every milestone and the stored definition.')
703
+ .option('--json', 'Machine-readable output')
704
+ .option('--window <days>', 'Window for merged PRs, artifacts, and focus areas', '7')
705
+ .option('--no-remote', 'Skip the GitHub and Linear lookups; faster, offline')
706
+ .option('--fleet', 'Also dial every fleet device for workspace presence, branch, and drift (one SSH per peer)')
707
+ .action(async (name, opts) => {
708
+ // Named invocation = `view` depth (all milestones + definition). Unnamed
709
+ // stays the scannable multi-project rollup. `view`/`show` are commander
710
+ // aliases of this same command, so there is only one implementation.
711
+ const mode = name ? 'view' : 'status';
712
+ await runProjectCard(name, opts, mode);
713
+ });
714
+ // ---- edit ----
715
+ projects
716
+ .command('edit <name>')
717
+ .description('Open the project YAML in $EDITOR (it is hand-editable regardless).')
718
+ .action((name) => {
719
+ const target = projectDefPath(name);
720
+ if (!fs.existsSync(target)) {
721
+ console.error(chalk.red(`No project named "${name}". Create it: agents projects add ${name}`));
722
+ process.exit(1);
723
+ }
724
+ const editor = process.env.VISUAL || process.env.EDITOR || 'vi';
725
+ // $EDITOR commonly carries args ("code --wait") — split like monitors/routines do.
726
+ const parts = editor.split(/\s+/).filter(Boolean);
727
+ const res = spawnSync(parts[0], [...parts.slice(1), target], { stdio: 'inherit' });
728
+ process.exit(res.status ?? 0);
714
729
  });
715
730
  // ---- probe (hidden; the peer half of `status --fleet`) ----
716
731
  projects
@@ -2071,7 +2071,7 @@ Examples:
2071
2071
  .action(async (bundleName, opts) => {
2072
2072
  try {
2073
2073
  // `--device` is an alias for `--host` (fleet vocabulary parity with
2074
- // `agents activity`/`run --device`); fold it into the host list up front so
2074
+ // `agents run --device`); fold it into the host list up front so
2075
2075
  // every downstream branch sees one resolved target list.
2076
2076
  if (opts.device?.length)
2077
2077
  opts.host = [...(opts.host ?? []), ...opts.device];
@@ -28,5 +28,38 @@ interface ToolBackfillOptions {
28
28
  }
29
29
  export declare function backfillToolsLocal(options: ToolBackfillOptions, oneBatch?: boolean): Promise<ToolBackfillMachineResult>;
30
30
  export declare function runToolsBackfill(options: ToolBackfillOptions): Promise<ToolBackfillEnvelope>;
31
+ interface ResourceBackfillOptions {
32
+ agent?: string;
33
+ project?: string;
34
+ since?: string;
35
+ until?: string;
36
+ teams?: boolean;
37
+ json?: boolean;
38
+ }
39
+ export interface ResourceBackfillEnvelope {
40
+ schemaVersion: 1;
41
+ kind: 'resources-backfill';
42
+ generatedAt: string;
43
+ machine: string;
44
+ /** Sessions considered (matched the filter, had a real transcript). */
45
+ scanned: number;
46
+ /** Sessions (re)parsed and written this run. */
47
+ updated: number;
48
+ /** Sessions already current at this extractor version, skipped. */
49
+ skipped: number;
50
+ /** Sessions whose transcript could not be stat'd or parsed. */
51
+ failed: number;
52
+ /** Total session_resource_usage rows written across updated sessions. */
53
+ resourceRows: number;
54
+ }
55
+ /**
56
+ * Populate session_resource_usage for historical sessions on THIS machine.
57
+ * Local-only by design: the resource signal is derived from each machine's own
58
+ * transcripts, and the index is machine-local, so there is no cross-machine
59
+ * merge to do — run it on each box (or over `agents ssh`). Ensures the session
60
+ * index is complete first (discoverSessions), then re-derives usage gated by the
61
+ * resource_scan_ledger so reruns skip completed transcripts.
62
+ */
63
+ export declare function runResourceBackfill(options: ResourceBackfillOptions): Promise<ResourceBackfillEnvelope>;
31
64
  export declare function registerSessionsBackfillCommand(sessionsCmd: Command): void;
32
65
  export {};
@@ -1,11 +1,12 @@
1
1
  import chalk from 'chalk';
2
2
  import { setHelpSections } from '../lib/help.js';
3
3
  import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
4
- import { discoverSessions } from '../lib/session/discover.js';
4
+ import { discoverSessions, parseTimeFilter } from '../lib/session/discover.js';
5
5
  import { machineId } from '../lib/session/sync/config.js';
6
6
  import { SESSION_AGENTS } from '../lib/session/types.js';
7
7
  import { ensureToolIndex, readToolIndexCoverage } from '../lib/session/tool-index.js';
8
8
  import { NO_FANOUT_ENV } from '../lib/session/remote-active.js';
9
+ import { backfillResourceUsage } from '../lib/session/db.js';
9
10
  const BACKFILL_REMOTE_TIMEOUT_MS = 10 * 60_000;
10
11
  function parseAgent(value) {
11
12
  if (!value)
@@ -138,6 +139,48 @@ export async function runToolsBackfill(options) {
138
139
  machines,
139
140
  };
140
141
  }
142
+ /**
143
+ * Populate session_resource_usage for historical sessions on THIS machine.
144
+ * Local-only by design: the resource signal is derived from each machine's own
145
+ * transcripts, and the index is machine-local, so there is no cross-machine
146
+ * merge to do — run it on each box (or over `agents ssh`). Ensures the session
147
+ * index is complete first (discoverSessions), then re-derives usage gated by the
148
+ * resource_scan_ledger so reruns skip completed transcripts.
149
+ */
150
+ export async function runResourceBackfill(options) {
151
+ const { agent, version } = parseAgent(options.agent);
152
+ // Make sure every matching transcript is indexed before we re-derive usage.
153
+ await discoverSessions({
154
+ agent,
155
+ version,
156
+ project: options.project,
157
+ since: options.since,
158
+ until: options.until,
159
+ all: true,
160
+ unbounded: true,
161
+ skipExistenceCheck: true,
162
+ excludeTeamOrigin: !options.teams,
163
+ });
164
+ const filter = { excludeTeamOrigin: !options.teams };
165
+ if (agent)
166
+ filter.agent = agent;
167
+ if (version)
168
+ filter.version = version;
169
+ if (options.project)
170
+ filter.project = options.project;
171
+ if (options.since)
172
+ filter.sinceMs = parseTimeFilter(options.since);
173
+ if (options.until)
174
+ filter.untilMs = parseTimeFilter(options.until);
175
+ const result = backfillResourceUsage(filter);
176
+ return {
177
+ schemaVersion: 1,
178
+ kind: 'resources-backfill',
179
+ generatedAt: new Date().toISOString(),
180
+ machine: machineId(),
181
+ ...result,
182
+ };
183
+ }
141
184
  export function registerSessionsBackfillCommand(sessionsCmd) {
142
185
  const backfill = sessionsCmd.command('backfill').description('Populate derived session data explicitly.');
143
186
  const tools = backfill.command('tools').description('Parse historical tool calls once into the local SQLite index.');
@@ -183,4 +226,43 @@ export function registerSessionsBackfillCommand(sessionsCmd) {
183
226
  process.exitCode = 1;
184
227
  }
185
228
  });
229
+ const resources = backfill.command('resources').description('Derive historical skill/slash-command usage once into the local SQLite index.');
230
+ setHelpSections(resources, {
231
+ examples: `
232
+ # Fold every historical session on this machine into the usage index
233
+ agents sessions backfill resources
234
+
235
+ # Narrow the historical work
236
+ agents sessions backfill resources --agent claude --since 30d
237
+
238
+ # Machine-readable summary
239
+ agents sessions backfill resources --json
240
+ `,
241
+ notes: `
242
+ - Populates session_resource_usage for sessions indexed before the usage signal shipped. New/changed sessions are recorded on their normal scan; this is the one-shot catch-up read by \`agents sessions stats\`.
243
+ - Local-only: the signal is derived per machine from its own transcripts. Run it on each box (or over \`agents ssh <host> agents sessions backfill resources\`).
244
+ - Reruns skip transcripts already current (resource_scan_ledger); bump the extractor version to force a full re-derive.
245
+ - Only slash commands and \`Skill\` tool calls are recorded — auto-triggered skills emit no signal.
246
+ `,
247
+ });
248
+ resources.action(async (_options, command) => {
249
+ const options = command.optsWithGlobals();
250
+ try {
251
+ const envelope = await runResourceBackfill(options);
252
+ if (options.json)
253
+ process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
254
+ else {
255
+ console.log(`${envelope.machine}: derived usage for ${envelope.updated.toLocaleString()} session${envelope.updated === 1 ? '' : 's'} ` +
256
+ `(${envelope.resourceRows.toLocaleString()} resource row${envelope.resourceRows === 1 ? '' : 's'}); ` +
257
+ `${envelope.skipped.toLocaleString()} already current, ${envelope.scanned.toLocaleString()} scanned.`);
258
+ if (envelope.failed > 0) {
259
+ console.log(chalk.yellow(`${envelope.failed.toLocaleString()} transcript${envelope.failed === 1 ? '' : 's'} could not be parsed; rerun to retry.`));
260
+ }
261
+ }
262
+ }
263
+ catch (error) {
264
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
265
+ process.exitCode = 1;
266
+ }
267
+ });
186
268
  }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * `agents sessions stats` — precomputed resource-usage insights.
3
+ *
4
+ * The read side of #12: which skills and slash-commands do you actually invoke,
5
+ * and which installed ones are dead weight? The write side already exists —
6
+ * every scanned transcript's skill/`Skill`-tool + slash-command tallies land in
7
+ * `session_resource_usage` at index time — so this is a cheap SQLite rollup, no
8
+ * re-scan of history. A both-ends view: the most-invoked resources, and the
9
+ * installed-but-never-invoked ones.
10
+ *
11
+ * Signal caveat, surfaced in help and output: only EXPLICIT invocations count
12
+ * (slash commands + `Skill` tool calls). An auto-triggered skill (loaded by
13
+ * description match) emits no event and reads as zero — "0" means "never
14
+ * explicitly invoked", not "never loaded". Skill invocations are recorded for
15
+ * Claude and Kimi (the `Skill`-tool harnesses); slash-commands are Claude-only;
16
+ * other harnesses contribute nothing.
17
+ */
18
+ import type { Command } from 'commander';
19
+ /** One installed resource, keyed the same way session_resource_usage stores it. */
20
+ export interface InstalledResource {
21
+ kind: 'skill' | 'command';
22
+ name: string;
23
+ plugin: string | null;
24
+ source: string | null;
25
+ }
26
+ /**
27
+ * The both-ends "dead weight" set: installed resources whose identity
28
+ * (kind + name — name already embeds `plugin:short`, so it matches the stored
29
+ * invoked name) never appears in the invoked rows. Sorted kind then name.
30
+ * Pure set-difference so it is unit-testable without touching the filesystem.
31
+ */
32
+ export declare function diffZeroInvoked(installed: InstalledResource[], invoked: Array<{
33
+ kind: string;
34
+ name: string;
35
+ }>): InstalledResource[];
36
+ export declare function registerSessionsStatsCommand(sessionsCmd: Command): void;