@phnx-labs/agents-cli 1.22.57 → 1.22.59

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 (152) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +39 -1
  4. package/dist/commands/accounts.js +7 -3
  5. package/dist/commands/apply.js +10 -2
  6. package/dist/commands/fork.d.ts +23 -10
  7. package/dist/commands/fork.js +115 -58
  8. package/dist/commands/monitors.js +198 -23
  9. package/dist/commands/prune.js +5 -3
  10. package/dist/commands/routines.d.ts +8 -0
  11. package/dist/commands/routines.js +57 -3
  12. package/dist/commands/routines.test-fixture.js +5 -0
  13. package/dist/commands/send.d.ts +2 -1
  14. package/dist/commands/send.js +7 -5
  15. package/dist/commands/sessions-picker.d.ts +11 -0
  16. package/dist/commands/sessions-picker.js +16 -0
  17. package/dist/commands/sessions-stats.js +37 -5
  18. package/dist/commands/sessions.js +40 -5
  19. package/dist/commands/share.d.ts +14 -0
  20. package/dist/commands/share.js +43 -2
  21. package/dist/commands/ssh.js +12 -1
  22. package/dist/commands/status.js +1 -1
  23. package/dist/commands/sync.js +83 -7
  24. package/dist/commands/traces.js +7 -0
  25. package/dist/commands/versions.js +12 -4
  26. package/dist/commands/view.js +7 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +6 -1
  29. package/dist/lib/account-registry.d.ts +5 -1
  30. package/dist/lib/account-registry.js +47 -14
  31. package/dist/lib/accounting/capacity.d.ts +18 -7
  32. package/dist/lib/accounting/capacity.js +19 -8
  33. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  34. package/dist/lib/accounting/usage-sync.js +76 -2
  35. package/dist/lib/accounting/usage.js +7 -1
  36. package/dist/lib/auth-mint.d.ts +11 -1
  37. package/dist/lib/auth-mint.js +21 -6
  38. package/dist/lib/auto-pull-worker.js +7 -2
  39. package/dist/lib/browser/ipc.d.ts +8 -0
  40. package/dist/lib/browser/ipc.js +87 -0
  41. package/dist/lib/browser/service.d.ts +19 -0
  42. package/dist/lib/browser/service.js +96 -11
  43. package/dist/lib/browser/sessions-list.js +10 -1
  44. package/dist/lib/cloud/rush.d.ts +7 -0
  45. package/dist/lib/cloud/rush.js +29 -1
  46. package/dist/lib/daemon/daemon.d.ts +22 -0
  47. package/dist/lib/daemon/daemon.js +39 -0
  48. package/dist/lib/daemon/runner.d.ts +3 -0
  49. package/dist/lib/daemon/runner.js +86 -45
  50. package/dist/lib/daemon/session-index-service.js +9 -1
  51. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +14 -8
  53. package/dist/lib/daemon-services.js +1 -1
  54. package/dist/lib/daemon-ticks.d.ts +15 -0
  55. package/dist/lib/daemon-ticks.js +26 -0
  56. package/dist/lib/device-config.d.ts +5 -1
  57. package/dist/lib/device-config.js +2 -2
  58. package/dist/lib/devices/connect.d.ts +17 -8
  59. package/dist/lib/devices/connect.js +31 -14
  60. package/dist/lib/devices/health.js +5 -1
  61. package/dist/lib/devices/pool.d.ts +25 -2
  62. package/dist/lib/devices/pool.js +32 -2
  63. package/dist/lib/devices/stats-cache.d.ts +0 -6
  64. package/dist/lib/devices/stats-cache.js +2 -9
  65. package/dist/lib/doctor-diff.d.ts +14 -0
  66. package/dist/lib/doctor-diff.js +120 -9
  67. package/dist/lib/fleet/manifest.d.ts +17 -0
  68. package/dist/lib/fleet/manifest.js +26 -0
  69. package/dist/lib/git.d.ts +38 -0
  70. package/dist/lib/git.js +58 -0
  71. package/dist/lib/hooks/install.d.ts +27 -11
  72. package/dist/lib/hooks/install.js +42 -17
  73. package/dist/lib/hosts/ready.d.ts +8 -0
  74. package/dist/lib/hosts/ready.js +13 -2
  75. package/dist/lib/hosts/reconnect.d.ts +52 -203
  76. package/dist/lib/hosts/reconnect.js +64 -284
  77. package/dist/lib/installations/migrate.d.ts +6 -120
  78. package/dist/lib/installations/migrate.js +27 -259
  79. package/dist/lib/installations/shims.d.ts +13 -95
  80. package/dist/lib/installations/shims.js +22 -139
  81. package/dist/lib/installations/store.js +1 -1
  82. package/dist/lib/installations/versions.d.ts +43 -133
  83. package/dist/lib/installations/versions.js +94 -206
  84. package/dist/lib/monitors/config.d.ts +71 -3
  85. package/dist/lib/monitors/config.js +100 -12
  86. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  87. package/dist/lib/monitors/pid-watch.js +45 -0
  88. package/dist/lib/monitors/remote.d.ts +18 -0
  89. package/dist/lib/monitors/remote.js +11 -0
  90. package/dist/lib/permissions.js +7 -2
  91. package/dist/lib/plugins/plugins.d.ts +17 -3
  92. package/dist/lib/plugins/plugins.js +84 -9
  93. package/dist/lib/plugins/skills.d.ts +8 -1
  94. package/dist/lib/plugins/skills.js +18 -2
  95. package/dist/lib/pty-server.d.ts +14 -0
  96. package/dist/lib/pty-server.js +49 -5
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/drivers/rush.js +5 -0
  108. package/dist/lib/secrets/reaper.d.ts +28 -70
  109. package/dist/lib/secrets/reaper.js +30 -85
  110. package/dist/lib/secrets/remote.d.ts +42 -129
  111. package/dist/lib/secrets/remote.js +55 -173
  112. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  113. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  114. package/dist/lib/self-heal/registry.js +2 -0
  115. package/dist/lib/self-heal/types.d.ts +1 -1
  116. package/dist/lib/self-update.d.ts +65 -0
  117. package/dist/lib/self-update.js +138 -0
  118. package/dist/lib/session/active.d.ts +13 -1
  119. package/dist/lib/session/active.js +2 -0
  120. package/dist/lib/session/cloud.js +5 -0
  121. package/dist/lib/session/db.d.ts +51 -6
  122. package/dist/lib/session/db.js +266 -20
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/tool-calls.d.ts +43 -1
  126. package/dist/lib/session/tool-calls.js +74 -44
  127. package/dist/lib/session/tool-store.d.ts +33 -2
  128. package/dist/lib/session/tool-store.js +56 -3
  129. package/dist/lib/smart-launch.d.ts +6 -0
  130. package/dist/lib/smart-launch.js +5 -2
  131. package/dist/lib/staleness/writers/plugins.js +5 -2
  132. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  133. package/dist/lib/staleness/writers/sources.js +2 -1
  134. package/dist/lib/staleness/writers/subagents.js +13 -3
  135. package/dist/lib/state.d.ts +7 -4
  136. package/dist/lib/state.js +7 -4
  137. package/dist/lib/subagents.js +8 -2
  138. package/dist/lib/sync-status.d.ts +22 -0
  139. package/dist/lib/sync-status.js +27 -0
  140. package/dist/lib/sync-umbrella.d.ts +9 -0
  141. package/dist/lib/sync-umbrella.js +21 -2
  142. package/dist/lib/teams/scheduler.d.ts +10 -0
  143. package/dist/lib/teams/scheduler.js +8 -0
  144. package/dist/lib/traces/insights.d.ts +47 -14
  145. package/dist/lib/traces/insights.js +92 -21
  146. package/dist/lib/traces/phenotype.d.ts +23 -3
  147. package/dist/lib/traces/phenotype.js +72 -24
  148. package/dist/lib/traces/sync.d.ts +128 -6
  149. package/dist/lib/traces/sync.js +294 -35
  150. package/dist/lib/traces/worker-template.js +154 -1
  151. package/dist/lib/view-types.d.ts +12 -0
  152. package/package.json +2 -2
