@phnx-labs/agents-cli 1.22.78 → 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.
Files changed (90) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +28 -1
  3. package/dist/bootstrap.js +40 -12
  4. package/dist/commands/accounts.d.ts +22 -0
  5. package/dist/commands/accounts.js +158 -35
  6. package/dist/commands/config.js +37 -0
  7. package/dist/commands/exec.js +21 -10
  8. package/dist/commands/update.js +169 -18
  9. package/dist/commands/versions.d.ts +11 -0
  10. package/dist/commands/versions.js +30 -4
  11. package/dist/commands/view.d.ts +69 -3
  12. package/dist/commands/view.js +235 -75
  13. package/dist/index.js +35 -2
  14. package/dist/lib/account-catalog.d.ts +97 -1
  15. package/dist/lib/account-catalog.js +134 -6
  16. package/dist/lib/account-registry.d.ts +43 -0
  17. package/dist/lib/account-registry.js +97 -2
  18. package/dist/lib/accounting/rotate.d.ts +2 -2
  19. package/dist/lib/accounting/rotate.js +5 -5
  20. package/dist/lib/accounts/auth-operation-lock.d.ts +9 -0
  21. package/dist/lib/accounts/auth-operation-lock.js +55 -0
  22. package/dist/lib/accounts/connect.d.ts +170 -0
  23. package/dist/lib/accounts/connect.js +383 -0
  24. package/dist/lib/capabilities.js +2 -0
  25. package/dist/lib/commands.js +2 -0
  26. package/dist/lib/config-keys.d.ts +11 -2
  27. package/dist/lib/config-keys.js +21 -1
  28. package/dist/lib/daemon/daemon.js +5 -0
  29. package/dist/lib/daemon/harness-update-service.d.ts +110 -0
  30. package/dist/lib/daemon/harness-update-service.js +216 -0
  31. package/dist/lib/daemon-services.d.ts +1 -1
  32. package/dist/lib/daemon-services.js +5 -0
  33. package/dist/lib/device-config.d.ts +0 -1
  34. package/dist/lib/device-config.js +50 -5
  35. package/dist/lib/exec.js +26 -2
  36. package/dist/lib/fs-atomic.d.ts +2 -0
  37. package/dist/lib/fs-atomic.js +2 -0
  38. package/dist/lib/hooks/install.js +7 -2
  39. package/dist/lib/installations/active-check.d.ts +48 -0
  40. package/dist/lib/installations/active-check.js +84 -0
  41. package/dist/lib/installations/index.d.ts +5 -1
  42. package/dist/lib/installations/index.js +4 -0
  43. package/dist/lib/installations/installation-lock.d.ts +6 -0
  44. package/dist/lib/installations/installation-lock.js +29 -0
  45. package/dist/lib/installations/launch-gate.d.ts +69 -0
  46. package/dist/lib/installations/launch-gate.js +133 -0
  47. package/dist/lib/installations/native-command.d.ts +5 -0
  48. package/dist/lib/installations/native-command.js +52 -0
  49. package/dist/lib/installations/shims.d.ts +8 -2
  50. package/dist/lib/installations/shims.js +105 -2
  51. package/dist/lib/installations/store.d.ts +5 -1
  52. package/dist/lib/installations/store.js +24 -3
  53. package/dist/lib/installations/strategies.js +55 -35
  54. package/dist/lib/installations/types.d.ts +17 -0
  55. package/dist/lib/installations/update-cancellation.d.ts +82 -0
  56. package/dist/lib/installations/update-cancellation.js +122 -0
  57. package/dist/lib/installations/update-policy.d.ts +69 -0
  58. package/dist/lib/installations/update-policy.js +114 -0
  59. package/dist/lib/installations/update-runtime.d.ts +118 -0
  60. package/dist/lib/installations/update-runtime.js +321 -0
  61. package/dist/lib/installations/update.d.ts +25 -0
  62. package/dist/lib/installations/update.js +141 -2
  63. package/dist/lib/installations/versions.d.ts +1 -0
  64. package/dist/lib/installations/versions.js +166 -131
  65. package/dist/lib/platform/process.d.ts +3 -1
  66. package/dist/lib/platform/process.js +2 -2
  67. package/dist/lib/staleness/detectors/commands.d.ts +1 -2
  68. package/dist/lib/staleness/detectors/hooks.d.ts +1 -2
  69. package/dist/lib/staleness/detectors/mcp.d.ts +1 -2
  70. package/dist/lib/staleness/detectors/permissions.d.ts +1 -2
  71. package/dist/lib/staleness/detectors/plugins.d.ts +1 -7
  72. package/dist/lib/staleness/detectors/rules.d.ts +1 -2
  73. package/dist/lib/staleness/detectors/skills.d.ts +1 -2
  74. package/dist/lib/staleness/detectors/subagents.d.ts +1 -7
  75. package/dist/lib/staleness/detectors/workflows.d.ts +1 -2
  76. package/dist/lib/staleness/writers/commands.d.ts +1 -2
  77. package/dist/lib/staleness/writers/hooks.d.ts +1 -2
  78. package/dist/lib/staleness/writers/mcp.d.ts +1 -2
  79. package/dist/lib/staleness/writers/permissions.d.ts +1 -12
  80. package/dist/lib/staleness/writers/plugins.d.ts +1 -6
  81. package/dist/lib/staleness/writers/rules.d.ts +1 -2
  82. package/dist/lib/staleness/writers/skills.d.ts +1 -2
  83. package/dist/lib/staleness/writers/subagents.d.ts +1 -2
  84. package/dist/lib/staleness/writers/workflows.d.ts +1 -8
  85. package/dist/lib/state.d.ts +3 -1
  86. package/dist/lib/state.js +38 -13
  87. package/dist/lib/types.d.ts +26 -1
  88. package/dist/lib/types.js +5 -0
  89. package/dist/lib/view-types.d.ts +6 -0
  90. package/package.json +1 -1
