@phnx-labs/agents-cli 1.14.1 → 1.14.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/README.md +31 -3
  2. package/dist/commands/browser.d.ts +2 -0
  3. package/dist/commands/browser.js +388 -0
  4. package/dist/commands/daemon.js +1 -1
  5. package/dist/commands/doctor.d.ts +16 -9
  6. package/dist/commands/doctor.js +248 -12
  7. package/dist/commands/exec.js +17 -17
  8. package/dist/commands/prune.js +9 -3
  9. package/dist/commands/refresh-rules.d.ts +15 -0
  10. package/dist/commands/{refresh-memory.js → refresh-rules.js} +14 -14
  11. package/dist/commands/routines.js +1 -1
  12. package/dist/commands/rules.js +100 -4
  13. package/dist/commands/secrets.js +206 -12
  14. package/dist/commands/sync.js +19 -0
  15. package/dist/commands/teams.js +162 -22
  16. package/dist/commands/trash.d.ts +10 -0
  17. package/dist/commands/trash.js +187 -0
  18. package/dist/commands/view.js +46 -13
  19. package/dist/index.js +62 -4
  20. package/dist/lib/agents.js +2 -2
  21. package/dist/lib/browser/cdp.d.ts +24 -0
  22. package/dist/lib/browser/cdp.js +94 -0
  23. package/dist/lib/browser/chrome.d.ts +16 -0
  24. package/dist/lib/browser/chrome.js +157 -0
  25. package/dist/lib/browser/drivers/local.d.ts +8 -0
  26. package/dist/lib/browser/drivers/local.js +22 -0
  27. package/dist/lib/browser/drivers/ssh.d.ts +9 -0
  28. package/dist/lib/browser/drivers/ssh.js +129 -0
  29. package/dist/lib/browser/index.d.ts +5 -0
  30. package/dist/lib/browser/index.js +5 -0
  31. package/dist/lib/browser/input.d.ts +6 -0
  32. package/dist/lib/browser/input.js +52 -0
  33. package/dist/lib/browser/ipc.d.ts +12 -0
  34. package/dist/lib/browser/ipc.js +223 -0
  35. package/dist/lib/browser/profiles.d.ts +11 -0
  36. package/dist/lib/browser/profiles.js +61 -0
  37. package/dist/lib/browser/refs.d.ts +21 -0
  38. package/dist/lib/browser/refs.js +88 -0
  39. package/dist/lib/browser/service.d.ts +45 -0
  40. package/dist/lib/browser/service.js +404 -0
  41. package/dist/lib/browser/types.d.ts +73 -0
  42. package/dist/lib/browser/types.js +7 -0
  43. package/dist/lib/cloud/codex.js +1 -1
  44. package/dist/lib/cloud/registry.js +2 -2
  45. package/dist/lib/cloud/rush.js +2 -2
  46. package/dist/lib/cloud/store.js +2 -2
  47. package/dist/lib/daemon.d.ts +1 -1
  48. package/dist/lib/daemon.js +47 -11
  49. package/dist/lib/diff-text.d.ts +25 -0
  50. package/dist/lib/diff-text.js +47 -0
  51. package/dist/lib/doctor-diff.d.ts +64 -0
  52. package/dist/lib/doctor-diff.js +497 -0
  53. package/dist/lib/git.js +3 -3
  54. package/dist/lib/hooks.d.ts +6 -0
  55. package/dist/lib/hooks.js +6 -1
  56. package/dist/lib/migrate.js +77 -0
  57. package/dist/lib/pty-client.js +3 -3
  58. package/dist/lib/pty-server.js +36 -7
  59. package/dist/lib/resources.js +1 -1
  60. package/dist/lib/rotate.d.ts +43 -26
  61. package/dist/lib/rotate.js +99 -44
  62. package/dist/lib/rules/compile.d.ts +104 -0
  63. package/dist/lib/{memory-compile.js → rules/compile.js} +160 -21
  64. package/dist/lib/rules/compose.d.ts +78 -0
  65. package/dist/lib/rules/compose.js +170 -0
  66. package/dist/lib/{memory.d.ts → rules/rules.d.ts} +5 -5
  67. package/dist/lib/{memory.js → rules/rules.js} +10 -10
  68. package/dist/lib/secrets/AgentsKeychain.app/Contents/CodeResources +0 -0
  69. package/dist/lib/secrets/AgentsKeychain.app/Contents/MacOS/AgentsKeychain +0 -0
  70. package/dist/lib/secrets/bundles.d.ts +61 -4
  71. package/dist/lib/secrets/bundles.js +222 -54
  72. package/dist/lib/secrets/index.d.ts +24 -5
  73. package/dist/lib/secrets/index.js +70 -41
  74. package/dist/lib/session/active.js +5 -5
  75. package/dist/lib/session/db.js +4 -4
  76. package/dist/lib/session/discover.js +2 -2
  77. package/dist/lib/session/render.js +21 -7
  78. package/dist/lib/shims.d.ts +28 -4
  79. package/dist/lib/shims.js +72 -14
  80. package/dist/lib/state.d.ts +22 -28
  81. package/dist/lib/state.js +83 -76
  82. package/dist/lib/sync-manifest.d.ts +2 -2
  83. package/dist/lib/sync-manifest.js +5 -5
  84. package/dist/lib/teams/agents.d.ts +4 -2
  85. package/dist/lib/teams/agents.js +11 -4
  86. package/dist/lib/teams/api.d.ts +1 -1
  87. package/dist/lib/teams/api.js +2 -2
  88. package/dist/lib/teams/index.d.ts +1 -0
  89. package/dist/lib/teams/index.js +1 -0
  90. package/dist/lib/teams/persistence.js +3 -3
  91. package/dist/lib/teams/registry.d.ts +8 -1
  92. package/dist/lib/teams/registry.js +8 -2
  93. package/dist/lib/teams/worktree.d.ts +30 -0
  94. package/dist/lib/teams/worktree.js +96 -0
  95. package/dist/lib/types.d.ts +13 -7
  96. package/dist/lib/types.js +3 -3
  97. package/dist/lib/versions.d.ts +30 -2
  98. package/dist/lib/versions.js +127 -105
  99. package/package.json +1 -1
  100. package/scripts/postinstall.js +29 -0
  101. package/dist/commands/refresh-memory.d.ts +0 -15
  102. package/dist/lib/memory-compile.d.ts +0 -66
