@phnx-labs/agents-cli 1.22.78 → 1.22.80

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +28 -1
  3. package/dist/bootstrap.js +40 -12
  4. package/dist/commands/accounts.d.ts +22 -0
  5. package/dist/commands/accounts.js +158 -35
  6. package/dist/commands/config.js +37 -0
  7. package/dist/commands/exec.js +21 -10
  8. package/dist/commands/update.js +169 -18
  9. package/dist/commands/versions.d.ts +11 -0
  10. package/dist/commands/versions.js +30 -4
  11. package/dist/commands/view.d.ts +69 -3
  12. package/dist/commands/view.js +235 -75
  13. package/dist/index.js +35 -2
  14. package/dist/lib/account-catalog.d.ts +97 -1
  15. package/dist/lib/account-catalog.js +134 -6
  16. package/dist/lib/account-registry.d.ts +43 -0
  17. package/dist/lib/account-registry.js +97 -2
  18. package/dist/lib/accounting/rotate.d.ts +2 -2
  19. package/dist/lib/accounting/rotate.js +5 -5
  20. package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
  21. package/dist/lib/accounts/auth-operation-lock.js +55 -0
  22. package/dist/lib/accounts/connect.d.ts +170 -0
  23. package/dist/lib/accounts/connect.js +383 -0
  24. package/dist/lib/capabilities.js +2 -0
  25. package/dist/lib/commands.js +2 -0
  26. package/dist/lib/config-keys.d.ts +11 -2
  27. package/dist/lib/config-keys.js +21 -1
  28. package/dist/lib/daemon/daemon.js +5 -0
  29. package/dist/lib/daemon/harness-update-service.d.ts +110 -0
  30. package/dist/lib/daemon/harness-update-service.js +216 -0
  31. package/dist/lib/daemon-services.d.ts +1 -1
  32. package/dist/lib/daemon-services.js +5 -0
  33. package/dist/lib/device-config.d.ts +0 -1
  34. package/dist/lib/device-config.js +50 -5
  35. package/dist/lib/exec.js +26 -2
  36. package/dist/lib/fs-atomic.d.ts +2 -0
  37. package/dist/lib/fs-atomic.js +2 -0
  38. package/dist/lib/hooks/install.js +7 -2
  39. package/dist/lib/installations/active-check.d.ts +48 -0
  40. package/dist/lib/installations/active-check.js +84 -0
  41. package/dist/lib/installations/index.d.ts +5 -1
  42. package/dist/lib/installations/index.js +4 -0
  43. package/dist/lib/installations/installation-lock.d.ts +6 -0
  44. package/dist/lib/installations/installation-lock.js +29 -0
  45. package/dist/lib/installations/launch-gate.d.ts +69 -0
  46. package/dist/lib/installations/launch-gate.js +133 -0
  47. package/dist/lib/installations/native-command.d.ts +5 -0
  48. package/dist/lib/installations/native-command.js +52 -0
  49. package/dist/lib/installations/shims.d.ts +8 -2
  50. package/dist/lib/installations/shims.js +105 -2
  51. package/dist/lib/installations/store.d.ts +5 -1
  52. package/dist/lib/installations/store.js +24 -3
  53. package/dist/lib/installations/strategies.js +55 -35
  54. package/dist/lib/installations/types.d.ts +17 -0
  55. package/dist/lib/installations/update-cancellation.d.ts +82 -0
  56. package/dist/lib/installations/update-cancellation.js +122 -0
  57. package/dist/lib/installations/update-policy.d.ts +69 -0
  58. package/dist/lib/installations/update-policy.js +114 -0
  59. package/dist/lib/installations/update-runtime.d.ts +118 -0
  60. package/dist/lib/installations/update-runtime.js +321 -0
  61. package/dist/lib/installations/update.d.ts +25 -0
  62. package/dist/lib/installations/update.js +141 -2
  63. package/dist/lib/installations/versions.d.ts +1 -0
  64. package/dist/lib/installations/versions.js +166 -131
  65. package/dist/lib/platform/process.d.ts +3 -1
  66. package/dist/lib/platform/process.js +2 -2
  67. package/dist/lib/staleness/detectors/commands.d.ts +1 -2
  68. package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
  69. package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
  70. package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
  71. package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
  72. package/dist/lib/staleness/detectors/rules.d.ts +1 -2
  73. package/dist/lib/staleness/detectors/skills.d.ts +1 -2
  74. package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
  75. package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
  76. package/dist/lib/staleness/writers/commands.d.ts +1 -2
  77. package/dist/lib/staleness/writers/hooks.d.ts +1 -2
  78. package/dist/lib/staleness/writers/mcp.d.ts +1 -2
  79. package/dist/lib/staleness/writers/permissions.d.ts +1 -12
  80. package/dist/lib/staleness/writers/plugins.d.ts +1 -6
  81. package/dist/lib/staleness/writers/rules.d.ts +1 -2
  82. package/dist/lib/staleness/writers/skills.d.ts +1 -2
  83. package/dist/lib/staleness/writers/subagents.d.ts +1 -2
  84. package/dist/lib/staleness/writers/workflows.d.ts +1 -8
  85. package/dist/lib/state.d.ts +3 -1
  86. package/dist/lib/state.js +38 -13
  87. package/dist/lib/types.d.ts +26 -1
  88. package/dist/lib/types.js +5 -0
  89. package/dist/lib/view-types.d.ts +6 -0
  90. package/package.json +1 -1