@@ -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';
@@ -21,6 +21,11 @@ import { isCapable } from '../lib/capabilities.js';
21
21
  import { discoverPlugins, pluginSupportsAgent } from '../lib/plugins/plugins.js';
22
22
  import { getAgentsDir, getUserAgentsDir, getEffectivePromptcutsPath, readMergedPromptcuts, readMeta } from '../lib/state.js';
23
23
  import { listNativeAccounts } from '../lib/account-registry.js';
24
+ import { buildNativeCatalog } from '../lib/account-catalog.js';
25
+ import { connectSupported } from '../lib/accounts/connect.js';
26
+ import { readInstallation } from '../lib/installations/store.js';
27
+ import { isAutoUpdateEnabledForAgent } from '../lib/installations/update-policy.js';
28
+ import { selectUpdateStrategy } from '../lib/installations/strategies.js';
24
29
  import { isGitRepo, getGitSyncStatus } from '../lib/git.js';
25
30
  import { getCentralRulesFileName } from '../lib/rules/rules.js';
26
31
  import { composeRulesFromState } from '../lib/rules/compose.js';
@@ -68,6 +73,10 @@ export function namedAccountColumnLabel(agentId, info) {
68
73
  const saved = listNativeAccounts(readMeta()).find(item => item.agent === agentId && item.identityKey === identityKey);
69
74
  return saved ? `${saved.name} · ${display || saved.identityLabel || identityKey}` : display;
70
75
  }