@@ -5,7 +5,8 @@ import { AgentManager, checkAllClis, getAgentsDir, VALID_TASK_TYPES, } from '../
5
5
  import { resolveProvider } from '../lib/cloud/registry.js';
6
6
  import { runSupervisor } from '../lib/teams/supervisor.js';
7
7
  import { handleSpawn, handleStatus, handleStop, handleTasks, } from '../lib/teams/api.js';
8
- import { createTeam, ensureTeam, loadTeams, removeTeam, teamExists, } from '../lib/teams/registry.js';
8
+ import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } from '../lib/teams/registry.js';
9
+ import { createWorktree, isGitRepo, hasUncommittedChanges, removeWorktree, } from '../lib/teams/worktree.js';
9
10
  import { isVersionInstalled, resolveVersionAlias, resolveVersionAliasLoose } from '../lib/versions.js';
10
11
  import { discoverSessions, parseTimeFilter, resolveSessionById } from '../lib/session/discover.js';
11
12
  import { buildPreview as buildSessionPreview } from './sessions-picker.js';
@@ -542,7 +543,8 @@ Examples:
542
543
  # Pull the live log of one teammate
543
544
  agents teams logs frontend
544
545
 
545
- # A teammate is stuck — pull them out, the rest keeps going
546
+ # A teammate is stuck — stop them, then remove if needed
547
+ agents teams stop pricing-page frontend
546
548
  agents teams remove pricing-page frontend
547
549
 
548
550
  # Ship done — wind everyone down
@@ -676,10 +678,14 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
676
678
  .aliases(['c', 'new'])
677
679
  .description('Start a new team. No teammates yet; add them with `teams add`.')
678
680
  .option('-d, --description <text>', 'One-line summary of what this team is working on')
681
+ .option('--enable-worktrees', 'Each teammate works in its own git worktree (requires --worktree on add)')
679
682
  .option('--json', 'Output machine-readable JSON')