@@ -2218,13 +2218,21 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2218
2218
  process.exit(1);
2219
2219
  }
2220
2220
  const { resolveAccountVersion } = await import('../lib/accounting/rotate.js');
2221
- accountConfigVersion = await resolveAccountVersion(agent, spawnAccount.identityKey) ?? undefined;
2221
+ const { nativeAccountHome } = await import('../lib/account-registry.js');
2222
+ const { readMeta } = await import('../lib/state.js');
2223
+ accountConfigVersion = await resolveAccountVersion(agent, spawnAccount.identityKey, nativeAccountHome(spawnAccount.id, readMeta())) ?? undefined;
2222
2224
  if (!accountConfigVersion) {
2223
2225
  console.error(chalk.red(`No installed ${spawnAccount.agent} version is signed in as the identity labeled '${spawnAccount.name}'. Sign in as that identity, or label a different account.`));
2224
2226
  process.exit(1);
2225
2227
  }
2228
+ // An account-only run executes the account's own stable installation.
2229
+ // This keeps its model/mode configuration and binary update policy
2230
+ // together. An explicit installation or profile still controls the
2231
+ // executable independently; configVersion continues to select auth.
2232
+ if (!version && !fromProfile)
2233
+ version = accountConfigVersion;
2226
2234
  if (!options.quiet)
2227
- process.stderr.write(chalk.gray(`[agents] account '${spawnAccount.name}' · ${agent} auth from ${accountConfigVersion}\n`));
2235
+ process.stderr.write(chalk.gray(`[agents] account '${spawnAccount.name}' · ${agent}\n`));
2228
2236
  }
2229
2237
  else {
2230
2238
  accountEnv = spawnAccount.env;
@@ -2666,11 +2674,9 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2666
2674
  // Login preflight (advisory, warn + continue). On a local INTERACTIVE
2667
2675
  // launch, probe whether this agent's account has a credential and print a
2668
2676
  // one-line warning if it looks logged out — so you find out BEFORE the TUI
2669
- // opens, not after typing a prompt and getting "/login" back. Uses the same
2670
- // account-global probe as `checkCliSignedIn` / `agents doctor`
2671
- // (getAccountInfo with no home): file-based, no Keychain ACL prompt, and
2672
- // correct for HOME-global credential agents (grok/codex) where a per-version
2673
- // home would false-negative. It can still false-negative for opaque
2677
+ // opens, not after typing a prompt and getting "/login" back. Inspect the
2678
+ // selected account home, not an unrelated global login. This file-based
2679
+ // read makes no Keychain ACL prompt. It can still false-negative for opaque
2674
2680
  // credentials, so this NEVER blocks — it warns and launches anyway. Skipped
2675
2681
  // for --json/--quiet, when a rotation already picked a signed-in account,
2676
2682
  // and via --no-auth-check / AGENTS_NO_AUTH_CHECK=1. (--device/--lease return
@@ -2688,12 +2694,13 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2688
2694
  // `signInLaunch` means the zero-healthy path already reported this exact
2689
2695
  // account as logged out and named the login command, so re-probing here
2690
2696
  // only prints a second, near-identical warning.
2691
- rotated: !!rotationResult || accountPickerRequested || signInLaunch,
2697
+ rotated: !!rotationResult || accountPickerRequested || signInLaunch || spawnAccount?.kind === 'provider',
2692
2698
  });