76
+ /** Human account identity; release labels belong only in installation diagnostics. */
77
+ export function nativeAccountViewLabel(row) {
78
+ return row.name && row.name !== row.display ? `${row.name} · ${row.display}` : row.display;
79
+ }
71
80
  /**
72
81
  * Human `agents view` row order: selected default first, then email-bearing
73
82
  * accounts alphabetically, then installs whose account has no email. Version
@@ -368,7 +377,7 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
368
377
  const cliStates = Object.fromEntries(await Promise.all(agentsToShow
369
378
  .filter((agentId) => !hasNonIsolatedVersion(agentId))
370
379
  .map(async (agentId) => [agentId, await getUnmanagedCliState(agentId)])));
371
- const showPaths = !!filterAgentId;
380
+ const showPaths = !!filterAgentId && viewOpts?.versions === true;
372
381
  // A filtered native view is about that native harness's installed versions.
373
382
  // Custom forks are standalone agent types and only belong in the overview.
374
383
  const harnesses = filterAgentId ? [] : getHarnesses();
@@ -452,7 +461,7 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
452
461
  allowInteractiveLogin: usageAllowInteractiveLogin(),
453
462
  });
454
463
  spinner.stop();
455
- console.log(chalk.bold('Installed Agent CLIs\n'));
464
+ console.log(chalk.bold(viewOpts?.versions ? 'Installed Agent CLIs\n' : 'Agents and accounts\n'));
456
465
  const mergeCanonical = (info) => {
457
466
  const key = getUsageLookupKey(info);
458
467
  if (!key)
@@ -504,12 +513,13 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
504
513
  if (live)
505
514
  liveVersionByAgent.set(agentId, live);
506
515
  }));
507
- const displayVersion = (agentId, dirVersion) => liveVersionByAgent.get(agentId) ?? dirVersion;
516
+ const displayVersion = (agentId, dirVersion) => liveVersionByAgent.get(agentId) ?? readInstallation(agentId, dirVersion)?.releaseVersion ?? dirVersion;
508
517
  // Uncolored row label, shared by the width pass and the render so padding lines
509
518
  // up. An isolated copy is never the global default (installing one deliberately
510
519
  // records no default), so the two tags can't collide.
511
520
  const versionRowLabel = (agentId, version, globalDefault) => {
512
- const shown = displayVersion(agentId, version);
521
+ const release = displayVersion(agentId, version);
522
+ const shown = release === version ? version : `${version} → ${release}`;
513
523
  if (version === globalDefault)
514
524
  return `${shown} (default)`;
515
525
  if (isVersionIsolated(agentId, version)) {
@@ -522,7 +532,69 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
522
532
  return shown;
523
533
  };
524
534
  // Show version-managed agents
525
- if (versionManaged.length > 0) {
535
+ if (versionManaged.length > 0 && !viewOpts?.versions) {
536
+ const catalog = buildNativeCatalog(infoResults.map(({ agentId, version, home, info }) => ({
537
+ agent: agentId,
538
+ label: version,
539
+ releaseVersion: displayVersion(agentId, version),
540
+ accountKey: info.accountKey,
541
+ email: info.email,
542
+ signedIn: isLaunchableSignedIn(info.signedIn, credentialPresence(agentId, home)),
543
+ })), readMeta(), (agent) => getGlobalDefault(agent) ?? getIsolatedDefault(agent));
544
+ for (const agentId of versionManaged) {
545
+ const accounts = catalog.filter((row) => row.agent === agentId);
546
+ let updateLabel = 'manual updates';
547
+ try {
548
+ if (selectUpdateStrategy(agentId).transactional) {
549
+ updateLabel = `automatic updates ${isAutoUpdateEnabledForAgent(agentId) ? 'on' : 'off'}`;
550
+ }
551
+ }
552
+ catch { /* unsupported updater: keep the truthful manual label */ }
553
+ console.log(` ${chalk.bold(agentLabel(agentId))}${chalk.gray(` · ${updateLabel}`)}`);
554
+ const width = Math.max(0, ...accounts.map((row) => stringWidth(nativeAccountViewLabel(row))));
555
+ for (const row of accounts) {
556
+ const source = row.installations.find((home) => home.label === row.home && home.signedIn)
557
+ ?? row.installations.find((home) => home.signedIn)
558
+ ?? row.installations[0];
559
+ const rawInfo = source ? infoMap.get(`${agentId}:${source.label}`) : undefined;
560
+ const info = rawInfo ? mergeCanonical(rawInfo) : undefined;
561
+ const usageKey = getUsageLookupKey(info);
562
+ const usageInfo = usageKey ? usageByKey.get(usageKey) : undefined;
563
+ const usage = formatUsageSummary(info?.plan ?? null, usageInfo?.snapshot ?? null, 3, viewUsageSummaryOptions(agentId, row.state === 'connected', usageInfo, usageWindowCap));
564
+ const connected = row.state === 'connected';
565
+ const model = source ? resolveConfiguredModel(agentId, source.label)?.model : null;
566
+ const pinned = row.installations.some((home) => readInstallation(agentId, home.label)?.updatePolicy === 'pinned');
567
+ const parts = [
568
+ ` ${row.isDefault ? chalk.green('*') : ' '} ${chalk.cyan(padToWidth(nativeAccountViewLabel(row), width))}`,
569
+ connected ? chalk.green('connected') : chalk.yellow('not connected here'),
570
+ model ? chalk.yellow(model) : '',
571
+ usage,
572
+ formatUsageStatusBadge(info?.usageStatus),
573
+ pinned ? chalk.gray('release pinned · --versions') : '',
574
+ ];
575
+ console.log(joinViewColumns(parts));
576
+ }
577
+ if (accounts.length === 0) {
578
+ const localIdentity = infoResults.some((row) => row.agentId === agentId && row.info.signedIn);
579
+ const hint = connectSupported(agentId) ? `agents accounts connect ${agentId}` : loginHint(agentId);
580
+ console.log(chalk.gray(localIdentity
581
+ ? ' Native login detected · account identity unavailable'
582
+ : ` No native login detected · ${hint}`));
583
+ }
584
+ const isolatedCount = listInstalledVersions(agentId).filter((label) => isVersionIsolated(agentId, label)).length;
585
+ if (isolatedCount > 0)
586
+ console.log(chalk.gray(` ${isolatedCount} isolated installation${isolatedCount === 1 ? '' : 's'} · inspect with --versions`));
587
+ const projectVersion = getProjectVersionFromCwd(agentId);
588
+ if (projectVersion)
589
+ console.log(chalk.gray(' Project installation override configured · inspect with --versions'));
590
+ console.log();
591
+ }
592
+ if (filterAgentId) {
593
+ const connect = connectSupported(filterAgentId) ? `Connect: agents accounts connect ${filterAgentId} [name] · ` : '';
594
+ console.log(chalk.gray(` ${connect}Details: agents view ${filterAgentId} --versions\n`));
595
+ }
596
+ }
597
+ if (versionManaged.length > 0 && viewOpts?.versions) {
526
598
  // Calculate column widths across all agents for alignment
527
599
  let maxVerLabel = 0;
528
600
  let maxEmail = 0;
@@ -596,7 +668,8 @@ async function showInstalledVersions(filterAgentId, viewOpts) {
596
668
  const isDefault = version === globalDefault;
597
669
  const isolated = !isDefault && isVersionIsolated(agentId, version);
598
670
  const isolatedTag = getIsolatedDefault(agentId) === version ? ' (isolated default)' : ' (isolated)';
599
- const shown = displayVersion(agentId, version);
671
+ const release = displayVersion(agentId, version);
672
+ const shown = release === version ? version : `${version} → ${release}`;
600
673
  const base = versionRowLabel(agentId, version, globalDefault);
601
674
  const tagPad = ' '.repeat(maxVerLabel - base.length);
602
675
  const label = isDefault
@@ -1253,6 +1326,15 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
1253
1326
  }
1254
1327
  }