680
683
  .action(async (team, opts) => {
681
684
  try {
682
- const meta = await createTeam(team, opts.description);
685
+ const meta = await createTeam(team, {
686
+ description: opts.description,
687
+ enableWorktrees: opts.enableWorktrees,
688
+ });
683
689
  if (isJsonMode(opts)) {
684
690
  console.log(JSON.stringify({ team, ...meta }, null, 2));
685
691
  return;
@@ -687,9 +693,16 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
687
693
  console.log(chalk.green(`New team: ${chalk.cyan(team)}`));
688
694
  if (meta.description)
689
695
  console.log(chalk.gray(` ${meta.description}`));
696
+ if (meta.enable_worktrees)
697
+ console.log(chalk.gray(` worktrees: enabled`));
690
698
  console.log();
691
699
  console.log(chalk.gray('Add your first teammate:'));
692
- console.log(chalk.gray(` agents teams add ${team} claude "your task here"`));
700
+ if (meta.enable_worktrees) {
701
+ console.log(chalk.gray(` agents teams add ${team} claude "your task here" --name alice --worktree feature-name`));
702
+ }
703
+ else {
704
+ console.log(chalk.gray(` agents teams add ${team} claude "your task here"`));
705
+ }
693
706
  }
694
707
  catch (err) {
695
708
  die(err.message);
@@ -706,6 +719,7 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
706
719
  .option('--model <model>', 'Override the effort tier and use this specific model (e.g. claude-opus-4-6)')
707
720
  .option('--env <key=value>', 'Set an environment variable for this teammate (repeatable for multiple vars)', (val, prev) => [...prev, val], [])
708
721
  .option('--cwd <dir>', 'Working directory for this teammate (default: current directory)')
722
+ .option('--worktree <name>', 'Run this teammate in a dedicated git worktree (required when team has --enable-worktrees)')
709
723
  .option('--after <names>', "DAG dependencies: comma-separated teammate names to wait for. Stages as PENDING; run 'teams start' to launch when ready.")
710
724
  .option('--task-type <type>', `Factory label: ${VALID_TASK_TYPES.join('|')}. Drives planner fan-out + test-oracle bugfix loop.`)
711
725
  .option('--cloud <provider>', `Dispatch to cloud backend instead of local CLI: ${VALID_CLOUD_PROVIDERS.join('|')}`)
@@ -762,7 +776,34 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
762
776
  }
763
777
  // Auto-create the team if it doesn't exist yet (friendlier UX than erroring).
764
778
  await ensureTeam(team);
765
- const cwd = opts.cwd ?? process.cwd();
779
+ // Check if team has worktrees enabled
780
+ const teamMeta = await getTeam(team);
781
+ const worktreesEnabled = teamMeta?.enable_worktrees ?? false;
782
+ let worktreeName = null;
783
+ let worktreePath = null;
784
+ if (worktreesEnabled) {
785
+ if (!opts.worktree) {
786
+ die(`Team '${team}' has worktrees enabled. Use --worktree <name> to specify a worktree name.`);
787
+ }
788
+ if (!opts.name) {
789
+ die(`Team '${team}' has worktrees enabled. Use --name <name> to identify this teammate.`);
790
+ }
791
+ const baseCwd = opts.cwd ?? process.cwd();
792
+ if (!(await isGitRepo(baseCwd))) {
793
+ die(`Worktrees require a git repository. ${baseCwd} is not inside a git repo.`);
794
+ }
795
+ try {
796
+ worktreeName = opts.worktree;
797
+ worktreePath = await createWorktree(baseCwd, worktreeName);
798
+ }
799
+ catch (err) {
800
+ die(`Failed to create worktree '${opts.worktree}': ${err.message}`);
801
+ }
802
+ }
803
+ else if (opts.worktree) {
804
+ die(`--worktree requires --enable-worktrees on the team. Recreate the team with: agents teams create ${team} --enable-worktrees`);
805
+ }
806
+ const cwd = worktreePath ?? opts.cwd ?? process.cwd();
766
807
  const mgr = mkManager();
767
808
  // Factory teammates: prepend the worker-skill preamble to every task
768
809
  // prompt so implementers/testers/reviewers know about the Ledger, the
@@ -811,7 +852,7 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
811
852
  }
812
853
  }
813
854
  try {
814
- const result = await handleSpawn(mgr, team, agent, effectiveTask, cwd, opts.mode, opts.effort, null, cwd, version, opts.name ?? null, after, opts.model ?? null, envOverrides ?? null, taskType, cloudProviderId, cloudSessionId, opts.repo ?? null, opts.branch ?? null);
855
+ const result = await handleSpawn(mgr, team, agent, effectiveTask, cwd, opts.mode, opts.effort, null, cwd, version, opts.name ?? null, after, opts.model ?? null, envOverrides ?? null, taskType, cloudProviderId, cloudSessionId, opts.repo ?? null, opts.branch ?? null, worktreeName, worktreePath);
815
856
  if (isJsonMode(opts)) {
816
857
  console.log(JSON.stringify(result, null, 2));
817
858
  return;
@@ -830,6 +871,9 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
830
871
  console.log(` ${chalk.gray('status ')} ${statusColor(result.status)(result.status)}`);
831
872
  console.log(` ${chalk.gray('mode ')} ${opts.mode}`);
832
873
  console.log(` ${chalk.gray('working ')} ${cwd}`);
874
+ if (worktreeName) {
875
+ console.log(` ${chalk.gray('worktree')} ${chalk.cyan(worktreeName)}`);
876
+ }
833
877
  if (result.task_type) {
834
878
  console.log(` ${chalk.gray('task ')} ${chalk.cyan(result.task_type)}`);
835
879
  }
@@ -988,11 +1032,90 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
988
1032
  console.error(chalk.yellow(`Stopped by signal after ${result.waves} waves.`));
989
1033
  }
990
1034
  });
