@phnx-labs/agents-cli 1.20.57 → 1.20.58

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 (43) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +34 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/defaults.js +24 -0
  5. package/dist/commands/exec.js +28 -4
  6. package/dist/commands/secrets.js +28 -19
  7. package/dist/commands/versions.js +11 -3
  8. package/dist/commands/view.js +19 -4
  9. package/dist/lib/agents.d.ts +21 -0
  10. package/dist/lib/agents.js +28 -4
  11. package/dist/lib/daemon.d.ts +5 -5
  12. package/dist/lib/daemon.js +65 -17
  13. package/dist/lib/git.d.ts +9 -0
  14. package/dist/lib/git.js +12 -0
  15. package/dist/lib/hosts/dispatch.d.ts +21 -0
  16. package/dist/lib/hosts/dispatch.js +88 -5
  17. package/dist/lib/permissions.d.ts +19 -1
  18. package/dist/lib/permissions.js +137 -0
  19. package/dist/lib/project-root.d.ts +65 -0
  20. package/dist/lib/project-root.js +133 -0
  21. package/dist/lib/resources/permissions.js +2 -0
  22. package/dist/lib/resources/types.d.ts +1 -1
  23. package/dist/lib/secrets/agent.d.ts +26 -23
  24. package/dist/lib/secrets/agent.js +196 -216
  25. package/dist/lib/secrets/remote.js +1 -0
  26. package/dist/lib/session/active.d.ts +3 -0
  27. package/dist/lib/session/active.js +1 -0
  28. package/dist/lib/session/parse.js +38 -15
  29. package/dist/lib/session/state.d.ts +4 -1
  30. package/dist/lib/session/state.js +18 -1
  31. package/dist/lib/session/types.d.ts +8 -0
  32. package/dist/lib/staleness/detectors/permissions.js +42 -0
  33. package/dist/lib/staleness/detectors/subagents.js +30 -0
  34. package/dist/lib/staleness/writers/subagents.js +13 -1
  35. package/dist/lib/subagents.d.ts +22 -0
  36. package/dist/lib/subagents.js +146 -0
  37. package/dist/lib/teams/agents.d.ts +14 -0
  38. package/dist/lib/teams/agents.js +158 -22
  39. package/dist/lib/types.d.ts +13 -0
  40. package/dist/lib/versions.d.ts +39 -0
  41. package/dist/lib/versions.js +199 -12
  42. package/package.json +1 -1
  43. package/scripts/postinstall.js +26 -11
@@ -27,7 +27,7 @@ import { recordRunName } from '../session/run-names.js';
27
27
  import { sshExec, shellQuote } from '../ssh-exec.js';
28
28
  import { resolveHost } from '../hosts/registry.js';
29
29
  import { sshTargetFor } from '../hosts/types.js';
30
- import { dispatchAgentsCommand } from '../hosts/dispatch.js';
30
+ import { dispatchAgentsCommand, terminateDispatchedTask } from '../hosts/dispatch.js';
31
31
  import { ensureHostReady } from '../hosts/ready.js';
32
32
  import { remoteShellFor } from '../hosts/remote-cmd.js';
33
33
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
@@ -746,6 +746,16 @@ export class AgentProcess {
746
746
  }
747
747
  }
748
748
  }
749
+ /** Reset the local stdout cursor for a newly truncated resume log. */
750
+ resetLogReadPosition() {
751
+ const previous = this.lastReadPos;
752
+ this.lastReadPos = 0;
753
+ return previous;
754
+ }
755
+ /** Restore the cursor when a resume transaction puts the prior log back. */
756
+ restoreLogReadPosition(position) {
757
+ this.lastReadPos = position;
758
+ }
749
759
  async readNewEvents() {
750
760
  // Distributed teammate: mirror the host's new log bytes locally first, then
751
761
  // fall through to the identical local read+parse below.
@@ -1107,6 +1117,62 @@ export class AgentProcess {
1107
1117
  }
1108
1118
  }
1109
1119
  }