1255
1328
  const infoResults = await Promise.all(infoFetches);
1329
+ const globalReleases = new Map();
1330
+ await Promise.all(agentsToShow.filter(isGlobalBinaryAgent).map(async (agent) => {
1331
+ if (!infoResults.some((row) => row.agentId === agent))
1332
+ return;
1333
+ const release = await getLiveVersion(agent);
1334
+ if (release)
1335
+ globalReleases.set(agent, release);
1336
+ }));
1337
+ const actualRelease = (agent, label) => globalReleases.get(agent) ?? readInstallation(agent, label)?.releaseVersion ?? label;
1256
1338
  const { canonicalByUsageKey, usageByKey } = await getUsageInfoByIdentity(infoResults.map(({ agentId, home, version, info }) => ({
1257
1339
  agentId,
1258
1340
  home,
@@ -1290,6 +1372,7 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
1290
1372
  const authHealth = authCache[authCacheKey(host, agentId, version)];
1291
1373
  const entry = {
1292
1374
  version,
1375
+ releaseVersion: actualRelease(agentId, version),
1293
1376
  isDefault: version === globalDefault,
1294
1377
  isolated: isVersionIsolated(agentId, version),
1295
1378
  isIsolatedDefault: getIsolatedDefault(agentId) === version,
@@ -1348,7 +1431,16 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
1348
1431
  return a.isDefault ? -1 : 1;
1349
1432
  return compareVersions(b.version, a.version);
1350
1433
  });
1351
- out.push({ agent: agentId, versions, harnesses: harnesses.filter((h) => h.agent === agentId) });
1434
+ const accounts = buildNativeCatalog(infoResults.filter((row) => row.agentId === agentId).map(({ version, home, info }) => ({
1435
+ agent: agentId,
1436
+ label: version,
1437
+ releaseVersion: actualRelease(agentId, version),
1438
+ accountKey: info.accountKey,
1439
+ email: info.email,
1440
+ signedIn: isLaunchableSignedIn(info.signedIn, credentialPresence(agentId, home)),
1441
+ })), readMeta(), (agent) => getGlobalDefault(agent) ?? getIsolatedDefault(agent))
1442
+ .filter((row) => row.agent === agentId);
1443
+ out.push({ agent: agentId, versions, accounts, harnesses: harnesses.filter((h) => h.agent === agentId) });
1352
1444
  }
1353
1445
  return out;
1354
1446
  }
@@ -1363,50 +1455,116 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
1363
1455
  export function pruneGroupKey(info) {
1364
1456
  return info.accountKey ?? info.email?.toLowerCase() ?? null;
1365
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
+ }
1366
1539
  async function buildAgentPrunePlan(agentId) {
1367
1540
  const dirInfos = listInstalledVersionDirs(agentId);
1368
1541
  const entries = await Promise.all(dirInfos.map(async ({ version, hasBinary }) => {
1369
1542
  const home = getVersionHomePath(agentId, version);
1370
1543
  const info = await getAccountInfo(agentId, home);
1371
- return { version, info, hasBinary };
1544
+ const release = readInstallation(agentId, version)?.releaseVersion ?? version;
1545
+ return { version, info, hasBinary, release };
1372
1546
  }));
1373
1547
  const globalDefault = getGlobalDefault(agentId);
1374
1548
  const toPrune = [];
1375
- const skippedDefaults = [];
1376
- // Duplicate-account detection runs only over installs that actually have a
1377
- // working binary — those are the things that compete for "the live install
1378
- // for this account."
1379
- const installed = entries.filter((e) => e.hasBinary);
1380
- const byAccount = new Map();
1381
- for (const e of installed) {
1382
- if (!e.info.email)
1383
- continue;
1384
- const key = pruneGroupKey(e.info);
1385
- if (!key)
1386
- continue;
1387
- const list = byAccount.get(key) ?? [];
1388
- list.push(e);
1389
- byAccount.set(key, list);
1390
- }
1391
- for (const [, group] of byAccount) {
1392
- if (group.length < 2)
1393
- continue;
1394
- const sorted = [...group].sort((a, b) => compareVersions(b.version, a.version));
1395
- const keeper = sorted[0].version;
1396
- for (const older of sorted.slice(1)) {
1397
- const plan = {
1398
- agentId,
1399
- version: older.version,
1400
- email: older.info.email,
1401
- keeper,
1402
- isDefault: older.version === globalDefault,
1403
- reason: 'duplicate',
1404
- };
1405
- if (plan.isDefault)
1406
- skippedDefaults.push(plan);
1407
- else
1408
- toPrune.push(plan);
1409
- }
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
+ });
1410
1568
  }
