@phnx-labs/agents-cli 1.20.70 → 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.
Files changed (80) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/apply.js +36 -3
  4. package/dist/commands/check.js +3 -1
  5. package/dist/commands/commands.js +2 -0
  6. package/dist/commands/exec.js +12 -0
  7. package/dist/commands/fleet-capture.d.ts +14 -0
  8. package/dist/commands/fleet-capture.js +107 -0
  9. package/dist/commands/fork.js +13 -7
  10. package/dist/commands/hosts.js +11 -0
  11. package/dist/commands/logs.js +38 -9
  12. package/dist/commands/mcp.js +2 -0
  13. package/dist/commands/resource-view.d.ts +2 -0
  14. package/dist/commands/resource-view.js +8 -0
  15. package/dist/commands/secrets.d.ts +1 -0
  16. package/dist/commands/secrets.js +24 -4
  17. package/dist/commands/sessions-tail.js +1 -2
  18. package/dist/commands/sessions.d.ts +6 -0
  19. package/dist/commands/sessions.js +14 -6
  20. package/dist/commands/skills.js +2 -0
  21. package/dist/commands/ssh.js +24 -6
  22. package/dist/commands/status.js +8 -2
  23. package/dist/commands/subagents.js +3 -1
  24. package/dist/commands/uninstall.d.ts +11 -0
  25. package/dist/commands/uninstall.js +158 -0
  26. package/dist/commands/usage.d.ts +13 -0
  27. package/dist/commands/usage.js +50 -28
  28. package/dist/commands/utils.d.ts +29 -0
  29. package/dist/commands/utils.js +24 -0
  30. package/dist/commands/versions.js +120 -11
  31. package/dist/index.js +5 -3
  32. package/dist/lib/commands.d.ts +10 -0
  33. package/dist/lib/commands.js +31 -7
  34. package/dist/lib/daemon.d.ts +9 -0
  35. package/dist/lib/daemon.js +43 -0
  36. package/dist/lib/devices/connect.d.ts +13 -0
  37. package/dist/lib/devices/connect.js +20 -0
  38. package/dist/lib/devices/fleet.d.ts +36 -3
  39. package/dist/lib/devices/fleet.js +42 -4
  40. package/dist/lib/devices/sync.d.ts +30 -0
  41. package/dist/lib/devices/sync.js +50 -0
  42. package/dist/lib/doctor-diff.js +38 -16
  43. package/dist/lib/fleet/apply.d.ts +4 -0
  44. package/dist/lib/fleet/apply.js +28 -5
  45. package/dist/lib/fleet/capture.d.ts +35 -0
  46. package/dist/lib/fleet/capture.js +51 -0
  47. package/dist/lib/fleet/manifest.d.ts +6 -0
  48. package/dist/lib/fleet/manifest.js +24 -1
  49. package/dist/lib/fleet/types.d.ts +24 -1
  50. package/dist/lib/git.d.ts +14 -0
  51. package/dist/lib/git.js +39 -1
  52. package/dist/lib/hosts/logs.d.ts +12 -0
  53. package/dist/lib/hosts/logs.js +18 -0
  54. package/dist/lib/menubar/install-menubar.d.ts +12 -0
  55. package/dist/lib/menubar/install-menubar.js +26 -13
  56. package/dist/lib/secrets/agent.d.ts +5 -0
  57. package/dist/lib/secrets/agent.js +35 -3
  58. package/dist/lib/secrets/bundles.js +28 -0
  59. package/dist/lib/secrets/index.d.ts +14 -0
  60. package/dist/lib/secrets/index.js +36 -5
  61. package/dist/lib/secrets/session-store.d.ts +90 -0
  62. package/dist/lib/secrets/session-store.js +221 -0
  63. package/dist/lib/session/render.d.ts +9 -1
  64. package/dist/lib/session/render.js +35 -4
  65. package/dist/lib/share/capture.d.ts +2 -0
  66. package/dist/lib/share/capture.js +12 -5
  67. package/dist/lib/shims.d.ts +27 -0
  68. package/dist/lib/shims.js +29 -3
  69. package/dist/lib/skills.d.ts +8 -1
  70. package/dist/lib/skills.js +11 -1
  71. package/dist/lib/startup/command-registry.d.ts +1 -0
  72. package/dist/lib/startup/command-registry.js +2 -0
  73. package/dist/lib/types.d.ts +5 -1
  74. package/dist/lib/uninstall.d.ts +84 -0
  75. package/dist/lib/uninstall.js +347 -0
  76. package/dist/lib/usage.d.ts +13 -1
  77. package/dist/lib/usage.js +32 -14
  78. package/dist/lib/versions.d.ts +16 -0
  79. package/dist/lib/versions.js +40 -3
  80. package/package.json +1 -1
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Complete teardown of agents-cli — the reverse of `agents setup`.
3
+ *
4
+ * The hard part is undoing "adoption": normal installs move the user's real
5
+ * `~/.<agent>` aside and replace it with a symlink into agents-cli's version
6
+ * homes (see `switchConfigSymlink` / `importAgent`). A clean uninstall must put
7
+ * those real directories back, release any adopted launchers, strip the shim
8
+ * directory from the user's PATH, and only then dispose of `~/.agents`.
9
+ *
10
+ * Safety invariant: a `~/.<agent>` that agents-cli never adopted is a REAL user
11
+ * directory and is never touched. Ownership is decided structurally by
12
+ * `getConfigSymlinkVersion` (non-null only for a symlink into our versions dir),
13
+ * exactly as `removeVersion` does it — no marker files, no guessing.
14
+ *
15
+ * This module is split into a read-only {@link planUninstall} and a mutating
16
+ * {@link executeUninstall} so `--dry-run` and the real run share one code path.
17
+ */
18
+ import * as fs from 'fs';
19
+ import * as os from 'os';
20
+ import * as path from 'path';
21
+ import { AGENTS, ALL_AGENT_IDS } from './agents.js';
22
+ import { getAgentConfigPath, getConfigSymlinkVersion, stripShimPathLines, releaseAdoptedLauncher, } from './shims.js';
23
+ import { getUserAgentsDir, getBackupsDir, getHistoryDir, getShimsDir, getLegacySystemAgentsDir, } from './state.js';
24
+ /** Home dir honoring the AGENTS_REAL_HOME test/override, mirroring shims.ts. */
25
+ function realHome() {
26
+ return process.env.AGENTS_REAL_HOME || os.homedir();
27
+ }
28
+ /** Newest timestamped backup dir under `<backups>/<agent>/`, or null. */
29
+ function newestBackupDir(agent) {
30
+ const dir = path.join(getBackupsDir(), agent);
31
+ let entries;
32
+ try {
33
+ entries = fs.readdirSync(dir).filter((n) => /^\d+$/.test(n));
34
+ }
35
+ catch {
36
+ return null;
37
+ }
38
+ if (entries.length === 0)
39
+ return null;
40
+ entries.sort((a, b) => Number(a) - Number(b));
41
+ return path.join(dir, entries[entries.length - 1]);
42
+ }
43
+ /** Absolute target of a symlink, or null if `p` is not a readable symlink. */
44
+ function symlinkTarget(p) {
45
+ try {
46
+ const raw = fs.readlinkSync(p);
47
+ return path.resolve(path.dirname(p), raw);
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ /**
54
+ * Remove a symlink/junction at `p` without following into its target. On POSIX
55
+ * this is a plain `unlinkSync`; on Windows the same call correctly deletes a
56
+ * junction or directory-symlink reparse point while leaving the target intact —
57
+ * verified on a real Windows host, where `fs.rmSync(p, { force: true })` instead
58
+ * throws `EFAULT` on a reparse point. `rmSync` is deliberately NOT used here.
59
+ */
60
+ function removeLink(p) {
61
+ fs.unlinkSync(p);
62
+ }
63
+ /**
64
+ * Move `source` onto `dest` across possibly-different volumes. `renameSync` is
65
+ * atomic but throws EXDEV when `~/.agents` lives on a different filesystem than
66
+ * `$HOME`; fall back to copy-then-remove so the restore still completes. The
67
+ * source (a backup inside `~/.agents`) is removed only after the copy succeeds,
68
+ * so a mid-copy failure never destroys the sole surviving copy.
69
+ */
70
+ function moveDirCrossDevice(source, dest) {
71
+ try {
72
+ fs.renameSync(source, dest);
73
+ }
74
+ catch (err) {
75
+ if (err.code !== 'EXDEV')
76
+ throw err;
77
+ fs.cpSync(source, dest, { recursive: true });
78
+ fs.rmSync(source, { recursive: true, force: true });
79
+ }
80
+ }
81
+ /**
82
+ * Copy `source` to `dest`, dropping any symlink whose target resolves back into
83
+ * `~/.agents`. Adoption syncs managed resources (skills/commands) into the
84
+ * version home as symlinks into `~/.agents`; copying them verbatim would leave
85
+ * the restored config full of links that dangle the moment `~/.agents` is
86
+ * disposed. Stripping them yields a clean, self-contained restore.
87
+ */
88
+ function copyDirStrippingAgentsSymlinks(source, dest, agentsDir) {
89
+ const inside = agentsDir + path.sep;
90
+ fs.cpSync(source, dest, {
91
+ recursive: true,
92
+ filter: (src) => {
93
+ try {
94
+ const st = fs.lstatSync(src);
95
+ if (st.isSymbolicLink()) {
96
+ const tgt = path.resolve(path.dirname(src), fs.readlinkSync(src));
97
+ if (tgt === agentsDir || tgt.startsWith(inside))
98
+ return false;
99
+ }
100
+ }
101
+ catch {
102
+ /* unreadable entry — let cpSync surface it on the real copy */
103
+ }
104
+ return true;
105
+ },
106
+ });
107
+ }
108
+ /** Classify one agent's config dir without mutating anything. */
109
+ function planConfig(agent) {
110
+ const realPath = getAgentConfigPath(agent);
111
+ let stat;
112
+ try {
113
+ stat = fs.lstatSync(realPath);
114
+ }
115
+ catch {
116
+ return { agent, realPath, kind: 'absent' };
117
+ }
118
+ // A real directory means agents-cli never adopted it — leave it alone.
119
+ if (!stat.isSymbolicLink())
120
+ return { agent, realPath, kind: 'leave-real' };
121
+ // A symlink we don't own (target not under our versions dir) — leave it alone.
122
+ if (getConfigSymlinkVersion(agent) === null)
123
+ return { agent, realPath, kind: 'leave-foreign' };
124
+ // Owned symlink: prefer the timestamped backup (switchConfigSymlink moved the
125
+ // real dir there); otherwise the symlink target itself holds the user's data
126
+ // (importAgent renamed the real dir INTO the version home).
127
+ const backup = newestBackupDir(agent);
128
+ if (backup)
129
+ return { agent, realPath, kind: 'restore-backup', source: backup };
130
+ const target = symlinkTarget(realPath);
131
+ if (target && fs.existsSync(target)) {
132
+ return { agent, realPath, kind: 'restore-version-home', source: target };
133
+ }
134
+ return { agent, realPath, kind: 'remove-dangling' };
135
+ }
136
+ /** Owned home-file symlinks (e.g. `~/.claude.json`) to copy back as real files. */
137
+ function planHomeFiles() {
138
+ const home = realHome();
139
+ const userDir = getUserAgentsDir();
140
+ const out = [];
141
+ for (const agent of ALL_AGENT_IDS) {
142
+ const homeFiles = AGENTS[agent].homeFiles;
143
+ if (!homeFiles)
144
+ continue;
145
+ for (const fileName of homeFiles) {
146
+ const realPath = path.join(home, fileName);
147
+ let stat;
148
+ try {
149
+ stat = fs.lstatSync(realPath);
150
+ }
151
+ catch {
152
+ continue;
153
+ }
154
+ if (!stat.isSymbolicLink())
155
+ continue;
156
+ const target = symlinkTarget(realPath);
157
+ // Only ours (points into ~/.agents) and only if the target still exists.
158
+ if (target && target.startsWith(userDir + path.sep) && fs.existsSync(target)) {
159
+ out.push({ realPath, source: target });
160
+ }
161
+ }
162
+ }
163
+ return out;
164
+ }
165
+ /** cliCommand basenames that have an adopted-launcher record to release. */
166
+ function planLaunchers() {
167
+ const dir = path.join(getHistoryDir(), 'adopted-launchers');
168
+ try {
169
+ return fs.readdirSync(dir).filter((n) => !n.startsWith('.'));
170
+ }
171
+ catch {
172
+ return [];
173
+ }
174
+ }
175
+ /** Candidate shell rc files that currently contain a shim PATH entry. */
176
+ function planRcFiles() {
177
+ const home = realHome();
178
+ const shimsDir = getShimsDir();
179
+ const candidates = ['.zshrc', '.bashrc', '.bash_profile', '.profile', path.join('.config', 'fish', 'config.fish')];
180
+ const out = [];
181
+ for (const rel of candidates) {
182
+ const rc = path.join(home, rel);
183
+ let content;
184
+ try {
185
+ content = fs.readFileSync(rc, 'utf-8');
186
+ }
187
+ catch {
188
+ continue;
189
+ }
190
+ if (stripShimPathLines(content, shimsDir) !== content)
191
+ out.push(rc);
192
+ }
193
+ return out;
194
+ }
195
+ /**
196
+ * Build a read-only plan of everything a complete uninstall would change.
197
+ * Performs no mutations; safe to run for `--dry-run` and to print for confirm.
198
+ */
199
+ export function planUninstall() {
200
+ const agentsDir = getUserAgentsDir();
201
+ const legacy = getLegacySystemAgentsDir();
202
+ let legacySymlink = null;
203
+ try {
204
+ // Only claim it if it's actually a link (symlink on POSIX, junction on Windows —
205
+ // both report isSymbolicLink()); a real directory here is left alone so removeLink
206
+ // (unlinkSync) is always the correct primitive for what we captured.
207
+ if (fs.lstatSync(legacy).isSymbolicLink())
208
+ legacySymlink = legacy;
209
+ }
210
+ catch {
211
+ legacySymlink = null;
212
+ }
213
+ return {
214
+ isInstalled: fs.existsSync(agentsDir),
215
+ agentsDir,
216
+ legacySymlink,
217
+ configs: ALL_AGENT_IDS.map(planConfig),
218
+ homeFiles: planHomeFiles(),
219
+ launchers: planLaunchers(),
220
+ rcFiles: planRcFiles(),
221
+ };
222
+ }
223
+ /**
224
+ * Execute a plan built by {@link planUninstall}. Restores adopted config dirs
225
+ * and home files, releases adopted launchers, strips shim PATH lines, then
226
+ * disposes of `~/.agents` — moved aside to `~/.agents.removed-<ts>` (recoverable)
227
+ * by default, or hard-deleted when `purge` is set. Config restore always runs
228
+ * before disposal because the backups live inside `~/.agents`.
229
+ */
230
+ export function executeUninstall(plan, opts) {
231
+ const result = {
232
+ restoredConfigs: [],
233
+ removedDanglingConfigs: [],
234
+ restoredHomeFiles: [],
235
+ releasedLaunchers: [],
236
+ cleanedRcFiles: [],
237
+ agentsDir: { path: plan.agentsDir, disposition: 'absent' },
238
+ legacySymlinkRemoved: false,
239
+ purgeDowngraded: false,
240
+ errors: [],
241
+ };
242
+ // 1. Restore adopted config directories (reads backups inside ~/.agents).
243
+ for (const c of plan.configs) {
244
+ try {
245
+ if (c.kind === 'restore-backup') {
246
+ // The adopted link carries no data (the real dir is the backup); drop it,
247
+ // then move the backup out of ~/.agents onto the real path — EXDEV-safe so a
248
+ // cross-volume ~/.agents can't strand the backup mid-restore. unlinkSync (not
249
+ // rmSync) is deliberate: it removes a POSIX symlink AND a Windows junction/
250
+ // dir-symlink without following into the target, whereas rmSync throws EFAULT
251
+ // on a Windows reparse point.
252
+ removeLink(c.realPath);
253
+ moveDirCrossDevice(c.source, c.realPath);
254
+ result.restoredConfigs.push({ agent: c.agent, realPath: c.realPath });
255
+ }
256
+ else if (c.kind === 'restore-version-home') {
257
+ // importAgent renamed the real dir INTO the version home; copy it back
258
+ // (step 6 disposes the original) while stripping resource symlinks that
259
+ // would dangle once ~/.agents is gone.
260
+ removeLink(c.realPath);
261
+ copyDirStrippingAgentsSymlinks(c.source, c.realPath, plan.agentsDir);
262
+ result.restoredConfigs.push({ agent: c.agent, realPath: c.realPath });
263
+ }
264
+ else if (c.kind === 'remove-dangling') {
265
+ removeLink(c.realPath);
266
+ result.removedDanglingConfigs.push({ agent: c.agent, realPath: c.realPath });
267
+ }
268
+ // leave-real / leave-foreign / absent: intentionally untouched.
269
+ }
270
+ catch (err) {
271
+ result.errors.push(`config ${c.agent} (${c.realPath}): ${err.message}`);
272
+ }
273
+ }
274
+ // 2. Restore owned home-file symlinks as real files (e.g. ~/.claude.json).
275
+ for (const hf of plan.homeFiles) {
276
+ try {
277
+ removeLink(hf.realPath);
278
+ fs.cpSync(hf.source, hf.realPath, { recursive: true });
279
+ result.restoredHomeFiles.push(hf.realPath);
280
+ }
281
+ catch (err) {
282
+ result.errors.push(`home file ${hf.realPath}: ${err.message}`);
283
+ }
284
+ }
285
+ // 3. Release adopted launchers (reads records inside ~/.agents).
286
+ const byCli = new Map(ALL_AGENT_IDS.map((a) => [AGENTS[a].cliCommand, a]));
287
+ for (const cli of plan.launchers) {
288
+ const agent = byCli.get(cli);
289
+ if (!agent)
290
+ continue;
291
+ try {
292
+ releaseAdoptedLauncher(agent);
293
+ result.releasedLaunchers.push(cli);
294
+ }
295
+ catch (err) {
296
+ result.errors.push(`launcher ${cli}: ${err.message}`);
297
+ }
298
+ }
299
+ // 4. Strip the shim directory from the user's PATH across all rc files.
300
+ const shimsDir = getShimsDir();
301
+ for (const rc of plan.rcFiles) {
302
+ try {
303
+ const content = fs.readFileSync(rc, 'utf-8');
304
+ fs.writeFileSync(rc, stripShimPathLines(content, shimsDir));
305
+ result.cleanedRcFiles.push(rc);
306
+ }
307
+ catch (err) {
308
+ result.errors.push(`rc file ${rc}: ${err.message}`);
309
+ }
310
+ }
311
+ // 5. Remove the legacy back-compat symlink, if present. `~/.agents-system` is a
312
+ // link (junction on Windows — createLink uses 'junction' for a dir source), so it
313
+ // goes through removeLink for the same reason as the config links: rmSync throws
314
+ // EFAULT on a Windows reparse point.
315
+ if (plan.legacySymlink) {
316
+ try {
317
+ removeLink(plan.legacySymlink);
318
+ result.legacySymlinkRemoved = true;
319
+ }
320
+ catch (err) {
321
+ result.errors.push(`legacy ${plan.legacySymlink}: ${err.message}`);
322
+ }
323
+ }
324
+ // 6. Dispose of ~/.agents LAST (its backups fed step 1). If any restore above
325
+ // failed, downgrade a --purge to a recoverable move-aside: a swallowed restore
326
+ // error must never let the hard-delete take the user's only copy with it.
327
+ if (fs.existsSync(plan.agentsDir)) {
328
+ const purge = !!opts.purge && result.errors.length === 0;
329
+ if (opts.purge && !purge)
330
+ result.purgeDowngraded = true;
331
+ try {
332
+ if (purge) {
333
+ fs.rmSync(plan.agentsDir, { recursive: true, force: true });
334
+ result.agentsDir = { path: plan.agentsDir, disposition: 'purged' };
335
+ }
336
+ else {
337
+ const movedTo = `${plan.agentsDir}.removed-${opts.timestamp}`;
338
+ fs.renameSync(plan.agentsDir, movedTo);
339
+ result.agentsDir = { path: plan.agentsDir, disposition: 'moved', movedTo };
340
+ }
341
+ }
342
+ catch (err) {
343
+ result.errors.push(`dispose ${plan.agentsDir}: ${err.message}`);
344
+ }
345
+ }
346
+ return result;
347
+ }
@@ -1,5 +1,17 @@
1
1
  import { type AccountInfo } from './agents.js';
2
2
  import type { AgentId } from './types.js';
3
+ /**
4
+ * True when a Claude OAuth access token is within the refresh leeway of expiry
5
+ * (or already expired) — i.e. it "would need a refresh" before the next use.
6
+ *
7
+ * Single source of truth for the expiry gate, shared by the two callers that
8
+ * must agree on it but act differently: the run/usage hot path
9
+ * (`getClaudeAccessToken`) refreshes when this is true; the health probe
10
+ * (`probeClaudeStatus`) must NOT refresh and instead reports the non-fatal
11
+ * `expired` state (RUSH-1822). A missing `expiresAt` is treated as "still
12
+ * fresh" (never force a refresh on a token with no known expiry).
13
+ */
14
+ export declare function claudeAccessTokenNeedsRefresh(expiresAt: number | null | undefined, nowMs?: number): boolean;
3
15
  /** Discriminator for usage window types. */
4
16
  export type UsageWindowKey = 'session' | 'week' | 'sonnet_week' | 'month';
5
17
  /** A single rate-limit window with utilization percentage and reset time. */
@@ -201,7 +213,7 @@ export interface ProviderProbe {
201
213
  /** Network/parse error message when status is null but a token was present. */
202
214
  error?: string;
203
215
  }
204
- /** Probe Claude's OAuth token against the usage endpoint. Refreshes an expired access token (safe for Claude). */
216
+ /** Probe Claude's OAuth token against the usage endpoint. Never refreshes — reports `expired` for a near-expiry token; see the comment below (RUSH-1822). */
205
217
  export declare function probeClaudeStatus(home?: string, cliVersion?: string | null): Promise<ProviderProbe>;
206
218
  /** Probe Kimi's OAuth token against the /usages endpoint. Never refreshes (single-use rotation — see getKimiUsageInfo). */
207
219
  export declare function probeKimiStatus(home?: string): Promise<ProviderProbe>;
package/dist/lib/usage.js CHANGED
@@ -25,6 +25,22 @@ const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
25
25
  const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
26
26
  const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20';
27
27
  const CLAUDE_REFRESH_LEEWAY_MS = 5 * 60 * 1000;
28
+ /**
29
+ * True when a Claude OAuth access token is within the refresh leeway of expiry
30
+ * (or already expired) — i.e. it "would need a refresh" before the next use.
31
+ *
32
+ * Single source of truth for the expiry gate, shared by the two callers that
33
+ * must agree on it but act differently: the run/usage hot path
34
+ * (`getClaudeAccessToken`) refreshes when this is true; the health probe
35
+ * (`probeClaudeStatus`) must NOT refresh and instead reports the non-fatal
36
+ * `expired` state (RUSH-1822). A missing `expiresAt` is treated as "still
37
+ * fresh" (never force a refresh on a token with no known expiry).
38
+ */
39
+ export function claudeAccessTokenNeedsRefresh(expiresAt, nowMs = Date.now()) {
40
+ if (expiresAt == null)
41
+ return false;
42
+ return nowMs + CLAUDE_REFRESH_LEEWAY_MS >= expiresAt;
43
+ }
28
44
  const CLAUDE_SCOPES = [
29
45
  'user:profile',
30
46
  'user:inference',
@@ -609,26 +625,29 @@ async function getDroidUsageInfo(options) {
609
625
  return { snapshot: null, error: null };
610
626
  }
611
627
  }
612
- /** Probe Claude's OAuth token against the usage endpoint. Refreshes an expired access token (safe for Claude). */
628
+ /** Probe Claude's OAuth token against the usage endpoint. Never refreshes — reports `expired` for a near-expiry token; see the comment below (RUSH-1822). */
613
629
  export async function probeClaudeStatus(home, cliVersion) {
614
630
  const oauth = await loadClaudeOauth(home);
615
- if (!oauth?.accessToken)
631
+ const accessToken = oauth?.accessToken?.trim();
632
+ if (!accessToken)
616
633
  return { status: null, token: 'missing' };
617
- let access = null;
618
- try {
619
- access = await getClaudeAccessToken(oauth, home);
620
- }
621
- catch {
622
- access = null;
634
+ // Never refresh from a health probe. Claude's refresh token is single-use and
635
+ // rotates on every refresh; with one account signed into several machines the
636
+ // daemon's every-3-min fleet-cache warm (probeLocalFleetAuth -> here) would
637
+ // stampede that one rotating token and silently invalidate every other
638
+ // holder, dropping the fleet to "run /login" (RUSH-1822). Mirror the sibling
639
+ // Kimi/Droid probes, which never refresh: if the stored token is within the
640
+ // refresh leeway of expiry, report the non-fatal `expired` state ("would need
641
+ // a refresh") instead of rotating it, and leave the single legitimate refresh
642
+ // to the run/usage hot path (getClaudeAccessToken).
643
+ if (claudeAccessTokenNeedsRefresh(oauth?.expiresAt ?? null)) {
644
+ return { status: null, token: 'expired' };
623
645
  }
624
- // Fall back to the stored (possibly stale) token so the server returns the
625
- // real verdict — a 401 here IS the revoked/expired signal we want to surface.
626
- const bearer = access || oauth.accessToken.trim();
627
646
  try {
628
647
  const response = await fetch(CLAUDE_USAGE_URL, {
629
648
  method: 'GET',
630
649
  headers: {
631
- Authorization: `Bearer ${bearer}`,
650
+ Authorization: `Bearer ${accessToken}`,
632
651
  'Content-Type': 'application/json',
633
652
  'anthropic-beta': CLAUDE_OAUTH_BETA_HEADER,
634
653
  'User-Agent': getClaudeUserAgent(cliVersion),
@@ -1058,8 +1077,7 @@ async function getClaudeAccessToken(oauth, home) {
1058
1077
  if (!accessToken) {
1059
1078
  return null;
1060
1079
  }
1061
- const expiresAt = oauth.expiresAt ?? null;
1062
- if (expiresAt === null || Date.now() + CLAUDE_REFRESH_LEEWAY_MS < expiresAt) {
1080
+ if (!claudeAccessTokenNeedsRefresh(oauth.expiresAt ?? null)) {
1063
1081
  return accessToken;
1064
1082
  }
1065
1083
  if (!oauth.refreshToken) {
@@ -216,6 +216,22 @@ export declare function getGlobalDefault(agent: AgentId): string | null;
216
216
  * Set the global default version for an agent.
217
217
  */
218
218
  export declare function setGlobalDefault(agent: AgentId, version: string | undefined): void;
219
+ /**
220
+ * Mark an installed version as an isolated install (`agents add --isolated`).
221
+ *
222
+ * Isolated versions are fully self-contained: they never become the global
223
+ * default and never own the user's real `~/.<agent>` config directory. This
224
+ * flag is what keeps every "adopting" code path away from them.
225
+ */
226
+ export declare function markVersionIsolated(agent: AgentId, version: string): void;
227
+ /**
228
+ * Whether a version was installed as an isolated install (`agents add --isolated`).
229
+ *
230
+ * Used to exclude such versions from global-default promotion and from any
231
+ * flow that would touch the user's real `~/.<agent>` directory, and to gate the
232
+ * `--isolated` safety check on `agents remove`.
233
+ */
234
+ export declare function isVersionIsolated(agent: AgentId, version: string): boolean;
219
235
  /**
220
236
  * Install a specific version of an agent.
221
237
  */
@@ -1148,6 +1148,37 @@ export function setGlobalDefault(agent, version) {
1148
1148
  }
1149
1149
  writeMeta(meta);
1150
1150
  }
1151
+ /**
1152
+ * Path to the sentinel file that marks a version as an isolated install.
1153
+ *
1154
+ * It lives at the version-dir root (a sibling of `home/`), so it is carried
1155
+ * along when `softDeleteVersionDir` moves the whole directory to trash and is
1156
+ * restored intact by `agents trash restore`. Its mere presence is the marker;
1157
+ * the contents are an informational timestamp only.
1158
+ */
1159
+ function getIsolatedMarkerPath(agent, version) {
1160
+ return path.join(getVersionDir(agent, version), '.isolated');
1161
+ }
1162
+ /**
1163
+ * Mark an installed version as an isolated install (`agents add --isolated`).
1164
+ *
1165
+ * Isolated versions are fully self-contained: they never become the global
1166
+ * default and never own the user's real `~/.<agent>` config directory. This
1167
+ * flag is what keeps every "adopting" code path away from them.
1168
+ */
1169
+ export function markVersionIsolated(agent, version) {
1170
+ fs.writeFileSync(getIsolatedMarkerPath(agent, version), `${new Date().toISOString()}\n`, { mode: 0o600 });
1171
+ }
1172
+ /**
1173
+ * Whether a version was installed as an isolated install (`agents add --isolated`).
1174
+ *
1175
+ * Used to exclude such versions from global-default promotion and from any
1176
+ * flow that would touch the user's real `~/.<agent>` directory, and to gate the
1177
+ * `--isolated` safety check on `agents remove`.
1178
+ */
1179
+ export function isVersionIsolated(agent, version) {
1180
+ return fs.existsSync(getIsolatedMarkerPath(agent, version));
1181
+ }
1151
1182
  /**
1152
1183
  * Grok's official installer writes into ~/.grok/downloads, which (because we
1153
1184
  * symlink ~/.grok to the active version home) resolves to the PREVIOUS default
@@ -1676,14 +1707,20 @@ export function removeVersion(agent, version) {
1676
1707
  // broke every launcher after removing the pinned default.
1677
1708
  if (getGlobalDefault(agent) === version) {
1678
1709
  const remaining = listInstalledVersions(agent);
1679
- if (remaining.length > 0) {
1680
- const newestRemaining = remaining[remaining.length - 1];
1710
+ // Never auto-promote an isolated install to the global default: it was
1711
+ // installed to stay separate from the user's setup, and promoting it would
1712
+ // silently make `<agent>` resolve to it (and let a later `use` adopt its
1713
+ // home into `~/.<agent>`). Prefer the newest NON-isolated survivor; if every
1714
+ // survivor is isolated, clear the default rather than adopt one.
1715
+ const promotable = remaining.filter((v) => !isVersionIsolated(agent, v));
1716
+ if (promotable.length > 0) {
1717
+ const newestRemaining = promotable[promotable.length - 1];
1681
1718
  setGlobalDefault(agent, newestRemaining);
1682
1719
  console.log(chalk.yellow(`Default ${agent} was ${version} (removed); reassigned to ${newestRemaining}. Change it with: agents use ${agent}@<version>`));
1683
1720
  }
1684
1721
  else {
1685
1722
  setGlobalDefault(agent, undefined);
1686
- console.log(chalk.yellow(`Removed the last installed ${agent} version and cleared its default. Reinstall with: agents add ${agent}, then set one with: agents use ${agent}@<version>`));
1723
+ console.log(chalk.yellow(`Removed the last non-isolated ${agent} version and cleared its default. Reinstall with: agents add ${agent}, then set one with: agents use ${agent}@<version>`));
1687
1724
  }
1688
1725
  }
1689
1726
  // Clean up dangling config symlink if it pointed to the removed version
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.70",
3
+ "version": "1.20.72",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",