2693
2699
  if (preflight) {
2694
2700
  try {
2695
2701
  const { getAccountInfo } = await import('../lib/agents.js');
2696
- const info = await getAccountInfo(agent);
2702
+ const authVersion = accountConfigVersion ?? version;
2703
+ const info = await getAccountInfo(agent, authVersion ? getVersionHomePath(agent, authVersion) : undefined);
2697
2704
  // Claude authenticates interactively from a per-version setup-token on a
2698
2705
  // keychain-less worker (the shim's .oauth_token fallback), which the
2699
2706
  // native-credential probe above can't see — so don't warn "logged out" when
@@ -2706,7 +2713,11 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2706
2713
  authedViaSetupToken = resolveClaudeSetupToken(getVersionHomePath('claude', version)) !== null;
2707
2714
  }
2708
2715
  if (!info.signedIn && !authedViaSetupToken) {
2709
- process.stderr.write(chalk.yellow(`⚠ ${agent} looks logged out — log in with: ${loginHint(agent)}. Launching anyway...\n`));
2716
+ const { connectSupported } = await import('../lib/accounts/connect.js');
2717
+ const hint = connectSupported(agent)
2718
+ ? `agents accounts connect ${agent}${spawnAccount?.kind === 'native' ? ` ${spawnAccount.name}` : ''}`
2719
+ : loginHint(agent);
2720
+ process.stderr.write(chalk.yellow(`${agent} looks logged out — sign in with: ${hint}. Launching anyway...\n`));
2710
2721
  }
2711
2722
  }