1120
+ export async function beginResumeLogTransaction(agent) {
1121
+ const stdoutPath = await agent.getStdoutPath();
1122
+ const backupPath = `${stdoutPath}.resume-backup-${randomUUID()}`;
1123
+ let hadOriginal = false;
1124
+ try {
1125
+ await fs.rename(stdoutPath, backupPath);
1126
+ hadOriginal = true;
1127
+ }
1128
+ catch (err) {
1129
+ if (err?.code !== 'ENOENT')
1130
+ throw err;
1131
+ }
1132
+ const previousReadPos = agent.resetLogReadPosition();
1133
+ return { agent, stdoutPath, backupPath, hadOriginal, previousReadPos };
1134
+ }
1135
+ export async function commitResumeLogTransaction(transaction) {
1136
+ if (transaction.hadOriginal)
1137
+ await fs.rm(transaction.backupPath, { force: true });
1138
+ }
1139
+ async function rollbackResumeLogTransaction(transaction) {
1140
+ try {
1141
+ await fs.rm(transaction.stdoutPath, { force: true });
1142
+ if (transaction.hadOriginal) {
1143
+ await fs.rename(transaction.backupPath, transaction.stdoutPath);
1144
+ }
1145
+ }
1146
+ finally {
1147
+ transaction.agent.restoreLogReadPosition(transaction.previousReadPos);
1148
+ }
1149
+ }
1150
+ export async function terminateSpawnedProcess(pid) {
1151
+ try {
1152
+ process.kill(-pid, 'SIGTERM');
1153
+ }
1154
+ catch (err) {
1155
+ if (err?.code === 'ESRCH')
1156
+ return;
1157
+ throw err;
1158
+ }
1159
+ await new Promise(resolve => setTimeout(resolve, 250));
1160
+ try {
1161
+ process.kill(-pid, 0);
1162
+ }
1163
+ catch (err) {
1164
+ if (err?.code === 'ESRCH')
1165
+ return;
1166
+ throw err;
1167
+ }
1168
+ try {
1169
+ process.kill(-pid, 'SIGKILL');
1170
+ }
1171
+ catch (err) {
1172
+ if (err?.code !== 'ESRCH')
1173
+ throw err;
1174
+ }
1175
+ }
1110
1176
  export class AgentManager {
1111
1177
  agents = new Map();
1112
1178
  maxAgents;
@@ -1402,16 +1468,42 @@ export class AgentManager {
1402
1468
  `(it may have failed before its first turn). Start a fresh teammate instead.`);
1403
1469
  }
1404
1470
  const resume = { id: agent.remoteSessionId ?? agent.agentId, message };
1471
+ const priorRuntime = {
1472
+ status: agent.status,
1473
+ completedAt: agent.completedAt,
1474
+ pid: agent.pid,
1475
+ startTime: agent.startTime,
1476
+ startedAt: agent.startedAt,
1477
+ remotePid: agent.remotePid,
1478
+ remoteLog: agent.remoteLog,
1479
+ remoteExit: agent.remoteExit,
1480
+ remoteLogOffset: agent.remoteLogOffset,
1481
+ worktreePath: agent.worktreePath,
1482
+ };
1405
1483
  // Flip to RUNNING up front so a concurrent status poll can't reap the
1406
1484
  // teammate between the exit-sentinel clear and the new PID landing; the
1407
- // launch re-persists with the fresh pid/startTime.
1485
+ // launch re-persists with the fresh pid/startTime. If relaunch fails before
1486
+ // that happens, restore the stopped lifecycle state and keep its existing
1487
+ // metadata/log directory intact so the user can retry.
1408
1488
  agent.status = AgentStatus.RUNNING;
1409
1489
  agent.completedAt = null;