@@ -1,18 +1,6 @@
1
1
  /**
2
- * Version management module for agents-cli.
3
- *
4
- * Handles installing, removing, listing, and switching between agent CLI versions.
5
- * Each version is installed into an isolated directory under ~/.agents/.system/versions/{agent}/{version}/
6
- * with its own HOME directory for config isolation. Resources (commands, skills, hooks, memory,
7
- * MCP servers, permissions, subagents, plugins) from ~/.agents/ are synced into version homes
8
- * via copies or conversions (not symlinks).
9
- *
10
- * Key responsibilities:
11
- * - Version lifecycle: install, remove, list, resolve (project-level or global default)
12
- * - Resource discovery: scan ~/.agents/ for available resources across all types
13
- * - Resource sync: copy/convert resources into a version's isolated config directory
14
- * - Diff and reconciliation: detect new/unsynced resources and prompt users to sync them
15
- * - Agent/version target resolution: parse agent@version specs from CLI flags
2
+ * Version lifecycle, resource sync, and agent@version resolution for agents-cli.
3
+ * Each version lives in an isolated home under ~/.agents/.history/versions/{agent}/{version}/.
16
4
  */
17
5
  import * as fs from 'fs';
18
6
  import * as path from 'path';
@@ -121,12 +109,7 @@ function sourceMapFromWorkflows(cwd) {
121
109
  .filter(d => d.isDirectory() && fs.existsSync(path.join(dir, d.name, 'WORKFLOW.md')))
122
110
  .map(d => d.name));
123
111
  }