1035
+ // stop
1036
+ teams
1037
+ .command('stop [team] [teammate]')
1038
+ .description('Stop a running teammate. Can be restarted later. Cleans up worktree if no uncommitted changes.')
1039
+ .option('--json', 'Output machine-readable JSON')
1040
+ .action(async (team, ref, opts) => {
1041
+ const mgr = mkManager();
1042
+ if (!team) {
1043
+ const { names } = await loadTeamRows(mgr);
1044
+ requireDestructiveArg({
1045
+ argName: 'team',
1046
+ command: 'agents teams stop',
1047
+ itemNoun: 'team',
1048
+ available: names,
1049
+ emptyHint: "You don't have any teams yet.",
1050
+ });
1051
+ }
1052
+ if (!ref) {
1053
+ const roster = await mgr.listByTask(team);
1054
+ const running = roster.filter((a) => a.status === 'running');
1055
+ requireDestructiveArg({
1056
+ argName: 'teammate',
1057
+ command: `agents teams stop ${team}`,
1058
+ itemNoun: 'teammate',
1059
+ available: running.map((a) => a.name || shortId(a.agentId)),
1060
+ emptyHint: `Team ${team} has no running teammates.`,
1061
+ });
1062
+ }
1063
+ const lookup = await mgr.resolveAgentIdInTask(team, ref);
1064
+ if (lookup.kind === 'none') {
1065
+ die(`No teammate matching '${ref}' in team ${team}`, 2);
1066
+ }
1067
+ if (lookup.kind === 'ambiguous') {
1068
+ const shorts = lookup.matches.map(shortId).join(', ');
1069
+ die(`'${ref}' matches multiple teammates: ${shorts}. Use more characters or a name.`, 2);
1070
+ }
1071
+ const agentId = lookup.agentId;
1072
+ const agent = await mgr.get(agentId);
1073
+ const display = agent?.name || shortId(agentId);
1074
+ const stopRes = await handleStop(mgr, team, agentId);
1075
+ if ('error' in stopRes)
1076
+ die(stopRes.error);
1077
+ // Clean up worktree if this teammate had one
1078
+ let worktreeKept = false;
1079
+ if (agent?.worktreeName && agent?.worktreePath) {
1080
+ try {
1081
+ const dirty = await hasUncommittedChanges(agent.worktreePath);
1082
+ if (dirty) {
1083
+ worktreeKept = true;
1084
+ }
1085
+ else {
1086
+ const baseCwd = process.cwd();
1087
+ await removeWorktree(baseCwd, agent.worktreeName);
1088
+ }
1089
+ }
1090
+ catch {
1091
+ // best-effort cleanup
1092
+ }
1093
+ }
1094
+ if (isJsonMode(opts)) {
1095
+ console.log(JSON.stringify({
1096
+ team,
1097
+ agent_id: agentId,
1098
+ name: agent?.name ?? null,
1099
+ stopped: stopRes.stopped.length > 0,
1100
+ worktree_kept: worktreeKept,
1101
+ }, null, 2));
1102
+ return;
1103
+ }
1104
+ if (stopRes.stopped.length) {
1105
+ console.log(chalk.green(`Stopped ${chalk.cyan(display)} in team ${chalk.cyan(team)}.`));
1106
+ }
1107
+ else if (stopRes.already_stopped.length) {
1108
+ console.log(chalk.gray(`${display} was already stopped.`));
1109
+ }
1110
+ if (worktreeKept && agent?.worktreeName) {
1111
+ console.log(chalk.yellow(`Worktree '${agent.worktreeName}' has uncommitted changes. Keeping it at: ${agent.worktreePath}`));
1112
+ }
1113
+ });
991
1114
  // remove
992
1115
  teams
993
1116
  .command('remove [team] [teammate]')
994
1117
  .alias('rm')
995
- .description("Remove a teammate from the team. Stops them cleanly if still working. Accepts name, UUID, or UUID prefix.")
1118
+ .description("Remove a stopped teammate's logs and metadata. Use 'stop' first to end a running teammate.")
996
1119
  .option('--keep-logs', 'Keep their log files on disk (default: delete them)')
997
1120
  .option('--json', 'Output machine-readable JSON')
998
1121
  .action(async (team, ref, opts) => {
@@ -1009,12 +1132,13 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
1009
1132
  }
1010
1133
  if (!ref) {
1011
1134
  const roster = await mgr.listByTask(team);
1135
+ const stopped = roster.filter((a) => a.status !== 'running' && a.status !== 'pending');
1012
1136
  requireDestructiveArg({
1013
1137
  argName: 'teammate',
1014
1138
  command: `agents teams remove ${team}`,
1015
- itemNoun: 'teammate',
1016
- available: roster.map((a) => a.name || shortId(a.agentId)),
1017
- emptyHint: `Team ${team} has no teammates.`,
1139
+ itemNoun: 'stopped teammate',
1140
+ available: stopped.map((a) => a.name || shortId(a.agentId)),
1141
+ emptyHint: `Team ${team} has no stopped teammates. Use 'agents teams stop' first.`,
1018
1142
  });
1019
1143
  }
1020
1144
  const lookup = await mgr.resolveAgentIdInTask(team, ref);
@@ -1026,13 +1150,13 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
1026
1150
  die(`'${ref}' matches multiple teammates: ${shorts}. Use more characters or a name.`, 2);
1027
1151
  }
