@phnx-labs/agents-cli 1.20.71 → 1.20.72

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.
@@ -8,9 +8,9 @@ import { AGENTS, ALL_AGENT_IDS, accountOrgBadge, getAccountEmail, getAccountInfo
8
8
  import { formatUsageSummary, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, } from '../lib/usage.js';
9
9
  import { viewAction } from './view.js';
10
10
  import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
11
- import { installVersion, removeVersion, listInstalledVersions, isVersionInstalled, isLatestInstalled, isOldestInstalled, getGlobalDefault, setGlobalDefault, getVersionHomePath, getVersionDir, syncResourcesToVersion, parseAgentSpec, promptResourceSelection, promptNewResourceSelection, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, printTrashFooter, } from '../lib/versions.js';
11
+ import { installVersion, removeVersion, listInstalledVersions, isVersionInstalled, isLatestInstalled, isOldestInstalled, getGlobalDefault, setGlobalDefault, markVersionIsolated, isVersionIsolated, getVersionHomePath, getVersionDir, syncResourcesToVersion, parseAgentSpec, promptResourceSelection, promptNewResourceSelection, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, printTrashFooter, } from '../lib/versions.js';
12
12
  import { carryForwardSettings } from '../lib/settings-manifest.js';
13
- import { createShim, createVersionedAlias, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/shims.js';
13
+ import { createShim, createVersionedAlias, supportsIsolatedInstall, CONFIG_ENV_ISOLATED_AGENTS, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/shims.js';
14
14
  import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
15
15
  import { tryAutoPull } from '../lib/git.js';
16
16
  import { getAgentsDir, getTrashVersionsDir } from '../lib/state.js';
@@ -98,8 +98,30 @@ function warnIfShimShadowed(agent) {
98
98
  console.log(chalk.green(` Added shim directory to ${result.location}.`));
99
99
  console.log(chalk.gray(` ${result.reloadHint}`));
100
100
  }
101
+ /**
102
+ * Install an isolated copy of an agent version: a fully self-contained install
103
+ * that never touches the user's existing setup.
104
+ *
105
+ * Unlike a normal install it does NOT set (or offer to set) the global default,
106
+ * does NOT create/replace the bare `<agent>` shim, does NOT back up or symlink
107
+ * the user's real `~/.<agent>`, and does NOT carry over settings or resources.
108
+ * It only creates the versioned alias (so the copy is launchable) and records
109
+ * the isolated marker. Invoke the copy explicitly with `agents run <agent>@<v>`.
110
+ */
111
+ function finalizeIsolatedInstall(agent, version) {
112
+ const agentConfig = AGENTS[agent];
113
+ const label = agentLabel(agentConfig.id);
114
+ createVersionedAlias(agent, version);
115
+ markVersionIsolated(agent, version);
116
+ console.log(chalk.green(` Installed ${label}@${version} as an isolated copy.`));
117
+ console.log(chalk.gray(` Your existing ${agentConfig.configDir} and default ${label} are untouched.`));
118
+ console.log(chalk.gray(` Run it: agents run ${agent}@${version} "your prompt"`));
119
+ console.log(chalk.gray(` It has its own config and login — sign in the first time you run it.`));
120
+ console.log(chalk.gray(` Remove it: agents remove ${agent}@${version} --isolated`));
121
+ }
101
122
  async function versionPruneAction(specs, options, commandName) {
102
123
  const isProject = options.project;
124
+ const isIsolated = options.isolated;
103
125
  const moved = [];
104
126
  for (const spec of specs) {
105
127
  const parsed = parseAgentSpec(spec);
@@ -110,6 +132,12 @@ async function versionPruneAction(specs, options, commandName) {
110
132
  }
111
133
  const { agent, version } = parsed;
112
134
  const agentConfig = AGENTS[agent];
135
+ // --isolated only ever applies to agents that can BE installed isolated;
136
+ // for the rest no isolated version can exist, so say so plainly.
137
+ if (isIsolated && !supportsIsolatedInstall(agent)) {
138
+ console.log(chalk.gray(`${agentLabel(agentConfig.id)} has no isolated installs (--isolated is not supported for it).`));
139
+ continue;
140
+ }
113
141
  // Script-installed agents (droid, grok) can have a *literal* `latest`
114
142
  // version dir on disk when the post-install version probe failed. An
115
143
  // explicit `<agent>@latest` should remove that dir directly rather than
@@ -117,9 +145,14 @@ async function versionPruneAction(specs, options, commandName) {
117
145
  // so treat an installed literal `latest` as a concrete pinned version.
118
146
  const isLiteralLatestInstalled = version === 'latest' && spec.includes('@') && isVersionInstalled(agent, 'latest');
119
147
  if (!isLiteralLatestInstalled && (version === 'latest' || version === 'oldest' || !spec.includes('@'))) {
120
- const versions = listInstalledVersions(agent);
148
+ // With --isolated, only isolated installs are eligible for the picker, so
149
+ // a normal/default install can never be selected here by accident.
150
+ const versions = listInstalledVersions(agent)
151
+ .filter((v) => !isIsolated || isVersionIsolated(agent, v));
121
152
  if (versions.length === 0) {
122
- console.log(chalk.gray(`No versions of ${agentLabel(agentConfig.id)} installed`));
153
+ console.log(chalk.gray(isIsolated
154
+ ? `No isolated ${agentLabel(agentConfig.id)} installs`
155
+ : `No versions of ${agentLabel(agentConfig.id)} installed`));
123
156
  continue;
124
157
  }
125
158
  if (!isInteractiveTerminal()) {
@@ -157,6 +190,9 @@ async function versionPruneAction(specs, options, commandName) {
157
190
  }
158
191
  fixSessionFilePaths(agent, v, versionDir);
159
192
  console.log(chalk.green(`Moved ${agentLabel(agentConfig.id)}@${v} to trash`));
193
+ if (isIsolated) {
194
+ console.log(chalk.gray(` Your real ${agentConfig.configDir} and default ${agentLabel(agentConfig.id)} were untouched.`));
195
+ }
160
196
  moved.push({ agent, version: v });
161
197
  }
162
198
  // Default reassignment/clearing is handled at the source in
@@ -177,6 +213,13 @@ async function versionPruneAction(specs, options, commandName) {
177
213
  else if (!isVersionInstalled(agent, version)) {
178
214
  console.log(chalk.gray(`${agentLabel(agentConfig.id)}@${version} not installed`));
179
215
  }
216
+ else if (isIsolated && !isVersionIsolated(agent, version)) {
217
+ // Safety guard: `--isolated` refuses to remove a normal/default install,
218
+ // so an accidental `remove <agent>@<default> --isolated` can never delete
219
+ // the user's primary version or disturb their real ~/.<agent>.
220
+ console.log(chalk.yellow(`${agentLabel(agentConfig.id)}@${version} is not an isolated install; refusing to remove it under --isolated.`));
221
+ console.log(chalk.gray(` Drop --isolated to remove a normal version: agents ${commandName} ${agent}@${version}`));
222
+ }
180
223
  else {
181
224
  const versionDir = getVersionDir(agent, version);
182
225
  const removed = removeVersion(agent, version);
@@ -186,6 +229,9 @@ async function versionPruneAction(specs, options, commandName) {
186
229
  }
187
230
  fixSessionFilePaths(agent, version, versionDir);
188
231
  console.log(chalk.green(`Moved ${agentLabel(agentConfig.id)}@${version} to trash`));
232
+ if (isIsolated) {
233
+ console.log(chalk.gray(` Your real ${agentConfig.configDir} and default ${agentLabel(agentConfig.id)} were untouched.`));
234
+ }
189
235
  moved.push({ agent, version });
190
236
  const remaining = listInstalledVersions(agent);
191
237
  if (remaining.length === 0) {
@@ -212,7 +258,8 @@ function configureVersionPruneCommand(cmd, commandName) {
212
258
  .description(isAlias
213
259
  ? 'Alias for agents prune. Uninstalls agent CLI versions.'
214
260
  : 'Uninstall agent CLI versions. Moves version data to trash for recovery.')
215
- .option('-p, --project', 'Also clear the pinned version from .agents/agents.yaml in the current project');
261
+ .option('-p, --project', 'Also clear the pinned version from .agents/agents.yaml in the current project')
262
+ .option('--isolated', 'Only act on isolated installs (created with `agents add --isolated`). Refuses to remove a normal/default install and never touches your real ~/.<agent>.');
216
263
  setHelpSections(cmd, {
217
264
  examples: `
218
265
  # Prune a specific version
@@ -223,11 +270,15 @@ function configureVersionPruneCommand(cmd, commandName) {
223
270
 
224
271
  # Prune and also clear the project pin
225
272
  agents ${commandName} claude@2.0.50 --project
273
+
274
+ # Cleanly remove an isolated copy, leaving your normal install alone
275
+ agents ${commandName} claude@2.1.112 --isolated
226
276
  `,
227
277
  notes: `
228
278
  - Pruned version directories move to trash with their home/ data intact.
229
279
  - Session file paths are rewritten so session history remains readable.
230
280
  - Removing the default version unsets the default; run 'agents use' to pick a new one.
281
+ - --isolated restricts the operation to isolated installs and refuses to remove a normal/default version, so your existing setup is never disturbed.
231
282
  - Reinstall any time with 'agents add'.
232
283
  `,
233
284
  });
@@ -239,6 +290,7 @@ export function registerVersionsCommands(program) {
239
290
  .command('add <specs...>')
240
291
  .description('Download and install agent CLI versions. Enables subsidized API usage through managed binaries.')
241
292
  .option('-p, --project', 'Lock this version to the current project directory only, stored in project-root agents.yaml')
293
+ .option('--isolated', 'Install a fully self-contained copy that never touches your existing ~/.<agent> or default. Launch it explicitly with `agents run <agent>@<version>`. Cannot be combined with --project.')
242
294
  .option('-y, --yes', 'Auto-accept defaults without prompting (useful for scripts and CI)');
243
295
  setHelpSections(addCmd, {
244
296
  examples: `
@@ -256,16 +308,26 @@ export function registerVersionsCommands(program) {
256
308
 
257
309
  # Lock a version to this project only (won't affect global default)
258
310
  agents add claude@2.1.100 --project
311
+
312
+ # Install a clean, separate copy that leaves your existing setup alone
313
+ agents add claude@2.1.112 --isolated
259
314
  `,
260
315
  notes: `
261
316
  - The first version you install becomes the default automatically.
262
317
  - 'add' does NOT change the default if a default already exists. Use 'agents use' to switch.
263
318
  - Multi-account: each installed version has separate auth, so you can install the same agent twice for two accounts.
319
+ - --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.
264
320
  `,
265
321
  });
266
322
  addCmd.action(async (specs, options) => {
267
323
  const isProject = options.project;
324
+ const isIsolated = options.isolated;
268
325
  const skipPrompts = options.yes || !isInteractiveTerminal();
326
+ if (isIsolated && isProject) {
327
+ console.log(chalk.red('--isolated and --project cannot be combined.'));
328
+ console.log(chalk.gray('An isolated copy is global-but-separate; a project pin selects a shared install for one directory.'));
329
+ return;
330
+ }
269
331
  for (const spec of specs) {
270
332
  const parsed = parseAgentSpec(spec);
271
333
  if (!parsed) {
@@ -276,6 +338,16 @@ export function registerVersionsCommands(program) {
276
338
  const { agent, version } = parsed;
277
339
  const agentConfig = AGENTS[agent];
278
340
  warnAgentDeprecated(agent);
341
+ // Isolation relies on a config-dir env var to redirect the copy away
342
+ // from the user's real ~/.<agent>. Agents without one isolate only by
343
+ // adopting ~/.<agent> (which --isolated skips), so an isolated copy
344
+ // would silently read/write the real config. Refuse rather than lie.
345
+ if (isIsolated && !supportsIsolatedInstall(agent)) {
346
+ console.log(chalk.red(`${agentLabel(agentConfig.id)} does not support --isolated installs.`));
347
+ console.log(chalk.gray(` It has no config-directory env var, so it can only isolate by adopting ${agentConfig.configDir} — which --isolated deliberately avoids.`));
348
+ console.log(chalk.gray(` Supported with --isolated: ${CONFIG_ENV_ISOLATED_AGENTS.join(', ')}.`));
349
+ continue;
350
+ }
279
351
  if (!agentConfig.npmPackage && !agentConfig.installScript) {
280
352
  console.log(chalk.yellow(`${agentLabel(agentConfig.id)} has no npm package. Install manually.`));
281
353
  continue;
@@ -301,6 +373,19 @@ export function registerVersionsCommands(program) {
301
373
  alreadyInstalled = isVersionInstalled(agent, version);
302
374
  }
303
375
  if (alreadyInstalled) {
376
+ if (isIsolated) {
377
+ if (!isVersionIsolated(agent, installedAsVersion)) {
378
+ // A normal and an isolated install of the SAME version share one
379
+ // on-disk dir, so they can't coexist. Refuse rather than silently
380
+ // convert the user's existing (possibly default) install.
381
+ console.log(chalk.yellow(`${agentLabel(agentConfig.id)}@${installedAsVersion} is already installed as a normal (default-eligible) version.`));
382
+ console.log(chalk.gray(` Remove it first (agents remove ${agent}@${installedAsVersion}) then re-add with --isolated, or pick a different version.`));
383
+ continue;
384
+ }
385
+ // Already isolated: re-affirm the alias + marker idempotently.
386
+ finalizeIsolatedInstall(agent, installedAsVersion);
387
+ continue;
388
+ }
304
389
  console.log(chalk.gray(`${agentLabel(agentConfig.id)}@${installedAsVersion} already installed`));
305
390
  // Ensure shim exists (in case it was deleted or needs updating)
306
391
  createShim(agent);
@@ -312,15 +397,23 @@ export function registerVersionsCommands(program) {
312
397
  });
313
398
  if (result.success) {
314
399
  spinner.succeed(`Installed ${agentLabel(agentConfig.id)}@${result.installedVersion}`);
400
+ const installedVersion = result.installedVersion || version;
401
+ // Track the concrete version so a `--project` pin records it instead
402
+ // of the `latest`/`oldest` alias.
403
+ installedAsVersion = installedVersion;
404
+ // Isolated installs stop here: no bare shim, no settings carry-over,
405
+ // no resource sync, no default switch, no PATH edits. Just a
406
+ // launchable versioned alias + the isolated marker, leaving the
407
+ // user's real ~/.<agent> and default untouched.
408
+ if (isIsolated) {
409
+ finalizeIsolatedInstall(agent, installedVersion);
410
+ continue;
411
+ }
315
412
  // Create shim if first install
316
413
  if (!shimExists(agent)) {
317
414
  createShim(agent);
318
415
  console.log(chalk.gray(` Created shim: ${getShimsDir()}/${agentConfig.cliCommand}`));
319
416
  }
320
- const installedVersion = result.installedVersion || version;
321
- // Track the concrete version so a `--project` pin records it instead
322
- // of the `latest`/`oldest` alias.
323
- installedAsVersion = installedVersion;
324
417
  // Seed the fresh version home with user settings from the current
325
418
  // default version (settings.json, keybindings, codex config/auth).
326
419
  // Gap-filling only — never overwrites what the new home has.
@@ -540,8 +633,11 @@ export function registerVersionsCommands(program) {
540
633
  const agentConfig = AGENTS[agentId];
541
634
  let selectedVersion = version;
542
635
  if (!version) {
543
- // Interactive version picker
544
- const versions = listInstalledVersions(agentId);
636
+ // Interactive version picker. Isolated installs are walled off from the
637
+ // real ~/.<agent> on purpose, so they must never be selectable here —
638
+ // setting one as the default would repoint the real config at it. Filter
639
+ // them out (same as the `remove` picker), leaving only usable versions.
640
+ const versions = listInstalledVersions(agentId).filter((v) => !isVersionIsolated(agentId, v));
545
641
  if (versions.length === 0) {
546
642
  console.log(chalk.red(`No versions of ${agentLabel(agentConfig.id)} installed`));
547
643
  console.log(chalk.gray(`Run: agents add ${agentId}@latest`));
@@ -602,6 +698,19 @@ export function registerVersionsCommands(program) {
602
698
  }
603
699
  // selectedVersion is guaranteed to be defined after the check above
604
700
  const finalVersion = selectedVersion;
701
+ // An isolated install is deliberately walled off from the real
702
+ // ~/.<agent>. `agents use` would repoint the real config symlink at it
703
+ // (switchConfigSymlink) and carry settings forward INTO it
704
+ // (carryForwardSettings) — a direct breach of the isolation guarantee.
705
+ // Refuse for both the explicit `use <agent>@<isolated>` path (the picker
706
+ // above already filters isolated versions out of the interactive path).
707
+ // Isolated copies are launched explicitly via `agents run <agent>@<v>`.
708
+ if (isVersionIsolated(agentId, finalVersion)) {
709
+ console.log(chalk.red(`${agentLabel(agentConfig.id)}@${finalVersion} is an isolated install and can't be set as your active version.`));
710
+ console.log(chalk.gray(`Isolated copies stay walled off from your real ${agentConfig.configDir}. Run it directly: agents run ${agentId}@${finalVersion}`));
711
+ console.log(chalk.gray(`To install this version normally: agents add ${agentId}@${finalVersion}`));
712
+ return;
713
+ }
605
714
  if (options.project) {
606
715
  // Set in project manifest
607
716
  const projectManifestDir = path.join(process.cwd(), '.agents');
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ if (IS_DEV_BUILD) {
50
50
  // module on each invocation (which loaded the whole ~50-module tree before the
51
51
  // first byte of output), the registry maps a command name to a thunk that
52
52
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
53
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadShare, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
53
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadPackages, loadDaemon, loadRoutines, loadMonitors, loadRun, loadFork, loadDefaults, loadModels, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadCheck, loadStatus, loadProfiles, loadSecrets, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadDrive, loadFactory, loadUsage, loadCost, loadOutput, loadBudget, loadAlias, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadShare, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
54
54
  import { applyGlobalHelpConventions } from './lib/help.js';
55
55
  import { renderWhatsNew } from './lib/whats-new.js';
56
56
  import { emit, redactArgs } from './lib/events.js';
@@ -738,6 +738,7 @@ async function registerAllEagerCommands() {
738
738
  await reg(loadPush);
739
739
  await reg(loadRepo);
740
740
  await reg(loadSetup);
741
+ await reg(loadUninstall);
741
742
  }
742
743
  /** Calculate the Levenshtein edit distance between two strings. */
743
744
  function levenshtein(a, b) {
@@ -865,8 +866,9 @@ if (firstRun) {
865
866
  process.exit(0);
866
867
  }
867
868
  // Every command requires the system repo to be cloned first. `setup` is the
868
- // only exemption it's the command that does the cloning.
869
- const SETUP_EXEMPT_COMMANDS = new Set(['setup', 'help']);
869
+ // command that does the cloning; `uninstall` is its reverse and must run even
870
+ // from a broken/half-setup state (that is exactly when you want to tear down).
871
+ const SETUP_EXEMPT_COMMANDS = new Set(['setup', 'help', 'uninstall']);
870
872
  // Fold legacy ~/.agents-system/ into ~/.agents/.system/ BEFORE ensureInitialized
871
873
  // runs. ensureInitialized checks for .git inside the new path; if the user is
872
874
  // upgrading from a layout where .git lives under the legacy path, the check
@@ -7,6 +7,15 @@
7
7
  * log output, reload (SIGHUP), and graceful shutdown are handled here.
8
8
  */
9
9
  import { getAgentsBinPath } from './cli-entry.js';
10
+ /**
11
+ * RUSH-1817: decide whether the daemon should (re)take over hosting the secrets
12
+ * broker. The startup host decision is one-shot; this drives the periodic
13
+ * self-heal re-check. Take over ONLY when the daemon is not already hosting AND
14
+ * no healthy broker answers a ping — i.e. a standalone the daemon deferred to at
15
+ * start has since died or crash-looped. Never take over while our in-process
16
+ * broker is hosting, and never clobber a reachable (healthy) broker.
17
+ */
18
+ export declare function shouldTakeOverBroker(isHosting: boolean, brokerReachable: boolean): boolean;
10
19
  /** Read the stored daemon PID from disk. Returns null if not present or invalid. */
11
20
  export declare function readDaemonPid(): number | null;
12
21
  /** Write the daemon PID to the pid file. */
@@ -38,6 +38,17 @@ const WEDGE_THRESHOLD_TICKS = 3;
38
38
  // session (which expires between runs and produces intermittent 401s).
39
39
  const DAEMON_OAUTH_BUNDLE = 'claude';
40
40
  const DAEMON_OAUTH_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
41
+ /**
42
+ * RUSH-1817: decide whether the daemon should (re)take over hosting the secrets
43
+ * broker. The startup host decision is one-shot; this drives the periodic
44
+ * self-heal re-check. Take over ONLY when the daemon is not already hosting AND
45
+ * no healthy broker answers a ping — i.e. a standalone the daemon deferred to at
46
+ * start has since died or crash-looped. Never take over while our in-process
47
+ * broker is hosting, and never clobber a reachable (healthy) broker.
48
+ */
49
+ export function shouldTakeOverBroker(isHosting, brokerReachable) {
50
+ return !isHosting && !brokerReachable;
51
+ }
41
52
  function getDaemonDir() {
42
53
  const dir = getDaemonDirRoot();
43
54
  fs.mkdirSync(dir, { recursive: true });
@@ -661,6 +672,37 @@ export async function runDaemon() {
661
672
  };
662
673
  const fleetCacheInterval = setInterval(() => { void runFleetCacheWarm(); }, 3 * 60_000);
663
674
  const fleetCacheKickoff = setTimeout(() => { void runFleetCacheWarm(); }, 60_000);
675
+ // RUSH-1817: the startup host decision above is one-shot. If a standalone
676
+ // broker answered agentPing() at daemon start, the daemon declined to host —
677
+ // but should that standalone later die or crash-loop, nothing takes over and
678
+ // every `agents secrets unlock|export|start` fails until a manual restart
679
+ // (this wedged all keychain-backed secrets on zion and blocked a release).
680
+ // Re-probe on a cadence: whenever the daemon is NOT itself hosting AND no
681
+ // healthy broker answers a ping, take over hosting. startHostedBroker binds
682
+ // the socket only when it is free, so a take-over never races a live broker.
683
+ let selfHealingBroker = false;
684
+ const runBrokerSelfHeal = async () => {
685
+ if (selfHealingBroker)
686
+ return;
687
+ selfHealingBroker = true;
688
+ try {
689
+ const { agentPing, startHostedBroker } = await import('./secrets/agent.js');
690
+ const reachable = (await agentPing()).reachable;
691
+ if (!shouldTakeOverBroker(hostedBroker != null, reachable))
692
+ return;
693
+ hostedBroker = await startHostedBroker();
694
+ if (hostedBroker) {
695
+ log('WARN', 'Secrets broker was unreachable; daemon took over hosting (self-heal)');
696
+ }
697
+ }
698
+ catch (err) {
699
+ log('WARN', `Secrets broker self-heal skipped: ${err.message}`);
700
+ }
701
+ finally {
702
+ selfHealingBroker = false;
703
+ }
704
+ };
705
+ const brokerSelfHealInterval = setInterval(() => { void runBrokerSelfHeal(); }, 60_000);
664
706
  const handleReload = () => {
665
707
  log('INFO', 'Reloading jobs (SIGHUP)');
666
708
  scheduler.reloadAll();
@@ -695,6 +737,7 @@ export async function runDaemon() {
695
737
  clearTimeout(launchHealthKickoff);
696
738
  clearInterval(fleetCacheInterval);
697
739
  clearTimeout(fleetCacheKickoff);
740
+ clearInterval(brokerSelfHealInterval);
698
741
  hostedBroker?.close();
699
742
  removeDaemonPid();
700
743
  removeHeartbeat();
@@ -10,6 +10,7 @@
10
10
  * runs to be discoverable there first; until then the bounded tail is the safe
11
11
  * concise default.) Kept in one place so the two commands can never drift.
12
12
  */
13
+ import { type HostTask } from './tasks.js';
13
14
  export interface HostLogResult {
14
15
  /** False when no host task with this id exists (caller may fall through to sessions). */
15
16
  found: boolean;
@@ -21,5 +22,16 @@ export interface HostLogResult {
21
22
  * default; `full` dumps the entire raw combined-stdout log.
22
23
  */
23
24
  export declare function showHostTaskLog(id: string, follow: boolean, full?: boolean): Promise<HostLogResult>;
25
+ /**
26
+ * Machine-readable form of a host-dispatch task's log — the task record plus its
27
+ * combined stdout. Powers `agents logs <id> --json` for the host-task branch.
28
+ * Reconciles a still-'running' record from the remote `.exit` first, like the
29
+ * text path does.
30
+ */
31
+ export declare function hostTaskLogJson(id: string): {
32
+ found: boolean;
33
+ task?: HostTask;
34
+ log?: string | null;
35
+ };
24
36
  /** Last `n` lines of `text`, prefixed with an elision note when truncated. */
25
37
  export declare function tailLines(text: string, n: number): string;
@@ -54,6 +54,24 @@ export async function showHostTaskLog(id, follow, full = false) {
54
54
  process.stdout.write(full ? raw : tailLines(raw, HOST_LOG_TAIL_LINES));
55
55
  return { found: true, exitCode: 0 };
56
56
  }
57
+ /**
58
+ * Machine-readable form of a host-dispatch task's log — the task record plus its
59
+ * combined stdout. Powers `agents logs <id> --json` for the host-task branch.
60
+ * Reconciles a still-'running' record from the remote `.exit` first, like the
61
+ * text path does.
62
+ */
63
+ export function hostTaskLogJson(id) {
64
+ const task = loadTask(id);
65
+ if (!task)
66
+ return { found: false };
67
+ // reconcileTask returns the healed record; it does NOT mutate its argument in
68
+ // place. Emit the reconciled task so a run that finished between dispatch and
69
+ // this one-shot read surfaces its terminal status/exitCode, not a stale
70
+ // 'running'. (The text path can discard the return — it only reads the log by
71
+ // id — but this JSON payload carries task.status straight to the consumer.)
72
+ const reconciled = reconcileTask(task);
73
+ return { found: true, task: reconciled, log: readTaskLog(reconciled) };
74
+ }
57
75
  /** Read the task's combined-stdout — local mirror first, else fetch+cache remote. */
58
76
  function readTaskLog(task) {
59
77
  try {
@@ -153,6 +153,27 @@ export declare function removeShim(agent: AgentId): boolean;
153
153
  * self-update under the shim).
154
154
  */
155
155
  export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 13;
156
+ /**
157
+ * Agents whose config directory can be relocated by an environment variable
158
+ * (the per-agent `managedEnv` block in `generateVersionedAliasScript` below).
159
+ *
160
+ * Only these can be installed with `agents add --isolated`: the versioned alias
161
+ * points that env var at the copy's private home, so the copy never reads or
162
+ * writes the user's real `~/.<agent>`. Every other agent isolates ONLY by
163
+ * adopting `~/.<agent>` via a symlink — which an isolated install deliberately
164
+ * skips — so an isolated copy would silently fall back to (and mutate) the real
165
+ * config dir. For those agents `--isolated` is refused up front.
166
+ *
167
+ * KEEP IN SYNC with the `managedEnv` switch in `generateVersionedAliasScript`.
168
+ * The colocated test `shims.isolation-capability.test.ts` enforces this.
169
+ */
170
+ export declare const CONFIG_ENV_ISOLATED_AGENTS: readonly AgentId[];
171
+ /**
172
+ * Whether an agent supports a clean `--isolated` install — i.e. its config
173
+ * location can be redirected by an env var so the isolated copy stays fully
174
+ * separate from the user's real `~/.<agent>`. See {@link CONFIG_ENV_ISOLATED_AGENTS}.
175
+ */
176
+ export declare function supportsIsolatedInstall(agent: AgentId): boolean;
156
177
  /**
157
178
  * Generate a versioned alias script that directly execs a specific version.
158
179
  * e.g., claude@2.0.65 -> directly runs that version's binary
@@ -209,6 +230,11 @@ export declare function removeVersionedAlias(agent: AgentId, version: string): b
209
230
  * Check if a versioned alias exists (the on-disk artifact for this platform).
210
231
  */
211
232
  export declare function versionedAliasExists(agent: AgentId, version: string): boolean;
233
+ /**
234
+ * Get the path to the agent's config directory in HOME.
235
+ * e.g., ~/.claude for claude, ~/.codex for codex
236
+ */
237
+ export declare function getAgentConfigPath(agent: AgentId): string;
212
238
  /**
213
239
  * Read the user's configured Codex model from their active `~/.codex/config.toml`.
214
240
  *
@@ -449,6 +475,7 @@ export declare function hasAliasShadowingShim(agent: AgentId, overrides?: {
449
475
  * Check if shims directory is in PATH.
450
476
  */
451
477
  export declare function isShimsInPath(): boolean;
478
+ export declare function stripShimPathLines(content: string, shimsDir: string): string;
452
479
  /**
453
480
  * Get shell configuration instructions for adding shims to PATH.
454
481
  */
package/dist/lib/shims.js CHANGED
@@ -785,6 +785,29 @@ function assertSafeVersion(version) {
785
785
  throw new Error(`Refusing to generate shim for unsafe version: ${JSON.stringify(version)}`);
786
786
  }
787
787
  }
788
+ /**
789
+ * Agents whose config directory can be relocated by an environment variable
790
+ * (the per-agent `managedEnv` block in `generateVersionedAliasScript` below).
791
+ *
792
+ * Only these can be installed with `agents add --isolated`: the versioned alias
793
+ * points that env var at the copy's private home, so the copy never reads or
794
+ * writes the user's real `~/.<agent>`. Every other agent isolates ONLY by
795
+ * adopting `~/.<agent>` via a symlink — which an isolated install deliberately
796
+ * skips — so an isolated copy would silently fall back to (and mutate) the real
797
+ * config dir. For those agents `--isolated` is refused up front.
798
+ *
799
+ * KEEP IN SYNC with the `managedEnv` switch in `generateVersionedAliasScript`.
800
+ * The colocated test `shims.isolation-capability.test.ts` enforces this.
801
+ */
802
+ export const CONFIG_ENV_ISOLATED_AGENTS = ['claude', 'codex', 'copilot', 'grok', 'kimi'];
803
+ /**
804
+ * Whether an agent supports a clean `--isolated` install — i.e. its config
805
+ * location can be redirected by an env var so the isolated copy stays fully
806
+ * separate from the user's real `~/.<agent>`. See {@link CONFIG_ENV_ISOLATED_AGENTS}.
807
+ */
808
+ export function supportsIsolatedInstall(agent) {
809
+ return CONFIG_ENV_ISOLATED_AGENTS.includes(agent);
810
+ }
788
811
  /**
789
812
  * Generate a versioned alias script that directly execs a specific version.
790
813
  * e.g., claude@2.0.65 -> directly runs that version's binary
@@ -1039,7 +1062,7 @@ export function versionedAliasExists(agent, version) {
1039
1062
  * Get the path to the agent's config directory in HOME.
1040
1063
  * e.g., ~/.claude for claude, ~/.codex for codex
1041
1064
  */
1042
- function getAgentConfigPath(agent) {
1065
+ export function getAgentConfigPath(agent) {
1043
1066
  const agentConfig = AGENTS[agent];
1044
1067
  const home = process.env.AGENTS_REAL_HOME || os.homedir();
1045
1068
  return agentConfig.configDir.replace(os.homedir(), home);
@@ -1567,7 +1590,10 @@ export function getConfigSymlinkVersion(agent) {
1567
1590
  if (!stat.isSymbolicLink()) {
1568
1591
  return null;
1569
1592
  }
1570
- const target = fs.readlinkSync(configPath);
1593
+ // Normalize separators so this matches on Windows too — readlinkSync there
1594
+ // returns backslash paths, which the forward-slash-only regex never matched
1595
+ // (misclassifying an owned symlink as foreign, e.g. in `agents uninstall`).
1596
+ const target = fs.readlinkSync(configPath).replace(/\\/g, '/');
1571
1597
  // Extract version from path like ~/.agents/versions/claude/2.0.65/home/.claude
1572
1598
  const match = target.match(/versions\/[^/]+\/([^/]+)\/home/);
1573
1599
  return match ? match[1] : null;
@@ -2194,7 +2220,7 @@ function isShimPathCommandLine(line, shimsDir) {
2194
2220
  }
2195
2221
  return trimmed.startsWith('export PATH=') || trimmed.startsWith('fish_add_path ');
2196
2222
  }
2197
- function stripShimPathLines(content, shimsDir) {
2223
+ export function stripShimPathLines(content, shimsDir) {
2198
2224
  const lines = content.split('\n');
2199
2225
  const kept = [];
2200
2226
  for (let i = 0; i < lines.length; i++) {
@@ -85,6 +85,7 @@ export declare const loadPull: ModuleLoader;
85
85
  export declare const loadPush: ModuleLoader;
86
86
  export declare const loadRepo: ModuleLoader;
87
87
  export declare const loadSetup: ModuleLoader;
88
+ export declare const loadUninstall: ModuleLoader;
88
89
  export declare const loadSessions: ModuleLoader;
89
90
  export declare const loadTeams: ModuleLoader;
90
91
  export declare const loadCloud: ModuleLoader;
@@ -63,6 +63,7 @@ export const loadPull = async () => (await import('../../commands/pull.js')).reg
63
63
  export const loadPush = async () => (await import('../../commands/push.js')).registerPushCommand;
64
64
  export const loadRepo = async () => (await import('../../commands/repo.js')).registerRepoCommands;
65
65
  export const loadSetup = async () => (await import('../../commands/setup.js')).registerSetupCommand;
66
+ export const loadUninstall = async () => (await import('../../commands/uninstall.js')).registerUninstallCommands;
66
67
  export const loadSessions = async () => (await import('../../commands/sessions.js')).registerSessionsCommands;
67
68
  export const loadTeams = async () => (await import('../../commands/teams.js')).registerTeamsCommands;
68
69
  export const loadCloud = async () => (await import('../../commands/cloud.js')).registerCloudCommands;
@@ -174,6 +175,7 @@ export const COMMAND_LOADERS = {
174
175
  repos: [loadRepo],
175
176
  repo: [loadRepo],
176
177
  setup: [loadSetup],
178
+ uninstall: [loadUninstall],
177
179
  sessions: [loadSessions],
178
180
  teams: [loadTeams],
179
181
  cloud: [loadCloud],
@@ -0,0 +1,84 @@
1
+ import type { AgentId } from './types.js';
2
+ /** What the uninstall intends to do with one agent's config directory. */
3
+ export type ConfigAction = {
4
+ agent: AgentId;
5
+ realPath: string;
6
+ kind: 'restore-backup';
7
+ source: string;
8
+ } | {
9
+ agent: AgentId;
10
+ realPath: string;
11
+ kind: 'restore-version-home';
12
+ source: string;
13
+ } | {
14
+ agent: AgentId;
15
+ realPath: string;
16
+ kind: 'remove-dangling';
17
+ } | {
18
+ agent: AgentId;
19
+ realPath: string;
20
+ kind: 'leave-real';
21
+ } | {
22
+ agent: AgentId;
23
+ realPath: string;
24
+ kind: 'leave-foreign';
25
+ } | {
26
+ agent: AgentId;
27
+ realPath: string;
28
+ kind: 'absent';
29
+ };
30
+ /** A home-level file symlink (e.g. `~/.claude.json`) agents-cli owns. */
31
+ export interface HomeFileAction {
32
+ realPath: string;
33
+ /** Resolved symlink target whose contents are copied back to `realPath`. */
34
+ source: string;
35
+ }
36
+ /** The full, read-only plan describing what an uninstall would change. */
37
+ export interface UninstallPlan {
38
+ isInstalled: boolean;
39
+ agentsDir: string;
40
+ legacySymlink: string | null;
41
+ configs: ConfigAction[];
42
+ homeFiles: HomeFileAction[];
43
+ launchers: string[];
44
+ rcFiles: string[];
45
+ }
46
+ /** Structured result of an executed uninstall (for reporting). */
47
+ export interface UninstallResult {
48
+ restoredConfigs: Array<{
49
+ agent: AgentId;
50
+ realPath: string;
51
+ }>;
52
+ removedDanglingConfigs: Array<{
53
+ agent: AgentId;
54
+ realPath: string;
55
+ }>;
56
+ restoredHomeFiles: string[];
57
+ releasedLaunchers: string[];
58
+ cleanedRcFiles: string[];
59
+ agentsDir: {
60
+ path: string;
61
+ disposition: 'moved' | 'purged' | 'absent';
62
+ movedTo?: string;
63
+ };
64
+ legacySymlinkRemoved: boolean;
65
+ /** True when `--purge` was requested but downgraded to move-aside after errors. */
66
+ purgeDowngraded: boolean;
67
+ errors: string[];
68
+ }
69
+ /**
70
+ * Build a read-only plan of everything a complete uninstall would change.
71
+ * Performs no mutations; safe to run for `--dry-run` and to print for confirm.
72
+ */
73
+ export declare function planUninstall(): UninstallPlan;
74
+ /**
75
+ * Execute a plan built by {@link planUninstall}. Restores adopted config dirs
76
+ * and home files, releases adopted launchers, strips shim PATH lines, then
77
+ * disposes of `~/.agents` — moved aside to `~/.agents.removed-<ts>` (recoverable)
78
+ * by default, or hard-deleted when `purge` is set. Config restore always runs
79
+ * before disposal because the backups live inside `~/.agents`.
80
+ */
81
+ export declare function executeUninstall(plan: UninstallPlan, opts: {
82
+ purge?: boolean;
83
+ timestamp: number;
84
+ }): UninstallResult;