@phnx-labs/agents-cli 1.22.78 → 1.22.79

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 +11 -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 +4 -0
  12. package/dist/commands/view.js +114 -19
  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
@@ -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
  }
@@ -1603,8 +1695,7 @@ export async function viewAction(agentArg, options) {
1603
1695
  console.log(JSON.stringify(data, null, 2));
1604
1696
  return;
1605
1697
  }
1606
- // No argument: show all installed versions
1607
- await showInstalledVersions(undefined, { forceRefresh });
1698
+ await showInstalledVersions(undefined, { forceRefresh, versions: options?.versions });
1608
1699
  return;
1609
1700
  }
1610
1701
  // Parse agent@version syntax
@@ -1675,15 +1766,15 @@ export async function viewAction(agentArg, options) {
1675
1766
  await showAgentResources(agentId, 'default', filter);
1676
1767
  }
1677
1768
  else {
1678
- // Just agent name: show versions for that agent
1679
- await showInstalledVersions(agentId, { forceRefresh });
1769
+ await showInstalledVersions(agentId, { forceRefresh, versions: options?.versions });
1680
1770
  }
1681
1771
  }
1682
1772
  /** Register the `agents view` command. */
1683
1773
  export function registerViewCommand(program) {
1684
1774
  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).')
1775
+ .description('Show your agents, connected accounts, and usage. Use --versions for installation diagnostics.')
1776
+ .option('--versions', 'Show every installation, actual release, account, model, and home path.')
1777
+ .option('--json', 'Emit machine-readable JSON (accounts plus backward-compatible installation list).')
1687
1778
  .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
1779
  .option('--detailed', 'Include all resources in --json output (alias for --resources all). Implies --json.')
