@phnx-labs/agents-cli 1.21.3 → 1.22.1

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 (90) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/README.md +32 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/doctor.js +34 -2
  6. package/dist/commands/exec.d.ts +27 -0
  7. package/dist/commands/exec.js +123 -6
  8. package/dist/commands/models.js +36 -1
  9. package/dist/commands/projects.js +120 -62
  10. package/dist/commands/sessions-backfill.d.ts +32 -0
  11. package/dist/commands/sessions-backfill.js +186 -0
  12. package/dist/commands/sessions.d.ts +17 -1
  13. package/dist/commands/sessions.js +317 -18
  14. package/dist/commands/teams.js +1 -1
  15. package/dist/commands/worktree.d.ts +3 -3
  16. package/dist/commands/worktree.js +35 -4
  17. package/dist/lib/app-bundle-install.d.ts +17 -0
  18. package/dist/lib/app-bundle-install.js +94 -0
  19. package/dist/lib/daemon.d.ts +5 -1
  20. package/dist/lib/daemon.js +63 -14
  21. package/dist/lib/devices/doctor-findings.js +12 -4
  22. package/dist/lib/devices/doctor-overview-cache.d.ts +45 -0
  23. package/dist/lib/devices/doctor-overview-cache.js +168 -0
  24. package/dist/lib/devices/fleet.js +7 -2
  25. package/dist/lib/devices/resolve-target.d.ts +6 -0
  26. package/dist/lib/devices/resolve-target.js +9 -3
  27. package/dist/lib/devices/self-host.d.ts +9 -0
  28. package/dist/lib/devices/self-host.js +61 -0
  29. package/dist/lib/exec.js +39 -8
  30. package/dist/lib/hosts/dispatch.d.ts +12 -0
  31. package/dist/lib/hosts/dispatch.js +23 -6
  32. package/dist/lib/hosts/passthrough.js +8 -6
  33. package/dist/lib/hosts/reconnect.d.ts +38 -0
  34. package/dist/lib/hosts/reconnect.js +85 -4
  35. package/dist/lib/hosts/run-target.js +14 -2
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  38. package/dist/lib/menubar/install-menubar.js +27 -23
  39. package/dist/lib/model-tiers.d.ts +54 -0
  40. package/dist/lib/model-tiers.js +229 -0
  41. package/dist/lib/models.d.ts +3 -0
  42. package/dist/lib/models.js +44 -7
  43. package/dist/lib/pricing/prices.json +16 -1
  44. package/dist/lib/project-focus.d.ts +42 -0
  45. package/dist/lib/project-focus.js +80 -0
  46. package/dist/lib/project-schedule.d.ts +75 -0
  47. package/dist/lib/project-schedule.js +110 -0
  48. package/dist/lib/project-status.d.ts +7 -0
  49. package/dist/lib/project-status.js +9 -0
  50. package/dist/lib/projects.d.ts +11 -2
  51. package/dist/lib/projects.js +57 -9
  52. package/dist/lib/redact.d.ts +2 -0
  53. package/dist/lib/redact.js +22 -0
  54. package/dist/lib/remote-agents-json.d.ts +2 -0
  55. package/dist/lib/remote-agents-json.js +3 -3
  56. package/dist/lib/rotate.d.ts +84 -1
  57. package/dist/lib/rotate.js +155 -5
  58. package/dist/lib/runner.d.ts +4 -2
  59. package/dist/lib/runner.js +13 -4
  60. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  61. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  62. package/dist/lib/secrets/install-helper.js +28 -31
  63. package/dist/lib/session/bash-command.js +60 -9
  64. package/dist/lib/session/db.d.ts +7 -1
  65. package/dist/lib/session/db.js +301 -32
  66. package/dist/lib/session/discover.d.ts +40 -7
  67. package/dist/lib/session/discover.js +144 -83
  68. package/dist/lib/session/parse.d.ts +8 -1
  69. package/dist/lib/session/parse.js +83 -32
  70. package/dist/lib/session/remote-list.d.ts +71 -0
  71. package/dist/lib/session/remote-list.js +410 -2
  72. package/dist/lib/session/shell-programs.d.ts +15 -0
  73. package/dist/lib/session/shell-programs.js +359 -0
  74. package/dist/lib/session/tool-calls.d.ts +88 -0
  75. package/dist/lib/session/tool-calls.js +612 -0
  76. package/dist/lib/session/tool-index.d.ts +100 -0
  77. package/dist/lib/session/tool-index.js +773 -0
  78. package/dist/lib/session/tool-store.d.ts +15 -0
  79. package/dist/lib/session/tool-store.js +198 -0
  80. package/dist/lib/session/types.d.ts +7 -0
  81. package/dist/lib/state.d.ts +10 -1
  82. package/dist/lib/state.js +11 -2
  83. package/dist/lib/teams/remoteWorktree.d.ts +3 -4
  84. package/dist/lib/teams/remoteWorktree.js +3 -4
  85. package/dist/lib/teams/worktree.d.ts +11 -1
  86. package/dist/lib/teams/worktree.js +42 -4
  87. package/dist/lib/types.d.ts +17 -0
  88. package/dist/lib/types.js +17 -0
  89. package/dist/lib/versions.js +69 -22
  90. package/package.json +3 -1