1411
1569
  // Home-only leftovers: dirs without a binary. These are residue from a
1412
1570
  // prior removeVersion before the soft-delete migration, plus any hand-edited
@@ -1425,10 +1583,20 @@ async function buildAgentPrunePlan(agentId) {
1425
1583
  reason: 'home-leftover',
1426
1584
  });
1427
1585
  }
1428
- return { agentId, toPrune, skippedDefaults };
1586
+ return { agentId, toPrune };
1429
1587
  }
1430
- async function executePrunePlan(plan) {
1588
+ export async function executePrunePlan(plan) {
1431
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
+ }
1432
1600
  for (const p of plan.toPrune) {
1433
1601
  const ok = removeVersion(p.agentId, p.version);
1434
1602
  if (ok) {
@@ -1445,14 +1613,6 @@ async function executePrunePlan(plan) {
1445
1613
  return moved;
1446
1614
  }
1447
1615
  function printPrunePlan(plan, isFirst) {
1448
- if (plan.skippedDefaults.length > 0) {
1449
- console.log(chalk.yellow(`Skipping default versions for ${agentLabel(plan.agentId)} (switch default first):`));
1450
- for (const s of plan.skippedDefaults) {
1451
- console.log(` ${agentLabel(s.agentId)}@${s.version} ${chalk.cyan(s.email)} ` +
1452
- chalk.gray(`— duplicate of ${s.agentId}@${s.keeper}. Run: agents use ${s.agentId}@${s.keeper}`));
1453
- }
1454
- console.log();
1455
- }
1456
1616
  if (plan.toPrune.length === 0)
1457
1617
  return;
1458
1618
  const heading = isFirst ? `Will move to trash for ${agentLabel(plan.agentId)}:` : `Also found candidates for ${agentLabel(plan.agentId)}:`;
@@ -1470,9 +1630,11 @@ function printPrunePlan(plan, isFirst) {
1470
1630
  console.log();
1471
1631
  }
1472
1632
  /**
1473
- * Prune older installed versions that share an email with a newer installed
1474
- * version. Keeps the highest semver per email, skips the global default (with
1475
- * a warning so the user can switch first).
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.
1476
1638
  *
1477
1639
  * When filterAgentId is set, prunes that agent first, then cascades: after
1478
1640
  * each agent, offers the next agent with duplicates. User answering "no"
@@ -1485,7 +1647,7 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
1485
1647
  const spinner = ora({ text: 'Scanning installed versions...', isSilent: !process.stdout.isTTY }).start();
1486
1648
  const plans = await Promise.all(ordered.map((a) => buildAgentPrunePlan(a)));
1487
1649
  spinner.stop();
1488
- const actionable = plans.filter((p) => p.toPrune.length > 0 || p.skippedDefaults.length > 0);
1650
+ const actionable = plans.filter((p) => p.toPrune.length > 0);
1489
1651
  if (actionable.length === 0) {
1490
1652
  console.log(chalk.gray('Nothing to prune — no duplicate-account installs and no home-only leftovers.'));
1491
1653
  return;
@@ -1496,11 +1658,6 @@ export async function pruneDuplicates(filterAgentId, yes, dryRun) {
1496
1658
  let processedAny = false;
1497
1659
  for (const plan of actionable) {
1498
1660
  printPrunePlan(plan, isFirst);
1499
- if (plan.toPrune.length === 0) {
1500
- // Only skippedDefaults for this agent; move on.
1501
- isFirst = false;
1502
- continue;
1503
- }
1504
1661
  if (dryRun) {
1505
1662
  processedAny = true;
1506
1663
  isFirst = false;
@@ -1603,8 +1760,7 @@ export async function viewAction(agentArg, options) {
1603
1760
  console.log(JSON.stringify(data, null, 2));
1604
1761
  return;
1605
1762
  }
1606
- // No argument: show all installed versions
1607
- await showInstalledVersions(undefined, { forceRefresh });
1763
+ await showInstalledVersions(undefined, { forceRefresh, versions: options?.versions });
1608
1764
  return;
1609
1765
  }
1610
1766
  // Parse agent@version syntax
@@ -1675,15 +1831,15 @@ export async function viewAction(agentArg, options) {
1675
1831
  await showAgentResources(agentId, 'default', filter);
1676
1832
  }
1677
1833
  else {
1678
- // Just agent name: show versions for that agent
1679
- await showInstalledVersions(agentId, { forceRefresh });
1834
+ await showInstalledVersions(agentId, { forceRefresh, versions: options?.versions });
1680
1835
  }
1681
1836
  }
1682
1837
  /** Register the `agents view` command. */
1683
1838
  export function registerViewCommand(program) {
1684
1839
  addHostOption(program.command('view [agent]'))
1685
- .description('Show what agent CLIs are installed and which versions you have. Inspect resources when you pass agent@version.')
1686
- .option('--json', 'Emit machine-readable JSON (version list, usage, signed-in status).')
1840
+ .description('Show your agents, connected accounts, and usage. Use --versions for installation diagnostics.')
1841
+ .option('--versions', 'Show every installation, actual release, account, model, and home path.')
1842
+ .option('--json', 'Emit machine-readable JSON (accounts plus backward-compatible installation list).')
1687
1843
  .option('--resources [sections]', 'In --json mode, include each version\'s resources: "all" (default) or a comma list (skills,plugins,mcp,commands,workflows,memory,hooks). Implies --json.')
1688
1844
  .option('--detailed', 'Include all resources in --json output (alias for --resources all). Implies --json.')
1689
1845
  .option('-r, --refresh', 'Force a live usage refresh, bypassing the cache (slower). Repopulates the S:/W: limit bars for every account whose token is reachable.')
@@ -1703,12 +1859,15 @@ export function registerViewCommand(program) {
1703
1859
  .option('--merged', 'Show the merged, first-wins resource surface across all layers (project, user, extras, system) in one table with the winning layer per row.')
1704
1860
  .addHelpText('after', `
1705
1861
  Examples:
1706
- # Show all installed agents with versions, accounts, and usage
1862
+ # Show installed agents with connected accounts and usage
1707
1863
  agents view
1708
1864
 
1709
- # Show versions for one agent
1865
+ # Show accounts for one agent
1710
1866
  agents view claude
1711
1867
 
1868
+ # Inspect installation labels, current releases, models, and paths
1869
+ agents view claude --versions
1870
+
1712
1871
  # Describe one custom harness (host, model, provider, auth, path)
1713
1872
  agents view deepseek-flash
1714
1873
 
@@ -1738,16 +1897,17 @@ Examples:
1738
1897
  agents view --merged
1739
1898
 
1740
1899
  When to use:
1741
- - Checking which agents are installed and what their default versions are
1742
- - Seeing which account each version is logged into (useful for multi-account setups)
1900
+ - Checking which agents and accounts are connected on this device
1901
+ - Seeing the selected default account and each account's usage
1743
1902
  - Inspecting commands, skills, hooks, and MCP servers synced to a version
1744
1903
  - Verifying a version is installed before running it
1745
1904
  - Cleaning up stale versions left behind after upgrading (--prune)
1746
1905
 
1747
1906
  Output:
1748
- - Without arguments: table of all agents with versions, emails, usage stats,
1907
+ - Without arguments: table of all agents with accounts, connection state, usage,
1749
1908
  then one block per custom harness (see 'agents harness')
1750
- - With agent name: versions for that agent, showing which is the default
1909
+ - With agent name: one row per native identity, showing the default account
1910
+ - With --versions: every retained installation, actual release, model, and home
1751
1911
  - With a custom harness name: that harness's host, model, provider, and auth
1752
1912
  - With agent@version: detailed breakdown of resources synced to that version
1753
1913
  - With --json: structured JSON with version, isDefault, signedIn, authVerdict,
package/dist/index.js CHANGED
@@ -24,8 +24,21 @@
24
24
  * agent.js would pull the whole secrets graph into every invocation.
25
25
  */
26
26
  import { SYNC_GET_CMD, SYNC_PING_CMD, SYNC_LOCK_CMD } from './lib/secrets/sync-commands.js';
27
- // Force exit on Ctrl+C when no interactive prompt is handling it.
28
- process.on('SIGINT', () => process.exit(130));
27
+ // Force exit on Ctrl+C when no interactive prompt is handling it — UNLESS a
28
+ // guarded harness auto-update pass is mutating this process transactionally
29
+ // (PHNX-3940). A hard exit mid-swap can leave an installation whose directory
30
+ // and metadata disagree, so defer ONLY while that guard is held; the pass sees
31
+ // the same SIGINT cooperatively (lib/installations/update-cancellation.ts) and
32
+ // stops at its next safe boundary. The depth is read through the shared
33
+ // `Symbol.for` registry key so this slim entry shell needs no static import of
34
+ // that module (agent.test.ts pins the single allowed import). Every other SIGINT
35
+ // still force-exits 130, and no other listener is touched.
36
+ process.on('SIGINT', () => {
37
+ const depth = globalThis[Symbol.for('agents.guardedAutoUpdateDepth')] ?? 0;
38
+ if (depth > 0)
39
+ return;
40
+ process.exit(130);
41
+ });
29
42
  // Ignore SIGPIPE — prevents exit code 13 crashes in piped environments
30
43
  // (e.g. `agents sessions | head`, or when stdout is captured by another process).
31
44
  process.on('SIGPIPE', () => { });
@@ -58,6 +71,15 @@ if (process.argv[2] === SYNC_GET_CMD ||
58
71
  : await runAgentLockSync(name);
59
72
  process.exit(code);
60
73
  }
74
+ // Launch-lease delegate: every generated native shim/alias calls
75
+ // `agents __launch-lease <agent> <label> <pid>` right before its final `exec`
76
+ // (PHNX-3940) — on the hot launch path of every managed agent, so it must skip
77
+ // the same update-check/bootstrap machinery `__shim` skips below. See
78
+ // `lib/installations/launch-gate.ts` for what this call actually does.
79
+ if (process.argv[2] === '__launch-lease') {
80
+ const { runLaunchLeaseCli } = await import('./lib/installations/launch-gate.js');
81
+ process.exit(await runLaunchLeaseCli(process.argv.slice(3)));
82
+ }
61
83
  // Transparent shim delegate: the generated Windows `.cmd` shims invoke
62
84
  // `agents __shim <agent>[@version] <raw args>`. Intercept here, before bootstrap
63
85
  // parses anything, so the agent's own flags (`--help`, `--version`, etc.) pass
@@ -98,6 +120,17 @@ if (process.argv[2] === '__usage-export') {
98
120
  process.stdout.write(JSON.stringify({ v: 1, rows: exportClaudeUsageCacheRows() }));
99
121
  process.exit(0);
100
122
  }
123
+ // Harness auto-update child (PHNX-3940): the daemon's harness-update tick spawns
124
+ // `agents __harness-update-run` over a Node IPC channel and cancels it
125
+ // cooperatively by MESSAGE, never a kill (see
126
+ // lib/daemon/harness-update-service.ts + lib/installations/update-cancellation.ts).
127
+ // Above bootstrap for the same reason as __daemon-run: this internal periodic
128
+ // child must not itself fire a self-update check or fork a detached sync, and its
129
+ // stdout carries a clean JSON summary the daemon logs.
130
+ if (process.argv[2] === '__harness-update-run') {
131
+ const { runHarnessUpdateChild } = await import('./lib/installations/update-runtime.js');
132
+ process.exit(await runHarnessUpdateChild());
133
+ }
101
134
  if (process.argv[2] === '__daemon-run') {
102
135
  const { runDaemon, log: daemonLog } = await import('./lib/daemon/daemon.js');
103
136
  // RUSH-2418: the daemon is the one always-on process here, and it ran with no
@@ -1,4 +1,15 @@
1
- import type { AgentId } from './types.js';
1
+ import { type AccountInfo } from './agents.js';
2
+ import type { AgentId, Meta } from './types.js';
3
+ /**
4
+ * Strict "is this home actually connected?" — a live CREDENTIAL in that exact
5
+ * version home, not just a metadata identity claim (PHNX-3940). A `.claude.json`
6
+ * carrying an `oauthAccount` block with no `.credentials.json`/`.oauth_token`
7
+ * beside it is stale/expired, and must read as `reconnect-needed`, never
8
+ * `connected`. Where agents-cli does not know the credential location
9
+ * (`knownLocation` false), it falls back to the metadata `signedIn` since there
10
+ * is nothing stricter to check.
11
+ */
12
+ export declare function isLaunchableSignedIn(agent: AgentId, versionHome: string, info: Pick<AccountInfo, 'signedIn'>): boolean;
2
13
  export interface NativeAccountCatalogEntry {
3
14
  kind: 'native';
4
15
  id: string;
@@ -16,3 +27,88 @@ export declare function groupNativeAccountRows(rows: Array<{
16
27
  }>): NativeAccountCatalogEntry[];
17
28
  /** Discover signed-in harness-native identities without copying their auth files. */
18
29
  export declare function discoverNativeAccounts(): Promise<NativeAccountCatalogEntry[]>;
30
+ /** One installed home carrying an identity — the secondary "release/home" facts. */
31
+ export interface AccountHome {
32
+ /** The installation label (version-dir basename). */
33
+ label: string;
34
+ /** The vendor release the home currently carries, when the record is present. */
35
+ releaseVersion: string | null;
36
+ /** Whether this exact home currently has a live signed-in credential. */
37
+ signedIn: boolean;
38
+ }
39
+ /**
40
+ * A live credential state, derived from the homes rather than the registry:
41
+ * - `connected` — at least one home for this identity is signed in.
42
+ * - `reconnect-needed` — the account is registered (or was created by connect)
43
+ * but no home currently holds a live credential; the label alone is not a
44
+ * connection. `agents accounts connect <name>` re-authenticates it.
45
+ */
46
+ export type AccountConnectionState = 'connected' | 'reconnect-needed';
47
+ /** Account-first row for a native (harness-owned OAuth) identity. */
48
+ export interface NativeAccountCatalogRow {
49
+ kind: 'native';
50
+ agent: AgentId;
51
+ identityKey: string;
52
+ /** Registered account name/label, or null for an unnamed discovered login. */
53
+ name: string | null;
54
+ /** Registered stable account id, or null when unnamed. */
55
+ id: string | null;
56
+ email: string | null;
57
+ display: string;
58
+ /**
59
+ * The LOCAL installation home for this identity on THIS box: the recorded
60
+ * connect home when installed here, else the local home carrying the login.
61
+ * Null when no local home exists (the identity may be connected on another
62
+ * box). Per-host by construction — never a label another box minted.
63
+ */
64
+ home: string | null;
65
+ /** Every installed home carrying this identity (secondary diagnostics). */
66
+ installations: AccountHome[];
67
+ /** Whether the harness's configured default account points at this identity. */
68
+ isDefault: boolean;
69
+ state: AccountConnectionState;
70
+ }
71
+ /** Account-first row for a durable provider (API-key / token) account bundle. */
72
+ export interface ProviderAccountCatalogRow {
73
+ kind: 'provider';
74
+ name: string;
75
+ id: string;
76
+ provider: string;
77
+ auth: string;
78
+ baseUrl?: string;
79
+ }
80
+ export type AccountCatalogRow = NativeAccountCatalogRow | ProviderAccountCatalogRow;
81
+ export interface AccountCatalog {
82
+ native: NativeAccountCatalogRow[];
83
+ provider: ProviderAccountCatalogRow[];
84
+ }
85
+ /** One installed home probed for its native identity — the raw input to the fold. */
86
+ export interface NativeHomeRow {
87
+ agent: AgentId;
88
+ label: string;
89
+ releaseVersion: string | null;
90
+ accountKey: string | null;
91
+ email: string | null;
92
+ signedIn: boolean;
93
+ }
94
+ /** Probe every installed home of every inspectable harness for its native identity. */
95
+ export declare function collectNativeHomeRows(): Promise<NativeHomeRow[]>;
96
+ /** The subset of Meta the pure builder reads — the registered native accounts + defaults. */
97
+ type CatalogMeta = Pick<Meta, 'accounts' | 'deviceAccounts'>;
98
+ /**
99
+ * Fold installed homes + the registry into account-first native rows (pure).
100
+ *
101
+ * Every home carrying an identity contributes an `AccountHome`; the identity is
102
+ * the group key. A registered account (name/id/connect home) is merged onto its
103
+ * identity, and — crucially — a registered account whose identity has NO live
104
+ * home still appears, as `reconnect-needed`, so a stale/expired login is never
105
+ * silently dropped from the list nor shown as connected.
106
+ */
107
+ export declare function buildNativeCatalog(rows: NativeHomeRow[], meta: CatalogMeta, globalDefault?: (agent: AgentId) => string | null): NativeAccountCatalogRow[];
108
+ /**
109
+ * Wire the real collectors to the pure builder. This is the canonical
110
+ * account-first read model `view.ts` renders: native identities (account +
111
+ * connection first, release/home secondary) plus the durable provider bundles.
112
+ */
113
+ export declare function loadAccountCatalog(): Promise<AccountCatalog>;
114
+ export {};