1689
1780
  .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 +1794,15 @@ export function registerViewCommand(program) {
1703
1794
  .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
1795
  .addHelpText('after', `
1705
1796
  Examples:
1706
- # Show all installed agents with versions, accounts, and usage
1797
+ # Show installed agents with connected accounts and usage
1707
1798
  agents view
1708
1799
 
1709
- # Show versions for one agent
1800
+ # Show accounts for one agent
1710
1801
  agents view claude
1711
1802
 
1803
+ # Inspect installation labels, current releases, models, and paths
1804
+ agents view claude --versions
1805
+
1712
1806
  # Describe one custom harness (host, model, provider, auth, path)
1713
1807
  agents view deepseek-flash
1714
1808
 
@@ -1738,16 +1832,17 @@ Examples:
1738
1832
  agents view --merged
1739
1833
 
1740
1834
  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)
1835
+ - Checking which agents and accounts are connected on this device
1836
+ - Seeing the selected default account and each account's usage
1743
1837
  - Inspecting commands, skills, hooks, and MCP servers synced to a version
1744
1838
  - Verifying a version is installed before running it
1745
1839
  - Cleaning up stale versions left behind after upgrading (--prune)
1746
1840
 
1747
1841
  Output:
1748
- - Without arguments: table of all agents with versions, emails, usage stats,
1842
+ - Without arguments: table of all agents with accounts, connection state, usage,
1749
1843
  then one block per custom harness (see 'agents harness')
1750
- - With agent name: versions for that agent, showing which is the default
1844
+ - With agent name: one row per native identity, showing the default account
1845
+ - With --versions: every retained installation, actual release, model, and home
1751
1846
  - With a custom harness name: that harness's host, model, provider, and auth
1752
1847
  - With agent@version: detailed breakdown of resources synced to that version
1753
1848
  - 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 {};
@@ -1,5 +1,21 @@
1
- import { ALL_AGENT_IDS, getAccountInfo, supportsAccountInspection } from './agents.js';
2
- import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
1
+ import { ALL_AGENT_IDS, credentialPresence, getAccountInfo, supportsAccountInspection } from './agents.js';
2
+ import { getGlobalDefault, getVersionHomePath, listInstalledVersions } from './installations/versions.js';
3
+ import { readInstallation } from './installations/store.js';
4
+ import { readMeta } from './state.js';
5
+ import { listNativeAccounts, readAccountRegistry } from './account-registry.js';
6
+ import { isLaunchableSignedIn as isCredentialLaunchable } from './accounting/rotate.js';
7
+ /**
8
+ * Strict "is this home actually connected?" — a live CREDENTIAL in that exact
9
+ * version home, not just a metadata identity claim (PHNX-3940). A `.claude.json`
10
+ * carrying an `oauthAccount` block with no `.credentials.json`/`.oauth_token`
11
+ * beside it is stale/expired, and must read as `reconnect-needed`, never
12
+ * `connected`. Where agents-cli does not know the credential location
13
+ * (`knownLocation` false), it falls back to the metadata `signedIn` since there
14
+ * is nothing stricter to check.
15
+ */
16
+ export function isLaunchableSignedIn(agent, versionHome, info) {
17
+ return isCredentialLaunchable(info.signedIn, credentialPresence(agent, versionHome));
18
+ }
3
19
  export function groupNativeAccountRows(rows) {
4
20
  const grouped = new Map();
5
21
  for (const row of rows) {
@@ -27,12 +43,124 @@ export function groupNativeAccountRows(rows) {
27
43
  }
28
44
  /** Discover signed-in harness-native identities without copying their auth files. */
29
45
  export async function discoverNativeAccounts() {
46
+ const rows = await collectNativeHomeRows();
47
+ return groupNativeAccountRows(rows.map(r => ({ agent: r.agent, version: r.label, accountKey: r.accountKey, email: r.email, signedIn: r.signedIn })));
48
+ }
49
+ /** Probe every installed home of every inspectable harness for its native identity. */
50
+ export async function collectNativeHomeRows() {
30
51
  const rows = [];
31
52
  for (const agent of ALL_AGENT_IDS.filter(supportsAccountInspection)) {
32
- for (const version of listInstalledVersions(agent)) {
33
- const info = await getAccountInfo(agent, getVersionHomePath(agent, version));
34
- rows.push({ agent, version, accountKey: info.accountKey, email: info.email, signedIn: info.signedIn });
53
+ for (const label of listInstalledVersions(agent)) {
54
+ const home = getVersionHomePath(agent, label);
55
+ const info = await getAccountInfo(agent, home);
56
+ rows.push({
57
+ agent,
58
+ label,
59
+ releaseVersion: readInstallation(agent, label)?.releaseVersion ?? null,
60
+ accountKey: info.accountKey,
61
+ email: info.email,
62
+ // Strict: a live credential in THIS home, not a bare metadata identity.
63
+ signedIn: isLaunchableSignedIn(agent, home, info),
64
+ });
65
+ }
66
+ }
67
+ return rows;
68
+ }
69
+ /**
70
+ * Fold installed homes + the registry into account-first native rows (pure).
71
+ *
72
+ * Every home carrying an identity contributes an `AccountHome`; the identity is
73
+ * the group key. A registered account (name/id/connect home) is merged onto its
74
+ * identity, and — crucially — a registered account whose identity has NO live
75
+ * home still appears, as `reconnect-needed`, so a stale/expired login is never
76
+ * silently dropped from the list nor shown as connected.
77
+ */
78
+ export function buildNativeCatalog(rows, meta, globalDefault = getGlobalDefault) {
79
+ const registered = listNativeAccounts(meta);
80
+ const defaults = meta.accounts?.defaults ?? {};
81
+ const groups = new Map();
82
+ const keyOf = (agent, identity) => `${agent}:${identity}`;
83
+ // Every installed home that carries a resolvable identity seeds a group.
84
+ for (const row of rows) {
85
+ const identity = row.accountKey ?? row.email?.toLowerCase();
86
+ if (!identity)
87
+ continue;
88
+ const key = keyOf(row.agent, identity);
89
+ const group = groups.get(key) ?? { agent: row.agent, identityKey: identity, email: null, homes: [] };
90
+ group.email ??= row.email;
91
+ group.homes.push({ label: row.label, releaseVersion: row.releaseVersion, signedIn: row.signedIn });
92
+ groups.set(key, group);
93
+ }
94
+ // A registered account whose identity has no discovered home still belongs in
95
+ // the catalog — it just has nothing live to connect through yet. Its
96
+ // `identityKey` is the same value a home row groups on (a raw accountKey, or
97
+ // an already-lowercased email), so an exact-key check finds the existing group.
98
+ for (const account of registered) {
99
+ const key = keyOf(account.agent, account.identityKey);
100
+ if (!groups.has(key)) {
101
+ groups.set(key, { agent: account.agent, identityKey: account.identityKey, email: account.identityLabel ?? null, homes: [] });
102
+ }
103
+ }
104
+ const out = [];
105
+ for (const group of groups.values()) {
106
+ const account = registered.find(a => a.agent === group.agent && a.identityKey === group.identityKey);
107
+ const email = group.email ?? account?.identityLabel ?? null;
108
+ const homes = [...group.homes].sort((a, b) => a.label.localeCompare(b.label));
109
+ const signedIn = homes.some(h => h.signedIn);
110
+ // The configured per-harness account default is AUTHORITATIVE: when present,
111
+ // only the matching native account is the default — an explicit default that
112
+ // names a provider (or a stale name) never invents a native default. Only
113
+ // when NO account default is configured does the global-default installation
114
+ // home decide (diagnostic fallback).
115
+ const defaultRef = defaults[group.agent];
116
+ let isDefault;
117
+ if (defaultRef !== undefined) {
118
+ isDefault = !!account && (account.name === defaultRef || account.id === defaultRef);
119
+ }
120
+ else {
121
+ const gd = globalDefault(group.agent);
122
+ isDefault = !!gd && homes.some(h => h.label === gd);
35
123
  }
124
+ // The home is a PER-HOST fact: this box's recorded connect home when it is
125
+ // actually installed here, else the local home carrying the identity. A home
126
+ // label recorded on another box is never assumed to exist locally.
127
+ const recordedHome = account ? (meta.deviceAccounts?.homes?.[account.id] ?? null) : null;
128
+ const home = (recordedHome && homes.some(h => h.label === recordedHome))
129
+ ? recordedHome
130
+ : (homes.find(h => h.signedIn)?.label ?? homes[0]?.label ?? null);
131
+ out.push({
132
+ kind: 'native',
133
+ agent: group.agent,
134
+ identityKey: group.identityKey,
135
+ name: account?.name ?? null,
136
+ id: account?.id ?? null,
137
+ email,
138
+ display: email ?? account?.name ?? group.identityKey,
139
+ home,
140
+ installations: homes,
141
+ isDefault,
142
+ state: signedIn ? 'connected' : 'reconnect-needed',
143
+ });
36
144
  }
37
- return groupNativeAccountRows(rows);
145
+ // Named accounts first, then default, then a stable display order.
146
+ return out.sort((a, b) => a.agent.localeCompare(b.agent)
147
+ || Number(!!b.name) - Number(!!a.name)
148
+ || Number(b.isDefault) - Number(a.isDefault)
149
+ || a.display.localeCompare(b.display));
150
+ }
151
+ function toProviderRow(account) {
152
+ return { kind: 'provider', name: account.name, id: account.id, provider: account.provider, auth: account.auth, baseUrl: account.baseUrl };
153
+ }
154
+ /**
155
+ * Wire the real collectors to the pure builder. This is the canonical
156
+ * account-first read model `view.ts` renders: native identities (account +
157
+ * connection first, release/home secondary) plus the durable provider bundles.
158
+ */
159
+ export async function loadAccountCatalog() {
160
+ const meta = readMeta();
161
+ const native = buildNativeCatalog(await collectNativeHomeRows(), meta);
162
+ const provider = Object.values(readAccountRegistry().accounts)
163
+ .map(toProviderRow)
164
+ .sort((a, b) => a.name.localeCompare(b.name));
165
+ return { native, provider };
38
166
  }
@@ -55,9 +55,52 @@ export declare function listNativeAccounts(meta: Pick<Meta, 'accounts' | 'device
55
55
  * able to omit it.
56
56
  */
57
57
  export declare function findUnifiedAccount(nameOrId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, doc?: AccountRegistryDocument, preferAgent?: AgentId): UnifiedAccount | null;
58
+ /**
59
+ * Validate a native account NAME (charset + per-harness uniqueness) WITHOUT a
60
+ * known identity — the pre-flight `agents accounts connect` runs before it
61
+ * installs a home and drives a login, so a bad/colliding name fails before any
62
+ * side effect instead of orphaning a freshly-minted home (PHNX-3940).
63
+ */
64
+ export declare function assertNativeAccountNameAvailable(name: string, agent: AgentId): void;
58
65
  export declare function addNativeAccount(name: string, agent: AgentId, identityKey: string, identityLabel: string | undefined, scope: 'version' | 'device'): NativeAccount;
59
66
  /** Create or replace the version-independent label for one native identity. */
60
67
  export declare function labelNativeAccount(agent: AgentId, identityKey: string, identityLabel: string | undefined, label: string | undefined, scope: 'version' | 'device'): NativeAccount;
68
+ /**
69
+ * Record THIS box's connect home for an account (PHNX-3940). Device-scoped: the
70
+ * home a box minted for an account is not assumed to exist on any other box, so
71
+ * it lives in the device doc keyed by the stable account id, never on the
72
+ * fleet-synced central identity row. Idempotent. A reconnect reads it back via
73
+ * {@link nativeAccountHome} to reuse the exact home even when the credential has
74
+ * expired and local signed-in discovery can no longer find it.
75
+ */
76
+ export declare function setNativeAccountHome(accountId: string, installationLabel: string): void;
77
+ /** This box's recorded connect home for an account, or null. */
78
+ export declare function nativeAccountHome(accountId: string, meta: Pick<Meta, 'deviceAccounts'>): string | null;
79
+ /**
80
+ * Every installation label THIS box has recorded as SOME account's connect home
81
+ * (PHNX-3940). These are identity-bearing and MUST NEVER be re-minted for a new
82
+ * account — the safe-allocation invariant that stops a new connect from
83
+ * overwriting another account's login.
84
+ */
85
+ export declare function ownedConnectHomeLabels(meta: Pick<Meta, 'deviceAccounts'>): Set<string>;
86
+ /** This box's in-flight connect slot for `(agent, name)`, or null. */
87
+ export declare function pendingConnectSlot(agent: AgentId, name: string, meta: Pick<Meta, 'deviceAccounts'>): string | null;
88
+ /** Record an in-flight connect slot for `(agent, name)` (device-scoped). */
89
+ export declare function setPendingConnectSlot(agent: AgentId, name: string, slot: string): void;
90
+ /** Clear the in-flight connect slot for `(agent, name)` once the account lands. */
91
+ export declare function clearPendingConnectSlot(agent: AgentId, name: string): void;
92
+ /**
93
+ * Set the per-harness default account by NAME only when none is configured
94
+ * (PHNX-3940). Never overrides an existing choice — a first connect selecting a
95
+ * default is a convenience, not a takeover. Returns whether it set the default.
96
+ *
97
+ * The check-then-write is performed INSIDE the `updateMeta` callback so two
98
+ * concurrent callers (concurrent Promises or back-to-back calls on an async
99
+ * boundary) cannot both observe "no default" and then both set themselves. Only
100
+ * the first write wins; the second callback sees the first's write and returns
101
+ * the current state unchanged.
102
+ */
103
+ export declare function setDefaultAccountIfAbsent(agent: AgentId, name: string): boolean;
61
104
  export declare function bindAccount(nameOrId: string, target: string, preferAgent?: AgentId): UnifiedAccount;
62
105
  export declare function unbindAccount(nameOrId: string, target: string, preferAgent?: AgentId): void;
63
106
  /** Every target bound to `accountId`: this box's device-doc bindings merged over