@@ -11,9 +11,11 @@ import { homeDir } from '../lib/platform/index.js';
11
11
  import { resolveAgentName, formatAgentError, agentLabel, } from '../lib/agents.js';
12
12
  import { listInstalledVersions, getGlobalDefault, resolveVersion, resolveVersionAlias } from '../lib/versions.js';
13
13
  import { getModelCatalog, locateModelSource } from '../lib/models.js';
14
+ import { resolveTierMap, MODEL_TIERS } from '../lib/model-tiers.js';
15
+ import { getModelPricing } from '../lib/pricing/index.js';
14
16
  import { terminalWidth, truncateToWidth, stringWidth } from '../lib/session/width.js';
15
17
  import { wrapJoined } from './inspect.js';
16
- const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'openclaw', 'antigravity', 'kimi', 'grok'];
18
+ const MODEL_CAPABLE_AGENTS = ['claude', 'codex', 'opencode', 'cursor', 'openclaw', 'antigravity', 'kimi', 'grok', 'droid'];
17
19
  /**
18
20
  * Agents that don't necessarily install under ~/.agents/versions (cursor ships
19
21
  * via a curl script). For these, fall back to the PATH binary and synthesize
@@ -52,6 +54,7 @@ export function registerModelsCommand(program) {
52
54
  agent,
53
55
  version,
54
56
  catalog: getModelCatalog(agent, version),
57
+ tiers: resolveTierMap(agent, version),
55
58
  }));
56
59
  console.log(JSON.stringify(out, null, 2));
57
60
  return;
@@ -122,8 +125,16 @@ function printCatalog(agent, version, isDefault, options) {
122
125
  const tag = isDefault ? chalk.gray(' (default)') : '';
123
126
  const header = `${agentLabel(agent)} ${chalk.bold(version)}${tag}`;
124
127
  console.log(header);
128
+ // Cost tiers first -- the thing an orchestrating agent reads to pick a model.
129
+ printTiers(agent, version);
125
130
  const src = locateModelSource(agent, version);
126
131
  if (!src) {
132
+ if (agent === 'droid') {
133
+ // Droid has no extractable catalog (no models CLI/API/config); the curated
134
+ // tier map above is the whole surface.
135
+ console.log(chalk.gray(' (Droid has no model list command; tiers are a curated, credit-multiplier map.)'));
136
+ return;
137
+ }
127
138
  console.log(chalk.yellow(` Could not locate model source for ${agent}@${version}.`));
128
139
  console.log(chalk.gray(` Expected the agent's CLI bundle or native binary under ~/.agents/.history/versions/${agent}/${version}/.`));
129
140
  return;
@@ -171,6 +182,30 @@ function printCatalog(agent, version, isDefault, options) {
171
182
  }
172
183
  }
173
184
  }
185
+ /** Rough blended $/Mtok label for a model id, or '' when unpriced. */
186
+ function priceLabel(id) {
187
+ const p = getModelPricing(id);
188
+ if (!p)
189
+ return chalk.gray(' --');
190
+ const perM = (p.inputPerToken + p.outputPerToken) * 1e6;
191
+ return chalk.gray(` ~$${perM.toFixed(0)}/Mtok`);
192
+ }
193
+ /** Print the cheap/default/best/ultra tier map for an (agent, version). */
194
+ function printTiers(agent, version) {
195
+ const map = resolveTierMap(agent, version);
196
+ if (!MODEL_TIERS.some((t) => map[t].model))
197
+ return;
198
+ console.log(chalk.gray(' tiers:'));
199
+ for (const t of MODEL_TIERS) {
200
+ const r = map[t];
201
+ if (!r.model)
202
+ continue;
203
+ const eff = r.effort ? chalk.gray(` @${r.effort}`) : '';
204
+ const clamp = r.clampedFrom ? chalk.gray(' (clamped)') : '';
205
+ console.log(` ${chalk.cyan(t.padEnd(8))} ${chalk.bold(r.model)}${eff}${priceLabel(r.model)}${clamp}`);
206
+ }
207
+ console.log();
208
+ }
174
209
  /** Abbreviate a path by replacing the home directory with ~. */
