@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.
- package/CHANGELOG.md +20 -0
- package/README.md +34 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/defaults.js +24 -0
- package/dist/commands/exec.js +28 -4
- package/dist/commands/secrets.js +28 -19
- package/dist/commands/versions.js +11 -3
- package/dist/commands/view.js +19 -4
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +28 -4
- package/dist/lib/daemon.d.ts +5 -5
- package/dist/lib/daemon.js +65 -17
- package/dist/lib/git.d.ts +9 -0
- package/dist/lib/git.js +12 -0
- package/dist/lib/hosts/dispatch.d.ts +21 -0
- package/dist/lib/hosts/dispatch.js +88 -5
- package/dist/lib/permissions.d.ts +19 -1
- package/dist/lib/permissions.js +137 -0
- package/dist/lib/project-root.d.ts +65 -0
- package/dist/lib/project-root.js +133 -0
- package/dist/lib/resources/permissions.js +2 -0
- package/dist/lib/resources/types.d.ts +1 -1
- package/dist/lib/secrets/agent.d.ts +26 -23
- package/dist/lib/secrets/agent.js +196 -216
- package/dist/lib/secrets/remote.js +1 -0
- package/dist/lib/session/active.d.ts +3 -0
- package/dist/lib/session/active.js +1 -0
- package/dist/lib/session/parse.js +38 -15
- package/dist/lib/session/state.d.ts +4 -1
- package/dist/lib/session/state.js +18 -1
- package/dist/lib/session/types.d.ts +8 -0
- package/dist/lib/staleness/detectors/permissions.js +42 -0
- package/dist/lib/staleness/detectors/subagents.js +30 -0
- package/dist/lib/staleness/writers/subagents.js +13 -1
- package/dist/lib/subagents.d.ts +22 -0
- package/dist/lib/subagents.js +146 -0
- package/dist/lib/teams/agents.d.ts +14 -0
- package/dist/lib/teams/agents.js +158 -22
- package/dist/lib/types.d.ts +13 -0
- package/dist/lib/versions.d.ts +39 -0
- package/dist/lib/versions.js +199 -12
- package/package.json +1 -1
- package/scripts/postinstall.js +26 -11
package/dist/lib/teams/agents.js
CHANGED
|
@@ -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
|
-
|
|
1411
|
-
|
|
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
|
-
|
|
1414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()
|
|
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
|
-
|
|
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
|
|
1914
|
-
//
|
|
1915
|
-
//
|
|
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
|
|
2059
|
+
sshExec(agent.hostTarget, `kill -TERM -- -${agent.remotePid} 2>/dev/null`, {
|
|
1924
2060
|
timeoutMs: 10000,
|
|
1925
2061
|
multiplex: true,
|
|
1926
2062
|
});
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -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: {
|
package/dist/lib/versions.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/lib/versions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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.
|
|
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",
|