@phnx-labs/agents-cli 1.22.79 → 1.22.80
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +4 -0
- package/dist/commands/view.d.ts +65 -3
- package/dist/commands/view.js +121 -56
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.80
|
|
4
|
+
|
|
5
|
+
- **`agents prune cleanup` collapses duplicate account homes to one per account — and keeps the RIGHT one (PHNX-3940).** After the automatic-update pass moves every home to the latest release in place, an account can be left with two homes: the original identity-captured login and a freshly re-logged-in home that only carries an email ("usage unavailable"). Consolidation now groups those together and keeps the home that best represents the account — captured identity first, then a signed-in credential, then the newest running release — instead of the blind highest dir-name semver, which was the just-created duplicate and whose selection trashed the working login. When the retired duplicate held the global default, the default is repointed onto the keeper first, so the duplicate actually collapses instead of being skipped. Retirement stays a reversible soft-delete (`agents restore`). Source: `cli/src/commands/view.ts` (`planDuplicatePrune`).
|
|
6
|
+
|
|
3
7
|
## 1.22.79
|
|
4
8
|
|
|
5
9
|
- **Managed harnesses follow the latest release without replacing account homes (PHNX-3940).** Automatic updates are on by default and configurable with `updates.auto` and per-harness `updates.<harness>.auto`. Updates preserve existing home paths and identities, defer active installations, and stage and verify the executable before committing it. A concrete `--to` release pins an installation; `--to latest` resumes automatic updates. `agents update --check` previews without changing local state. Source: `cli/src/lib/installations/update.ts`, `cli/src/lib/daemon/harness-update-service.ts`, `cli/src/commands/update.ts`.
|
package/dist/commands/view.d.ts
CHANGED
|
@@ -95,6 +95,23 @@ export declare function parseResourceSections(options: {
|
|
|
95
95
|
export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSections?: Set<ResourceSection>, opts?: {
|
|
96
96
|
forceRefresh?: boolean;
|
|
97
97
|
}): Promise<ViewJsonAgent[]>;
|
|
98
|
+
interface PrunePlanEntry {
|
|
99
|
+
agentId: AgentId;
|
|
100
|
+
version: string;
|
|
101
|
+
email: string;
|
|
102
|
+
keeper: string;
|
|
103
|
+
isDefault: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* 'duplicate' — older version sharing an email with a newer install.
|
|
106
|
+
* 'home-leftover' — home-only dir left over after a previous removeVersion;
|
|
107
|
+
* no binary, but transcripts may still live here.
|
|
108
|
+
*/
|
|
109
|
+
reason: 'duplicate' | 'home-leftover';
|
|
110
|
+
}
|
|
111
|
+
interface AgentPrunePlan {
|
|
112
|
+
agentId: AgentId;
|
|
113
|
+
toPrune: PrunePlanEntry[];
|
|
114
|
+
}
|
|
98
115
|
/**
|
|
99
116
|
* Identity key for duplicate-install detection. Prefers accountKey — which
|
|
100
117
|
* encodes account AND org — over the bare email: two installs can share an
|
|
@@ -104,10 +121,55 @@ export declare function collectAgentsJson(filterAgentId?: AgentId, resourceSecti
|
|
|
104
121
|
* identity key. Null when there is no usable identity.
|
|
105
122
|
*/
|
|
106
123
|
export declare function pruneGroupKey(info: Pick<AccountInfo, 'accountKey' | 'email'>): string | null;
|
|
124
|
+
/** One installed home, reduced to the fields duplicate detection needs. */
|
|
125
|
+
export interface PruneCandidate {
|
|
126
|
+
/** The version-dir label (opaque slot id). */
|
|
127
|
+
version: string;
|
|
128
|
+
/** The RUNNING release inside the home (installation.releaseVersion), or the label. */
|
|
129
|
+
release: string;
|
|
130
|
+
email: string | null;
|
|
131
|
+
/** Present only once the home's native identity is captured (claude: account+org). */
|
|
132
|
+
accountKey: string | null;
|
|
133
|
+
signedIn: boolean;
|
|
134
|
+
hasBinary: boolean;
|
|
135
|
+
}
|
|
136
|
+
/** A duplicate home to retire, and the keeper it collapses into. */
|
|
137
|
+
export interface DuplicatePruneEntry {
|
|
138
|
+
version: string;
|
|
139
|
+
email: string;
|
|
140
|
+
keeper: string;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* PURE duplicate-home detection: given every installed home for one agent, decide
|
|
144
|
+
* which are redundant duplicates of the SAME logical account and which single home
|
|
145
|
+
* to keep per account. Extracted from buildAgentPrunePlan so the keeper/merge rules
|
|
146
|
+
* are unit-tested without touching disk.
|
|
147
|
+
*
|
|
148
|
+
* Two rules, both conservative:
|
|
149
|
+
* - GROUPING. A home with a captured `accountKey` groups by it (account+org, so a
|
|
150
|
+
* personal Max and a Team seat on the same email stay separate). A home with NO
|
|
151
|
+
* accountKey folds into a same-email account ONLY when a sibling home for that
|
|
152
|
+
* email IS identified — that email-only home is an incompletely-captured re-login
|
|
153
|
+
* of the known account, not a distinct seat (PHNX-3887). Otherwise it groups by
|
|
154
|
+
* its bare email and only ever collapses against another equally-bare home.
|
|
155
|
+
* - KEEPER. Within a group the keeper is the home that best represents the account:
|
|
156
|
+
* captured identity first, then a signed-in credential, then the newest RUNNING
|
|
157
|
+
* release, then the highest dir label as a stable tiebreak. This is deliberately
|
|
158
|
+
* NOT "highest dir-name semver" — in the wild that is the freshly re-logged-in
|
|
159
|
+
* duplicate that never captured its identity or usage, and keeping it while
|
|
160
|
+
* trashing the identified home is the exact bug this replaces.
|
|
161
|
+
*/
|
|
162
|
+
export declare function planDuplicatePrune(candidates: PruneCandidate[]): DuplicatePruneEntry[];
|
|
163
|
+
export declare function executePrunePlan(plan: AgentPrunePlan): Promise<Array<{
|
|
164
|
+
agent: AgentId;
|
|
165
|
+
version: string;
|
|
166
|
+
}>>;
|
|
107
167
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
168
|
+
* Consolidate to one home per logical account: retire the redundant duplicate
|
|
169
|
+
* homes an account accumulated, keeping the single home that best represents it
|
|
170
|
+
* (see {@link planDuplicatePrune} for the keeper/merge rules). When the retired
|
|
171
|
+
* duplicate holds the global default, the default is first repointed onto the
|
|
172
|
+
* keeper, so consolidation collapses it instead of leaving it pinned.
|
|
111
173
|
*
|
|
112
174
|
* When filterAgentId is set, prunes that agent first, then cascades: after
|
|
113
175
|
* each agent, offers the next agent with duplicates. User answering "no"
|
package/dist/commands/view.js
CHANGED
|
@@ -11,7 +11,7 @@ import { authCacheKey, readAuthHealthCache } from '../lib/auth-health.js';
|
|
|
11
11
|
import { agentReportsUsage, classifyUsageErrorKind, deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageBenignState, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, isUsageHeadlessScopeError, usageErrorForDisplay, } from '../lib/accounting/usage.js';
|
|
12
12
|
import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../lib/device-config.js';
|
|
13
13
|
import { readManifest } from '../lib/manifest.js';
|
|
14
|
-
import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, isVersionIsolated, getIsolatedDefault, } from '../lib/installations/versions.js';
|
|
14
|
+
import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, setGlobalDefault, getVersionHomePath, getVersionDir, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, isVersionIsolated, getIsolatedDefault, } from '../lib/installations/versions.js';
|
|
15
15
|
import { ensureVersionedAliasCurrent, removeShim, } from '../lib/installations/shims.js';
|
|
16
16
|
import { getAgentResources } from '../lib/resources.js';
|
|
17
17
|
import { renderMergedResources } from '../lib/merged-resources.js';
|
|
@@ -1455,50 +1455,116 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
|
|
|
1455
1455
|
export function pruneGroupKey(info) {
|
|
1456
1456
|
return info.accountKey ?? info.email?.toLowerCase() ?? null;
|
|
1457
1457
|
}
|
|
1458
|
+
/**
|
|
1459
|
+
* PURE duplicate-home detection: given every installed home for one agent, decide
|
|
1460
|
+
* which are redundant duplicates of the SAME logical account and which single home
|
|
1461
|
+
* to keep per account. Extracted from buildAgentPrunePlan so the keeper/merge rules
|
|
1462
|
+
* are unit-tested without touching disk.
|
|
1463
|
+
*
|
|
1464
|
+
* Two rules, both conservative:
|
|
1465
|
+
* - GROUPING. A home with a captured `accountKey` groups by it (account+org, so a
|
|
1466
|
+
* personal Max and a Team seat on the same email stay separate). A home with NO
|
|
1467
|
+
* accountKey folds into a same-email account ONLY when a sibling home for that
|
|
1468
|
+
* email IS identified — that email-only home is an incompletely-captured re-login
|
|
1469
|
+
* of the known account, not a distinct seat (PHNX-3887). Otherwise it groups by
|
|
1470
|
+
* its bare email and only ever collapses against another equally-bare home.
|
|
1471
|
+
* - KEEPER. Within a group the keeper is the home that best represents the account:
|
|
1472
|
+
* captured identity first, then a signed-in credential, then the newest RUNNING
|
|
1473
|
+
* release, then the highest dir label as a stable tiebreak. This is deliberately
|
|
1474
|
+
* NOT "highest dir-name semver" — in the wild that is the freshly re-logged-in
|
|
1475
|
+
* duplicate that never captured its identity or usage, and keeping it while
|
|
1476
|
+
* trashing the identified home is the exact bug this replaces.
|
|
1477
|
+
*/
|
|
1478
|
+
export function planDuplicatePrune(candidates) {
|
|
1479
|
+
// Only installs with a working binary compete for "the live install for this account".
|
|
1480
|
+
const installed = candidates.filter((c) => c.hasBinary && c.email);
|
|
1481
|
+
// email -> every DISTINCT captured accountKey seen for it. An email-only home
|
|
1482
|
+
// folds into an identified account only when that email maps to EXACTLY ONE
|
|
1483
|
+
// account; when two distinct orgs share the email (the Personal+Team case) the
|
|
1484
|
+
// fold is ambiguous, so the identity-less home is left ungrouped rather than
|
|
1485
|
+
// guessed into one — guessing could retire the actually-working re-login of the
|
|
1486
|
+
// OTHER org (the exact bug this function exists to prevent, one level up).
|
|
1487
|
+
const emailToAccountKeys = new Map();
|
|
1488
|
+
for (const c of installed) {
|
|
1489
|
+
if (!c.accountKey)
|
|
1490
|
+
continue;
|
|
1491
|
+
const email = c.email.toLowerCase();
|
|
1492
|
+
(emailToAccountKeys.get(email) ?? emailToAccountKeys.set(email, new Set()).get(email)).add(c.accountKey);
|
|
1493
|
+
}
|
|
1494
|
+
const groupKeyFor = (c) => {
|
|
1495
|
+
if (c.accountKey)
|
|
1496
|
+
return c.accountKey;
|
|
1497
|
+
const email = c.email?.toLowerCase();
|
|
1498
|
+
const keys = email ? emailToAccountKeys.get(email) : undefined;
|
|
1499
|
+
if (keys && keys.size === 1)
|
|
1500
|
+
return [...keys][0];
|
|
1501
|
+
// ≥2 identified orgs on this email: never merge the ambiguous home. A unique
|
|
1502
|
+
// key makes it its own singleton group (length 1 → never pruned).
|
|
1503
|
+
if (keys && keys.size >= 2)
|
|
1504
|
+
return `ambiguous:${c.email.toLowerCase()}:${c.version}`;
|
|
1505
|
+
// No identified sibling at all: group equally-bare homes by email as before.
|
|
1506
|
+
return pruneGroupKey(c);
|
|
1507
|
+
};
|
|
1508
|
+
const byAccount = new Map();
|
|
1509
|
+
for (const c of installed) {
|
|
1510
|
+
const key = groupKeyFor(c);
|
|
1511
|
+
if (!key)
|
|
1512
|
+
continue;
|
|
1513
|
+
(byAccount.get(key) ?? byAccount.set(key, []).get(key)).push(c);
|
|
1514
|
+
}
|
|
1515
|
+
const rank = (c) => [c.accountKey ? 1 : 0, c.signedIn ? 1 : 0];
|
|
1516
|
+
const better = (a, b) => {
|
|
1517
|
+
const [aa, ab] = rank(a);
|
|
1518
|
+
const [ba, bb] = rank(b);
|
|
1519
|
+
if (aa !== ba)
|
|
1520
|
+
return ba - aa;
|
|
1521
|
+
if (ab !== bb)
|
|
1522
|
+
return bb - ab;
|
|
1523
|
+
if (a.release !== b.release)
|
|
1524
|
+
return compareVersions(b.release, a.release);
|
|
1525
|
+
return compareVersions(b.version, a.version);
|
|
1526
|
+
};
|
|
1527
|
+
const out = [];
|
|
1528
|
+
for (const group of byAccount.values()) {
|
|
1529
|
+
if (group.length < 2)
|
|
1530
|
+
continue;
|
|
1531
|
+
const sorted = [...group].sort(better);
|
|
1532
|
+
const keeper = sorted[0].version;
|
|
1533
|
+
for (const dup of sorted.slice(1)) {
|
|
1534
|
+
out.push({ version: dup.version, email: dup.email, keeper });
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
return out;
|
|
1538
|
+
}
|
|
1458
1539
|
async function buildAgentPrunePlan(agentId) {
|
|
1459
1540
|
const dirInfos = listInstalledVersionDirs(agentId);
|
|
1460
1541
|
const entries = await Promise.all(dirInfos.map(async ({ version, hasBinary }) => {
|
|
1461
1542
|
const home = getVersionHomePath(agentId, version);
|
|
1462
1543
|
const info = await getAccountInfo(agentId, home);
|
|
1463
|
-
|
|
1544
|
+
const release = readInstallation(agentId, version)?.releaseVersion ?? version;
|
|
1545
|
+
return { version, info, hasBinary, release };
|
|
1464
1546
|
}));
|
|
1465
1547
|
const globalDefault = getGlobalDefault(agentId);
|
|
1466
1548
|
const toPrune = [];
|
|
1467
|
-
const
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
const sorted = [...group].sort((a, b) => compareVersions(b.version, a.version));
|
|
1487
|
-
const keeper = sorted[0].version;
|
|
1488
|
-
for (const older of sorted.slice(1)) {
|
|
1489
|
-
const plan = {
|
|
1490
|
-
agentId,
|
|
1491
|
-
version: older.version,
|
|
1492
|
-
email: older.info.email,
|
|
1493
|
-
keeper,
|
|
1494
|
-
isDefault: older.version === globalDefault,
|
|
1495
|
-
reason: 'duplicate',
|
|
1496
|
-
};
|
|
1497
|
-
if (plan.isDefault)
|
|
1498
|
-
skippedDefaults.push(plan);
|
|
1499
|
-
else
|
|
1500
|
-
toPrune.push(plan);
|
|
1501
|
-
}
|
|
1549
|
+
for (const d of planDuplicatePrune(entries.map((e) => ({
|
|
1550
|
+
version: e.version,
|
|
1551
|
+
release: e.release,
|
|
1552
|
+
email: e.info.email,
|
|
1553
|
+
accountKey: e.info.accountKey,
|
|
1554
|
+
signedIn: e.info.signedIn,
|
|
1555
|
+
hasBinary: e.hasBinary,
|
|
1556
|
+
})))) {
|
|
1557
|
+
toPrune.push({
|
|
1558
|
+
agentId,
|
|
1559
|
+
version: d.version,
|
|
1560
|
+
email: d.email,
|
|
1561
|
+
keeper: d.keeper,
|
|
1562
|
+
// The default may itself be a to-be-retired duplicate (a freshly created
|
|
1563
|
+
// home often becomes the global default). We do NOT skip it: executePrune
|
|
1564
|
+
// repoints the default onto the keeper first, then retires the duplicate.
|
|
1565
|
+
isDefault: d.version === globalDefault,
|
|
1566
|
+
reason: 'duplicate',
|
|
1567
|
+
});
|
|
1502
1568
|
}
|
|
1503
1569
|
// Home-only leftovers: dirs without a binary. These are residue from a
|
|
1504
1570
|
// prior removeVersion before the soft-delete migration, plus any hand-edited
|
|
@@ -1517,10 +1583,20 @@ async function buildAgentPrunePlan(agentId) {
|
|
|
1517
1583
|
reason: 'home-leftover',
|
|
1518
1584
|
});
|
|
1519
1585
|
}
|
|
1520
|
-
return { agentId, toPrune
|
|
1586
|
+
return { agentId, toPrune };
|
|
1521
1587
|
}
|
|
1522
|
-
async function executePrunePlan(plan) {
|
|
1588
|
+
export async function executePrunePlan(plan) {
|
|
1523
1589
|
const moved = [];
|
|
1590
|
+
// Repoint the global default onto the keeper BEFORE retiring a duplicate that
|
|
1591
|
+
// currently holds it, so consolidation collapses the duplicate instead of
|
|
1592
|
+
// leaving it pinned as the default (and so removeVersion's own fallback never
|
|
1593
|
+
// has to guess a replacement).
|
|
1594
|
+
for (const p of plan.toPrune) {
|
|
1595
|
+
if (p.reason === 'duplicate' && p.isDefault && p.keeper && p.keeper !== p.version) {
|
|
1596
|
+
setGlobalDefault(p.agentId, p.keeper);
|
|
1597
|
+
console.log(chalk.gray(`Default ${agentLabel(p.agentId)} moved ${p.version} → ${p.keeper} (keeper) before pruning the duplicate.`));
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1524
1600
|
for (const p of plan.toPrune) {
|
|
1525
1601
|
const ok = removeVersion(p.agentId, p.version);
|
|
1526
1602
|
if (ok) {
|
|
@@ -1537,14 +1613,6 @@ async function executePrunePlan(plan) {
|
|
|
1537
1613
|
return moved;
|
|
1538
1614
|
}
|
|
1539
1615
|
function printPrunePlan(plan, isFirst) {
|
|
1540
|
-
if (plan.skippedDefaults.length > 0) {
|
|
1541
|
-
console.log(chalk.yellow(`Skipping default versions for ${agentLabel(plan.agentId)} (switch default first):`));
|
|
1542
|
-
for (const s of plan.skippedDefaults) {
|
|
1543
|
-
console.log(` ${agentLabel(s.agentId)}@${s.version} ${chalk.cyan(s.email)} ` +
|
|
1544
|
-
chalk.gray(`— duplicate of ${s.agentId}@${s.keeper}. Run: agents use ${s.agentId}@${s.keeper}`));
|
|
1545
|
-
}
|
|
1546
|
-
console.log();
|
|
1547
|
-
}
|
|
1548
1616
|
if (plan.toPrune.length === 0)
|
|
1549
1617
|
return;
|
|
1550
1618
|
const heading = isFirst ? `Will move to trash for ${agentLabel(plan.agentId)}:` : `Also found candidates for ${agentLabel(plan.agentId)}:`;
|
|
@@ -1562,9 +1630,11 @@ function printPrunePlan(plan, isFirst) {
|
|
|
1562
1630
|
console.log();
|
|
1563
1631
|
}
|
|
1564
1632
|
/**
|
|
1565
|
-
*
|
|
1566
|
-
*
|
|
1567
|
-
*
|
|
1633
|
+
* Consolidate to one home per logical account: retire the redundant duplicate
|
|
1634
|
+
* homes an account accumulated, keeping the single home that best represents it
|
|
1635
|
+
* (see {@link planDuplicatePrune} for the keeper/merge rules). When the retired
|
|
1636
|
+
* duplicate holds the global default, the default is first repointed onto the
|
|
1637
|
+
* keeper, so consolidation collapses it instead of leaving it pinned.
|
|
1568
1638
|
*
|
|
1569
1639
|
* When filterAgentId is set, prunes that agent first, then cascades: after
|
|
1570
1640
|
* each agent, offers the next agent with duplicates. User answering "no"
|
|
@@ -1577,7 +1647,7 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
|
|
|
1577
1647
|
const spinner = ora({ text: 'Scanning installed versions...', isSilent: !process.stdout.isTTY }).start();
|
|
1578
1648
|
const plans = await Promise.all(ordered.map((a) => buildAgentPrunePlan(a)));
|
|
1579
1649
|
spinner.stop();
|
|
1580
|
-
const actionable = plans.filter((p) => p.toPrune.length > 0
|
|
1650
|
+
const actionable = plans.filter((p) => p.toPrune.length > 0);
|
|
1581
1651
|
if (actionable.length === 0) {
|
|
1582
1652
|
console.log(chalk.gray('Nothing to prune — no duplicate-account installs and no home-only leftovers.'));
|
|
1583
1653
|
return;
|
|
@@ -1588,11 +1658,6 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
|
|
|
1588
1658
|
let processedAny = false;
|
|
1589
1659
|
for (const plan of actionable) {
|
|
1590
1660
|
printPrunePlan(plan, isFirst);
|
|
1591
|
-
if (plan.toPrune.length === 0) {
|
|
1592
|
-
// Only skippedDefaults for this agent; move on.
|
|
1593
|
-
isFirst = false;
|
|
1594
|
-
continue;
|
|
1595
|
-
}
|
|
1596
1661
|
if (dryRun) {
|
|
1597
1662
|
processedAny = true;
|
|
1598
1663
|
isFirst = false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.80",
|
|
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",
|