124
- // A plugin is a directory whose marker is `.claude-plugin/plugin.json`. Attribute
125
- // each to the layer it actually resolves from (system / user / project / extra) —
126
- // the same first-wins resolution the plugins staleness checker uses. Hardcoding
127
- // 'user' here made a `system:*` selection expand to zero plugins, so
128
- // `agents sync <agent> system` silently skipped system-layer plugins like `swarm`
129
- // (RUSH-3207).
112
+ // Attribute each plugin to the layer it resolves from so `system:*` selections include system-layer plugins.
130
113
  function sourceMapFromPlugins(cwd) {
131
114
  return sourceMapFromLayeredDirectory(cwd, ['plugins'], (dir) => fs.readdirSync(dir, { withFileTypes: true })
132
115
  .filter(d => d.isDirectory() && fs.existsSync(path.join(dir, d.name, '.claude-plugin', 'plugin.json')))
@@ -155,9 +138,7 @@ function sourceMapFromPluginSkills(plugins, activePluginNames, cwd) {
155
138
  }
156
139
  return sources;
157
140
  }
158
- /**
159
- * Get all available resources from ~/.agents/.
160
- */
141
+ /** Discover all resources available for syncing from ~/.agents/. */
161
142
  export function getAvailableResources(cwd = process.cwd()) {
162
143
  const result = {
163
144
  commands: [],
@@ -201,14 +182,7 @@ export function getAvailableResources(cwd = process.cwd()) {
201
182
  }
202
183
  }
203
184
  result.skills = filterNamesForActiveResourceProfile('skills', Array.from(skillNames), sourceMapFromResources('skills', cwd));
204
- // Hooks:
205
- // - top-level script files
206
- // - one-level *group* dirs that contain top-level scripts (session-starts/*.sh)
207
- // → each script is its own hook resource (install name = basename)
208
- // - other directories (e.g. tests/ with fixtures only) → directory bundles,
209
- // copied wholesale as one resource (pre-existing layout)
210
- // Auxiliary content like README.md / promptcuts.yaml is not a hook. Older sync
211
- // runs chmod 0o755'd everything, so an exec bit alone is not the signal.
185
+ // Hooks: top-level scripts, expanded one-level group dirs, or whole-dir bundles. Exec bit alone is not the signal (older syncs chmod'd everything).
212
186
  const NON_SCRIPT_EXTS = new Set(['.md', '.markdown', '.rst', '.txt', '.yaml', '.yml', '.json', '.toml', '.ini', '.conf']);
213
187
  const SCRIPT_EXTS = new Set(['.sh', '.bash', '.zsh', '.py', '.js', '.ts', '.mjs', '.cjs', '.rb', '.pl', '.ps1']);
214
188
  const HOOK_GROUP_SKIP = new Set(['node_modules', '.git', '.cache']);
@@ -238,8 +212,7 @@ export function getAvailableResources(cwd = process.cwd()) {
238
212
  }
239
213
  if (!stat.isDirectory() || HOOK_GROUP_SKIP.has(name))
240
214
  continue;
241
- // Group vs bundle: if the dir has any top-level script files, expand
242
- // them; otherwise treat the whole dir as one hook resource.
215
+ // Expand dirs containing top-level scripts; bundle dirs without any.
243
216
  let nestedNames;
244
217
  try {
245
218
  nestedNames = fs.readdirSync(full);
@@ -273,10 +246,7 @@ export function getAvailableResources(cwd = process.cwd()) {
273
246
  }
274
247
  }
275
248
  result.hooks = filterNamesForActiveResourceProfile('hooks', Array.from(hookNames), sourceMapFromResources('hooks', cwd));
276
- // Rules — list available presets across layers (project > user > extras > system).
277
- // The composer selects exactly one preset per sync; this list drives the
278
- // resource-count display and `agents rules switch` picker. Routes through
279
- // the rules-dir getters so test mocks work the same as production paths.
249
+ // Rules — list available presets across layers.
280
250
  const presetNames = new Set();
281
251
  const rulesDirs = [];
282
252
  if (projectAgentsDir)
@@ -303,10 +273,8 @@ export function getAvailableResources(cwd = process.cwd()) {
303
273
  result.memory = filterNamesForActiveResourceProfile('memory', Array.from(presetNames));
304
274
  const scopedMcp = getScopedMcpResources(cwd);
305
275
  result.mcp = filterNamesForActiveResourceProfile('mcp', scopedMcp.map(resource => resource.name), new Map(scopedMcp.map(resource => [resource.name, resource.scope])));
306
- // Permission groups (from permissions/groups/*.yaml)
307
276
  const permissionSources = sourceMapFromPermissionGroups(cwd);
308
277
  result.permissions = filterNamesForActiveResourceProfile('permissions', Array.from(permissionSources.keys()), permissionSources);
309
- // Subagents (directories with AGENT.md)
310
278
  const subagentNames = new Set();
311
279
  for (const { base } of resourceBases) {
312
280
  const subagentsDir = path.join(base, 'subagents');
@@ -320,7 +288,6 @@ export function getAvailableResources(cwd = process.cwd()) {
320
288
  }
321
289
  }
322
290
  result.subagents = filterNamesForActiveResourceProfile('subagents', Array.from(subagentNames), sourceMapFromResources('subagents', cwd));
323
- // Workflows (directories with WORKFLOW.md)
324
291
  const workflowSources = sourceMapFromWorkflows(cwd);
325
292
  result.workflows = filterNamesForActiveResourceProfile('workflows', Array.from(workflowSources.keys()), workflowSources);
326
293
  // Plugins (directories with .claude-plugin/plugin.json)
@@ -342,10 +309,7 @@ const SKILL_COPY_IGNORE = new Set(['.DS_Store', '.git', '.gitignore', '.venv', '
342
309
  function shouldSkillEntryBeSkipped(name) {
343
310
  return SKILL_COPY_IGNORE.has(name);
344
311
  }
345
- /**
346
- * Recursively compare two directories: every file in src must exist in dest with identical content.
347
- * Skips the same entries that copyDir skips (symlinks and SKILL_COPY_IGNORE members).
348
- */
312
+ /** Recursively compare two directories for identical content, skipping symlinks and ignored entries. */
349
313
  function skillDirsMatch(src, dest) {
350
314
  const entries = fs.readdirSync(src, { withFileTypes: true });
351
315
  for (const entry of entries) {
@@ -362,9 +326,7 @@ function skillDirsMatch(src, dest) {
362
326
  return false;
363
327
  }
364
328
  else {
365
- // Stat-first (RUSH-2320 #2): size mismatch = definitive miss, no reads.
366
- // Equal mtimes across different trees are accidental — content-compare
367
- // whenever sizes match.
329
+ // Size-first check avoids reads on mismatch; equal mtimes are unreliable across trees.
368
330
  let srcStat;
369
331
  let destStat;
370
332
  try {
@@ -382,10 +344,7 @@ function skillDirsMatch(src, dest) {
382
344
  }
383
345
  return true;
384
346
  }
385
- /**
386
- * Get what's ACTUALLY synced to a version by inspecting the version home.
387
- * This is the source of truth - not the tracking in agents.yaml.
388
- */
347
+ /** Return what's actually synced to a version home (source of truth, not agents.yaml tracking). */
389
348
  export function getActuallySyncedResources(agent, version, options = {}) {
390
349
  const versionHome = path.join(getVersionsDir(), agent, version, 'home');
391
350
  const cwd = options.cwd || process.cwd();
@@ -401,10 +360,7 @@ export function getActuallySyncedResources(agent, version, options = {}) {
401
360
  workflows: [],
402
361
  promptcuts: false,
403
362
  };
404
- // Dispatch each kind through DETECTORS. The registry guarantees a detector
405
- // exists for every supported (agent, kind) pair; unsupported pairs leave
406
- // the field empty. The previous per-agent if-ladder silently dropped
407
- // antigravity/gemini/grok detection — see PR description for details.
363
+ // Dispatch through per-kind detectors; unsupported (agent, kind) pairs leave the field empty.
408
364
  const ctx = { version, versionHome, cwd };
409
365
  result.commands = getDetector('commands', agent)?.list(ctx) ?? [];
410
366
  result.skills = getDetector('skills', agent)?.list(ctx) ?? [];
@@ -417,14 +373,7 @@ export function getActuallySyncedResources(agent, version, options = {}) {
417
373
  result.workflows = getDetector('workflows', agent)?.list(ctx) ?? [];
418
374
  return result;
419
375
  }
420
- /**
421
- * Names that exist ONLY in the project's `.agents/` layer (no matching entry in
422
- * user/system/extra layers). Sync intentionally skips project-layer commands,
423
- * skills, hooks, subagents, plugins, and workflows for security — see the
424
- * defense comments above each sync branch in syncResourcesToVersion. Without
425
- * this filter, those names would forever appear in the "New resources" diff
426
- * because they live in `available` but never reach `actuallySynced`.
427
- */
376
+ /** Names that exist only in the project's `.agents/` layer. Sync skips project-layer resources for security, so filter them out of the "new resources" diff. */
428
377
  export function getProjectOnlyResources(cwd = process.cwd()) {
429
378
  const empty = {
430
379
  commands: new Set(), skills: new Set(), hooks: new Set(),
@@ -522,16 +471,7 @@ export function getProjectOnlyResources(cwd = process.cwd()) {
522
471
  }
523
472
  return empty;
524
473
  }
525
- /**
526
- * Compare available resources with what's ACTUALLY synced to version home.
527
- * Returns only NEW resources that haven't been synced yet.
528
- * Source of truth: the actual files/config, NOT agents.yaml tracking.
529
- *
530
- * `projectOnly` (recommended): the result of `getProjectOnlyResources(cwd)`.
531
- * Names listed there are filtered out for kinds that sync intentionally
532
- * excludes the project layer — otherwise they would re-appear as "new"
533
- * on every run and "Yes, sync all new" would silently do nothing for them.
534
- */
474
+ /** Return resources in `available` that are not yet synced to the version home. `projectOnly` filters project-layer resources that sync skips for security. */
535
475
  export function getNewResources(available, actuallySynced, projectOnly) {
536
476
  const exclude = projectOnly || {
537
477
  commands: new Set(), skills: new Set(), hooks: new Set(),
@@ -541,8 +481,7 @@ export function getNewResources(available, actuallySynced, projectOnly) {
541
481
  commands: available.commands.filter(c => !actuallySynced.commands.includes(c) && !exclude.commands.has(c)),
542
482
  skills: available.skills.filter(s => !actuallySynced.skills.includes(s) && !exclude.skills.has(s)),
543
483
  hooks: available.hooks.filter(h => !actuallySynced.hooks.includes(h) && !exclude.hooks.has(h)),
544
- // Memory/rules presets are mutually exclusive — only one can be active.
545
- // If any preset is synced, don't report others as "new".
484
+ // Only one rules preset can be active; if any is synced, don't report others as new.
546
485
  memory: actuallySynced.memory.length > 0
547
486
  ? []
548
487
  : available.memory.filter(m => !actuallySynced.memory.includes(m)),
@@ -551,15 +490,11 @@ export function getNewResources(available, actuallySynced, projectOnly) {
551
490
  subagents: available.subagents.filter(s => !actuallySynced.subagents.includes(s) && !exclude.subagents.has(s)),
552
491
  plugins: available.plugins.filter(p => !actuallySynced.plugins.includes(p) && !exclude.plugins.has(p)),
553
492
  workflows: available.workflows.filter(w => !actuallySynced.workflows.includes(w) && !exclude.workflows.has(w)),
554
- // Promptcuts aren't version-scoped — the hook reads ~/.agents/promptcuts.yaml
555
- // directly, so there is never a "new" per-version state to reconcile.
493
+ // Promptcuts are not version-scoped; the hook reads the user/system file directly.
556
494
  promptcuts: false,
557
495
  };
558
496
  }
559
- /**
560
- * Check if there are any new resources to sync.
561
- * When version is provided, uses version-specific capability checks.
562
- */
497
+ /** Return true when `diff` contains any resources the agent/version actually supports. */
563
498
  export function hasNewResources(diff, agent, version) {
564
499
  const commandsApply = agent ? supports(agent, 'commands', version).ok : true;
565
500
  const hooksApply = agent ? supports(agent, 'hooks', version).ok : true;
@@ -578,16 +513,11 @@ export function hasNewResources(diff, agent, version) {
578
513
  (diff.plugins.length > 0 && pluginsApply) ||
579
514
  (diff.workflows.length > 0 && workflowsApply));
580
515
  }
581
- /**
582
- * Build a summary string of new resources.
583
- * E.g., "2 commands, 5 permission groups"
584
- */
516
+ /** Build a human-readable summary of new resources, e.g. "2 commands, 5 permission groups". */
585
517
  function buildNewResourcesSummary(newResources, agent, version) {
586
518
  const agentConfig = AGENTS[agent];
587
519
  const parts = [];
588
- // Use version-aware gates so Codex >= 0.117.0 (which converts commands to skills) doesn't
589
- // double-count and so "16 commands" never appears in the summary when commands have
590
- // already been emitted as skills in the version home.
520
+ // Version-aware gates avoid double-counting commands already emitted as skills (Codex >= 0.117.0).
591
521
  const commandsApply = supports(agent, 'commands', version).ok;
592
522
  const commandsAsSkills = version ? shouldInstallCommandAsSkill(agent, version) : false;
593
523
  const rulesApply = supports(agent, 'rules', version).ok;
@@ -620,10 +550,7 @@ function buildNewResourcesSummary(newResources, agent, version) {
620
550
  }
621
551
  return parts.join(', ');
622
552
  }
623
- /**
624
- * Prompt user to select which NEW resources to sync.
625
- * Only shows resources that haven't been synced yet.
626
- */
553
+ /** Prompt the user to select which new resources to sync. */
627
554
  export async function promptNewResourceSelection(agent, newResources, version) {
628
555
  const agentConfig = AGENTS[agent];
629
556
  const selection = {};
@@ -634,15 +561,12 @@ export async function promptNewResourceSelection(agent, newResources, version) {
634
561
  const commandsAsSkills = version ? shouldInstallCommandAsSkill(agent, version) : false;
635
562
  const commandsBranch = commandsApply || commandsAsSkills;
636
563
  const rulesBranch = supports(agent, 'rules', version).ok;
637
- // Get permission group info for display
638
564
  const permissionGroups = discoverPermissionGroups();
639
565
  const newPermissionGroups = permissionGroups.filter(g => newResources.permissions.includes(g.name));
640
566
  const totalNewPermissionRules = newPermissionGroups.reduce((sum, g) => sum + g.ruleCount, 0);
641
- // Build the summary
642
567
  const summary = buildNewResourcesSummary(newResources, agent, version);
643
568
  console.log(chalk.cyan(`\nNew resources available:`));
644
569
  console.log(chalk.gray(` ${summary}`));
645
- // Ask how to handle new resources
646
570
  const action = await select({
647
571
  message: 'Sync new resources?',
648
572
  choices: [
@@ -656,7 +580,6 @@ export async function promptNewResourceSelection(agent, newResources, version) {
656
580
  return null;
657
581
  }
658
582
  if (action === 'all') {
659
- // Sync all new resources
660
583
  if (newResources.commands.length > 0 && commandsBranch)
661
584
  selection.commands = newResources.commands;
662
585
  if (newResources.skills.length > 0)
@@ -677,7 +600,6 @@ export async function promptNewResourceSelection(agent, newResources, version) {
677
600
  selection.workflows = newResources.workflows;
678
601
  return selection;
679
602
  }
680
- // Select specific items for each category
681
603
  if (newResources.commands.length > 0 && commandsBranch) {
682
604
  const selected = await checkbox({
683
605
  message: 'Select new commands to sync:',
@@ -762,15 +684,11 @@ export async function promptNewResourceSelection(agent, newResources, version) {
762
684
  }
763
685
  return selection;
764
686
  }
765
- /**
766
- * Prompt user to select which resources to sync from ~/.agents/.
767
- * Returns the selection, or null if user cancels.
768
- */
687
+ /** Prompt the user to select which resources to sync from ~/.agents/. */
769
688
  export async function promptResourceSelection(agent) {
770
689
  const available = getAvailableResources();
771
690
  const agentConfig = AGENTS[agent];
772
691
  const selection = {};
773
- // Get permission group info for display
774
692
  const permissionGroups = discoverPermissionGroups();
775
693
  const totalPermissionRules = permissionGroups.reduce((sum, g) => sum + g.ruleCount, 0);
776
694
  const categories = [
@@ -788,7 +706,6 @@ export async function promptResourceSelection(agent) {
788
706
  console.log(chalk.gray('No resources available to sync.'));
789
707
  return {};
790
708
  }
791
- // Step 1: Select categories (with "Select All" shortcut at the top)
792
709
  console.log();
793
710
  const SELECT_ALL_KEY = '__select_all__';
794
711
  const selectedCategories = await checkbox({
@@ -805,7 +722,6 @@ export async function promptResourceSelection(agent) {
805
722
  if (selectedCategories.length === 0) {
806
723
  return {};
807
724
  }
808
- // If "Select All" was picked, or all individual categories are selected, sync everything without per-category prompts
809
725
  const allCategoryKeys = availableCategories.map(c => c.key);
810
726
  if (selectedCategories.includes(SELECT_ALL_KEY) || allCategoryKeys.every(k => selectedCategories.includes(k))) {
811
727
  for (const c of availableCategories) {
@@ -813,10 +729,8 @@ export async function promptResourceSelection(agent) {
813
729
  }
814
730
  return selection;
815
731
  }
816
- // Step 2: For each selected category, ask all/specific/skip
817
732
  for (const category of selectedCategories) {
818
733
  const categoryLabel = categories.find(c => c.key === category).label;
819
- // Special handling for permissions - show groups
820
734
  if (category === 'permissions') {
821
735
  const choice = await select({
822
736
  message: `${categoryLabel}:`,
@@ -845,7 +759,6 @@ export async function promptResourceSelection(agent) {
845
759
  }
846
760
  }
847
761
  else {
848
- // Standard handling for other categories
849
762
  const items = available[category];
850
763
  const choice = await select({
851
764
  message: `${categoryLabel}:`,
@@ -877,13 +790,7 @@ export async function promptResourceSelection(agent) {
877
790
  }
878
791
  return selection;
879
792
  }
880
- /**
881
- * Parse agent@version syntax.
882
- * Examples:
883
- * "claude@1.5.0" -> { agent: "claude", version: "1.5.0" }
884
- * "claude" -> { agent: "claude", version: "latest" }
885
- * "codex@latest" -> { agent: "codex", version: "latest" }
886
- */
793
+ /** Parse an `agent@version` spec; bare agent means `latest`. */
887
794
  export function parseAgentSpec(spec) {
888
795
  const parts = spec.split('@');
889
796
  if (parts.length > 2) {
@@ -904,9 +811,6 @@ export function parseAgentSpec(spec) {
904
811
  version,
905
812
  };
906
813
  }
907
- /**
908
- * Get the latest available version from npm for an agent.
909
- */
910
814
  export async function getLatestNpmVersion(agent) {
911
815
  const agentConfig = AGENTS[agent];
912
816
  if (!agentConfig.npmPackage)
@@ -919,9 +823,6 @@ export async function getLatestNpmVersion(agent) {
919
823
  return null;
920
824
  }
921
825
  }
922
- /**
923
- * Get the oldest published version from npm for an agent.
924
- */
925
826
  export async function getOldestNpmVersion(agent) {
926
827
  const agentConfig = AGENTS[agent];
927
828
  if (!agentConfig.npmPackage)
@@ -929,8 +830,7 @@ export async function getOldestNpmVersion(agent) {
929
830
  try {
930
831
  const { stdout } = await execFileAsync('npm', ['view', agentConfig.npmPackage, 'versions', '--json'], { shell: process.platform === 'win32' });
931
832
  const parsed = JSON.parse(stdout.trim());
932
- // `npm view ... versions --json` returns an array (multiple versions) or a
933
- // bare string (single published version). Normalize to an array.
833
+ // npm view returns an array for multiple versions or a bare string for one.
934
834
  const versions = Array.isArray(parsed) ? parsed : [parsed];
935
835
  const sorted = versions.filter((v) => VERSION_RE.test(v)).sort(compareVersions);
936
836
  return sorted[0] ?? null;
@@ -939,9 +839,7 @@ export async function getOldestNpmVersion(agent) {
939
839
  return null;
940
840
  }
941
841
  }
942
- /**
943
- * Check if 'latest' version is already installed (by resolving to actual version).
944
- */
842
+ /** Check whether the npm `latest` version is installed. */
945
843
  export async function isLatestInstalled(agent) {
946
844
  const latestVersion = await getLatestNpmVersion(agent);
947
845
  if (!latestVersion) {
@@ -949,9 +847,7 @@ export async function isLatestInstalled(agent) {
949
847
  }
950
848
  return { installed: isVersionInstalled(agent, latestVersion), version: latestVersion };
951
849
  }
952
- /**
953
- * Check if 'oldest' published version is already installed (by resolving to actual version).
954
- */
850
+ /** Check whether the npm `oldest` version is installed. */
955
851
  export async function isOldestInstalled(agent) {
956
852
  const oldestVersion = await getOldestNpmVersion(agent);
957
853
  if (!oldestVersion) {
@@ -960,12 +856,9 @@ export async function isOldestInstalled(agent) {
960
856
  return { installed: isVersionInstalled(agent, oldestVersion), version: oldestVersion };
961
857
  }
962
858
  /**
963
- * List every version directory for an agent, including ones missing the
964
- * binary (typically home-only leftovers from a prior `removeVersion`).
965
- *
966
- * Used by `agents prune cleanup` to surface stale installs that the regular
967
- * `listInstalledVersions` filters out. Do NOT use elsewhere — every other
968
- * call site assumes a working binary.
859
+ * List every version directory for an agent, including home-only leftovers, for
860
+ * `agents prune cleanup` only. Do NOT use elsewhere — every other call site
861
+ * assumes a working binary.
969
862
  */
970
863
  export function listInstalledVersionDirs(agent) {
971
864
  const agentVersionsDir = path.join(getVersionsDir(), agent);
@@ -984,15 +877,9 @@ export function listInstalledVersionDirs(agent) {
984
877
  }
985
878
  return out.sort((a, b) => compareVersions(a.version, b.version));
986
879
  }
987
- /**
988
- * Set the global default version for an agent.
989
- */
880
+ /** Set (or clear) the global default version for an agent. */
990
881
  export function setGlobalDefault(agent, version) {
991
- // A global default is what owns the launcher and arms the self-heal `shadowing`
992
- // check, so recording one for an isolated-only agent is the root of the original
993
- // breach. Clearing is always allowed — `removeVersion` legitimately clears a
994
- // default as the last non-isolated version goes away, which is the very moment
995
- // an agent BECOMES isolated-only.
882
+ // Setting a global default for an isolated-only agent would breach the isolation boundary; clearing is allowed.
996
883
  if (version !== undefined) {
997
884
  assertIsolationBoundary(agent, 'set a global default');
998
885
  }
@@ -1009,13 +896,7 @@ export function setGlobalDefault(agent, version) {
1009
896
  }
1010
897
  writeMeta(meta);
1011
898
  }
1012
- /**
1013
- * Set (or clear, with `undefined`) the preferred isolated version.
1014
- *
1015
- * Deliberately does NOT touch the launcher, the bare shim, the `~/.<agent>` config
1016
- * symlink or the global default — the five things `setDefaultVersion` does. This is
1017
- * a pointer inside the sandbox, so it stays inside the sandbox.
1018
- */
899
+ /** Set (or clear) the preferred isolated version without touching the launcher, shim, or global default. */
1019
900
  export function setIsolatedDefault(agent, version) {
1020
901
  const meta = readMeta();
1021
902
  if (!meta.isolatedAgents) {
@@ -1147,18 +1028,14 @@ async function checkGrokAccountCollision(installedVersion) {
1147
1028
  `Grok's self-updater can't distinguish two accounts that land on the same release — sign in to ${targetEmail}'s ` +
1148
1029
  `install (agents use grok@${installedVersion}) before updating it, or wait until the releases diverge.`);
1149
1030
  }
1150
- /**
1151
- * Install a specific version of an agent.
1152
- */
1031
+ /** Install a specific version of an agent. */
1153
1032
  export async function installVersion(agent, version, onProgress, opts) {
1154
1033
  const agentConfig = AGENTS[agent];
1155
1034
  const requestedLabel = version;
1156
1035
  if (isAgentHardDeprecated(agent)) {
1157
1036
  return { success: false, installedVersion: version, error: hardDeprecationError(agent) };
1158
1037
  }
1159
- // Validate before deriving filesystem paths or npm package specs. The CLI
1160
- // parser already enforces this for user input; this guard protects direct
1161
- // callers and tests the critical install path at the source.
1038
+ // Also validate at the source so direct callers and tests cannot pass invalid versions.
1162
1039
  if (!VERSION_RE.test(version)) {
1163
1040
  throw new Error(`Invalid version: ${JSON.stringify(version)}`);
1164
1041
  }
@@ -1166,12 +1043,7 @@ export async function installVersion(agent, version, onProgress, opts) {
1166
1043
  if (!agentConfig.installScript) {
1167
1044
  return { success: false, installedVersion: version, error: 'Agent has no npm package' };
1168
1045
  }
1169
- // A self-updating agent (droid, grok, …) is a single global binary whose
1170
- // installer only ever fetches the CURRENT release — there is no semver to
1171
- // pin. Rather than hard-refuse `<agent>@1.2.3` (the old behavior):
1172
- // - if the binary is already installed, a pin is a no-op — it self-updates
1173
- // in place, so skip the installer and just refresh our bookkeeping;
1174
- // - otherwise redirect the pin to a current-release install.
1046
+ // Self-updating agents have no pinnable semver; an installed binary is a no-op, otherwise redirect to latest.
1175
1047
  let runInstaller = true;
1176
1048
  if (version !== 'latest' && isSelfUpdatingAgent(agent)) {
1177
1049
  const liveVersion = await getLiveVersion(agent);
@@ -1624,7 +1496,7 @@ export async function reconcileStaleLatestDir(agent, installedVersion) {
1624
1496
  * re-install) without collision and gives a chronological audit trail.
1625
1497
  *
1626
1498
  * The whole versionDir moves — including `home/` (transcripts, sessions). The
1627
- * user can recover everything via `agents trash restore <agent>@<version>`.
1499
+ * user can recover everything via `agents restore <agent>@<version>`.
1628
1500
  * Nothing is ever hard-deleted.
1629
1501
  */
1630
1502
  export function softDeleteVersionDir(agent, version) {
@@ -1658,7 +1530,7 @@ export function softDeleteVersionDir(agent, version) {
1658
1530
  * Remove a specific version of an agent.
1659
1531
  *
1660
1532
  * Soft-delete only: moves the entire version directory (including `home/`)
1661
- * to ~/.agents/.system/trash/versions/. Recoverable via `agents trash restore`.
1533
+ * to ~/.agents/.system/trash/versions/. Recoverable via `agents restore`.
1662
1534
  * Nothing is hard-deleted.
1663
1535
  */
1664
1536
  export function removeVersion(agent, version) {
@@ -1814,24 +1686,7 @@ export async function healDanglingVersionPointers(agent, cwd) {
1814
1686
  }
1815
1687
  return healed;
1816
1688
  }
1817
- /**
1818
- * Normalize a user-supplied @version token across CLI subcommands.
1819
- *
1820
- * undefined / "" / "default" / "pinned" -> undefined (caller falls back to project pin or global default)
1821
- * "any" -> undefined (caller imposes no version constraint — e.g. resume across any version)
1822
- * "latest" -> highest installed version (process.exit if none installed)
1823
- * "oldest" -> lowest installed version (process.exit if none installed)
1824
- * "x.y.z" (installed) -> "x.y.z"
1825
- * "x.y.z" (not installed) -> process.exit with installed-list hint
1826
- *
1827
- * `pinned` is a synonym for `default`: both name the project pin / global
1828
- * default, which the caller resolves.
1829
- *
1830
- * Use this anywhere the user can type `agents <cmd> claude@<token>` to keep the
1831
- * vocabulary consistent. Subcommands with different semantics for `latest`
1832
- * (install/remove/use, where `latest` means npm-latest) keep their existing
1833
- * parsing.
1834
- */
1689
+ /** Normalize a user-supplied `@version` token. `default`/`pinned`/`any` → undefined; `latest`/`oldest` → extreme installed version; concrete versions must be installed. */
1835
1690
  export function resolveVersionAlias(agent, raw) {
1836
1691
  if (!raw || raw === 'default' || raw === 'pinned' || raw === 'any')
1837
1692
  return undefined;
@@ -2343,6 +2198,46 @@ export function mergeRepoScopedSelections(repos, cwd = process.cwd()) {
2343
2198
  *
2344
2199
  * For Gemini: commands are converted from markdown to TOML.
2345
2200
  */
2201
+ /**
2202
+ * Resolve a caller's hook selection against the available hook set, matching a
2203
+ * selection entry by exact name OR by its extensionless basename, and returning
2204
+ * the AVAILABLE (extensioned) name in every case.
2205
+ *
2206
+ * `available.hooks` carries the source filename WITH its extension
2207
+ * (`git-guard.sh`) — the shape the hooks writer's source resolver requires — but
2208
+ * the doctor/heal resource diff identifies a hook by its extensionless basename
2209
+ * (`git-guard`). A heal pass feeds those diff names straight back as the
2210
+ * selection, so a plain exact-set filter matched NOTHING and `agents doctor
2211
+ * --fix` could never reconcile a flagged hook (PHNX-3187). Basename tolerance
2212
+ * closes that gap without changing the extensioned names the writer needs.
2213
+ *
2214
+ * Order-stable and de-duplicated; a selection entry that matches nothing is
2215
+ * dropped (mirrors resolveSelection).
2216
+ */
2217
+ export function resolveHookSelection(sel, available) {
2218
+ if (sel === 'all')
2219
+ return available;
2220
+ if (!Array.isArray(sel))
2221
+ return [];
2222
+ const stripExt = (n) => n.replace(/\.[^./\\]+$/, '');
2223
+ const exact = new Set(available);
2224
+ const byBase = new Map();
2225
+ for (const a of available) {
2226
+ const base = stripExt(a);
2227
+ if (!byBase.has(base))
2228
+ byBase.set(base, a); // first available wins the basename
2229
+ }
2230
+ const out = [];
2231
+ const seen = new Set();
2232
+ for (const name of sel) {
2233
+ const resolved = exact.has(name) ? name : byBase.get(stripExt(name));
2234
+ if (resolved && !seen.has(resolved)) {
2235
+ seen.add(resolved);
2236
+ out.push(resolved);
2237
+ }
2238
+ }
2239
+ return out;
2240
+ }
2346
2241
  export function syncResourcesToVersion(agent, version, selection, options = {}) {
2347
2242
  if (isAgentHardDeprecated(agent)) {
2348
2243
  return { commands: false, skills: false, hooks: false, memory: [], permissions: false, mcp: [], subagents: [], plugins: [], workflows: [], projectSkipped: [], pruned: { commands: [], skills: [] }, declined: [] };
@@ -2639,8 +2534,14 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2639
2534
  console.warn(explainSkip(agent, 'hooks', hooksGate, version) + ' -- skipped');
2640
2535
  }
2641
2536
  else {
2537
+ // Resolve requested hooks against the available set BY BASENAME as well as
2538
+ // exact name. `available.hooks` carries the source filename WITH its
2539
+ // extension (`git-guard.sh`), but the doctor/heal diff identifies a hook
2540
+ // by its extensionless basename (`git-guard`) — so a heal pass that feeds
2541
+ // the diff's names straight back through the plain resolveSelection
2542
+ // matched NOTHING and could never reconcile a flagged hook (PHNX-3187).
2642
2543
  const hooksToSync = selection
2643
- ? resolveSelection(selection.hooks, available.hooks)
2544
+ ? resolveHookSelection(selection.hooks, available.hooks)
2644
2545
  : available.hooks;
2645
2546
  let hookManifest = {};
2646
2547
  if (hooksToSync.length > 0) {
@@ -2792,6 +2693,8 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2792
2693
  if (r.paths)
2793
2694
  writtenTargets.push(...r.paths);
2794
2695
  result.subagents.push(...r.synced);
2696
+ if (r.errors?.length)
2697
+ result.declined.push(...r.errors.map((e) => `subagents: ${e}`));
2795
2698
  // Orphan-sweep for Claude only — see comment on commands/skills sweep
2796
2699
  // for the no-selection guard. OpenClaw stores subagents as siblings of
2797
2700
  // other resources so a readdir sweep would over-reach.
@@ -2818,7 +2721,10 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2818
2721
  if (pluginsToSync.length > 0 && pluginsWriter) {
2819
2722
  if (options.allowExecSurfaces) {
2820
2723
  const allPlugins = discoverPlugins();
2821
- cleanOrphanedPluginSkills(agent, versionHome, new Set(allPlugins.map(p => p.name)));
2724
+ // Pass the discovered plugins (with marketplace provenance) so a stale
2725
+ // install under one marketplace is trashed even when another marketplace
2726
+ // still ships that name — the PHNX-2618 shadow `code` plugin.
2727
+ cleanOrphanedPluginSkills(agent, versionHome, allPlugins);
2822
2728
  const pluginMap = new Map(allPlugins.map(p => [p.name, p]));
2823
2729
  for (const name of pluginsToSync) {
2824
2730
  const plugin = pluginMap.get(name);
@@ -2911,11 +2817,7 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2911
2817
  }
2912
2818
  return result;
2913
2819
  }
2914
- /**
2915
- * Thrown when the user references an agent@version that is not installed.
2916
- * Carries the parsed (agentId, version) so callers can react — e.g. prompt
2917
- * to install it on demand — without having to parse the error message.
2918
- */
2820
+ /** Thrown when an `agent@version` target is not installed; carries the parsed ids so callers can react without parsing the message. */
2919
2821
  export class VersionNotInstalledError extends Error {
2920
2822
  agentId;
2921
2823
  version;
@@ -2929,11 +2831,7 @@ export class VersionNotInstalledError extends Error {
2929
2831
  this.name = 'VersionNotInstalledError';
2930
2832
  }
2931
2833
  }
2932
- /**
2933
- * Resolve a comma-separated --agents list into concrete version selections.
2934
- * Bare agents target the default version, or the newest installed version when no default exists.
2935
- * Explicit agent@version targets only that installed version.
2936
- */
2834
+ /** Resolve a comma-separated `--agents` list into concrete installed version selections. */
2937
2835
  export function resolveAgentVersionTargets(value, availableAgents, options = {}) {
2938
2836
  const selectedAgents = [];
2939
2837
  const versionSelections = new Map();
@@ -3025,12 +2923,7 @@ export function resolveAgentVersionTargets(value, availableAgents, options = {})
3025
2923
  }
3026
2924
  return { selectedAgents, versionSelections };
3027
2925
  }
3028
- /**
3029
- * Resolve a comma-separated --agents list into install/apply targets.
3030
- * Bare agents target the default version (or newest installed version) when managed,
3031
- * and fall back to the agent's effective HOME when unmanaged.
3032
- * Explicit agent@version targets only that installed version.
3033
- */
2926
+ /** Resolve a comma-separated `--agents` list into install/apply targets, distinguishing managed versions from direct homes. */
3034
2927
  export function resolveInstalledAgentTargets(value, availableAgents, options = {}) {
3035
2928
  const selectedAgents = [];
3036
2929
  const directAgents = [];
@@ -3128,9 +3021,7 @@ export function resolveInstalledAgentTargets(value, availableAgents, options = {
3128
3021
  }
3129
3022
  return { selectedAgents, directAgents, versionSelections };
3130
3023
  }
3131
- /**
3132
- * Resolve configured manifest targets into direct homes and managed versions.
3133
- */
3024
+ /** Resolve configured manifest targets into direct homes and managed versions. */
3134
3025
  export function resolveConfiguredAgentTargets(agents, agentVersions, availableAgents, options = {}) {
3135
3026
  const targetSpecs = [];
3136
3027
  const broadTargets = agents ? [...agents] : [...availableAgents];
@@ -3157,10 +3048,7 @@ export function resolveConfiguredAgentTargets(agents, agentVersions, availableAg
3157
3048
  }
3158
3049
  return resolveInstalledAgentTargets(targetSpecs.join(','), availableAgents, options);
3159
3050
  }
3160
- /**
3161
- * Prompt user to select agents and versions for resource installation.
3162
- * Returns selected agents and their version selections.
3163
- */
3051
+ /** Prompt the user to select agents and versions for resource installation. */
3164
3052
  export async function promptAgentVersionSelection(availableAgents, options = {}) {
3165
3053
  const versionSelections = new Map();
3166
3054
  // Filter to installed agents (only those with versions managed by agents CLI)