1028
1152
  const agentId = lookup.agentId;
1029
- // Look up the display handle (name if they had one) before we tear down.
1030
1153
  const agent = await mgr.get(agentId);
1031
1154
  const display = agent?.name || shortId(agentId);
1032
- const stopRes = await handleStop(mgr, team, agentId);
1033
- if ('error' in stopRes)
1034
- die(stopRes.error);
1035
- if (!opts.keepLogs && stopRes.not_found.length === 0) {
1155
+ // Require agent to be stopped first
1156
+ if (agent?.status === 'running' || agent?.status === 'pending') {
1157
+ die(`Teammate '${display}' is still ${agent.status}. Run 'agents teams stop ${team} ${display}' first.`);
1158
+ }
1159
+ if (!opts.keepLogs) {
1036
1160
  try {
1037
1161
  const dir = path.join(await getAgentsDir(), agentId);
1038
1162
  await fs.rm(dir, { recursive: true, force: true });
@@ -1042,15 +1166,10 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
1042
1166
  }
1043
1167
  }
1044
1168
  if (isJsonMode(opts)) {
1045
- console.log(JSON.stringify({ team, agent_id: agentId, name: agent?.name ?? null, result: stopRes }, null, 2));
1169
+ console.log(JSON.stringify({ team, agent_id: agentId, name: agent?.name ?? null, removed: true }, null, 2));
1046
1170
  return;
1047
1171
  }
1048
- if (stopRes.stopped.length) {
1049
- console.log(chalk.green(`${display} has left team ${chalk.cyan(team)} (was working, now stopped).`));
1050
- }
1051
- else {
1052
- console.log(chalk.green(`${display} has left team ${chalk.cyan(team)}.`));
1053
- }
1172
+ console.log(chalk.green(`Removed ${chalk.cyan(display)} from team ${chalk.cyan(team)}.`));
1054
1173
  });
1055
1174
  // disband
1056
1175
  teams
@@ -1075,6 +1194,24 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
1075
1194
  if ('error' in stopRes)
1076
1195
  die(stopRes.error);
1077
1196
  const status = await handleStatus(mgr, team, 'all');
1197
+ // Clean up worktrees for all teammates
1198
+ const baseCwd = process.cwd();
1199
+ const keptWorktrees = [];
1200
+ for (const a of status.agents) {
1201
+ const agent = await mgr.get(a.agent_id);
1202
+ if (agent?.worktreeName && agent?.worktreePath) {
1203
+ try {
1204
+ const dirty = await hasUncommittedChanges(agent.worktreePath);
1205
+ if (dirty) {
1206
+ keptWorktrees.push(agent.worktreeName);
1207
+ }
1208
+ else {
1209
+ await removeWorktree(baseCwd, agent.worktreeName);
1210
+ }
1211
+ }
1212
+ catch { /* best-effort */ }
1213
+ }
1214
+ }
1078
1215
  const removedIds = [];
1079
1216
  if (!opts.keepLogs) {
1080
1217
  const base = await getAgentsDir();
@@ -1099,6 +1236,9 @@ Name teammates with --name alice to refer to them as 'alice' instead of a UUID.
1099
1236
  console.log(chalk.gray(` Stopped ${stopRes.stopped.length} working teammate(s).`));
1100
1237
  if (removedIds.length)
1101
1238
  console.log(chalk.gray(` Cleared ${removedIds.length} teammate log(s).`));
1239
+ if (keptWorktrees.length) {
1240
+ console.log(chalk.yellow(` Kept ${keptWorktrees.length} worktree(s) with uncommitted changes: ${keptWorktrees.join(', ')}`));
1241
+ }
1102
1242
  });
1103
1243
  // logs