2712
2723
  catch {
@@ -1,7 +1,7 @@
1
1
  import chalk from 'chalk';
2
- import { formatAgentError, resolveAgentName } from '../lib/agents.js';
2
+ import { AGENTS, formatAgentError, resolveAgentName } from '../lib/agents.js';
3
3
  import { setHelpSections } from '../lib/help.js';
4
- import { describeInstallation, listInstallations, resolveInstallation, selectUpdateStrategy, supportsPinnedUpdate, updateInstallation, } from '../lib/installations/index.js';
4
+ import { describeInstallation, effectiveUpdatePolicy, listInstallations, planAutoUpdates, resolveInstallation, runAutoUpdatePass, selectUpdateStrategy, supportsPinnedUpdate, updateInstallation, } from '../lib/installations/index.js';
5
5
  /**
6
6
  * Split `<agent>[@<selector>]`. The selector names an INSTALLATION — its frozen
7
7
  * label, or the release it currently carries — never a release to install; that
@@ -38,11 +38,16 @@ function printOutcome(outcome, json) {
38
38
  fromRelease: outcome.fromRelease,
39
39
  toRelease: outcome.toRelease,
40
40
  unchanged: outcome.unchanged,
41
+ deferred: outcome.deferred,
41
42
  alsoUpdated: outcome.alsoUpdated.map(serialize),
42
43
  }, null, 2));
43
44
  return;
44
45
  }
45
46
  const name = `${outcome.installation.agent}@${outcome.installation.label}`;
47
+ if (outcome.deferred) {
48
+ console.log(chalk.yellow(`${name}: ${outcome.deferred} Still on release ${outcome.installation.releaseVersion}.`));
49
+ return;
50
+ }
46
51
  if (outcome.unchanged) {
47
52
  console.log(chalk.gray(`${name} is already on release ${outcome.toRelease}.`));
48
53
  return;
@@ -78,37 +83,174 @@ function fail(err) {
78
83
  console.error(chalk.red(err.message));
79
84
  process.exitCode = 1;
80
85
  }
86
+ function serializePlanEntry(entry) {
87
+ const wouldUpdate = entry.eligible && !entry.deferred
88
+ && !!entry.targetRelease && entry.targetRelease !== entry.currentRelease;
89
+ return {
90
+ agent: entry.agent,
91
+ label: entry.installation.label,
92
+ currentRelease: entry.currentRelease,
93
+ targetRelease: entry.targetRelease,
94
+ policy: entry.policy,
95
+ eligible: entry.eligible,
96
+ deferred: entry.deferred,
97
+ wouldUpdate,
98
+ reason: entry.reason,
99
+ };
100
+ }
101
+ /** `--check`'s dry-run output: eligibility/current/target/policy/deferral, machine-readable with `--json`. */
102
+ function printPlan(plan, json) {
103
+ const rows = plan.map(serializePlanEntry);
104
+ if (json) {
105
+ console.log(JSON.stringify(rows, null, 2));
106
+ return;
107
+ }
108
+ if (rows.length === 0) {
109
+ console.log(chalk.gray('No managed installations to check.'));
110
+ return;
111
+ }
112
+ console.log(chalk.bold('Automatic-update plan\n'));
113
+ for (const row of rows) {
114
+ const name = `${row.agent}@${row.label}`;
115
+ const status = row.wouldUpdate
116
+ ? chalk.green(`would update ${row.currentRelease} -> ${row.targetRelease}`)
117
+ : row.deferred
118
+ ? chalk.yellow(`deferred — ${row.reason}`)
119
+ : !row.eligible
120
+ ? chalk.gray(`not eligible — ${row.reason}`)
121
+ : chalk.gray('already current');
122
+ console.log(` ${chalk.cyan(name.padEnd(28))} policy=${row.policy.padEnd(7)} ${status}`);
123
+ }
124
+ }
125
+ /**
126
+ * `--to` also decides the installation's update policy going forward: a
127
+ * concrete release is a manual pin (excluded from the automatic pass until
128
+ * explicitly unpinned), `latest` (the default) unpins it. Returns `null` when
129
+ * `--to` was not passed at all, meaning: don't touch the stored policy.
130
+ */
131
+ function policyForTo(to) {
132
+ if (to === undefined)
133
+ return null;
134
+ return to === 'latest' ? 'latest' : 'pinned';
135
+ }
136
+ async function updateOne(agent, installation, options) {
137
+ const strategy = selectUpdateStrategy(agent);
138
+ if (!options.json && !strategy.transactional) {
139
+ console.log(chalk.yellow(`${agent} installs one vendor-managed binary, so this update cannot be staged or rolled back; `
140
+ + `a failure leaves whatever its installer wrote.`));
141
+ }
142
+ if (!options.json) {
143
+ console.log(chalk.gray(`Updating ${agent}@${describeInstallation(installation)} via the ${strategy.id} strategy...`));
144
+ }
145
+ const outcome = await updateInstallation(installation, {
146
+ to: options.to,
147
+ updatePolicy: policyForTo(options.to) ?? undefined,
148
+ onProgress: options.json ? undefined : (message) => console.log(chalk.gray(` ${message}`)),
149
+ });
150
+ return outcome;
151
+ }
81
152
  export function registerUpdateCommand(program) {
82
153
  const update = program
83
154
  .command('update [target]')
84
155
  .description('Move a frozen agent installation to a new release, keeping its name and every reference to it')
85
- .option('--to <release>', 'Release to move to: latest (default), oldest, or an exact version')
156
+ .option('--to <release>', 'Release to move to: latest (default, and unpins), oldest, or an exact version (pins)')
86
157
  .option('--json', 'Machine-readable result')
158
+ .option('--check', 'Dry run: report eligibility/current/target/policy/deferral without changing anything')
159
+ .option('--auto', 'Run the same automatic-update pass the daemon runs, across every managed harness (ignores <target>)')
87
160
  .action(async (target, options) => {
88
161
  try {
162
+ if (options.auto || (options.check && !target)) {
163
+ if (options.check) {
164
+ printPlan(await planAutoUpdates(), !!options.json);
165
+ return;
166
+ }
167
+ const result = await runAutoUpdatePass({
168
+ onProgress: options.json ? undefined : (message) => console.log(chalk.gray(` ${message}`)),
169
+ });
170
+ if (options.json) {
171
+ console.log(JSON.stringify({
172
+ plan: result.plan.map(serializePlanEntry),
173
+ outcomes: result.outcomes.map((o) => ({
174
+ agent: o.entry.agent,
175
+ label: o.entry.installation.label,
176
+ outcome: o.outcome && {
177
+ fromRelease: o.outcome.fromRelease,
178
+ toRelease: o.outcome.toRelease,
179
+ unchanged: o.outcome.unchanged,
180
+ deferred: o.outcome.deferred,
181
+ },
182
+ error: o.error,
183
+ })),
184
+ }, null, 2));
185
+ }
186
+ else if (result.outcomes.length === 0) {
187
+ console.log(chalk.gray('Nothing eligible to update this pass.'));
188
+ }
189
+ else {
190
+ for (const o of result.outcomes) {
191
+ const name = `${o.entry.agent}@${o.entry.installation.label}`;
192
+ if (o.error)
193
+ console.error(chalk.red(`${name}: ${o.error}`));
194
+ else if (o.outcome)
195
+ printOutcome(o.outcome, false);
196
+ }
197
+ }
198
+ if (result.outcomes.some((o) => o.error))
199
+ process.exitCode = 1;
200
+ return;
201
+ }
89
202
  if (!target) {
90
- throw new Error('Which agent? Use: agents update <agent>[@<installed-version>]');
203
+ throw new Error('Which agent? Use: agents update <agent>[@<installed-version>], or agents update --auto.');
91
204
  }
92
205
  const { agent, selector } = parseTarget(target);
206
+ if (options.check) {
207
+ const plan = (await planAutoUpdates({ agents: [agent] })).filter((entry) => !selector || entry.installation.label === selector || entry.installation.releaseVersion === selector);
208
+ if (selector && plan.length === 0) {
209
+ throw new Error(`No ${AGENTS[agent].name} installation matches '${selector}'.`);
210
+ }
211
+ printPlan(plan, !!options.json);
212
+ return;
213
+ }
93
214
  if (options.to && options.to !== 'latest' && !supportsPinnedUpdate(agent)) {
94
215
  // Fail loud at the boundary rather than installing the current release
95
216
  // and reporting it as the pin that was asked for.
96
217
  throw new Error(`${agent} is a single self-updating binary with no pinnable releases — drop --to, or pass --to latest.`);
97
218
  }
98
- const installation = await resolveInstallation(agent, selector);
99
- const strategy = selectUpdateStrategy(agent);
100
- if (!options.json && !strategy.transactional) {
101
- console.log(chalk.yellow(`${agent} installs one vendor-managed binary, so this update cannot be staged or rolled back; `
102
- + `a failure leaves whatever its installer wrote.`));
219
+ if (selector) {
220
+ const installation = await resolveInstallation(agent, selector);
221
+ const outcome = await updateOne(agent, installation, options);
222
+ printOutcome(outcome, !!options.json);
223
+ return;
224
+ }
225
+ // Bare `<agent>`, no `@label`: act on every installation of that agent
226
+ // that isn't explicitly pinned — the bulk case. A single installation
227
+ // keeps the old single-target behavior even if it happens to be pinned,
228
+ // since naming the agent with only one installed copy is unambiguous
229
+ // about which one is meant; `--to` still applies to it either way.
230
+ const installations = listInstallations(agent);
231
+ if (installations.length === 0) {
232
+ throw new Error(`No ${AGENTS[agent].name} installations are managed by agents-cli. Install one with: agents add ${agent}@latest`);
233
+ }
234
+ const targets = installations.length === 1
235
+ ? installations
236
+ : installations.filter((i) => effectiveUpdatePolicy(i) !== 'pinned');
237
+ if (installations.length > 1 && targets.length === 0) {
238
+ throw new Error(`Every ${AGENTS[agent].name} installation is pinned to a concrete release. `
239
+ + `Select one explicitly: agents update ${agent}@<label> --to latest`);
103
240
  }
104
- if (!options.json) {
105
- console.log(chalk.gray(`Updating ${agent}@${describeInstallation(installation)} via the ${strategy.id} strategy...`));
241
+ let anyError = false;
242
+ for (const installation of targets) {
243
+ try {
244
+ const outcome = await updateOne(agent, installation, options);
245
+ printOutcome(outcome, !!options.json);
246
+ }
247
+ catch (err) {
248
+ anyError = true;
249
+ fail(err);
250
+ }
106
251
  }
107
- const outcome = await updateInstallation(installation, {
108
- to: options.to,
109
- onProgress: options.json ? undefined : (message) => console.log(chalk.gray(` ${message}`)),
110
- });
111
- printOutcome(outcome, !!options.json);
252
+ if (anyError)
253
+ process.exitCode = 1;
112
254
  }
113
255
  catch (err) {
114
256
  fail(err);
@@ -136,11 +278,20 @@ export function registerUpdateCommand(program) {
136
278
  examples: `agents update list claude
137
279
  agents update claude@2.0.65
138
280
  agents update claude@2.0.65 --to 2.1.220
139
- agents update claude@2.0.65 --json`,
281
+ agents update claude@2.0.65 --to latest
282
+ agents update claude
283
+ agents update --check
284
+ agents update claude --check --json
285
+ agents update --auto`,
140
286
  notes: 'An installation keeps its name for life; only the release inside it moves. That is why a default, a project pin, '
141
287
  + 'a routine version, or a profile that names claude@2.0.65 keeps working after you update it. '
142
288
  + 'The selector matches either the installation name or the release it currently carries — when two installations '
143
289
  + 'share a release, select one by its installation name. '
144
- + 'The new release is fetched and launched before it replaces the working one, so a bad release leaves your agent running.',
290
+ + 'The new release is fetched and launched before it replaces the working one, so a bad release leaves your agent running. '
291
+ + 'A bare `<agent>` (no @label) updates every one of its installations that is not pinned; `--to <release>` pins the '
292
+ + 'installations it touches, `--to latest` unpins them. Pinned and manual/vendor-managed installations are also skipped '
293
+ + 'by the automatic background pass (agents config set updates.auto / updates.<agent>.auto). `--check` reports what '
294
+ + '`--auto` would do without changing anything; `--auto` (no target) runs the exact pass the daemon runs, across every '
295
+ + 'harness.',
145
296
  });
146
297
  }
@@ -7,5 +7,16 @@
7
7
  * switching, resource sync prompts, and project-level version pinning.
8
8
  */
9
9
  import type { Command } from 'commander';
10
+ /** Bare installs reuse an account home; explicit labels keep their expert semantics. */
11
+ export declare function planAccountFirstInstall(input: {
12
+ spec: string;
13
+ supported: boolean;
14
+ isolated: boolean;
15
+ labels: string[];
16
+ defaultLabel: string | null;
17
+ }): {
18
+ existingLabel?: string;
19
+ installationLabel?: string;
20
+ };
10
21
  /** Register `agents add`, `agents prune`, `agents remove`, and `agents use`. */
11
22
  export declare function registerVersionsCommands(program: Command): void;
@@ -15,6 +15,15 @@ import { tryAutoPullSystemRepo } from '../lib/git.js';
15
15
  import { getAgentsDir, getTrashVersionsDir } from '../lib/state.js';
16
16
  import { setHelpSections } from '../lib/help.js';
17
17
  import { updateSessionFilePaths } from '../lib/session/db.js';
18
+ import { connectSupported } from '../lib/accounts/connect.js';
19
+ /** Bare installs reuse an account home; explicit labels keep their expert semantics. */
20
+ export function planAccountFirstInstall(input) {
21
+ if (!input.supported || input.isolated || input.spec.includes('@'))
22
+ return {};
23
+ const existingLabel = input.defaultLabel && input.labels.includes(input.defaultLabel)
24
+ ? input.defaultLabel : input.labels[0];
25
+ return existingLabel ? { existingLabel } : { installationLabel: 'main' };
26
+ }
18
27
  /**
19
28
  * After removeVersion soft-deletes a version dir to trash, rewrite session
20
29
  * file_path entries in the DB so reads still work from the new trash location.
@@ -317,7 +326,7 @@ export function registerVersionsCommands(program) {
317
326
  notes: `
318
327
  - The first version you install becomes the default automatically.
319
328
  - 'add' does NOT change the default if a default already exists. Use 'agents use' to switch.
320
- - Multi-account: each installed version has separate auth, so you can install the same agent twice for two accounts.
329
+ - Bare Claude/Codex installs reuse your existing home. Connect another account with 'agents accounts connect <agent> [name]'.
321
330
  - --isolated installs a self-contained copy: it never sets the default, never creates the bare '<agent>' shim, and never backs up or symlinks your real ~/.<agent>. Run it with 'agents run <agent>@<version>' and remove it with 'agents remove <agent>@<version> --isolated'. Mutually exclusive with --project.
322
331
  `,
323
332
  });
@@ -376,7 +385,19 @@ export function registerVersionsCommands(program) {
376
385
  // Check if already installed (resolve 'latest'/'oldest' against npm first)
377
386
  let alreadyInstalled = false;
378
387
  let installedAsVersion = version;
379
- if (version === 'latest') {
388
+ const accountInstall = planAccountFirstInstall({
389
+ spec, supported: connectSupported(agent), isolated: !!isIsolated,
390
+ labels: listInstalledVersions(agent).filter(label => !isVersionIsolated(agent, label)),
391
+ defaultLabel: getGlobalDefault(agent),
392
+ });
393
+ if (accountInstall.existingLabel) {
394
+ alreadyInstalled = true;
395
+ installedAsVersion = accountInstall.existingLabel;
396
+ }
397
+ else if (accountInstall.installationLabel) {
398
+ // A fresh managed home has a stable label, independent of npm's release.
399
+ }
400
+ else if (version === 'latest') {
380
401
  const latestCheck = await isLatestInstalled(agent);
381
402
  if (latestCheck.installed && latestCheck.version) {
382
403
  alreadyInstalled = true;
@@ -407,7 +428,12 @@ export function registerVersionsCommands(program) {
407
428
  finalizeIsolatedInstall(agent, installedAsVersion);
408
429
  continue;
409
430
  }
410
- console.log(chalk.gray(`${agentLabel(agentConfig.id)}@${installedAsVersion} already installed`));
431
+ console.log(chalk.gray(accountInstall.existingLabel
432
+ ? `${agentLabel(agentConfig.id)} is already installed. Your account home is unchanged.`
433
+ : `${agentLabel(agentConfig.id)}@${installedAsVersion} already installed`));
434
+ if (accountInstall.existingLabel) {
435
+ console.log(chalk.gray(` Accounts: agents view ${agent}. Update now: agents update ${agent}.`));
436
+ }
411
437
  // Ensure shim exists (in case it was deleted or needs updating)
412
438
  createShim(agent);
413
439
  }
@@ -415,7 +441,7 @@ export function registerVersionsCommands(program) {
415
441
  const spinner = ora(`Installing ${agentLabel(agentConfig.id)}@${version}...`).start();
416
442
  const result = await installVersion(agent, version, (msg) => {
417
443
  spinner.text = msg;
418
- });
444
+ }, accountInstall.installationLabel ? { installationLabel: accountInstall.installationLabel } : undefined);
419
445
  if (result.success) {
420
446
  const installedVer = result.installedVersion || version;
421
447
  const installedModel = resolveConfiguredModel(agentConfig.id, installedVer)?.model;
@@ -12,6 +12,7 @@ import type { AccountInfo } from '../lib/agents.js';
12
12
  import type { AgentId } from '../lib/types.js';
13
13
  import type { FormatUsageSummaryOpts, UsageInfo } from '../lib/accounting/usage.js';
14
14
  import { type ConfiguredDeviceRole } from '../lib/device-config.js';
15
+ import { type NativeAccountCatalogRow } from '../lib/account-catalog.js';
15
16
  import type { ResourceSection, ViewJsonAgent } from '../lib/view-types.js';
16
17
  export type { ResourceItemJson, ResourceSection, SyncState, VersionResourcesJson, ViewJsonAgent, ViewJsonVersion } from '../lib/view-types.js';
17
18
  import { type ProfileSummary } from '../lib/profiles.js';
@@ -26,6 +27,8 @@ export declare const accountColumnLabel: typeof accountDisplayLabel;
26
27
  * display label when the identity is unnamed.
27
28
  */
28
29
  export declare function namedAccountColumnLabel(agentId: AgentId, info: AccountInfo | undefined): string;
30
+ /** Human account identity; release labels belong only in installation diagnostics. */
31
+ export declare function nativeAccountViewLabel(row: Pick<NativeAccountCatalogRow, 'name' | 'display'>): string;
29
32
  export interface AccountOrderedVersion {
30
33
  version: string;
31
34
  email: string | null;
@@ -92,6 +95,23 @@ export declare function parseResourceSections(options: {
92
95
  export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSections?: Set<ResourceSection>, opts?: {
93
96
  forceRefresh?: boolean;
94
97
  }): Promise<ViewJsonAgent[]>;
98
+ interface PrunePlanEntry {
99
+ agentId: AgentId;
100
+ version: string;
101
+ email: string;
102
+ keeper: string;
103
+ isDefault: boolean;
104
+ /**
105
+ * 'duplicate' — older version sharing an email with a newer install.
106
+ * 'home-leftover' — home-only dir left over after a previous removeVersion;
107
+ * no binary, but transcripts may still live here.
108
+ */
109
+ reason: 'duplicate' | 'home-leftover';
110
+ }
111
+ interface AgentPrunePlan {
112
+ agentId: AgentId;
113
+ toPrune: PrunePlanEntry[];
114
+ }
95
115
  /**
96
116
  * Identity key for duplicate-install detection. Prefers accountKey — which
97
117
  * encodes account AND org — over the bare email: two installs can share an
@@ -101,10 +121,55 @@ export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSecti
101
121
  * identity key. Null when there is no usable identity.
102
122
  */
103
123
  export declare function pruneGroupKey(info: Pick<AccountInfo, 'accountKey' | 'email'>): string | null;
124
+ /** One installed home, reduced to the fields duplicate detection needs. */
125
+ export interface PruneCandidate {
126
+ /** The version-dir label (opaque slot id). */
127
+ version: string;
128
+ /** The RUNNING release inside the home (installation.releaseVersion), or the label. */
129
+ release: string;
130
+ email: string | null;
131
+ /** Present only once the home's native identity is captured (claude: account+org). */
132
+ accountKey: string | null;
133
+ signedIn: boolean;
134
+ hasBinary: boolean;
135
+ }
136
+ /** A duplicate home to retire, and the keeper it collapses into. */
137
+ export interface DuplicatePruneEntry {
138
+ version: string;
139
+ email: string;
140
+ keeper: string;
141
+ }
142
+ /**
143
+ * PURE duplicate-home detection: given every installed home for one agent, decide
144
+ * which are redundant duplicates of the SAME logical account and which single home
145
+ * to keep per account. Extracted from buildAgentPrunePlan so the keeper/merge rules
146
+ * are unit-tested without touching disk.
147
+ *
148
+ * Two rules, both conservative:
149
+ * - GROUPING. A home with a captured `accountKey` groups by it (account+org, so a
150
+ * personal Max and a Team seat on the same email stay separate). A home with NO
151
+ * accountKey folds into a same-email account ONLY when a sibling home for that
152
+ * email IS identified — that email-only home is an incompletely-captured re-login
153
+ * of the known account, not a distinct seat (PHNX-3887). Otherwise it groups by
154
+ * its bare email and only ever collapses against another equally-bare home.
155
+ * - KEEPER. Within a group the keeper is the home that best represents the account:
156
+ * captured identity first, then a signed-in credential, then the newest RUNNING
157
+ * release, then the highest dir label as a stable tiebreak. This is deliberately
158
+ * NOT "highest dir-name semver" — in the wild that is the freshly re-logged-in
159
+ * duplicate that never captured its identity or usage, and keeping it while
160
+ * trashing the identified home is the exact bug this replaces.
161
+ */
162
+ export declare function planDuplicatePrune(candidates: PruneCandidate[]): DuplicatePruneEntry[];
163
+ export declare function executePrunePlan(plan: AgentPrunePlan): Promise<Array<{
164
+ agent: AgentId;
165
+ version: string;
166
+ }>>;
104
167
  /**
105
- * Prune older installed versions that share an email with a newer installed
106
- * version. Keeps the highest semver per email, skips the global default (with
107
- * a warning so the user can switch first).
168
+ * Consolidate to one home per logical account: retire the redundant duplicate
169
+ * homes an account accumulated, keeping the single home that best represents it
170
+ * (see {@link planDuplicatePrune} for the keeper/merge rules). When the retired
171
+ * duplicate holds the global default, the default is first repointed onto the
172
+ * keeper, so consolidation collapses it instead of leaving it pinned.
108
173
  *
109
174
  * When filterAgentId is set, prunes that agent first, then cascades: after
110
175
  * each agent, offers the next agent with duplicates. User answering "no"
@@ -117,6 +182,7 @@ export declare function pruneDuplicates(filterAgentId: AgentId | undefined, yes:
117
182
  */
118
183
  export declare function viewAction(agentArg?: string, options?: {
119
184
  json?: boolean;
185
+ versions?: boolean;
120
186
  prune?: boolean;
121
187
  yes?: boolean;
122
188
  dryRun?: boolean;