1410
- if (agent.hostName) {
1411
- await this.launchRemoteProcess(agent, resume);
1490
+ try {
1491
+ if (agent.hostName) {
1492
+ await this.launchRemoteProcess(agent, resume);
1493
+ }
1494
+ else {
1495
+ await this.launchProcess(agent, resume);
1496
+ }
1412
1497
  }
1413
- else {
1414
- await this.launchProcess(agent, resume);
1498
+ catch (err) {
1499
+ Object.assign(agent, priorRuntime);
1500
+ try {
1501
+ await agent.saveMeta();
1502
+ }
1503
+ catch (restoreErr) {
1504
+ throw new Error(`Failed to resume teammate: ${err.message}; restoring stopped state also failed: ${restoreErr.message}`, { cause: err });
1505
+ }
1506
+ throw err;
1415
1507
  }
1416
1508
  return agent;
1417
1509
  }
@@ -1429,8 +1521,13 @@ export class AgentManager {
1429
1521
  const resolvedModel = agent.model ?? null;
1430
1522
  const cmd = this.buildCommand(agent.agentType, agent.prompt, agent.mode, resolvedModel, agent.cwd, agent.agentId, effort, agent.version, agent.profileName, resume);
1431
1523
  debug(`Launching ${agent.agentType} agent ${agent.agentId} [${agent.mode}]${resume ? ' (resume)' : ''}: ${cmd.slice(0, 3).join(' ')}...`);
1524
+ let childProcess = null;
1525
+ let stdoutFile = null;
1526
+ let resumeLog = null;
1432
1527
  try {
1433
- const stdoutPath = await agent.getStdoutPath();
1528
+ if (resume)
1529
+ resumeLog = await beginResumeLogTransaction(agent);
1530
+ const stdoutPath = resumeLog?.stdoutPath ?? await agent.getStdoutPath();
1434
1531
  // Always TRUNCATE — including on resume. The status reader re-reads the
1435
1532
  // whole log from byte 0 every poll (lastReadPos is in-memory, not
1436
1533
  // persisted) and marks terminal status from the last `result` event it
@@ -1441,7 +1538,7 @@ export class AgentManager {
1441
1538
  // a forked session. Truncating keeps exactly one turn in the log, so the
1442
1539
  // re-read is always correct. The authoritative transcript lives in the
1443
1540
  // agent's own session (resumed via --resume), not this stdout mirror.
1444
- const stdoutFile = await fs.open(stdoutPath, 'w');
1541
+ stdoutFile = await fs.open(stdoutPath, 'w');
1445
1542
  const stdoutFd = stdoutFile.fd;
1446
1543
  // Wrap the teammate command in a shell that records the underlying CLI's
1447
1544
  // exit code to a sentinel file. Detached + unref()'d children can't be
@@ -1455,7 +1552,7 @@ export class AgentManager {
1455
1552
  const wrappedCmd = buildSentinelCommand(cmd, exitCodePath);
1456
1553
  // detached:true makes the shell the process-group leader, so stop()'s
1457
1554
  // `kill(-pid)` still reaches the underlying CLI through the group.
1458
- const childProcess = spawn('/bin/sh', ['-c', wrappedCmd], {
1555
+ childProcess = spawn('/bin/sh', ['-c', wrappedCmd], {
1459
1556
  stdio: ['ignore', stdoutFd, stdoutFd],
1460
1557
  cwd: agent.cwd || undefined,
1461
1558
  detached: true,
@@ -1463,8 +1560,13 @@ export class AgentManager {
1463
1560
  ? { ...sanitizeProcessEnv(process.env), ...agent.envOverrides }
1464
1561
  : sanitizeProcessEnv(process.env),
1465
1562
  });
1563
+ await new Promise((resolve, reject) => {
1564
+ childProcess.once('spawn', resolve);
1565
+ childProcess.once('error', reject);
1566
+ });
1466
1567
  childProcess.unref();
1467
- stdoutFile.close().catch(() => { });
1568
+ await stdoutFile.close();
1569
+ stdoutFile = null;
1468
1570
  agent.pid = childProcess.pid || null;
1469
1571
  // Capture start-time NOW, while we know the PID is ours. Once the
1470
1572
  // OS reuses this PID slot, /proc and `ps` will report a different
@@ -1474,9 +1576,21 @@ export class AgentManager {
1474
1576
  agent.status = AgentStatus.RUNNING;
1475
1577
  agent.startedAt = new Date();
1476
1578
  await agent.saveMeta();
1579
+ if (resumeLog)
1580
+ await commitResumeLogTransaction(resumeLog);
1477
1581
  }
1478
1582
  catch (err) {
1479
- await this.cleanupPartialAgent(agent);
1583
+ if (stdoutFile)
1584
+ await stdoutFile.close().catch(() => { });
1585
+ if (childProcess?.pid)
1586
+ await terminateSpawnedProcess(childProcess.pid);
1587
+ if (resumeLog)
1588
+ await rollbackResumeLogTransaction(resumeLog);
1589
+ // Fresh spawns own a newly-created directory, so a failed launch removes
1590
+ // that partial record. A resume reuses an existing teammate: its caller
1591
+ // restores the prior terminal state and preserves the directory for retry.
1592
+ if (!resume)
1593
+ await this.cleanupPartialAgent(agent);
1480
1594
  console.error(`Failed to spawn agent ${agent.agentId}:`, err);
1481
1595
  throw new Error(`Failed to spawn agent: ${err.message}`);
1482
1596
  }
@@ -1535,12 +1649,19 @@ export class AgentManager {
1535
1649
  // — the supervisor polls the host, we don't block here.
1536
1650
  const effort = agent.effort ?? 'medium';
1537
1651
  const forwardedArgs = this.buildRunArgv(agent.agentType, agent.prompt, agent.mode, agent.model ?? null, effort, agent.version, agent.profileName, resume);
1652
+ let dispatchedTask = null;
1653
+ let resumeLog = null;
1538
1654
  try {
1655
+ if (resume) {
1656
+ resumeLog = await beginResumeLogTransaction(agent);
1657
+ await fs.writeFile(resumeLog.stdoutPath, '');
1658
+ }
1539
1659
  const { task } = await dispatchAgentsCommand(host, {
1540
1660
  forwardedArgs,
1541
1661
  remoteCwd,
1542
1662
  follow: false,
1543
1663
  });
1664
+ dispatchedTask = task;
1544
1665
  agent.remotePid = task.pid ?? null;
1545
1666
  agent.remoteLog = task.remoteLog ?? null;
1546
1667
  agent.remoteExit = task.remoteExit ?? null;
@@ -1549,15 +1670,34 @@ export class AgentManager {
1549
1670
  // syncRemoteMirror appends the delta onto the local mirror. Truncate that
1550
1671
  // mirror first so the prior turn's terminal event can't linger and get
1551
1672
  // re-read as the current status (same hazard the local path truncates for).
1552
- if (resume) {
1553
- await fs.writeFile(await agent.getStdoutPath(), '').catch(() => { });
1554
- }
1555
1673
  agent.status = AgentStatus.RUNNING;
1556
1674
  agent.startedAt = new Date();
1557
1675
  await agent.saveMeta();
1676
+ if (resumeLog)
1677
+ await commitResumeLogTransaction(resumeLog);
1558
1678
  }
1559
1679
  catch (err) {
1680
+ let cleanupError = null;
1681
+ if (dispatchedTask) {
1682
+ try {
1683
+ terminateDispatchedTask(dispatchedTask);
1684
+ }
1685
+ catch (cleanupErr) {
1686
+ cleanupError = cleanupErr;
1687
+ }
1688
+ }
1689
+ if (resumeLog) {
1690
+ try {
1691
+ await rollbackResumeLogTransaction(resumeLog);
1692
+ }
1693
+ catch (cleanupErr) {
1694
+ cleanupError = cleanupError ?? cleanupErr;
1695
+ }
1696
+ }
1560
1697
  console.error(`Failed to launch remote teammate ${agent.agentId} on ${agent.hostName}:`, err);
1698
+ if (cleanupError) {
1699
+ throw new Error(`Failed to launch remote teammate: ${err.message}; cleanup failed: ${cleanupError.message}`, { cause: err });
1700
+ }
1561
1701
  throw new Error(`Failed to launch remote teammate: ${err.message}`);
1562
1702
  }
1563
1703
  debug(`Launched remote agent ${agent.agentId} on ${agent.hostName} (remote pid ${agent.remotePid})`);
@@ -1910,17 +2050,13 @@ export class AgentManager {
1910
2050
  if (!agent) {
1911
2051
  return false;
1912
2052
  }
1913
- // Distributed teammate: no local PID — signal it over SSH. Try the process
1914
- // GROUP first (negative pid, matching local `kill(-pid)`) to catch the
1915
- // detached `agents run` and its children; but the remote launcher is
1916
- // `nohup bash -lc … &` under a non-interactive shell where job control is off,
1917
- // so `&` may NOT open a new group — fall back to signalling the wrapper pid
1918
- // directly. Best-effort either way; the `.exit` sentinel is the durable
1919
- // terminal-status source if a grandchild lingers.
2053
+ // Distributed teammate: no local PID — signal the dedicated process group
2054
+ // created by dispatch.ts. The persisted PID is the group leader, so one
2055
+ // negative-PID signal reaches the login-shell wrapper and every descendant.
1920
2056
  if (agent.hostName && agent.status === AgentStatus.RUNNING) {
1921
2057
  if (agent.hostTarget && agent.remotePid) {
1922
2058
  try {
1923
- sshExec(agent.hostTarget, `kill -TERM -- -${agent.remotePid} 2>/dev/null || kill -TERM ${agent.remotePid} 2>/dev/null`, {
2059
+ sshExec(agent.hostTarget, `kill -TERM -- -${agent.remotePid} 2>/dev/null`, {
1924
2060
  timeoutMs: 10000,
1925
2061
  multiplex: true,
1926
2062
  });
@@ -643,6 +643,12 @@ export interface Meta {
643
643
  registries?: Record<RegistryType, Record<string, RegistryConfig>>;
644
644
  versions?: Partial<Record<AgentId, Record<string, VersionResources>>>;
645
645
  source?: string;
646
+ /**
647
+ * Projects root for the `agents run --project <slug>` shorthand, e.g.
648
+ * `~/src/github.com/<user>`. Auto-inferred from the repo you launch inside and
649
+ * cached here; stored home-relative (`~/…`) so it resolves on remote hosts too.
650
+ */
651
+ projectRoot?: string;
646
652
  /**
647
653
  * Extra DotAgent repos merged after ~/.agents/. Managed clones live as peer
648
654
  * dirs at ~/.agents-<alias>/; user-owned repos can point at arbitrary paths
@@ -771,6 +777,13 @@ export interface ClaudePermissions {
771
777
  additionalDirectories?: string[];
772
778
  };
773
779
  }
780
+ /** Cursor CLI native format in ~/.cursor/cli-config.json (Shell/Read/Write/WebFetch/Mcp). */
781
+ export interface CursorPermissions {
782
+ permissions: {
783
+ allow: string[];
784
+ deny: string[];
785
+ };
786
+ }
774
787
  /** OpenCode's native permission format (per-command allow/deny/ask). */
775
788
  export interface OpenCodePermissions {
776
789
  permission: {
@@ -114,6 +114,41 @@ export declare function getVersionDir(agent: AgentId, version: string): string;
114
114
  * Get the binary path for a specific agent version.
115
115
  */
116
116
  export declare function getBinaryPath(agent: AgentId, version: string): string;
117
+ /**
118
+ * Does this agent resolve to ONE global binary that is the same file regardless
119
+ * of the `version` argument? (droid → always `~/.local/bin/droid`.) Computed
120
+ * generically by probing `getBinaryPath` with two distinct versions rather than
121
+ * hardcoding an agent id, so it stays correct if another global-binary agent is
122
+ * added.
123
+ *
124
+ * This is the narrower cousin of `isSelfUpdatingAgent`: every global-binary
125
+ * agent is self-updating, but grok is self-updating WITHOUT a global binary — it
126
+ * stores a real per-version binary copy under each version-home
127
+ * (`versions/grok/<v>/home/.grok/downloads/grok-<v>`), so its version-homes are
128
+ * genuinely distinct and must NOT be collapsed. Gate the single-binary
129
+ * collapse/live-version logic on THIS predicate; gate pin-refusal / "switch
130
+ * profile" copy on `isSelfUpdatingAgent`.
131
+ */
132
+ export declare function isGlobalBinaryAgent(agent: AgentId): boolean;
133
+ /** Drop the live-version cache (call after an install/remove that changes the
134
+ * running binary, e.g. `agents add droid@latest`). */
135
+ export declare function invalidateLiveVersionCache(agent?: AgentId): void;
136
+ /**
137
+ * Resolve the version the ONE globally-installed binary actually reports via
138
+ * `<cli> --version`, cached for {@link LIVE_VERSION_TTL_MS}. For a self-updating
139
+ * global-binary agent (droid) this is the single source of truth for "which
140
+ * version is installed" — the on-disk version-dir NAMES are just stale labels
141
+ * left behind by successive `agents add`/self-update cycles. Returns null when
142
+ * the binary isn't on PATH or the probe fails.
143
+ */
144
+ export declare function getLiveVersion(agent: AgentId): Promise<string | null>;
145
+ /**
146
+ * Synchronous, non-blocking read of the live-version cache — returns the value
147
+ * only if a recent {@link getLiveVersion} call already warmed it, else null.
148
+ * `listInstalledVersions` is sync and must not shell out, so it prefers this
149
+ * warm value (accurate) and otherwise falls back to the newest on-disk dir.
150
+ */
151
+ export declare function getCachedLiveVersion(agent: AgentId): string | null;
117
152
  /**
118
153
  * Get the isolated HOME directory for a specific agent version.
119
154
  * Each version has its own config isolation (like jobs sandbox).
@@ -155,6 +190,10 @@ export declare function isOldestInstalled(agent: AgentId): Promise<{
155
190
  export declare function invalidateInstalledVersionsCache(agent?: AgentId): void;
156
191
  /**
157
192
  * List all installed versions for an agent (cached by versions-dir mtime).
193
+ *
194
+ * For a self-updating global-binary agent (droid) every version dir resolves to
195
+ * the SAME binary, so this collapses them to a single canonical entry — one
196
+ * install, one row in `agents view`, never the phantom set of semver dir names.
158
197
  */
159
198
  export declare function listInstalledVersions(agent: AgentId): string[];
160
199
  /**
@@ -28,7 +28,7 @@ import { listResources } from './resources.js';
28
28
  // (single source of truth). Re-exported below so existing importers of
29
29
  // `compareVersions` from './versions.js' keep working.
30
30
  import { VERSION_RE, compareVersions } from './agent-spec/primitives.js';
31
- import { AGENTS, agentConfigDirName, getAccountEmail, resolveAgentName, formatAgentError, findInPath } from './agents.js';
31
+ import { AGENTS, agentConfigDirName, getAccountEmail, resolveAgentName, formatAgentError, findInPath, isSelfUpdatingAgent } from './agents.js';
32
32
  import { discoverPermissionGroups, getActivePermissionPresetName, readPermissionPresetRecipe, PERMISSION_PRESET_ENV_VAR } from './permissions.js';
33
33
  import { parseMcpServerConfig } from './mcp.js';
34
34
  import { createVersionedAlias, removeVersionedAlias, getConfigSymlinkVersion, ensureClaudeInsideSymlink } from './shims.js';
@@ -835,6 +835,65 @@ export function getBinaryPath(agent, version) {
835
835
  const versionDir = getVersionDir(agent, version);
836
836
  return path.join(versionDir, 'node_modules', '.bin', agentConfig.cliCommand);
837
837
  }
838
+ /**
839
+ * Does this agent resolve to ONE global binary that is the same file regardless
840
+ * of the `version` argument? (droid → always `~/.local/bin/droid`.) Computed
841
+ * generically by probing `getBinaryPath` with two distinct versions rather than
842
+ * hardcoding an agent id, so it stays correct if another global-binary agent is
843
+ * added.
844
+ *
845
+ * This is the narrower cousin of `isSelfUpdatingAgent`: every global-binary
846
+ * agent is self-updating, but grok is self-updating WITHOUT a global binary — it
847
+ * stores a real per-version binary copy under each version-home
848
+ * (`versions/grok/<v>/home/.grok/downloads/grok-<v>`), so its version-homes are
849
+ * genuinely distinct and must NOT be collapsed. Gate the single-binary
850
+ * collapse/live-version logic on THIS predicate; gate pin-refusal / "switch
851
+ * profile" copy on `isSelfUpdatingAgent`.
852
+ */
853
+ export function isGlobalBinaryAgent(agent) {
854
+ return getBinaryPath(agent, '0.0.0-probe-a') === getBinaryPath(agent, '0.0.0-probe-b');
855
+ }
856
+ // Live-version cache for self-updating global binaries. `<cli> --version` is a
857
+ // ~real shell-out, so hold the result briefly: the same `agents view` render
858
+ // asks for it from both listInstalledVersions (sync) and the label path (async).
859
+ const LIVE_VERSION_TTL_MS = 5000;
860
+ const liveVersionCache = new Map();
861
+ /** Drop the live-version cache (call after an install/remove that changes the
862
+ * running binary, e.g. `agents add droid@latest`). */
863
+ export function invalidateLiveVersionCache(agent) {
864
+ if (agent)
865
+ liveVersionCache.delete(agent);
866
+ else
867
+ liveVersionCache.clear();
868
+ }
869
+ /**
870
+ * Resolve the version the ONE globally-installed binary actually reports via
871
+ * `<cli> --version`, cached for {@link LIVE_VERSION_TTL_MS}. For a self-updating
872
+ * global-binary agent (droid) this is the single source of truth for "which
873
+ * version is installed" — the on-disk version-dir NAMES are just stale labels
874
+ * left behind by successive `agents add`/self-update cycles. Returns null when
875
+ * the binary isn't on PATH or the probe fails.
876
+ */
877
+ export async function getLiveVersion(agent) {
878
+ const cached = liveVersionCache.get(agent);
879
+ if (cached && Date.now() - cached.at < LIVE_VERSION_TTL_MS)
880
+ return cached.version;
881
+ const version = await getCliVersionFromPath(agent);
882
+ liveVersionCache.set(agent, { at: Date.now(), version });
883
+ return version;
884
+ }
885
+ /**
886
+ * Synchronous, non-blocking read of the live-version cache — returns the value
887
+ * only if a recent {@link getLiveVersion} call already warmed it, else null.
888
+ * `listInstalledVersions` is sync and must not shell out, so it prefers this
889
+ * warm value (accurate) and otherwise falls back to the newest on-disk dir.
890
+ */
891
+ export function getCachedLiveVersion(agent) {
892
+ const cached = liveVersionCache.get(agent);
893
+ if (cached && Date.now() - cached.at < LIVE_VERSION_TTL_MS)
894
+ return cached.version;
895
+ return null;
896
+ }
838
897
  /**
839
898
  * Get the isolated HOME directory for a specific agent version.
840
899
  * Each version has its own config isolation (like jobs sandbox).
@@ -970,8 +1029,43 @@ export function invalidateInstalledVersionsCache(agent) {
970
1029
  else
971
1030
  installedVersionsCache.clear();
972
1031
  }
1032
+ /**
1033
+ * Choose the single canonical version-dir to represent a self-updating
1034
+ * global-binary agent (droid). All its version dirs map to ONE binary, so
1035
+ * exactly one is real; the rest are stale labels. Prefer, in order: the dir the
1036
+ * live config symlink points at (what actually runs), the recorded global
1037
+ * default, the live `--version` (when the cache is warm), else the newest dir.
1038
+ * `versions` MUST be sorted ascending. Callers guarantee it is non-empty.
1039
+ */
1040
+ function pickCanonicalGlobalBinaryVersion(agent, versions) {
1041
+ const symlinkVersion = getConfigSymlinkVersion(agent);
1042
+ if (symlinkVersion && versions.includes(symlinkVersion))
1043
+ return symlinkVersion;
1044
+ const globalDefault = getGlobalDefault(agent);
1045
+ if (globalDefault && versions.includes(globalDefault))
1046
+ return globalDefault;
1047
+ const live = getCachedLiveVersion(agent);
1048
+ if (live && versions.includes(live))
1049
+ return live;
1050
+ return versions[versions.length - 1];
1051
+ }
1052
+ /**
1053
+ * Collapse a global-binary agent's phantom version dirs to the single canonical
1054
+ * one (see {@link pickCanonicalGlobalBinaryVersion}). No-op for npm-packaged and
1055
+ * per-version agents (claude/codex/grok/…), whose version dirs are genuinely
1056
+ * distinct installs.
1057
+ */
1058
+ function collapseGlobalBinaryVersions(agent, versions) {
1059
+ if (!isGlobalBinaryAgent(agent) || versions.length === 0)
1060
+ return versions;
1061
+ return [pickCanonicalGlobalBinaryVersion(agent, versions)];
1062
+ }
973
1063
  /**
974
1064
  * List all installed versions for an agent (cached by versions-dir mtime).
1065
+ *
1066
+ * For a self-updating global-binary agent (droid) every version dir resolves to
1067
+ * the SAME binary, so this collapses them to a single canonical entry — one
1068
+ * install, one row in `agents view`, never the phantom set of semver dir names.
975
1069
  */
976
1070
  export function listInstalledVersions(agent) {
977
1071
  const agentVersionsDir = path.join(getVersionsDir(), agent);
@@ -985,7 +1079,9 @@ export function listInstalledVersions(agent) {
985
1079
  }
986
1080
  const cached = installedVersionsCache.get(agent);
987
1081
  if (cached && cached.stamp === stamp) {
988
- return cached.versions;
1082
+ // Collapse is applied per-call (not cached): it depends on the live-version
1083
+ // cache + config symlink, which can change without the versions-dir mtime.
1084
+ return collapseGlobalBinaryVersions(agent, cached.versions);
989
1085
  }
990
1086
  const entries = fs.readdirSync(agentVersionsDir, { withFileTypes: true });
991
1087
  const versions = [];
@@ -1001,7 +1097,7 @@ export function listInstalledVersions(agent) {
1001
1097
  }
1002
1098
  versions.sort(compareVersions);
1003
1099
  installedVersionsCache.set(agent, { stamp, versions });
1004
- return versions;
1100
+ return collapseGlobalBinaryVersions(agent, versions);
1005
1101
  }
1006
1102
  /**
1007
1103
  * List every version directory for an agent, including ones missing the
@@ -1067,18 +1163,31 @@ export async function installVersion(agent, version, onProgress, opts) {
1067
1163
  if (!agentConfig.installScript) {
1068
1164
  return { success: false, installedVersion: version, error: 'Agent has no npm package' };
1069
1165
  }
1070
- if (version !== 'latest' && !agentConfig.installScript.includes('VERSION')) {
1071
- return {
1072
- success: false,
1073
- installedVersion: version,
1074
- error: `${agentConfig.name} installer does not support version-pinned installs. Use ${agent}@latest.`,
1075
- };
1166
+ // A self-updating agent (droid, grok, …) is a single global binary whose
1167
+ // installer only ever fetches the CURRENT release — there is no semver to
1168
+ // pin. Rather than hard-refuse `<agent>@1.2.3` (the old behavior):
1169
+ // - if the binary is already installed, a pin is a no-op — it self-updates
1170
+ // in place, so skip the installer and just refresh our bookkeeping;
1171
+ // - otherwise redirect the pin to a current-release install.
1172
+ let runInstaller = true;
1173
+ if (version !== 'latest' && isSelfUpdatingAgent(agent)) {
1174
+ const liveVersion = await getLiveVersion(agent);
1175
+ if (liveVersion) {
1176
+ onProgress?.(`${agentConfig.name} is a single self-updating binary — @${version} maps to the already-installed current release (${liveVersion}); nothing to install.`);
1177
+ runInstaller = false;
1178
+ }
1179
+ else {
1180
+ onProgress?.(`${agentConfig.name} is a single self-updating binary with no pinnable versions — installing the current release (ignoring @${version}).`);
1181
+ }
1182
+ version = 'latest';
1076
1183
  }
1077
1184
  let installedVersion = version;
1078
1185
  try {
1079
- const script = agentConfig.installScript.replaceAll('VERSION', version);
1080
- onProgress?.(`Installing ${agentConfig.name}@${version} via official installer...`);
1081
- await execAsync(script, { timeout: 120000 });
1186
+ if (runInstaller) {
1187
+ const script = agentConfig.installScript.replaceAll('VERSION', version);
1188
+ onProgress?.(`Installing ${agentConfig.name}@${version} via official installer...`);
1189
+ await execAsync(script, { timeout: 120000 });
1190
+ }
1082
1191
  if (version === 'latest') {
1083
1192
  installedVersion = await getCliVersionFromPath(agent) || version;
1084
1193
  // Fold any stale literal `latest` dir from an earlier probe-failed
@@ -1125,6 +1234,9 @@ export async function installVersion(agent, version, onProgress, opts) {
1125
1234
  correctly reports it uninstalled. */
1126
1235
  }
1127
1236
  createVersionedAlias(agent, installedVersion);
1237
+ // The self-updating binary just changed on disk — drop the cached
1238
+ // `--version` so `agents view` reflects the freshly-installed release.
1239
+ invalidateLiveVersionCache(agent);
1128
1240
  emit('version.install', { agent, version: installedVersion });
1129
1241
  return { success: true, installedVersion };
1130
1242
  }
@@ -1319,6 +1431,13 @@ function removeInstallArtifacts(versionDir) {
1319
1431
  * renaming that dir would dangle the live symlink.
1320
1432
  */
1321
1433
  export async function reconcileStaleLatestForAgent(agent) {
1434
+ // Global-binary agents (droid) can accumulate MANY stale semver dirs — not
1435
+ // just a literal `latest` — because every `agents add` after an in-place
1436
+ // self-update creates a fresh dir for the same one binary. Fold them all.
1437
+ if (isGlobalBinaryAgent(agent)) {
1438
+ await reconcileGlobalBinaryVersions(agent);
1439
+ return;
1440
+ }
1322
1441
  if (!fs.existsSync(getVersionDir(agent, 'latest')))
1323
1442
  return;
1324
1443
  if (getConfigSymlinkVersion(agent) === 'latest')
@@ -1328,6 +1447,74 @@ export async function reconcileStaleLatestForAgent(agent) {
1328
1447
  await reconcileStaleLatestDir(agent, concrete);
1329
1448
  }
1330
1449
  }
1450
+ /**
1451
+ * Fold every stale version-dir of a global-binary agent (droid) into the single
1452
+ * canonical survivor. All its dirs point at ONE binary, so the extras — left by
1453
+ * successive `agents add` + in-place self-updates, and by probe-failed installs
1454
+ * that created a literal `latest` dir — are phantom labels. Soft-delete each
1455
+ * non-survivor dir (its `home/` stays recoverable via `agents restore`) so disk
1456
+ * converges to a single install matching what `agents view` shows.
1457
+ *
1458
+ * Survivor selection matches listInstalledVersions' canonical choice
1459
+ * (config-symlink target → global default → live version → newest). The survivor
1460
+ * is NOT renamed: droid's ~/.factory symlink points inside its home, and the
1461
+ * live version label is surfaced by the view renderer via getLiveVersion.
1462
+ */
1463
+ async function reconcileGlobalBinaryVersions(agent) {
1464
+ // Step 1 — preserve the original literal-`latest` reconcile: a probe-failed
1465
+ // install leaves a `versions/<agent>/latest/` dir; fold it onto the concrete
1466
+ // live version (rename if that dir is absent, else trash). Skipped while the
1467
+ // config symlink still points at `latest` (renaming would dangle it).
1468
+ if (fs.existsSync(getVersionDir(agent, 'latest')) && getConfigSymlinkVersion(agent) !== 'latest') {
1469
+ const concrete = await getCliVersionFromPath(agent);
1470
+ if (concrete && concrete !== 'latest') {
1471
+ await reconcileStaleLatestDir(agent, concrete);
1472
+ }
1473
+ }
1474
+ // Step 2 — collapse any remaining phantom SEMVER dirs (successive `agents add`
1475
+ // after in-place self-updates) into a single survivor.
1476
+ const agentVersionsDir = path.join(getVersionsDir(), agent);
1477
+ let entries;
1478
+ try {
1479
+ entries = fs.readdirSync(agentVersionsDir, { withFileTypes: true });
1480
+ }
1481
+ catch {
1482
+ return;
1483
+ }
1484
+ const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name).sort(compareVersions);
1485
+ if (dirs.length <= 1)
1486
+ return;
1487
+ // Warm the live-version cache so the survivor pick (and later view labels) can
1488
+ // prefer the version the binary actually reports.
1489
+ await getLiveVersion(agent);
1490
+ const survivor = pickCanonicalGlobalBinaryVersion(agent, dirs);
1491
+ const symlinkVersion = getConfigSymlinkVersion(agent);
1492
+ let foldedAny = false;
1493
+ for (const version of dirs) {
1494
+ if (version === survivor)
1495
+ continue;
1496
+ // Never trash the dir the live config symlink points at — that would dangle
1497
+ // the symlink. pickCanonical already prefers it as survivor; this guards the
1498
+ // rare mismatch.
1499
+ if (version === symlinkVersion)
1500
+ continue;
1501
+ const staleDir = getVersionDir(agent, version);
1502
+ const trashPath = softDeleteVersionDir(agent, version);
1503
+ if (trashPath) {
1504
+ const { updateSessionFilePaths } = await import('./session/db.js');
1505
+ updateSessionFilePaths(staleDir, trashPath);
1506
+ foldedAny = true;
1507
+ }
1508
+ }
1509
+ if (foldedAny) {
1510
+ invalidateInstalledVersionsCache(agent);
1511
+ // Keep the recorded default pointing at a dir that still exists on disk.
1512
+ const def = getGlobalDefault(agent);
1513
+ if (!def || !fs.existsSync(getVersionDir(agent, def))) {
1514
+ setGlobalDefault(agent, survivor);
1515
+ }
1516
+ }
1517
+ }
1331
1518
  export async function reconcileStaleLatestDir(agent, installedVersion) {
1332
1519
  if (installedVersion === 'latest')
1333
1520
  return 'none';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.20.57",
3
+ "version": "1.20.58",
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",