1104
1244
  teams
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `agents trash` — list and restore soft-deleted version directories.
3
+ *
4
+ * `removeVersion` moves a version dir to ~/.agents-system/trash/versions/<agent>/<version>/<timestamp>/
5
+ * instead of hard-deleting. These commands let the user inspect what's there
6
+ * and put a soft-deleted version back. The trash never auto-expires; only
7
+ * `rm -rf ~/.agents-system/trash/` removes bytes from disk.
8
+ */
9
+ import type { Command } from 'commander';
10
+ export declare function registerTrashCommands(program: Command): void;
@@ -0,0 +1,187 @@
1
+ import chalk from 'chalk';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { resolveAgentName, agentLabel } from '../lib/agents.js';
5
+ import { getTrashVersionsDir } from '../lib/state.js';
6
+ import { getVersionDir } from '../lib/versions.js';
7
+ function listTrashEntries(filterAgent) {
8
+ const root = getTrashVersionsDir();
9
+ if (!fs.existsSync(root))
10
+ return [];
11
+ const out = [];
12
+ let agentDirs;
13
+ try {
14
+ agentDirs = fs.readdirSync(root, { withFileTypes: true });
15
+ }
16
+ catch {
17
+ return [];
18
+ }
19
+ for (const agentEntry of agentDirs) {
20
+ if (!agentEntry.isDirectory())
21
+ continue;
22
+ const agent = resolveAgentName(agentEntry.name);
23
+ if (!agent)
24
+ continue;
25
+ if (filterAgent && agent !== filterAgent)
26
+ continue;
27
+ const agentDir = path.join(root, agentEntry.name);
28
+ let versionDirs;
29
+ try {
30
+ versionDirs = fs.readdirSync(agentDir, { withFileTypes: true });
31
+ }
32
+ catch {
33
+ continue;
34
+ }
35
+ for (const ver of versionDirs) {
36
+ if (!ver.isDirectory())
37
+ continue;
38
+ const versionDir = path.join(agentDir, ver.name);
39
+ let stampDirs;
40
+ try {
41
+ stampDirs = fs.readdirSync(versionDir, { withFileTypes: true });
42
+ }
43
+ catch {
44
+ continue;
45
+ }
46
+ for (const stamp of stampDirs) {
47
+ if (!stamp.isDirectory())
48
+ continue;
49
+ out.push({
50
+ agent,
51
+ version: ver.name,
52
+ stamp: stamp.name,
53
+ trashPath: path.join(versionDir, stamp.name),
54
+ });
55
+ }
56
+ }
57
+ }
58
+ return out.sort((a, b) => a.stamp.localeCompare(b.stamp)).reverse();
59
+ }
60
+ function pickLatest(entries, agent, version) {
61
+ const matches = entries.filter((e) => e.agent === agent && e.version === version);
62
+ if (matches.length === 0)
63
+ return null;
64
+ return matches[0];
65
+ }
66
+ function parseAgentVersion(target) {
67
+ const at = target.indexOf('@');
68
+ if (at < 1 || at === target.length - 1)
69
+ return null;
70
+ const agent = resolveAgentName(target.slice(0, at));
71
+ if (!agent)
72
+ return null;
73
+ return { agent, version: target.slice(at + 1) };
74
+ }
75
+ function dirSizeBytes(dir) {
76
+ let total = 0;
77
+ const stack = [dir];
78
+ while (stack.length > 0) {
79
+ const cur = stack.pop();
80
+ let stat;
81
+ try {
82
+ stat = fs.lstatSync(cur);
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ if (stat.isDirectory()) {
88
+ let entries;
89
+ try {
90
+ entries = fs.readdirSync(cur);
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ for (const e of entries)
96
+ stack.push(path.join(cur, e));
97
+ }
98
+ else {
99
+ total += stat.size;
100
+ }
101
+ }
102
+ return total;
103
+ }
104
+ function humanSize(bytes) {
105
+ if (bytes < 1024)
106
+ return `${bytes} B`;
107
+ if (bytes < 1024 * 1024)
108
+ return `${(bytes / 1024).toFixed(0)} KB`;
109
+ if (bytes < 1024 * 1024 * 1024)
110
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
111
+ return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
112
+ }
113
+ export function registerTrashCommands(program) {
114
+ const trash = program
115
+ .command('trash')
116
+ .description('Inspect and restore soft-deleted agent version directories');
117
+ trash
118
+ .command('list [agent]')
119
+ .description('List soft-deleted version directories (optionally filtered to one agent)')
120
+ .action((agentArg) => {
121
+ let filter;
122
+ if (agentArg) {
123
+ const a = resolveAgentName(agentArg);
124
+ if (!a) {
125
+ console.error(chalk.red(`Unknown agent: ${agentArg}`));
126
+ process.exit(1);
127
+ }
128
+ filter = a;
129
+ }
130
+ const entries = listTrashEntries(filter);
131
+ if (entries.length === 0) {
132
+ console.log(chalk.gray('Trash is empty.'));
133
+ return;
134
+ }
135
+ console.log(chalk.bold(`Soft-deleted versions (${entries.length})`));
136
+ for (const e of entries) {
137
+ const size = humanSize(dirSizeBytes(e.trashPath));
138
+ console.log(` ${agentLabel(e.agent)}@${e.version} ` +
139
+ chalk.gray(`${e.stamp} ${size} ${e.trashPath}`));
140
+ }
141
+ console.log();
142
+ console.log(chalk.gray('Restore with: agents trash restore <agent>@<version>'));
143
+ });
144
+ trash
145
+ .command('restore <target>')
146
+ .description('Restore a soft-deleted version (e.g. "claude@2.1.110") back to ~/.agents-system/versions/')
147
+ .action((target) => {
148
+ const parsed = parseAgentVersion(target);
149
+ if (!parsed) {
150
+ console.error(chalk.red(`Expected <agent>@<version>, got: ${target}`));
151
+ process.exit(1);
152
+ }
153
+ const { agent, version } = parsed;
154
+ const entries = listTrashEntries(agent);
155
+ const entry = pickLatest(entries, agent, version);
156
+ if (!entry) {
157
+ console.error(chalk.red(`No trashed copy found for ${agent}@${version}`));
158
+ console.error(chalk.gray('Run `agents trash list` to see what exists.'));
159
+ process.exit(1);
160
+ }
161
+ const dest = getVersionDir(agent, version);
162
+ if (fs.existsSync(dest)) {
163
+ console.error(chalk.red(`Cannot restore: ${dest} already exists.`));
164
+ console.error(chalk.gray('Move or remove the existing dir first, then re-run restore.'));
165
+ process.exit(1);
166
+ }
167
+ try {
168
+ fs.mkdirSync(path.dirname(dest), { recursive: true, mode: 0o700 });
169
+ fs.renameSync(entry.trashPath, dest);
170
+ }
171
+ catch (err) {
172
+ console.error(chalk.red(`Restore failed: ${err.message}`));
173
+ process.exit(1);
174
+ }
175
+ // Best-effort cleanup of empty stamp/version parents in trash.
176
+ try {
177
+ const verDir = path.dirname(entry.trashPath);
178
+ if (fs.readdirSync(verDir).length === 0)
179
+ fs.rmdirSync(verDir);
180
+ const agentDir = path.dirname(verDir);
181
+ if (fs.readdirSync(agentDir).length === 0)
182
+ fs.rmdirSync(agentDir);
183
+ }
184
+ catch { /* best-effort */ }
185
+ console.log(chalk.green(`Restored ${agentLabel(agent)}@${version} to ${dest}`));
186
+ });
187
+ }
@@ -6,12 +6,12 @@ import * as yaml from 'yaml';
6
6
  import { AGENTS, ALL_AGENT_IDS, getAllCliStates, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
7
7
  import { formatUsageSection, formatUsageSummary, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
8
8
  import { readManifest } from '../lib/manifest.js';
9
- import { listInstalledVersions, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, } from '../lib/versions.js';
9
+ import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, resolveVersionAlias, getAvailableResources, getActuallySyncedResources, getNewResources, hasNewResources, promptNewResourceSelection, syncResourcesToVersion, removeVersion, } from '../lib/versions.js';
10
10
  import { getShimsDir, isShimsInPath, ensureVersionedAliasCurrent, removeShim, } from '../lib/shims.js';
11
11
  import { getAgentResources } from '../lib/resources.js';
12
12
  import { getAgentsDir, getUserAgentsDir, getPromptcutsPath } from '../lib/state.js';
13
13
  import { isGitRepo, getGitSyncStatus } from '../lib/git.js';
14
- import { getCentralMemoryFileName } from '../lib/memory.js';
14
+ import { getCentralRulesFileName } from '../lib/rules/rules.js';
15
15
  import { getConfiguredRunStrategy } from '../lib/rotate.js';
16
16
  import { confirm } from '@inquirer/prompts';
17
17
  import { formatPath, isInteractiveTerminal, isPromptCancelled } from './utils.js';
@@ -426,7 +426,7 @@ async function showAgentResources(agentId, requestedVersion) {
426
426
  }
427
427
  else {
428
428
  // Rules files: map agent-specific name (CLAUDE.md) back to canonical (AGENTS.md)
429
- const centralName = getCentralMemoryFileName(agentId);
429
+ const centralName = getCentralRulesFileName(agentId);
430
430
  relativePath = `rules/${centralName}`;
431
431
  }
432
432
  const matchesPath = (f) => f === relativePath || f.startsWith(relativePath + '/');
@@ -653,14 +653,21 @@ async function collectAgentsJson(filterAgentId) {
653
653
  return out;
654
654
  }
655
655
  async function buildAgentPrunePlan(agentId) {
656
- const entries = await Promise.all(listInstalledVersions(agentId).map(async (version) => {
656
+ const dirInfos = listInstalledVersionDirs(agentId);
657
+ const entries = await Promise.all(dirInfos.map(async ({ version, hasBinary }) => {
657
658
  const home = getVersionHomePath(agentId, version);
658
659
  const info = await getAccountInfo(agentId, home);
659
- return { version, info };
660
+ return { version, info, hasBinary };
660
661
  }));
661
662
  const globalDefault = getGlobalDefault(agentId);
663
+ const toPrune = [];
664
+ const skippedDefaults = [];
665
+ // Duplicate-account detection runs only over installs that actually have a
666
+ // working binary — those are the things that compete for "the live install
667
+ // for this account."
668
+ const installed = entries.filter((e) => e.hasBinary);
662
669
  const byEmail = new Map();
663
- for (const e of entries) {
670
+ for (const e of installed) {
664
671
  if (!e.info.email)
665
672
  continue;
666
673
  const key = e.info.email.toLowerCase();
@@ -668,8 +675,6 @@ async function buildAgentPrunePlan(agentId) {
668
675
  list.push(e);
669
676
  byEmail.set(key, list);
670
677
  }
671
- const toPrune = [];
672
- const skippedDefaults = [];
673
678
  for (const [, group] of byEmail) {
674
679
  if (group.length < 2)
675
680
  continue;
@@ -682,6 +687,7 @@ async function buildAgentPrunePlan(agentId) {
682
687
  email: older.info.email,
683
688
  keeper,
684
689
  isDefault: older.version === globalDefault,
690
+ reason: 'duplicate',
685
691
  };
686
692
  if (plan.isDefault)
687
693
  skippedDefaults.push(plan);
@@ -689,6 +695,23 @@ async function buildAgentPrunePlan(agentId) {
689
695
  toPrune.push(plan);
690
696
  }
691
697
  }
698
+ // Home-only leftovers: dirs without a binary. These are residue from a
699
+ // prior removeVersion before the soft-delete migration, plus any hand-edited
700
+ // installs. Surface them so the user can move them to trash.
701
+ for (const e of entries) {
702
+ if (e.hasBinary)
703
+ continue;
704
+ if (e.version === globalDefault)
705
+ continue; // never auto-suggest the default
706
+ toPrune.push({
707
+ agentId,
708
+ version: e.version,
709
+ email: e.info.email || '',
710
+ keeper: '',
711
+ isDefault: false,
712
+ reason: 'home-leftover',
713
+ });
714
+ }
692
715
  return { agentId, toPrune, skippedDefaults };
693
716
  }
694
717
  async function executePrunePlan(plan) {
@@ -720,11 +743,17 @@ function printPrunePlan(plan, isFirst) {
720
743
  }
721
744
  if (plan.toPrune.length === 0)
722
745
  return;
723
- const heading = isFirst ? `Will prune ${agentLabel(plan.agentId)}:` : `Also found duplicates for ${agentLabel(plan.agentId)}:`;
746
+ const heading = isFirst ? `Will move to trash for ${agentLabel(plan.agentId)}:` : `Also found candidates for ${agentLabel(plan.agentId)}:`;
724
747
  console.log(chalk.bold(heading));
725
748
  for (const p of plan.toPrune) {
726
- console.log(` ${agentLabel(p.agentId)}@${p.version} ${chalk.cyan(p.email)} ` +
727
- chalk.gray(`— keeping ${p.agentId}@${p.keeper}`));
749
+ if (p.reason === 'duplicate') {
750
+ console.log(` ${agentLabel(p.agentId)}@${p.version} ${chalk.cyan(p.email)} ` +
751
+ chalk.gray(`— duplicate, keeping ${p.agentId}@${p.keeper}`));
752
+ }
753
+ else {
754
+ console.log(` ${agentLabel(p.agentId)}@${p.version} ` +
755
+ chalk.gray(`— home-only leftover (no binary; transcripts preserved in trash)`));
756
+ }
728
757
  }
729
758
  console.log();
730
759
  }
@@ -746,7 +775,7 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
746
775
  spinner.stop();
747
776
  const actionable = plans.filter((p) => p.toPrune.length > 0 || p.skippedDefaults.length > 0);
748
777
  if (actionable.length === 0) {
749
- console.log(chalk.gray('Nothing to prune — no older versions share an account with a newer version.'));
778
+ console.log(chalk.gray('Nothing to prune — no duplicate-account installs and no home-only leftovers.'));
750
779
  return;
751
780
  }
752
781
  const totalCandidates = actionable.reduce((n, plan) => n + plan.toPrune.length, 0);
@@ -844,7 +873,11 @@ export async function viewAction(agentArg, options) {
844
873
  console.log(chalk.red(formatAgentError(agentName)));
845
874
  process.exit(1);
846
875
  }
847
- const requestedVersion = resolveVersionAlias(agentId, parts[1]) ?? null;
876
+ // Keep 'default' as-is since showAgentResources handles it; resolveVersionAlias
877
+ // returns undefined for 'default' which would skip the detailed view.
878
+ const requestedVersion = parts[1] === 'default'
879
+ ? 'default'
880
+ : (resolveVersionAlias(agentId, parts[1]) ?? null);
848
881
  if (prune) {
849
882
  if (requestedVersion) {
850
883
  console.log(chalk.red('--prune does not take a @version suffix.'));