@phnx-labs/agents-cli 1.20.13 → 1.20.14

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ **`agents inspect` summary: expanded detail for hooks, plugins, and MCP**
6
+
7
+ - The bare `agents inspect <agent>` / `agents inspect <repo>` summary no longer collapses everything to a count table. Simple kinds (commands, skills, rules, subagents, workflows) keep a count line but now preview a few names; the rich kinds get their own expanded sections: **hooks** show their events + `matches:` predicates + cache (`PreToolUse(Bash) · git_dirty · prompt~"deploy" (5m cache)`), **plugins** show version + bundle contents (`v2.1.0 skills:6 commands:5 hooks:2 mcp:1`), and **MCP** show transport + url/command. Drill-down flags (`--hooks`, `--plugins`, `--mcp`) and `--brief` are unchanged; `--json` gains the structured detail additively (existing keys retained).
8
+ - Hook detail joins installed hooks to the manifest by **script basename** (installed hooks are named after their script file while the manifest keys on the logical name), and the repo Hooks section uses the grouped hook reader so a script + its data file collapse to one clean entry.
9
+
10
+ **Plugin hooks were misreported — fixed**
11
+
12
+ - `discoverPluginHooks` read the **top-level** keys of a plugin's `hooks/hooks.json`, so the official `{ description, hooks: { SessionStart: [...] } }` format surfaced as `description, hooks` instead of the real events. It now reads the `hooks` wrapper when present (falling back to top-level keys for the flat format), so `agents inspect --plugin <name>` and the plugin row show the actual lifecycle events (e.g. `SessionStart, PreToolUse, …`).
13
+
14
+ **`agents doctor` / `agents prune`: precise orphan-hooks detection**
15
+
16
+ - Orphan-hook detection now flags hook scripts present in a version home that **no `agents.yaml`/`hooks.yaml` entry registers** — i.e. scripts that sync to disk but are never wired to a lifecycle event, so they never fire. This replaces the source-diff heuristic, which compared only against the user hooks dir and so **false-flagged valid system-sourced, registered hooks** (e.g. `03-linear-inject`, `04-capture`) as orphans — meaning `agents prune cleanup` could have deleted live hooks. Doctor's Orphans section and `prune cleanup hooks` now share this single manifest-based definition. `parseHookManifest` gained a silent (`{ warn: false }`) option so the diagnostic doesn't emit shadow/override warnings.
17
+
18
+ **Regression coverage: resource sync from extras repos**
19
+
20
+ - Added end-to-end regression tests (`src/lib/__tests__/extras-sync.test.ts`) locking in two behaviors for repos registered via `agents repo add` (`~/.agents-<alias>/`): a top-level `commands/<name>.md` is written into the agent's version home on `agents sync`, and plugins under `plugins/<name>/` are synthesized into a registered `agents-<alias>` marketplace on launch. Both already work in `main`; the tests exercise the real sync path (no mocking, isolated `$HOME`) so the extras-repo behavior can't silently regress (#313, #314).
21
+
5
22
  **Windows: `agents` is discoverable right after `npm i -g`**
6
23
 
7
24
  - On a global Windows install, postinstall now prepends npm's global-bin dir (where `agents.cmd`/`agents.ps1` live) to the **User PATH** via the .NET environment API. Node's installer normally adds it, but winget / portable / nvm-windows setups often don't — and then `npm i -g @phnx-labs/agents-cli` succeeds yet `agents` is "not recognized". The shims dir (claude/codex/…) is still left to `agents setup`, which the user can now run because `agents` resolves.
@@ -5,9 +5,10 @@ import { getGlobalDefault, getVersionHomePath, isVersionInstalled, listInstalled
5
5
  import { loadManifest, isStale } from '../lib/staleness/index.js';
6
6
  import { diffVersionCommands, iterCommandsCapableVersions } from '../lib/commands.js';
7
7
  import { diffVersionSkills, iterSkillsCapableVersions } from '../lib/skills.js';
8
- import { diffVersionHooks, iterHooksCapableVersions } from '../lib/hooks.js';
8
+ import { iterHooksCapableVersions, listUnmanagedHooksInVersionHome } from '../lib/hooks.js';
9
9
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
10
10
  import { unifiedDiff, colorizeUnifiedDiff } from '../lib/diff-text.js';
11
+ import { listCliStatus } from '../lib/cli-resources.js';
11
12
  import { setHelpSections } from '../lib/help.js';
12
13
  import * as fs from 'fs';
13
14
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
@@ -53,16 +54,20 @@ function countOrphans() {
53
54
  if (diff.orphans.length > 0)
54
55
  ensure(agent, version).skills = diff.orphans.length;
55
56
  }
57
+ // Orphan hooks are scripts in the version home that no agents.yaml/hooks.yaml
58
+ // entry registers — so the registrar never wires them to an event and they
59
+ // never fire. (Distinct from the source-diff `diffVersionHooks().orphans`,
60
+ // which false-flags valid system-sourced registered hooks.)
56
61
  for (const { agent, version } of iterHooksCapableVersions()) {
57
62
  if (version !== getGlobalDefault(agent))
58
63
  continue;
59
- const diff = diffVersionHooks(agent, version);
60
- if (diff.orphans.length > 0)
61
- ensure(agent, version).hooks = diff.orphans.length;
64
+ const dead = listUnmanagedHooksInVersionHome(agent, version);
65
+ if (dead.length > 0)
66
+ ensure(agent, version).hooks = dead.length;
62
67
  }
63
68
  return Array.from(byKey.values()).filter((r) => r.commands + r.skills + r.hooks > 0);
64
69
  }
65
- function renderOverviewText(clis, syncRows, orphanRows) {
70
+ function renderOverviewText(clis, syncRows, orphanRows, hostClis) {
66
71
  console.log(chalk.bold('Agent CLIs'));
67
72
  if (Object.keys(clis).length === 0) {
68
73
  console.log(chalk.gray(' (no agents reported)'));
@@ -116,6 +121,31 @@ function renderOverviewText(clis, syncRows, orphanRows) {
116
121
  }
117
122
  console.log(chalk.gray(' Run `agents prune cleanup` to remove.'));
118
123
  }
124
+ console.log();
125
+ // Host CLIs are host-global (declared in any DotAgents repo's cli/, installed
126
+ // to PATH — not synced into version homes), so they live in the overview, not
127
+ // the per-version resource diff. Source tag shows which repo layer declared
128
+ // each, including user-level and extra repos.
129
+ console.log(chalk.bold('Host CLIs'));
130
+ if (hostClis.statuses.length === 0) {
131
+ console.log(chalk.gray(' (none declared — add one with `agents cli add <name>`)'));
132
+ }
133
+ else {
134
+ const nameWidth = Math.max(...hostClis.statuses.map((s) => s.manifest.name.length));
135
+ for (const { manifest, installed } of hostClis.statuses) {
136
+ const label = manifest.name.padEnd(nameWidth);
137
+ const src = chalk.gray(`[${manifest.source}]`);
138
+ if (installed) {
139
+ console.log(` ${chalk.green('ready')} ${label} ${src} ${chalk.gray(manifest.description || '')}`);
140
+ }
141
+ else {
142
+ console.log(` ${chalk.red('miss ')} ${label} ${src} ${chalk.gray(`not installed — run \`agents cli install ${manifest.name}\``)}`);
143
+ }
144
+ }
145
+ }
146
+ for (const err of hostClis.errors) {
147
+ console.log(` ${chalk.red('err ')} ${chalk.gray(err.file)}: ${chalk.gray(err.reason)}`);
148
+ }
119
149
  }
120
150
  function parseTargetArg(arg) {
121
151
  const at = arg.indexOf('@');
@@ -346,11 +376,25 @@ export function registerDoctorCommand(program) {
346
376
  const clis = checkAllClis();
347
377
  const syncRows = checkSyncStatus(cwd);
348
378
  const orphanRows = countOrphans();
379
+ const hostClis = listCliStatus(cwd);
349
380
  if (opts.json) {
350
- console.log(JSON.stringify({ clis, sync: syncRows, orphans: orphanRows }, null, 2));
381
+ console.log(JSON.stringify({
382
+ clis,
383
+ sync: syncRows,
384
+ orphans: orphanRows,
385
+ hostClis: {
386
+ statuses: hostClis.statuses.map((s) => ({
387
+ name: s.manifest.name,
388
+ source: s.manifest.source,
389
+ description: s.manifest.description ?? null,
390
+ installed: s.installed,
391
+ })),
392
+ errors: hostClis.errors,
393
+ },
394
+ }, null, 2));
351
395
  return;
352
396
  }
353
- renderOverviewText(clis, syncRows, orphanRows);
397
+ renderOverviewText(clis, syncRows, orphanRows, hostClis);
354
398
  return;
355
399
  }
356
400
  const parsed = parseTargetArg(target);
@@ -12,6 +12,8 @@
12
12
  * AGENT.md / the file itself) so users can click straight to the source.
13
13
  */
14
14
  import { Command } from 'commander';
15
+ import type { ManifestHook } from '../lib/types.js';
16
+ import { type McpYamlConfig } from '../lib/mcp.js';
15
17
  import { type PluginResourceGroup } from '../lib/plugins.js';
16
18
  /** Resource kinds the inspect command can drill into. */
17
19
  declare const DRILLABLE_KINDS: readonly ["commands", "skills", "hooks", "mcp", "rules", "plugins", "workflows", "subagents"];
@@ -101,4 +103,19 @@ export interface RepoGitInfo {
101
103
  behind: number | null;
102
104
  }
103
105
  export declare function repoGitInfo(root: string): RepoGitInfo | null;
106
+ /**
107
+ * Compact one-liner for a hook from its manifest entry: the firing events (with
108
+ * the matcher/tool-name in parens), then a `·`-separated predicate summary, then
109
+ * an optional cache tail. Plain text — the caller applies color.
110
+ */
111
+ export declare function summarizeHook(hook: ManifestHook): string;
112
+ /** Compact one-liner for an MCP server: padded transport + the url (http) or command line (stdio). */
113
+ export declare function summarizeMcp(cfg: McpYamlConfig): string;
114
+ /**
115
+ * Index a hook manifest by script basename (no extension). Installed hooks are
116
+ * named after their script file (`04-capture-…`), while the manifest is keyed by
117
+ * logical name (`capture-…`) with the filename in `script:` — so we join on the
118
+ * script basename, not the manifest key.
119
+ */
120
+ export declare function hookManifestByScript(manifest: Record<string, ManifestHook>): Map<string, ManifestHook>;
104
121
  export {};
@@ -23,6 +23,8 @@ import { readMeta, getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, ge
23
23
  import { getVersionHomePath } from '../lib/versions.js';
24
24
  import { getShimsDir, getVersionedAliasPath } from '../lib/shims.js';
25
25
  import { getAgentResources, listResources, } from '../lib/resources.js';
26
+ import { listHookEntriesFromDir } from '../lib/hooks.js';
27
+ import { listMcpServerConfigs, discoverMcpConfigsFromRepo } from '../lib/mcp.js';
26
28
  import { discoverPlugins, discoverPluginsInDir, pluginResourceGroups } from '../lib/plugins.js';
27
29
  import { PLUGIN_GROUP_COLORS } from './plugins.js';
28
30
  import { countSessionsInScope } from '../lib/session/discover.js';
@@ -38,6 +40,14 @@ const DRILLABLE_KINDS = [
38
40
  'workflows',
39
41
  'subagents',
40
42
  ];
43
+ /**
44
+ * Summary-view partition. SIMPLE kinds render as a one-line count + name preview;
45
+ * RICH kinds (hooks/plugins/mcp) get their own expanded section showing each
46
+ * item's key detail (events/predicates, bundle contents, transport/url). Together
47
+ * they cover every DrillableKind.
48
+ */
49
+ const SIMPLE_KINDS = ['commands', 'skills', 'rules', 'subagents', 'workflows'];
50
+ const RICH_KINDS = ['hooks', 'plugins', 'mcp'];
41
51
  /**
42
52
  * Singular aliases for the plural drill-down flags. `--plugin code` reads as
43
53
  * "show the one plugin named code" — a required-value flag that always lands in
@@ -355,6 +365,9 @@ function renderRepoSummary(repo, options) {
355
365
  const manifest = repoManifestSummary(repo.root);
356
366
  const kindData = {};
357
367
  let totalBytes = 0, totalFiles = 0;
368
+ let repoHookByScript = new Map();
369
+ let repoHookItemList = [];
370
+ let repoMcpConfigs = new Map();
358
371
  if (!options.brief) {
359
372
  for (const kind of DRILLABLE_KINDS) {
360
373
  const items = collectRepoKind(repo, kind);
@@ -363,6 +376,9 @@ function renderRepoSummary(repo, options) {
363
376
  totalBytes += size.bytes;
364
377
  totalFiles += size.files;
365
378
  }
379
+ repoHookByScript = hookManifestByScript(hookManifestFromFile(path.join(repo.root, 'agents.yaml')));
380
+ repoHookItemList = repoHookItems(repo);
381
+ repoMcpConfigs = new Map(discoverMcpConfigsFromRepo(repo.root).map(s => [s.name, s.config]));
366
382
  }
367
383
  if (options.json) {
368
384
  console.log(JSON.stringify({
@@ -372,12 +388,34 @@ function renderRepoSummary(repo, options) {
372
388
  manifests,
373
389
  manifest,
374
390
  size: options.brief ? null : { bytes: totalBytes, files: totalFiles },
375
- resources: options.brief ? null : Object.fromEntries(DRILLABLE_KINDS.map(kind => [kind, {
376
- count: kindData[kind].items.length,
377
- bytes: kindData[kind].size.bytes,
378
- files: kindData[kind].size.files,
379
- names: kindData[kind].items.map(i => i.name),
380
- }])),
391
+ resources: options.brief ? null : Object.fromEntries(DRILLABLE_KINDS.map(kind => {
392
+ const size = kindData[kind].size;
393
+ // Hooks use the grouped reader (clean names) instead of the raw readdir.
394
+ const items = kind === 'hooks' ? repoHookItemList : kindData[kind].items;
395
+ const base = {
396
+ count: items.length,
397
+ bytes: size.bytes,
398
+ files: size.files,
399
+ names: items.map(i => i.name),
400
+ };
401
+ if (kind === 'hooks')
402
+ return [kind, { ...base, items: items.map(i => {
403
+ const h = repoHookByScript.get(i.name);
404
+ return { name: i.name, events: h?.events ?? [], matcher: h?.matcher, matches: h?.matches, cache: h?.cache };
405
+ }) }];
406
+ if (kind === 'mcp')
407
+ return [kind, { ...base, items: items.map(i => {
408
+ const c = repoMcpConfigs.get(i.name);
409
+ return { name: i.name, transport: c?.transport, url: c?.url, command: c?.command, args: c?.args };
410
+ }) }];
411
+ if (kind === 'plugins')
412
+ return [kind, { ...base, items: items.map(i => ({
413
+ name: i.name,
414
+ version: i.extra?.find(([k]) => k === 'version')?.[1],
415
+ groups: Object.fromEntries((i.groups ?? []).map(g => [g.label, g.items.length])),
416
+ })) }];
417
+ return [kind, base];
418
+ })),
381
419
  }, null, 2));
382
420
  return;
383
421
  }
@@ -416,13 +454,16 @@ function renderRepoSummary(repo, options) {
416
454
  if (!options.brief) {
417
455
  console.log(` ${'size'.padEnd(10)} ${formatBytes(totalBytes)} ${chalk.gray('·')} ${totalFiles} files`);
418
456
  console.log('\n' + chalk.bold('Resources'));
419
- for (const kind of DRILLABLE_KINDS) {
457
+ for (const kind of SIMPLE_KINDS) {
420
458
  const { items, size } = kindData[kind];
421
459
  const count = String(items.length).padStart(4);
422
460
  const sz = items.length > 0 ? formatBytes(size.bytes).padStart(8) : ''.padEnd(8);
423
461
  const preview = items.length > 0 ? chalk.gray(truncate(previewNames(items, 4), 60)) : '';
424
462
  console.log(` ${kind.padEnd(10)} ${count} ${sz} ${preview}`.trimEnd());
425
463
  }
464
+ printExpandedSection('Hooks', hookRows(repoHookItemList, repoHookByScript));
465
+ printExpandedSection('Plugins', pluginRows(kindData.plugins.items));
466
+ printExpandedSection('MCP', mcpRows(kindData.mcp.items, repoMcpConfigs));
426
467
  }
427
468
  console.log('');
428
469
  console.log(chalk.gray(`Drill in: agents inspect ${repo.label} --skills <query>`));
@@ -480,7 +521,9 @@ async function renderSummary(agent, version, versionHome, options) {
480
521
  const shimPath = path.join(getShimsDir(), AGENTS[agent].cliCommand);
481
522
  const aliasPath = getVersionedAliasPath(agent, version);
482
523
  const capabilities = collectCapabilities(agent, version);
483
- const counts = options.brief ? null : collectCounts(agent, versionHome);
524
+ const itemsByKind = options.brief ? null : collectItemsByKind(agent, versionHome);
525
+ const hookByScript = options.brief ? null : hookManifestByScript(loadCentralHookManifest());
526
+ const mcpConfigs = options.brief ? null : new Map(listMcpServerConfigs().map(s => [s.name, s.config]));
484
527
  const sessions = options.brief ? null : {
485
528
  total: safeCountSessions(agent),
486
529
  };
@@ -496,7 +539,7 @@ async function renderSummary(agent, version, versionHome, options) {
496
539
  strategy,
497
540
  installedShim: cliState?.installed === true ? cliState.path : null,
498
541
  capabilities,
499
- resources: counts,
542
+ resources: itemsByKind ? summaryResourcesJson(itemsByKind, hookByScript, mcpConfigs) : null,
500
543
  sessions,
501
544
  };
502
545
  console.log(JSON.stringify(json, null, 2));
@@ -521,15 +564,14 @@ async function renderSummary(agent, version, versionHome, options) {
521
564
  const reason = res.ok ? '' : chalk.gray(`(${res.reason}${res.need ? ' ' + res.need : ''})`);
522
565
  console.log(` ${cap.padEnd(10)} ${mark} ${reason}`);
523
566
  }
524
- if (counts) {
567
+ if (itemsByKind) {
525
568
  console.log('\n' + chalk.bold('Resources'));
526
- for (const kind of DRILLABLE_KINDS) {
527
- const c = counts[kind];
528
- if (!c)
529
- continue;
530
- const breakdown = formatScopeBreakdown(c.bySource);
531
- console.log(` ${kind.padEnd(10)} ${String(c.total).padStart(4)} ${chalk.gray(breakdown)}`);
569
+ for (const kind of SIMPLE_KINDS) {
570
+ printSimpleResourceRow(kind, itemsByKind[kind]);
532
571
  }
572
+ printExpandedSection('Hooks', hookRows(itemsByKind.hooks, hookByScript));
573
+ printExpandedSection('Plugins', pluginRows(itemsByKind.plugins));
574
+ printExpandedSection('MCP', mcpRows(itemsByKind.mcp, mcpConfigs));
533
575
  }
534
576
  if (sessions) {
535
577
  console.log('\n' + chalk.bold('Sessions'));
@@ -649,14 +691,52 @@ function collectCapabilities(agent, version) {
649
691
  }
650
692
  return out;
651
693
  }
652
- function collectCounts(agent, versionHome) {
694
+ function collectItemsByKind(agent, versionHome) {
695
+ const out = {};
696
+ for (const kind of DRILLABLE_KINDS)
697
+ out[kind] = collectKind(agent, versionHome, kind);
698
+ return out;
699
+ }
700
+ /** A simple-kind count row: `kind N user:30 system:12 name, name …(+K)`. */
701
+ function printSimpleResourceRow(kind, items) {
702
+ const count = String(items.length).padStart(4);
703
+ const breakdown = chalk.gray(scopeBreakdownPlain(countBySource(items.map(i => i.source))).padEnd(18));
704
+ const preview = items.length > 0 ? chalk.gray(truncate(previewNames(items, 3), 48)) : '';
705
+ console.log(` ${kind.padEnd(10)} ${count} ${breakdown} ${preview}`.trimEnd());
706
+ }
707
+ /**
708
+ * Build the `resources` JSON: every kind keeps `total` + `bySource` (back-compat),
709
+ * simple kinds add `names`, and the rich kinds add structured `items` (hook
710
+ * events/predicates, mcp transport/url/command, plugin version + group counts).
711
+ */
712
+ function summaryResourcesJson(itemsByKind, hookByScript, mcpConfigs) {
653
713
  const out = {};
654
714
  for (const kind of DRILLABLE_KINDS) {
655
- const items = collectKind(agent, versionHome, kind);
656
- const bySource = {};
657
- for (const item of items)
658
- bySource[item.source] = (bySource[item.source] || 0) + 1;
659
- out[kind] = { total: items.length, bySource };
715
+ const items = itemsByKind[kind];
716
+ const base = { total: items.length, bySource: countBySource(items.map(i => i.source)) };
717
+ if (kind === 'hooks') {
718
+ out[kind] = { ...base, items: items.map(i => {
719
+ const h = hookByScript.get(i.name);
720
+ return { name: i.name, source: i.source, events: h?.events ?? [], matcher: h?.matcher, matches: h?.matches, cache: h?.cache };
721
+ }) };
722
+ }
723
+ else if (kind === 'mcp') {
724
+ out[kind] = { ...base, items: items.map(i => {
725
+ const c = mcpConfigs.get(i.name);
726
+ return { name: i.name, source: i.source, transport: c?.transport, url: c?.url, command: c?.command, args: c?.args };
727
+ }) };
728
+ }
729
+ else if (kind === 'plugins') {
730
+ out[kind] = { ...base, items: items.map(i => ({
731
+ name: i.name,
732
+ source: i.source,
733
+ version: i.extra?.find(([k]) => k === 'version')?.[1],
734
+ groups: Object.fromEntries((i.groups ?? []).map(g => [g.label, g.items.length])),
735
+ })) };
736
+ }
737
+ else {
738
+ out[kind] = { ...base, names: items.map(i => i.name) };
739
+ }
660
740
  }
661
741
  return out;
662
742
  }
@@ -782,6 +862,181 @@ function buildDetailRows(item, kind) {
782
862
  }
783
863
  return rows;
784
864
  }
865
+ /** `system` → `sys`; everything else unchanged. Keeps the tag column narrow. */
866
+ function abbrevSource(s) {
867
+ return s === 'system' ? 'sys' : s;
868
+ }
869
+ /**
870
+ * Compact one-liner for a hook from its manifest entry: the firing events (with
871
+ * the matcher/tool-name in parens), then a `·`-separated predicate summary, then
872
+ * an optional cache tail. Plain text — the caller applies color.
873
+ */
874
+ export function summarizeHook(hook) {
875
+ const events = (hook.events ?? []).join('/') || '(no event)';
876
+ let matcher = hook.matcher;
877
+ if (!matcher && hook.matches?.tool_name) {
878
+ const tn = hook.matches.tool_name;
879
+ matcher = Array.isArray(tn) ? tn.join('|') : tn;
880
+ }
881
+ const head = matcher ? `${events}(${matcher})` : events;
882
+ const parts = [head];
883
+ const preds = summarizeMatches(hook.matches);
884
+ if (preds)
885
+ parts.push(preds);
886
+ let line = parts.join(' · ');
887
+ const ttl = hookCacheTtl(hook.cache);
888
+ if (ttl)
889
+ line += ` (${ttl} cache)`;
890
+ return line;
891
+ }
892
+ /** `·`-separated predicate summary from a hook's `matches:` block (tool_name omitted — shown in the matcher parens). */
893
+ function summarizeMatches(m) {
894
+ if (!m)
895
+ return '';
896
+ const bits = [];
897
+ if (m.git_dirty)
898
+ bits.push('git_dirty');
899
+ if (m.prompt_contains)
900
+ bits.push(`prompt~"${truncate(m.prompt_contains, 24)}"`);
901
+ if (m.prompt_matches)
902
+ bits.push(`prompt=/${truncate(m.prompt_matches, 24)}/`);
903
+ if (m.tool_args_match)
904
+ bits.push(`args=/${truncate(m.tool_args_match, 20)}/`);
905
+ if (m.cwd_includes) {
906
+ const c = Array.isArray(m.cwd_includes) ? m.cwd_includes.join('|') : m.cwd_includes;
907
+ bits.push(`cwd~${truncate(c, 24)}`);
908
+ }
909
+ if (m.project_has)
910
+ bits.push(`has ${m.project_has}`);
911
+ return bits.join(' · ');
912
+ }
913
+ /** Normalize a hook cache shorthand/object to a display ttl ("5m", "1h"); null when uncached. */
914
+ function hookCacheTtl(cache) {
915
+ if (cache === undefined || cache === null)
916
+ return null;
917
+ if (typeof cache === 'string')
918
+ return cache.replace(/-bg$/, '');
919
+ return String(cache.ttl);
920
+ }
921
+ /** Compact one-liner for an MCP server: padded transport + the url (http) or command line (stdio). */
922
+ export function summarizeMcp(cfg) {
923
+ const target = cfg.transport === 'http'
924
+ ? (cfg.url ?? '')
925
+ : [cfg.command, ...(cfg.args ?? [])].filter(Boolean).join(' ');
926
+ return `${cfg.transport.padEnd(5)} ${truncate(target, 60)}`.trimEnd();
927
+ }
928
+ /** Print `Title (N)` then up to `max` aligned `[source] name detail` rows with a `…(+K)` tail. */
929
+ function printExpandedSection(title, rows, max = 6) {
930
+ console.log('\n' + chalk.bold(title) + chalk.gray(` (${rows.length})`));
931
+ if (rows.length === 0) {
932
+ console.log(chalk.gray(' (none)'));
933
+ return;
934
+ }
935
+ const shown = rows.slice(0, max);
936
+ const nameW = Math.max(...shown.map(r => r.name.length));
937
+ for (const r of shown) {
938
+ const tag = chalk.gray(`[${abbrevSource(r.source)}]`.padEnd(8));
939
+ const padded = r.name.padEnd(nameW);
940
+ const name = r.linkTarget ? termLink(chalk.cyan(padded), r.linkTarget) : chalk.cyan(padded);
941
+ const detail = r.detail ? ' ' + chalk.gray(r.detail) : '';
942
+ console.log(` ${tag} ${name}${detail}`);
943
+ }
944
+ if (rows.length > max)
945
+ console.log(chalk.gray(` …(+${rows.length - max})`));
946
+ }
947
+ /** Tally a source list into `{user: n, system: m}`. */
948
+ function countBySource(sources) {
949
+ const out = {};
950
+ for (const s of sources)
951
+ out[s] = (out[s] || 0) + 1;
952
+ return out;
953
+ }
954
+ /** Unbracketed scope breakdown for the simple count rows: `user:30 system:12`. */
955
+ function scopeBreakdownPlain(bySource) {
956
+ return Object.entries(bySource).map(([k, v]) => `${k}:${v}`).join(' ');
957
+ }
958
+ /**
959
+ * Index a hook manifest by script basename (no extension). Installed hooks are
960
+ * named after their script file (`04-capture-…`), while the manifest is keyed by
961
+ * logical name (`capture-…`) with the filename in `script:` — so we join on the
962
+ * script basename, not the manifest key.
963
+ */
964
+ export function hookManifestByScript(manifest) {
965
+ const out = new Map();
966
+ for (const hook of Object.values(manifest)) {
967
+ if (hook && typeof hook.script === 'string') {
968
+ out.set(path.basename(hook.script).replace(/\.[^.]+$/, ''), hook);
969
+ }
970
+ }
971
+ return out;
972
+ }
973
+ /** Build hook rows by enriching the installed hook items with manifest events/predicates. */
974
+ function hookRows(items, byScript) {
975
+ return items.map(item => {
976
+ const hook = byScript.get(item.name);
977
+ return {
978
+ source: item.source,
979
+ name: item.name,
980
+ linkTarget: item.linkTarget,
981
+ // Hooks are shell scripts with no human description — show events/predicates
982
+ // from the manifest, or nothing rather than a meaningless shebang line.
983
+ detail: hook ? summarizeHook(hook) : '',
984
+ };
985
+ });
986
+ }
987
+ /** Build plugin rows: `vVERSION skills:6 commands:5 …` from the bundle's groups. */
988
+ function pluginRows(items) {
989
+ return items.map(item => {
990
+ const version = item.extra?.find(([k]) => k === 'version')?.[1];
991
+ const counts = (item.groups ?? []).map(g => `${g.label}:${g.items.length}`).join(' ');
992
+ const detail = [version ? `v${version}` : '', counts].filter(Boolean).join(' ');
993
+ return { source: item.source, name: item.name, detail, linkTarget: item.linkTarget };
994
+ });
995
+ }
996
+ /** Build MCP rows by joining the installed mcp items with their full configs (transport/url/command). */
997
+ function mcpRows(items, configs) {
998
+ return items.map(item => {
999
+ const cfg = configs.get(item.name);
1000
+ return { source: item.source, name: item.name, detail: cfg ? summarizeMcp(cfg) : item.description };
1001
+ });
1002
+ }
1003
+ /** Read a repo/agents.yaml `hooks:` section into a name→ManifestHook map (best-effort). */
1004
+ function hookManifestFromFile(agentsYamlPath) {
1005
+ try {
1006
+ const meta = yaml.parse(fs.readFileSync(agentsYamlPath, 'utf-8'));
1007
+ return meta?.hooks ?? {};
1008
+ }
1009
+ catch {
1010
+ return {};
1011
+ }
1012
+ }
1013
+ /**
1014
+ * Merge the system + user `agents.yaml` hook manifests (user wins on key
1015
+ * collision). Built directly from the two layer files rather than via
1016
+ * `parseHookManifest()` so inspecting never emits the shadow/override warnings
1017
+ * that the registrar path prints.
1018
+ */
1019
+ function loadCentralHookManifest() {
1020
+ return {
1021
+ ...hookManifestFromFile(path.join(getSystemAgentsDir(), 'agents.yaml')),
1022
+ ...hookManifestFromFile(path.join(getUserAgentsDir(), 'agents.yaml')),
1023
+ };
1024
+ }
1025
+ /**
1026
+ * Hook items for a repo's Hooks section. Uses the grouped hook reader (script +
1027
+ * data file collapsed into one entry, non-hook files like promptcuts.yaml or
1028
+ * README.md filtered out) rather than a naive readdir, so names are clean and
1029
+ * join cleanly against the manifest by script basename.
1030
+ */
1031
+ function repoHookItems(repo) {
1032
+ return listHookEntriesFromDir(path.join(repo.root, 'hooks')).map(h => ({
1033
+ name: h.name,
1034
+ source: repo.label,
1035
+ path: h.scriptPath,
1036
+ linkTarget: h.scriptPath,
1037
+ description: '',
1038
+ }));
1039
+ }
785
1040
  function findMatches(items, query) {
786
1041
  const q = query.toLowerCase();
787
1042
  const out = [];
@@ -979,9 +1234,3 @@ function truncate(s, n) {
979
1234
  return s;
980
1235
  return s.slice(0, n - 1) + '…';
981
1236
  }
982
- function formatScopeBreakdown(bySource) {
983
- const entries = Object.entries(bySource);
984
- if (entries.length === 0)
985
- return '';
986
- return '[' + entries.map(([k, v]) => `${k}:${v}`).join(' ') + ']';
987
- }
@@ -26,7 +26,7 @@ import chalk from 'chalk';
26
26
  import { confirm } from '@inquirer/prompts';
27
27
  import { diffVersionCommands, iterCommandsCapableVersions, removeCommandFromVersion, } from '../lib/commands.js';
28
28
  import { diffVersionSkills, iterSkillsCapableVersions, removeSkillFromVersion, } from '../lib/skills.js';
29
- import { diffVersionHooks, iterHooksCapableVersions, removeHookFromVersion, } from '../lib/hooks.js';
29
+ import { listUnmanagedHooksInVersionHome, iterHooksCapableVersions, removeHookFromVersion, } from '../lib/hooks.js';
30
30
  import { diffVersionPlugins, iterPluginsCapableVersions, removePluginSkillFromVersion, } from '../lib/plugins.js';
31
31
  import { diffVersionSubagents, iterSubagentsCapableVersions, removeSubagentFromVersion, } from '../lib/subagents.js';
32
32
  import { getGlobalDefault } from '../lib/versions.js';
@@ -64,9 +64,12 @@ function collectOrphans(types, all) {
64
64
  }
65
65
  if (types.includes('hooks')) {
66
66
  for (const { agent, version } of scopePairs(iterHooksCapableVersions(), all)) {
67
- const diff = diffVersionHooks(agent, version);
68
- if (diff.orphans.length > 0) {
69
- groups.push({ type: 'hooks', agent, version, orphans: diff.orphans });
67
+ // Orphan hooks = scripts present in the version home that no
68
+ // agents.yaml/hooks.yaml entry registers, so they never fire. Same
69
+ // definition the doctor overview reports.
70
+ const orphans = listUnmanagedHooksInVersionHome(agent, version);
71
+ if (orphans.length > 0) {
72
+ groups.push({ type: 'hooks', agent, version, orphans });
70
73
  }
71
74
  }
72
75
  }
@@ -210,7 +213,7 @@ async function runOrphanPrune(resourceTypes, options) {
210
213
  return;
211
214
  }
212
215
  const total = groups.reduce((n, g) => n + g.orphans.length, 0);
213
- console.log(chalk.bold('Orphans (in version home, not in any source)\n'));
216
+ console.log(chalk.bold('Orphans (in version home, unmanaged by any source)\n'));
214
217
  for (const g of groups) {
215
218
  const label = `${g.type} · ${g.agent}@${g.version}`;
216
219
  console.log(` ${chalk.cyan(label)} ${g.orphans.join(', ')}`);
@@ -19,6 +19,7 @@ export interface ViewSectionFilter {
19
19
  rules?: boolean;
20
20
  hooks?: boolean;
21
21
  promptcuts?: boolean;
22
+ cli?: boolean;
22
23
  }
23
24
  /** Machine-readable entry for a single installed version. */
24
25
  export interface ViewJsonVersion {
@@ -8,6 +8,7 @@ import { readManifest } from '../lib/manifest.js';
8
8
  import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, printTrashFooter, } from '../lib/versions.js';
9
9
  import { ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
10
10
  import { getAgentResources } from '../lib/resources.js';
11
+ import { listCliStatus } from '../lib/cli-resources.js';
11
12
  import { isCapable } from '../lib/capabilities.js';
12
13
  import { discoverPlugins, pluginSupportsAgent } from '../lib/plugins.js';
13
14
  import { getAgentsDir, getUserAgentsDir, getEffectivePromptcutsPath, readMergedPromptcuts } from '../lib/state.js';
@@ -114,7 +115,7 @@ function getProjectVersionFromCwd(agent) {
114
115
  return null;
115
116
  }
116
117
  }
117
- const SECTION_KEYS = ['commands', 'skills', 'mcp', 'workflows', 'plugins', 'rules', 'hooks', 'promptcuts'];
118
+ const SECTION_KEYS = ['commands', 'skills', 'mcp', 'workflows', 'plugins', 'rules', 'hooks', 'promptcuts', 'cli'];
118
119
  /**
119
120
  * Decide whether a section should render given the filter. If no flags are set,
120
121
  * everything renders (current behavior). If any flag is set, only those sections
@@ -167,6 +168,51 @@ function renderProfilesSection(profiles) {
167
168
  * Show installed versions for one or all agents.
168
169
  * Called when: `agents view` or `agents view claude`
169
170
  */
171
+ /** Color the source-layer tag for a host CLI, matching the rules-section convention. */
172
+ function hostCliSourceTag(source) {
173
+ if (source === 'project')
174
+ return chalk.blue('[project]');
175
+ if (source === 'user')
176
+ return chalk.cyan('[user]');
177
+ if (source === 'system')
178
+ return chalk.gray('[system]');
179
+ // Anything else is an extra repo, tagged by its alias.
180
+ return chalk.magenta(`[${source}]`);
181
+ }
182
+ /**
183
+ * Render the host-CLI section. Host CLIs are host-global: declared in any
184
+ * DotAgents repo's `cli/` (project > user > system > extras), installed to PATH
185
+ * rather than copied into a version home. They render identically in the overview
186
+ * and in a per-agent detail view because every agent on the host shares them.
187
+ * The source tag shows which repo layer declared each — so user-level and
188
+ * extra-repo manifests are visibly supported.
189
+ */
190
+ function renderHostClisSection(cwd) {
191
+ const { statuses, errors } = listCliStatus(cwd);
192
+ console.log(chalk.bold('\nHost CLIs\n'));
193
+ if (statuses.length === 0) {
194
+ console.log(` ${chalk.gray('none declared')} ${chalk.gray('— add one with `agents cli add <name>`')}`);
195
+ }
196
+ else {
197
+ const nameWidth = Math.max(...statuses.map((s) => s.manifest.name.length));
198
+ let anyMissing = false;
199
+ for (const { manifest, installed } of statuses) {
200
+ if (!installed)
201
+ anyMissing = true;
202
+ const status = installed ? chalk.green('installed') : chalk.red('missing ');
203
+ const linkedName = termLink(manifest.name.padEnd(nameWidth), linkTarget(manifest.path));
204
+ const tag = hostCliSourceTag(manifest.source);
205
+ const desc = manifest.description ? chalk.gray(` ${summarizeDescription(manifest.description, 60)}`) : '';
206
+ console.log(` ${status} ${chalk.cyan(linkedName)} ${tag}${desc}`);
207
+ }
208
+ if (anyMissing) {
209
+ console.log(chalk.gray(' Install missing with `agents cli install`'));
210
+ }
211
+ }
212
+ for (const err of errors) {
213
+ console.log(` ${chalk.red('error')} ${chalk.gray(err.file)}: ${chalk.gray(err.reason)}`);
214
+ }
215
+ }
170
216
  async function showInstalledVersions(filterAgentId) {
171
217
  const spinnerText = filterAgentId
172
218
  ? `Checking ${agentLabel(filterAgentId)} agents...`
@@ -537,6 +583,10 @@ async function showInstalledVersions(filterAgentId) {
537
583
  console.log(chalk.gray(' Run: agents add claude@latest'));
538
584
  console.log();
539
585
  }
586
+ // Host CLIs are host-global, not per-agent — show them once in the overview.
587
+ if (!filterAgentId) {
588
+ renderHostClisSection(process.cwd());
589
+ }
540
590
  // Check for new resources when viewing a specific agent
541
591
  if (filterAgentId && versionManaged.length > 0) {
542
592
  const defaultVersion = getGlobalDefault(filterAgentId);
@@ -892,6 +942,9 @@ async function showAgentResources(agentId, requestedVersion, filter) {
892
942
  if (shouldRenderSection('promptcuts', filter)) {
893
943
  renderPromptcuts();
894
944
  }
945
+ if (shouldRenderSection('cli', filter)) {
946
+ renderHostClisSection(cwd);
947
+ }
895
948
  // Show legend at the end if git repo exists and we showed all sections.
896
949
  // Filtered single-section views skip it — noise for promptcuts or plugins.
897
950
  if (hasGitRepo && !anyFilterSet) {
@@ -1183,6 +1236,7 @@ export async function viewAction(agentArg, options) {
1183
1236
  rules: options?.rules,
1184
1237
  hooks: options?.hooks,
1185
1238
  promptcuts: options?.promptcuts,
1239
+ cli: options?.cli,
1186
1240
  };
1187
1241
  const filterIsSet = SECTION_KEYS.some((k) => filter[k]);
1188
1242
  if (!agentArg) {
@@ -1263,6 +1317,7 @@ export function registerViewCommand(program) {
1263
1317
  .option('--rules', 'Show only rules in the detail view.')
1264
1318
  .option('--hooks', 'Show only hooks in the detail view.')
1265
1319
  .option('--promptcuts', 'Show only promptcuts in the detail view.')
1320
+ .option('--cli', 'Show only host CLIs (declared in cli/, installed to PATH).')
1266
1321
  .addHelpText('after', `
1267
1322
  Examples:
1268
1323
  # Show all installed agents with versions, accounts, and usage
@@ -123,7 +123,27 @@ export declare function listCentralHooks(): HookEntry[];
123
123
  *
124
124
  * Hooks marked `enabled: false` are dropped from the returned map.
125
125
  */
126
- export declare function parseHookManifest(): Record<string, ManifestHook>;
126
+ export declare function parseHookManifest(opts?: {
127
+ warn?: boolean;
128
+ }): Record<string, ManifestHook>;
129
+ /**
130
+ * Hook script files present on disk that no manifest entry declares — "dead"
131
+ * hooks. The registrar only wires manifest-declared hooks into an agent's
132
+ * native config (settings.json / config.toml), matching the installed file to a
133
+ * manifest entry by script basename. So a file whose basename matches no
134
+ * manifest `script:` is never registered: it occupies the hooks dir and shows
135
+ * up in listings, but no lifecycle event ever fires it.
136
+ *
137
+ * Pure on purpose (no disk reads) so it is trivially testable; callers pass the
138
+ * installed hook names and the manifest's script paths.
139
+ */
140
+ export declare function unmanagedHookNames(installedHookNames: string[], manifestScripts: string[]): string[];
141
+ /**
142
+ * The dead hooks (see {@link unmanagedHookNames}) sitting in one version home.
143
+ * Reads the merged hook manifest silently — a diagnostic must not emit the
144
+ * shadow/override warnings the registrar path prints.
145
+ */
146
+ export declare function listUnmanagedHooksInVersionHome(agent: AgentId, version: string): string[];
127
147
  export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string): {
128
148
  registered: string[];
129
149
  errors: string[];
package/dist/lib/hooks.js CHANGED
@@ -650,7 +650,8 @@ export function listCentralHooks() {
650
650
  *
651
651
  * Hooks marked `enabled: false` are dropped from the returned map.
652
652
  */
653
- export function parseHookManifest() {
653
+ export function parseHookManifest(opts = {}) {
654
+ const warn = opts.warn !== false;
654
655
  const merged = {};
655
656
  const systemHooks = {};
656
657
  // System layer: hooks: section of agents.yaml (npm-shipped, separate repo).
@@ -673,7 +674,7 @@ export function parseHookManifest() {
673
674
  const meta = yaml.parse(fs.readFileSync(userMetaPath, 'utf-8'));
674
675
  if (meta?.hooks)
675
676
  for (const [name, def] of Object.entries(meta.hooks)) {
676
- if (systemHooks[name] && def.override !== true) {
677
+ if (warn && systemHooks[name] && def.override !== true) {
677
678
  const action = def.enabled === false ? 'disables' : 'shadows';
678
679
  console.warn(`[agents hooks] User-layer hook '${name}' ${action} system-shipped hook. Set 'override: true' to silence this warning.`);
679
680
  }
@@ -689,6 +690,35 @@ export function parseHookManifest() {
689
690
  }
690
691
  return merged;
691
692
  }
693
+ /**
694
+ * Hook script files present on disk that no manifest entry declares — "dead"
695
+ * hooks. The registrar only wires manifest-declared hooks into an agent's
696
+ * native config (settings.json / config.toml), matching the installed file to a
697
+ * manifest entry by script basename. So a file whose basename matches no
698
+ * manifest `script:` is never registered: it occupies the hooks dir and shows
699
+ * up in listings, but no lifecycle event ever fires it.
700
+ *
701
+ * Pure on purpose (no disk reads) so it is trivially testable; callers pass the
702
+ * installed hook names and the manifest's script paths.
703
+ */
704
+ export function unmanagedHookNames(installedHookNames, manifestScripts) {
705
+ const managed = new Set(manifestScripts.map((s) => path.basename(s).replace(/\.[^.]+$/, '')));
706
+ return installedHookNames.filter((name) => !managed.has(name)).sort();
707
+ }
708
+ /**
709
+ * The dead hooks (see {@link unmanagedHookNames}) sitting in one version home.
710
+ * Reads the merged hook manifest silently — a diagnostic must not emit the
711
+ * shadow/override warnings the registrar path prints.
712
+ */
713
+ export function listUnmanagedHooksInVersionHome(agent, version) {
714
+ if (!AGENTS[agent].supportsHooks)
715
+ return [];
716
+ const scripts = Object.values(parseHookManifest({ warn: false }))
717
+ .map((h) => h.script)
718
+ .filter((s) => typeof s === 'string');
719
+ const installed = listHooksInVersionHome(agent, version).map((e) => e.name);
720
+ return unmanagedHookNames(installed, scripts);
721
+ }
692
722
  // Codex events that support a matcher field (matches tool name or session type).
693
723
  // UserPromptSubmit and Stop never include a matcher.
694
724
  const CODEX_MATCHER_EVENTS = new Set(['PreToolUse', 'PostToolUse', 'SessionStart']);
@@ -74,6 +74,16 @@ export declare function getPlugin(name: string): DiscoveredPlugin | null;
74
74
  * Otherwise defaults to all plugin-capable agents.
75
75
  */
76
76
  export declare function pluginSupportsAgent(plugin: DiscoveredPlugin, agent: AgentId): boolean;
77
+ /**
78
+ * The lifecycle events a plugin hooks into, read from hooks/hooks.json.
79
+ *
80
+ * The official plugin format wraps the event map under a `hooks` key
81
+ * (`{ description, hooks: { SessionStart: [...], PreToolUse: [...] } }`), so the
82
+ * meaningful keys are the events — NOT the top-level keys (`description`,
83
+ * `hooks`). Older/flat files put the event names at the top level directly; we
84
+ * read whichever object actually holds the event map.
85
+ */
86
+ export declare function discoverPluginHooks(pluginRoot: string): string[];
77
87
  /** Discover command .md files inside a plugin's commands/ directory. */
78
88
  export declare function discoverPluginCommands(pluginRoot: string): string[];
79
89
  /** Discover agent definition .md files inside a plugin's agents/ directory. */
@@ -213,13 +213,25 @@ function discoverPluginSkills(pluginRoot) {
213
213
  .filter(d => d.isDirectory() && !d.name.startsWith('.'))
214
214
  .map(d => d.name);
215
215
  }
216
- function discoverPluginHooks(pluginRoot) {
216
+ /**
217
+ * The lifecycle events a plugin hooks into, read from hooks/hooks.json.
218
+ *
219
+ * The official plugin format wraps the event map under a `hooks` key
220
+ * (`{ description, hooks: { SessionStart: [...], PreToolUse: [...] } }`), so the
221
+ * meaningful keys are the events — NOT the top-level keys (`description`,
222
+ * `hooks`). Older/flat files put the event names at the top level directly; we
223
+ * read whichever object actually holds the event map.
224
+ */
225
+ export function discoverPluginHooks(pluginRoot) {
217
226
  const hooksFile = path.join(pluginRoot, 'hooks', 'hooks.json');
218
227
  if (!fs.existsSync(hooksFile))
219
228
  return [];
220
229
  try {
221
230
  const content = JSON.parse(fs.readFileSync(hooksFile, 'utf-8'));
222
- return Object.keys(content);
231
+ const eventMap = content.hooks && typeof content.hooks === 'object' && !Array.isArray(content.hooks)
232
+ ? content.hooks
233
+ : content;
234
+ return Object.keys(eventMap);
223
235
  }
224
236
  catch {
225
237
  return [];
@@ -11,6 +11,13 @@ export interface RotateCandidate {
11
11
  agent: AgentId;
12
12
  version: string;
13
13
  email: string | null;
14
+ /**
15
+ * Per-org usage/quota key (e.g. `claude:org=<orgUuid>`) — the unit rate
16
+ * limits are actually measured in. Distinct orgs signed in under the same
17
+ * email have distinct keys, so this is the correct dedup boundary; null when
18
+ * no usage identity is available (then we fall back to email).
19
+ */
20
+ usageKey: string | null;
14
21
  usageStatus: AccountInfo['usageStatus'];
15
22
  usageSnapshot: UsageSnapshot | null;
16
23
  authValid: boolean;
@@ -105,19 +105,29 @@ function compareCandidates(a, b) {
105
105
  return ta - tb;
106
106
  return Math.random() - 0.5;
107
107
  }
108
+ /**
109
+ * Identity a candidate dedups on. Quota is tracked per-org, so two versions
110
+ * that share an org are the same rate-limit bucket and must collapse — but two
111
+ * orgs under the same email (e.g. Enterprise + Personal on one Google identity)
112
+ * are genuinely separate buckets and must stay distinct. Prefer the org usage
113
+ * key; fall back to email only when no usage identity is available.
114
+ */
115
+ function candidateIdentity(c) {
116
+ return c.usageKey ?? c.email;
117
+ }
108
118
  function dedupeAndSortCandidates(candidates) {
109
- const byEmail = new Map();
119
+ const byIdentity = new Map();
110
120
  for (const c of candidates) {
111
- const email = c.email;
112
- const existing = byEmail.get(email);
121
+ const id = candidateIdentity(c);
122
+ const existing = byIdentity.get(id);
113
123
  if (!existing) {
114
- byEmail.set(email, c);
124
+ byIdentity.set(id, c);
115
125
  continue;
116
126
  }
117
127
  if (compareCandidates(c, existing) < 0)
118
- byEmail.set(email, c);
128
+ byIdentity.set(id, c);
119
129
  }
120
- return [...byEmail.values()].sort(compareCandidates);
130
+ return [...byIdentity.values()].sort(compareCandidates);
121
131
  }
122
132
  /**
123
133
  * Pick a healthy candidate using weighted random by remaining capacity.
@@ -252,7 +262,7 @@ async function collectRunCandidates(agent) {
252
262
  const usageSnapshot = usageKey
253
263
  ? usageByKey.get(usageKey)?.snapshot ?? null
254
264
  : null;
255
- return { ...candidate, usageSnapshot };
265
+ return { ...candidate, usageKey, usageSnapshot };
256
266
  });
257
267
  }
258
268
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.13",
3
+ "version": "1.20.14",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",