175
210
  function shortPath(p) {
176
211
  return p.replace(homeDir(), '~');
@@ -25,10 +25,12 @@ 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, liveDeadSplit, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
28
+ import { rollupSessionsByProject, isDeadStatus, 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
31
  import { checkRepoSlug } from '../lib/project-doctor.js';
32
+ import { readFocusAreas } from '../lib/project-focus.js';
33
+ import { formatVerdict, scheduleVerdict } from '../lib/project-schedule.js';
32
34
  import { buildFactoryImportCandidates, buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
33
35
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
34
36
  export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
@@ -266,9 +268,45 @@ function statusBar(r) {
266
268
  parts.push(chalk.gray(`+${live - shown} other`));
267
269
  return parts.join(' · ') || chalk.gray('no live agents');
268
270
  }
271
+ /**
272
+ * Gather the live/remote/Linear/focus signals for the projects about to be
273
+ * rendered. `status` and `view` share this deliberately: `view` used to build
274
+ * its own thinner picture, so the command you open to learn everything about
275
+ * ONE project showed strictly less than the roll-up across all of them — no
276
+ * agents, no ships, no focus, no schedule verdict. One gatherer means a signal
277
+ * added for either surface appears on both.
278
+ *
279
+ * Only the shown projects are enriched. `skipRemote` still reads the local
280
+ * artifact log and git focus, and skips just the network calls (`gh`, Linear).
281
+ */
282
+ async function enrichProjectsForRender(defs, all, opts) {
283
+ const roll = rollupSessionsByProject(all, [...(await getActiveSessions()), ...(opts.extraSessions ?? [])]);
284
+ const remote = new Map();
285
+ const linear = new Map();
286
+ // Local git, no API, no rate limit — measured 0.23s over a 897-commit week.
287
+ const focus = new Map();
288
+ await Promise.all(defs.map(async (d) => {
289
+ const [sig, counts] = await Promise.all([
290
+ enrichProjectSignals(d, opts.windowDays, opts.nowMs, { skipRemote: opts.skipRemote }),
291
+ !opts.skipRemote && d.linear?.projectId
292
+ ? fetchLinearProjectCounts(d.linear.projectId)
293
+ : Promise.resolve(undefined),
294
+ ]);
295
+ remote.set(d.name, sig);
296
+ if (counts)
297
+ linear.set(d.name, counts);
298
+ if (d.root)
299
+ focus.set(d.name, await readFocusAreas(expandLocalHome(d.root), opts.windowDays));
300
+ }));
301
+ return { roll, remote, linear, focus };
302
+ }
269
303
  function renderCard(def, r, remote, fleet, linear, nowMs = Date.now(),
270
304
  /** How many milestones to print. `status` shows the next one; `view` shows all. */
271
- milestoneLimit = 1) {
305
+ milestoneLimit = 1,
306
+ /** Directories the window's work landed in, from local git. */
307
+ focus = [],
308
+ /** `view` mode: the caller prints the stored definition in full afterwards. */
309
+ detail = false) {
272
310
  // The headline counts LIVE agents. It used to be every matched session, which
273
311
  // read `39 agents` on a project where 19 had crashed. `planPct` used to sit
274
312
  // here too and is gone: it summed each session's latest checklist snapshot,
@@ -284,8 +322,12 @@ milestoneLimit = 1) {
284
322
  const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
285
323
  console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`);
286
324
  }
287
- if (r && r.members.length)
288
- console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
325
+ // Live only. The roster used to list every matched session, so a card headed
326
+ // `23 live` went on to show `crashed ×25` — the corpses the `dead` row above
327
+ // already accounts for, counted twice and contradicting the headline.
328
+ const liveMembers = r?.members.filter((m) => !isDeadStatus(m.status)) ?? [];
329
+ if (liveMembers.length)
330
+ console.log(` ${chalk.dim('agents')} ${formatProjectMembers(liveMembers)}`);
289
331
  const ships = [];
290
332
  if (remote?.mergedPrs) {
291
333
  ships.push(chalk.green(`${remote.mergedPrs}${remote.mergedPrsTruncated ? '+' : ''} merged (${remote.windowDays}d)`));
@@ -304,6 +346,14 @@ milestoneLimit = 1) {
304
346
  for (const line of formatMilestoneLines(linear?.milestones ?? [], linear?.nextMilestone, nowMs, milestoneLimit)) {
305
347
  console.log(line);
306
348
  }
349
+ // What the dates prove — never an invented "on track". See project-schedule.ts.
350
+ const verdict = linear?.milestones?.length ? formatVerdict(scheduleVerdict(linear.milestones, nowMs)) : undefined;
351
+ if (verdict) {
352
+ console.log(` ${chalk.dim('schedule')} ${verdict.warn ? chalk.yellow(verdict.text) : verdict.text}`);
353
+ }
354
+ if (focus.length) {
355
+ console.log(` ${chalk.dim('focus')} ${focus.map((f) => `${f.path} ${chalk.dim(String(f.touches))}`).join(chalk.dim(' · '))}`);
356
+ }
307
357
  if (r && r.tickets.length) {
308
358
  console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
309
359
  }
@@ -331,13 +381,16 @@ milestoneLimit = 1) {
331
381
  console.log(` ${chalk.yellow('!')} ${chalk.yellow(mismatch.message)}`);
332
382
  console.log(` ${' '.repeat(8)}${chalk.gray(mismatch.remediation)}`);
333
383
  }
334
- if (def.contexts?.length) {
384
+ // `view` prints these in full (path + purpose, label + URL) right below, so
385
+ // the compact one-line summaries would just say the same thing twice.
386
+ if (!detail && def.contexts?.length) {
335
387
  console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
336
388
  }
337
- if (def.integrations?.length) {
389
+ if (!detail && def.integrations?.length) {
338
390
  console.log(` ${chalk.dim('links')} ${def.integrations.map((i) => i.label ?? i.kind).join(' · ')}`);
339
391
  }
340
- console.log('');
392
+ if (!detail)
393
+ console.log('');
341
394
  }
342
395
  export function registerProjectsCommands(program) {
343
396
  const enabled = isBetaEnabled('projects');
@@ -456,58 +509,70 @@ export function registerProjectsCommands(program) {
456
509
  projects
457
510
  .command('view <name>')
458
511
  .alias('show')
459
- .description('Show a project definition, resolved paths, repos, contexts, links, and milestones.')
512
+ .description('Everything about one project: the full status card, every milestone, and the stored definition.')
460
513
  .option('--json', 'Machine-readable output')
461
- .option('--no-remote', 'Skip the Linear lookup; definition only')
514
+ .option('--window <days>', 'Window for merged PRs, artifacts, and focus areas', '7')
515
+ .option('--no-remote', 'Skip the GitHub and Linear lookups; definition only')
462
516
  .action(async (name, opts) => {
463
517
  const def = loadProjectDef(name);
464
518
  if (!def) {
465
519
  console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
466
520
  process.exit(1);
467
521
  }
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;
522
+ // `view` is `status` for one project PLUS the stored definition and every
523
+ // milestone. It used to render its own short list — root, repos, a raw
524
+ // Linear id, issues — so opening a single project told you LESS than the
525
+ // roll-up did: no agents, no ships, no focus, no schedule verdict.
526
+ const windowDays = Math.max(1, Number.parseInt(opts.window ?? '7', 10) || 7);
527
+ const nowMs = Date.now();
528
+ const all = listProjectDefs();
529
+ const { roll, remote, linear, focus } = await enrichProjectsForRender([def], all, {
530
+ windowDays,
531
+ nowMs,
532
+ skipRemote: opts.remote === false,
533
+ });
534
+ const r = roll.get(def.name);
535
+ const sig = remote.get(def.name);
536
+ const counts = linear.get(def.name);
473
537
  if (opts.json) {
474
- console.log(JSON.stringify({ ...def, linear: { ...def.linear, ...(counts ?? {}) } }, null, 2));
538
+ console.log(JSON.stringify({
539
+ ...def,
540
+ linear: { ...def.linear, ...(counts ?? {}) },
541
+ schedule: counts?.milestones?.length ? scheduleVerdict(counts.milestones, nowMs) : null,
542
+ live: r ? liveDeadSplit(r.byStatus).live : 0,
543
+ dead: r ? liveDeadSplit(r.byStatus).dead : 0,
544
+ byStatus: r?.byStatus ?? {},
545
+ members: r?.members ?? [],
546
+ openPrs: r?.openPrs ?? [],
547
+ tickets: r?.tickets ?? [],
548
+ mergedPrs: sig?.mergedPrs ?? 0,
549
+ latestRelease: sig?.latestRelease ?? null,
550
+ artifacts: sig?.artifacts ?? 0,
551
+ focus: focus.get(def.name) ?? [],
552
+ }, null, 2));
475
553
  return;
476
554
  }
477
- console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
555
+ // The same card `status` prints, with every milestone instead of the next.
556
+ renderCard(def, r, sig, undefined, counts, nowMs, Number.POSITIVE_INFINITY, focus.get(def.name) ?? [], true);
557
+ // Then what only `view` shows: the stored definition, in full.
558
+ console.log();
478
559
  if (def.root)
479
- console.log(` root ${def.root}`);
560
+ console.log(` ${chalk.dim('root')} ${def.root}`);
480
561
  if (def.defaultPath)
481
- console.log(` defaultPath ${def.defaultPath}`);
482
- const repos = [def.repo, ...(def.repos ?? []).map((r) => (r.subpath ? `${r.slug} (${r.subpath})` : r.slug))].filter(Boolean);
483
- if (repos.length)
484
- console.log(` repos ${repos.join(', ')}`);
562
+ console.log(` ${chalk.dim('path')} ${def.defaultPath}`);
563
+ for (const rp of def.repos ?? []) {
564
+ const where = [rp.subpath ? `subpath ${rp.subpath}` : undefined, rp.path].filter(Boolean).join(' · ');
565
+ console.log(` ${chalk.dim('repo')} ${rp.slug}${where ? chalk.dim(` (${where})`) : ''}`);
566
+ }
485
567
  for (const c of def.contexts ?? [])
486
- console.log(` context ${chalk.cyan(c.path)} — ${c.purpose}`);
487
- for (const i of def.integrations ?? [])
488
- console.log(` ${i.kind.padEnd(12)} ${i.url}${i.label ? chalk.dim(` (${i.label})`) : ''}`);
568
+ console.log(` ${chalk.dim('context')} ${chalk.cyan(c.path)} ${chalk.dim('—')} ${c.purpose}`);
569
+ for (const ig of def.integrations ?? []) {
570
+ console.log(` ${chalk.dim(ig.kind.padEnd(8))} ${ig.url}${ig.label ? chalk.dim(` (${ig.label})`) : ''}`);
571
+ }
489
572
  if (def.linear?.url || def.linear?.projectId)
490
- console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
573
+ console.log(` ${chalk.dim('linear')} ${def.linear.url ?? def.linear.projectId}`);
491
574
  for (const d of def.docs ?? [])
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
- }
575
+ console.log(` ${chalk.dim('doc')} ${d}`);
511
576
  console.log(chalk.gray(` ${projectDefPath(name)}`));
512
577
  });
513
578
  // ---- edit ----
@@ -578,23 +643,12 @@ export function registerProjectsCommands(program) {
578
643
  fleetSkipped = probeRes.skipped;
579
644
  fleetSessions = activeRes.sessions;
580
645
  }
581
- const roll = rollupSessionsByProject(all, [...await getActiveSessions(), ...fleetSessions]);
582
- // Enrich only the shown projects. --no-remote still reads the local artifact
583
- // log but skips the gh calls + Linear counts (both are network).
584
- const remote = new Map();
585
- const linear = new Map();
586
- await Promise.all(defs.map(async (d) => {
587
- const skipRemote = opts.remote === false;
588
- const [sig, counts] = await Promise.all([
589
- enrichProjectSignals(d, windowDays, nowMs, { skipRemote }),
590
- !skipRemote && d.linear?.projectId
591
- ? fetchLinearProjectCounts(d.linear.projectId)
592
- : Promise.resolve(undefined),
593
- ]);
594
- remote.set(d.name, sig);
595
- if (counts)
596
- linear.set(d.name, counts);
597
- }));
646
+ const { roll, remote, linear, focus } = await enrichProjectsForRender(defs, all, {
647
+ windowDays,
648
+ nowMs,
649
+ skipRemote: opts.remote === false,
650
+ extraSessions: fleetSessions,
651
+ });
598
652
  /** This def's slice of the fleet probe, in its own target order. */
599
653
  const fleetFor = (d) => {
600
654
  const targets = new Set(workspaceTargetsForDef(d));
@@ -612,6 +666,10 @@ export function registerProjectsCommands(program) {
612
666
  byStatus: r?.byStatus ?? {},
613
667
  members: r?.members ?? [],
614
668
  plan: r?.plan ?? { done: 0, total: 0 },
669
+ schedule: linear.get(d.name)?.milestones?.length
670
+ ? scheduleVerdict(linear.get(d.name).milestones, nowMs)
671
+ : null,
672
+ focus: focus.get(d.name) ?? [],
615
673
  live: r ? liveDeadSplit(r.byStatus).live : 0,
616
674
  dead: r ? liveDeadSplit(r.byStatus).dead : 0,
617
675
  openPrs: r?.openPrs ?? [],
@@ -630,7 +688,7 @@ export function registerProjectsCommands(program) {
630
688
  return;
631
689
  }
632
690
  for (const d of defs) {
633
- renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name), nowMs);
691
+ 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) ?? []);
634
692
  }
635
693
  if (fleetSkipped.length > 0)
636
694
  process.stdout.write(formatFleetSkippedNote(fleetSkipped));
@@ -0,0 +1,32 @@
1
+ import type { Command } from 'commander';
2
+ import { type ToolIndexCoverage } from '../lib/session/tool-index.js';
3
+ export interface ToolBackfillMachineResult {
4
+ machine: string;
5
+ indexedFiles: number;
6
+ indexedCalls: number;
7
+ coverage: ToolIndexCoverage;
8
+ }
9
+ export interface ToolBackfillEnvelope {
10
+ schemaVersion: 1;
11
+ kind: 'tools-backfill';
12
+ generatedAt: string;
13
+ complete: boolean;
14
+ machines: ToolBackfillMachineResult[];
15
+ }
16
+ interface ToolBackfillOptions {
17
+ agent?: string;
18
+ project?: string;
19
+ since?: string;
20
+ until?: string;
21
+ unmanaged?: boolean;
22
+ teams?: boolean;
23
+ json?: boolean;
24
+ local?: boolean;
25
+ fleet?: boolean;
26
+ host?: string[] | string;
27
+ device?: string[] | string;
28
+ }
29
+ export declare function backfillToolsLocal(options: ToolBackfillOptions, oneBatch?: boolean): Promise<ToolBackfillMachineResult>;
30
+ export declare function runToolsBackfill(options: ToolBackfillOptions): Promise<ToolBackfillEnvelope>;
31
+ export declare function registerSessionsBackfillCommand(sessionsCmd: Command): void;
32
+ export {};
@@ -0,0 +1,186 @@
1
+ import chalk from 'chalk';
2
+ import { setHelpSections } from '../lib/help.js';
3
+ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
4
+ import { discoverSessions } from '../lib/session/discover.js';
5
+ import { machineId } from '../lib/session/sync/config.js';
6
+ import { SESSION_AGENTS } from '../lib/session/types.js';
7
+ import { ensureToolIndex, readToolIndexCoverage } from '../lib/session/tool-index.js';
8
+ import { NO_FANOUT_ENV } from '../lib/session/remote-active.js';
9
+ const BACKFILL_REMOTE_TIMEOUT_MS = 10 * 60_000;
10
+ function parseAgent(value) {
11
+ if (!value)
12
+ return {};
13
+ const separator = value.indexOf('@');
14
+ const name = (separator < 0 ? value : value.slice(0, separator));
15
+ if (!SESSION_AGENTS.includes(name))
16
+ throw new Error(`Unknown session agent: ${name}`);
17
+ return { agent: name, version: separator < 0 ? undefined : value.slice(separator + 1) || undefined };
18
+ }
19
+ function mergeHosts(options) {
20
+ const values = [options.host, options.device].flatMap((value) => value === undefined ? [] : Array.isArray(value) ? value : [value]);
21
+ return [...new Set(values)];
22
+ }
23
+ export async function backfillToolsLocal(options, oneBatch = false) {
24
+ const { agent, version } = parseAgent(options.agent);
25
+ const sessions = await discoverSessions({
26
+ agent,
27
+ version,
28
+ project: options.project,
29
+ since: options.since,
30
+ until: options.until,
31
+ all: true,
32
+ unbounded: true,
33
+ skipExistenceCheck: true,
34
+ includeUnmanaged: options.unmanaged,
35
+ excludeTeamOrigin: !options.teams,
36
+ });
37
+ let indexedFiles = 0;
38
+ let indexedCalls = 0;
39
+ for (;;) {
40
+ const batch = await ensureToolIndex(sessions);
41
+ indexedFiles += batch.indexedFiles;
42
+ indexedCalls += batch.indexedCalls;
43
+ if (oneBatch || batch.remainingFiles === 0 || batch.indexedFiles === 0)
44
+ break;
45
+ }
46
+ return {
47
+ machine: machineId(),
48
+ indexedFiles,
49
+ indexedCalls,
50
+ coverage: readToolIndexCoverage(sessions),
51
+ };
52
+ }
53
+ function parseRemoteBackfill(stdout, machine) {
54
+ try {
55
+ const envelope = JSON.parse(stdout);
56
+ if (envelope.schemaVersion !== 1 || envelope.kind !== 'tools-backfill'
57
+ || !Array.isArray(envelope.machines) || envelope.machines.length !== 1) {
58
+ return { items: [], valid: false };
59
+ }
60
+ const item = envelope.machines[0];
61
+ const coverage = item.coverage;
62
+ const coverageKeys = ['indexedFiles', 'indexedCalls', 'skippedFiles', 'limitedFiles', 'remainingFiles'];
63
+ if (!coverage || typeof coverage.complete !== 'boolean'
64
+ || !Number.isSafeInteger(item.indexedFiles) || item.indexedFiles < 0
65
+ || !Number.isSafeInteger(item.indexedCalls) || item.indexedCalls < 0
66
+ || coverageKeys.some((key) => !Number.isSafeInteger(coverage[key]) || coverage[key] < 0)) {
67
+ return { items: [], valid: false };
68
+ }
69
+ return { items: [{ ...item, machine }], valid: true };
70
+ }
71
+ catch {
72
+ return { items: [], valid: false };
73
+ }
74
+ }
75
+ function peerArgs(options) {
76
+ const args = ['sessions', 'backfill', 'tools', '--json', '--local'];
77
+ if (options.agent)
78
+ args.push('--agent', options.agent);
79
+ if (options.project)
80
+ args.push('--project', options.project);
81
+ if (options.since)
82
+ args.push('--since', options.since);
83
+ if (options.until)
84
+ args.push('--until', options.until);
85
+ if (options.unmanaged)
86
+ args.push('--unmanaged');
87
+ if (options.teams)
88
+ args.push('--teams');
89
+ return args;
90
+ }
91
+ export async function runToolsBackfill(options) {
92
+ const hosts = mergeHosts(options);
93
+ const includeLocal = options.local === true || hosts.length === 0;
94
+ const fanOut = options.local !== true && (options.fleet === true || hosts.length > 0);
95
+ if (!fanOut) {
96
+ const machines = includeLocal
97
+ ? [await backfillToolsLocal(options, process.env[NO_FANOUT_ENV] === '1')]
98
+ : [];
99
+ return {
100
+ schemaVersion: 1,
101
+ kind: 'tools-backfill',
102
+ generatedAt: new Date().toISOString(),
103
+ complete: machines.every((machine) => machine.coverage.complete),
104
+ machines,
105
+ };
106
+ }
107
+ const machineTotals = new Map();
108
+ let unreachable = false;
109
+ for (;;) {
110
+ const localPromise = includeLocal ? backfillToolsLocal(options, true) : Promise.resolve(undefined);
111
+ const [local, remote] = await Promise.all([localPromise, gatherRemoteAgentsJson({
112
+ args: peerArgs(options),
113
+ noFanoutEnv: NO_FANOUT_ENV,
114
+ hosts: hosts.length > 0 ? hosts : undefined,
115
+ timeoutMs: BACKFILL_REMOTE_TIMEOUT_MS,
116
+ parse: parseRemoteBackfill,
117
+ })]);
118
+ const round = [local, ...remote.items].filter((item) => item !== undefined);
119
+ for (const item of round) {
120
+ const prior = machineTotals.get(item.machine);
121
+ machineTotals.set(item.machine, {
122
+ ...item,
123
+ indexedFiles: (prior?.indexedFiles ?? 0) + item.indexedFiles,
124
+ indexedCalls: (prior?.indexedCalls ?? 0) + item.indexedCalls,
125
+ });
126
+ }
127
+ unreachable = remote.discoveryFailed || remote.skipped.length > 0 || remote.parseFailed.length > 0;
128
+ const progressed = round.reduce((sum, item) => sum + item.indexedFiles, 0);
129
+ if (unreachable || progressed === 0 || (round.length > 0 && round.every((item) => item.coverage.complete)))
130
+ break;
131
+ }
132
+ const machines = [...machineTotals.values()];
133
+ return {
134
+ schemaVersion: 1,
135
+ kind: 'tools-backfill',
136
+ generatedAt: new Date().toISOString(),
137
+ complete: !unreachable && machines.every((machine) => machine.coverage.complete),
138
+ machines,
139
+ };
140
+ }
141
+ export function registerSessionsBackfillCommand(sessionsCmd) {
142
+ const backfill = sessionsCmd.command('backfill').description('Populate derived session data explicitly.');
143
+ const tools = backfill.command('tools').description('Parse historical tool calls once into the local SQLite index.');
144
+ setHelpSections(tools, {
145
+ examples: `
146
+ # Backfill this machine once; reruns resume and skip completed transcripts
147
+ agents sessions backfill tools
148
+
149
+ # Backfill every online compute device, keeping each index local
150
+ agents sessions backfill tools --fleet --json
151
+
152
+ # Narrow the historical work
153
+ agents sessions backfill tools --agent codex --since 7d
154
+ `,
155
+ notes: `
156
+ - This command is the only historical transcript parse for tool indexing. Tool queries never trigger it.
157
+ - New and changed sessions are indexed during their normal incremental scan.
158
+ - No embeddings, vector database, network model, or semantic processing is used.
159
+ `,
160
+ });
161
+ tools.action(async (_options, command) => {
162
+ const options = command.optsWithGlobals();
163
+ if (options.local && (options.fleet || mergeHosts(options).length > 0)) {
164
+ console.error(chalk.red('--local cannot be combined with --fleet, --host, or --device.'));
165
+ process.exitCode = 1;
166
+ return;
167
+ }
168
+ try {
169
+ const envelope = await runToolsBackfill(options);
170
+ if (options.json)
171
+ process.stdout.write(JSON.stringify(envelope, null, 2) + '\n');
172
+ else {
173
+ for (const machine of envelope.machines) {
174
+ console.log(`${machine.machine}: indexed ${machine.indexedFiles.toLocaleString()} transcript${machine.indexedFiles === 1 ? '' : 's'} and ${machine.indexedCalls.toLocaleString()} tool call${machine.indexedCalls === 1 ? '' : 's'}.`);
175
+ }
176
+ if (!envelope.complete) {
177
+ console.log(chalk.yellow('Backfill is partial; rerun the command after resolving skipped or limited transcripts.'));
178
+ }
179
+ }
180
+ }
181
+ catch (error) {
182
+ console.error(chalk.red(error instanceof Error ? error.message : String(error)));
183
+ process.exitCode = 1;
184
+ }
185
+ });
186
+ }
@@ -4,6 +4,7 @@ import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types
4
4
  import { type ActiveSession } from '../lib/session/active.js';
5
5
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
6
6
  import { type PickedSession } from './sessions-picker.js';
7
+ import { type ToolSearchEnvelope, type ToolProgramCountEnvelope } from '../lib/session/tool-index.js';
7
8
  interface SessionFilterOptions {
8
9
  agent?: string;
9
10
  project?: string;
@@ -17,7 +18,7 @@ interface SessionFilterOptions {
17
18
  interface SessionsOptions extends SessionFilterOptions {
18
19
  /** Also list sessions from the user's own unmanaged ~/.<agent> installs. */
19
20
  unmanaged?: boolean;
20
- query?: string;
21
+ query?: string[];
21
22
  /** Resolve one historical selector to metadata only (requires --json). */
22
23
  resolve?: string;
23
24
  /** Versioned internal peer protocol; old/unsafe peers must reject it. */
@@ -55,6 +56,10 @@ interface SessionsOptions extends SessionFilterOptions {
55
56
  local?: boolean;
56
57
  /** --device <target...> — alias for --host; resolves against the device registry. */
57
58
  device?: string[];
59
+ /** Query every registered online compute device and merge tool evidence. */
60
+ fleet?: boolean;
61
+ /** Aggregate static program sites instead of returning matching call evidence. */
62
+ count?: boolean;
58
63
  /** Per-agent shorthands: aliases for `--agent <name>` (prioritized harnesses). */
59
64
  claude?: boolean;
60
65
  codex?: boolean;
@@ -338,6 +343,15 @@ export declare function renderSessionPreview(query: string, scope: {
338
343
  * attached", so those two spell it out.
339
344
  */
340
345
  export declare function formatLiveStatusHeadline(live: ActiveSession | undefined, favorite?: boolean): string;
346
+ /** Merge local and peer envelopes without changing the versioned JSON shape. */
347
+ export declare function mergeToolSearchEnvelopes(local: ToolSearchEnvelope, remotes: ToolSearchEnvelope[]): ToolSearchEnvelope;
348
+ export declare function mergeToolProgramCountEnvelopes(local: ToolProgramCountEnvelope, remotes: ToolProgramCountEnvelope[]): ToolProgramCountEnvelope;
349
+ /** Partition a fleet query by transcript origin so synced mirrors cannot duplicate results. */
350
+ export declare function toolOriginSessions(sessions: SessionMeta[], machine: string, originOnly: boolean): SessionMeta[];
351
+ export declare function printToolProgramCount(envelope: ToolProgramCountEnvelope): void;
352
+ export declare function toolSearchFleetSortError(sort: string | undefined, spansDevices: boolean): string | undefined;
353
+ /** Compact grouped evidence for humans; JSON retains every bounded field. */
354
+ export declare function printToolSearch(envelope: ToolSearchEnvelope): void;
341
355
  /**
342
356
  * Whether a session belongs to `team`, from either end: it spawned the team, or
343
357
  * it is one of the team's teammates. Case-insensitive, matching the SQL
@@ -605,6 +619,8 @@ export declare function fleetCandidatesByQuery(rows: SessionMeta[], query: strin
605
619
  /** Fixed peer argv for the metadata resolver. Scope flags compose identically on
606
620
  * every host; `--all` removes the SSH login cwd/time window, not agent/project filters. */
607
621
  export declare function metadataResolveForwardedArgs(selector: string, scope: Pick<SessionFilterOptions, 'agent' | 'project'>): string[];
622
+ /** Build the local-only peer argv for a distributed tool search. */
623
+ export declare function toolSearchForwardedArgs(argv: string[], hosts: string[]): string[];
608
624
  /** Resolution must fail closed when any selected peer did not answer. Choosing
609
625
  * from a partial fleet can turn an unseen candidate into a false unique match. */
610
626
  export declare function metadataResolveOutcome(localMatches: SessionMeta[], remote: {