@phnx-labs/agents-cli 1.22.57 → 1.22.59

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 (152) hide show
  1. package/CHANGELOG.md +294 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +39 -1
  4. package/dist/commands/accounts.js +7 -3
  5. package/dist/commands/apply.js +10 -2
  6. package/dist/commands/fork.d.ts +23 -10
  7. package/dist/commands/fork.js +115 -58
  8. package/dist/commands/monitors.js +198 -23
  9. package/dist/commands/prune.js +5 -3
  10. package/dist/commands/routines.d.ts +8 -0
  11. package/dist/commands/routines.js +57 -3
  12. package/dist/commands/routines.test-fixture.js +5 -0
  13. package/dist/commands/send.d.ts +2 -1
  14. package/dist/commands/send.js +7 -5
  15. package/dist/commands/sessions-picker.d.ts +11 -0
  16. package/dist/commands/sessions-picker.js +16 -0
  17. package/dist/commands/sessions-stats.js +37 -5
  18. package/dist/commands/sessions.js +40 -5
  19. package/dist/commands/share.d.ts +14 -0
  20. package/dist/commands/share.js +43 -2
  21. package/dist/commands/ssh.js +12 -1
  22. package/dist/commands/status.js +1 -1
  23. package/dist/commands/sync.js +83 -7
  24. package/dist/commands/traces.js +7 -0
  25. package/dist/commands/versions.js +12 -4
  26. package/dist/commands/view.js +7 -2
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.js +6 -1
  29. package/dist/lib/account-registry.d.ts +5 -1
  30. package/dist/lib/account-registry.js +47 -14
  31. package/dist/lib/accounting/capacity.d.ts +18 -7
  32. package/dist/lib/accounting/capacity.js +19 -8
  33. package/dist/lib/accounting/usage-sync.d.ts +29 -1
  34. package/dist/lib/accounting/usage-sync.js +76 -2
  35. package/dist/lib/accounting/usage.js +7 -1
  36. package/dist/lib/auth-mint.d.ts +11 -1
  37. package/dist/lib/auth-mint.js +21 -6
  38. package/dist/lib/auto-pull-worker.js +7 -2
  39. package/dist/lib/browser/ipc.d.ts +8 -0
  40. package/dist/lib/browser/ipc.js +87 -0
  41. package/dist/lib/browser/service.d.ts +19 -0
  42. package/dist/lib/browser/service.js +96 -11
  43. package/dist/lib/browser/sessions-list.js +10 -1
  44. package/dist/lib/cloud/rush.d.ts +7 -0
  45. package/dist/lib/cloud/rush.js +29 -1
  46. package/dist/lib/daemon/daemon.d.ts +22 -0
  47. package/dist/lib/daemon/daemon.js +39 -0
  48. package/dist/lib/daemon/runner.d.ts +3 -0
  49. package/dist/lib/daemon/runner.js +86 -45
  50. package/dist/lib/daemon/session-index-service.js +9 -1
  51. package/dist/lib/daemon/usage-sync-service.d.ts +3 -3
  52. package/dist/lib/daemon/usage-sync-service.js +14 -8
  53. package/dist/lib/daemon-services.js +1 -1
  54. package/dist/lib/daemon-ticks.d.ts +15 -0
  55. package/dist/lib/daemon-ticks.js +26 -0
  56. package/dist/lib/device-config.d.ts +5 -1
  57. package/dist/lib/device-config.js +2 -2
  58. package/dist/lib/devices/connect.d.ts +17 -8
  59. package/dist/lib/devices/connect.js +31 -14
  60. package/dist/lib/devices/health.js +5 -1
  61. package/dist/lib/devices/pool.d.ts +25 -2
  62. package/dist/lib/devices/pool.js +32 -2
  63. package/dist/lib/devices/stats-cache.d.ts +0 -6
  64. package/dist/lib/devices/stats-cache.js +2 -9
  65. package/dist/lib/doctor-diff.d.ts +14 -0
  66. package/dist/lib/doctor-diff.js +120 -9
  67. package/dist/lib/fleet/manifest.d.ts +17 -0
  68. package/dist/lib/fleet/manifest.js +26 -0
  69. package/dist/lib/git.d.ts +38 -0
  70. package/dist/lib/git.js +58 -0
  71. package/dist/lib/hooks/install.d.ts +27 -11
  72. package/dist/lib/hooks/install.js +42 -17
  73. package/dist/lib/hosts/ready.d.ts +8 -0
  74. package/dist/lib/hosts/ready.js +13 -2
  75. package/dist/lib/hosts/reconnect.d.ts +52 -203
  76. package/dist/lib/hosts/reconnect.js +64 -284
  77. package/dist/lib/installations/migrate.d.ts +6 -120
  78. package/dist/lib/installations/migrate.js +27 -259
  79. package/dist/lib/installations/shims.d.ts +13 -95
  80. package/dist/lib/installations/shims.js +22 -139
  81. package/dist/lib/installations/store.js +1 -1
  82. package/dist/lib/installations/versions.d.ts +43 -133
  83. package/dist/lib/installations/versions.js +94 -206
  84. package/dist/lib/monitors/config.d.ts +71 -3
  85. package/dist/lib/monitors/config.js +100 -12
  86. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  87. package/dist/lib/monitors/pid-watch.js +45 -0
  88. package/dist/lib/monitors/remote.d.ts +18 -0
  89. package/dist/lib/monitors/remote.js +11 -0
  90. package/dist/lib/permissions.js +7 -2
  91. package/dist/lib/plugins/plugins.d.ts +17 -3
  92. package/dist/lib/plugins/plugins.js +84 -9
  93. package/dist/lib/plugins/skills.d.ts +8 -1
  94. package/dist/lib/plugins/skills.js +18 -2
  95. package/dist/lib/pty-server.d.ts +14 -0
  96. package/dist/lib/pty-server.js +49 -5
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/drivers/rush.js +5 -0
  108. package/dist/lib/secrets/reaper.d.ts +28 -70
  109. package/dist/lib/secrets/reaper.js +30 -85
  110. package/dist/lib/secrets/remote.d.ts +42 -129
  111. package/dist/lib/secrets/remote.js +55 -173
  112. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  113. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  114. package/dist/lib/self-heal/registry.js +2 -0
  115. package/dist/lib/self-heal/types.d.ts +1 -1
  116. package/dist/lib/self-update.d.ts +65 -0
  117. package/dist/lib/self-update.js +138 -0
  118. package/dist/lib/session/active.d.ts +13 -1
  119. package/dist/lib/session/active.js +2 -0
  120. package/dist/lib/session/cloud.js +5 -0
  121. package/dist/lib/session/db.d.ts +51 -6
  122. package/dist/lib/session/db.js +266 -20
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/tool-calls.d.ts +43 -1
  126. package/dist/lib/session/tool-calls.js +74 -44
  127. package/dist/lib/session/tool-store.d.ts +33 -2
  128. package/dist/lib/session/tool-store.js +56 -3
  129. package/dist/lib/smart-launch.d.ts +6 -0
  130. package/dist/lib/smart-launch.js +5 -2
  131. package/dist/lib/staleness/writers/plugins.js +5 -2
  132. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  133. package/dist/lib/staleness/writers/sources.js +2 -1
  134. package/dist/lib/staleness/writers/subagents.js +13 -3
  135. package/dist/lib/state.d.ts +7 -4
  136. package/dist/lib/state.js +7 -4
  137. package/dist/lib/subagents.js +8 -2
  138. package/dist/lib/sync-status.d.ts +22 -0
  139. package/dist/lib/sync-status.js +27 -0
  140. package/dist/lib/sync-umbrella.d.ts +9 -0
  141. package/dist/lib/sync-umbrella.js +21 -2
  142. package/dist/lib/teams/scheduler.d.ts +10 -0
  143. package/dist/lib/teams/scheduler.js +8 -0
  144. package/dist/lib/traces/insights.d.ts +47 -14
  145. package/dist/lib/traces/insights.js +92 -21
  146. package/dist/lib/traces/phenotype.d.ts +23 -3
  147. package/dist/lib/traces/phenotype.js +72 -24
  148. package/dist/lib/traces/sync.d.ts +128 -6
  149. package/dist/lib/traces/sync.js +294 -35
  150. package/dist/lib/traces/worker-template.js +154 -1
  151. package/dist/lib/view-types.d.ts +12 -0
  152. package/package.json +2 -2
@@ -45,6 +45,7 @@ import { runLaunchSync } from '../lib/project-launch.js';
45
45
  import { formatKeptProjectResources } from '../lib/project-resources.js';
46
46
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
47
47
  import { runUmbrellaSync } from '../lib/sync-umbrella.js';
48
+ import { verifyVersionConverged, formatResidualDrift } from '../lib/sync-status.js';
48
49
  import { addHostOption } from '../lib/hosts/option.js';
49
50
  import { syncRepoGit, adoptUserRepoIfNeeded, recordUserRepoRemote, resolveUserRepoRemoteUrl } from '../lib/git.js';
50
51
  import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos } from '../lib/state.js';
@@ -53,6 +54,46 @@ import { registerStatusCommand } from './status.js';
53
54
  function emitJson(payload) {
54
55
  console.log(JSON.stringify(payload));
55
56
  }
57
+ /**
58
+ * Post-reconcile verification (PHNX-3186): after a sync writes into a set of
59
+ * version homes, re-read each home and confirm it now matches its resolved
60
+ * sources. The `agents sync` success line MUST NOT read "reconciled" while the
61
+ * drift it was asked to fix stays put. Any residual drifted/missing resource
62
+ * sets a non-zero exit code and — outside `--json` — names the exact unfixed
63
+ * drift so the operator sees what did not converge instead of a false ✓. Orphans
64
+ * are excluded (sync never removes them). Returns the residual for `--json`.
65
+ */
66
+ function verifyReconciled(pairs, cwd) {
67
+ const residual = [];
68
+ const seen = new Set();
69
+ for (const { agent, version } of pairs) {
70
+ const key = `${agent}@${version}`;
71
+ if (seen.has(key))
72
+ continue;
73
+ seen.add(key);
74
+ const r = verifyVersionConverged(agent, version, cwd);
75
+ if (r)
76
+ residual.push(r);
77
+ }
78
+ // Residual drift is reported loudly via `ok:false` + the printed ⚠ block, but
79
+ // does NOT change the exit code — matching the declined-write precedent
80
+ // (RUSH-2700). The fleet fan-out (`agents sync --device all`) THROWS on a
81
+ // non-zero peer exit (`hosts/passthrough.ts`), discarding that box's JSON, so a
82
+ // non-zero exit here would hide the very `residualDrift` payload it emitted.
83
+ // Callers that must treat an incomplete sync as failure read `ok`/`residualDrift`
84
+ // from `--json`, exactly as they already do for declines.
85
+ return residual;
86
+ }
87
+ /** Print the residual-drift block naming exactly what did not converge. */
88
+ function printResidual(residual, errLog) {
89
+ if (residual.length === 0)
90
+ return;
91
+ const lines = formatResidualDrift(residual);
92
+ errLog(chalk.yellow(`⚠ sync did not fully reconcile — ${lines.length} resource(s) still drift after writing:`));
93
+ for (const line of lines)
94
+ errLog(chalk.yellow(` ${line}`));
95
+ errLog(chalk.gray(' Re-run the sync; a gap that survives a re-run is a real unreconcilable drift — report it.'));
96
+ }
56
97
  /**
57
98
  * Translate per-kind CLI flags into a `ResourceSelection` for `buildSelection`.
58
99
  * Returns `undefined` when no kind flag was given (caller should use full sync).
@@ -429,6 +470,7 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
429
470
  await runInteractiveReconcile(opts, outLog, errLog);
430
471
  return;
431
472
  }
473
+ const cwd = opts.cwd || process.cwd();
432
474
  const flags = {
433
475
  repos: opts.repos,
434
476
  secrets: opts.secrets,
@@ -459,10 +501,18 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
459
501
  // --cloud (fetch-only, no local reconcile).
460
502
  if (!opts.cloud)
461
503
  evictCentralBrowserProfilesForSync(quiet, json, outLog, errLog);
504
+ // Post-reconcile verification (PHNX-3186): re-read every version the reconcile
505
+ // wrote into and confirm it now matches source. Without this the umbrella
506
+ // printed `✓ sync: reconciled` unconditionally, the exact false-success the
507
+ // ticket reports. Residual drift downgrades the line and sets a non-zero exit.
508
+ const residual = result.reconciled
509
+ ? verifyReconciled(result.reconciledVersions.map((r) => ({ agent: r.agent, version: r.version })), cwd)
510
+ : [];
462
511
  if (json) {
463
512
  emitJson({
464
- // A refused resource is not a clean sync (RUSH-2700).
465
- ok: result.declined.length === 0,
513
+ // A refused resource OR residual drift is not a clean sync (RUSH-2700 +
514
+ // PHNX-3186).
515
+ ok: result.declined.length === 0 && residual.length === 0,
466
516
  mode: 'umbrella',
467
517
  plan: result.plan,
468
518
  repos: result.repos,
@@ -470,6 +520,7 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
470
520
  devices: result.devices,
471
521
  reconciled: result.reconciled,
472
522
  declined: result.declined,
523
+ residualDrift: residual,
473
524
  });
474
525
  return;
475
526
  }
@@ -482,12 +533,18 @@ async function runUmbrella(opts, quiet, outLog, errLog, json = false) {
482
533
  if (result.secrets) {
483
534
  parts.push(result.secrets.skipped ? 'secrets skipped' : `secrets ${result.secrets.pulled} pulled`);
484
535
  }
536
+ // Only claim "reconciled" when the reconcile actually converged. Residual
537
+ // drift is already printed loudly by verifyReconciled above; reflect it in
538
+ // the one-line summary rather than a bare ✓.
485
539
  if (result.reconciled)
486
- parts.push('reconciled');
487
- outLog(chalk.green(`✓ sync: ${parts.join(' · ') || 'nothing to do'}`));
540
+ parts.push(residual.length === 0 ? 'reconciled' : 'reconcile INCOMPLETE');
541
+ const symbol = residual.length === 0 ? chalk.green('✓') : chalk.yellow('⚠');
542
+ const line = `${symbol} sync: ${parts.join(' · ') || 'nothing to do'}`;
543
+ outLog(residual.length === 0 ? chalk.green(line) : chalk.yellow(line));
488
544
  const errs = [...(result.repos?.errors ?? []), ...(result.secrets?.errors ?? [])];
489
545
  for (const e of errs)
490
546
  errLog(chalk.yellow(` ! ${e}`));
547
+ printResidual(residual, errLog);
491
548
  }
492
549
  }
493
550
  catch (err) {
@@ -700,13 +757,23 @@ async function runSync(agentSpec, repoArg, opts) {
700
757
  if (!quiet && !json)
701
758
  printSyncDetail(result, agentId, v, cwd);
702
759
  }
760
+ // Verify each version actually converged; a repo-scoped sync only touched
761
+ // that repo's kinds, so skip verification there (the other layers legitimately
762
+ // still differ and are not this run's responsibility).
763
+ const residual = repoScope
764
+ ? []
765
+ : verifyReconciled(versions.map(({ version: v }) => ({ agent: agentId, version: v })), cwd);
766
+ if (!quiet && !json)
767
+ printResidual(residual, errLog);
703
768
  if (json) {
704
769
  emitJson({
705
- // Any version that refused a write makes the whole run not-ok (RUSH-2700).
706
- ok: versions.every(({ result }) => result.declined.length === 0),
770
+ // Any version that refused a write, or any residual drift after the
771
+ // reconcile, makes the whole run not-ok (RUSH-2700 + PHNX-3186).
772
+ ok: versions.every(({ result }) => result.declined.length === 0) && residual.length === 0,
707
773
  mode: 'agent-all',
708
774
  agent: agentId,
709
775
  repo: repoScope,
776
+ residualDrift: residual,
710
777
  ...healedPointers,
711
778
  versions: versions.map(({ version: v, result }) => ({
712
779
  version: v,
@@ -901,6 +968,11 @@ async function runSync(agentSpec, repoArg, opts) {
901
968
  return;
902
969
  }
903
970
  const result = syncResourcesToVersion(agentId, version, selection, { projectDir, cwd, force, allowExecSurfaces: !!opts.allowExecSurfaces });
971
+ // Post-reconcile verification (PHNX-3186). Only for a FULL reconcile
972
+ // (`!selection`): an interactive subset-selection deliberately touched only the
973
+ // picked kinds, so the rest legitimately still differs and is not this run's
974
+ // failure. This is the exact path `agents sync <agent>[@version]` takes.
975
+ const residual = selection ? [] : verifyReconciled([{ agent: agentId, version }], cwd);
904
976
  // Compile project-scope rules into the workspace itself so each agent's
905
977
  // native loader picks up cwd/<INSTRUCTIONS_FILE>. projectDir is the
906
978
  // .agents/ directory; the workspace root is its parent.
@@ -910,8 +982,11 @@ async function runSync(agentSpec, repoArg, opts) {
910
982
  projectCompile = compileRulesForProject(projectRoot);
911
983
  }
912
984
  if (json) {
985
+ const base = agentSyncJson(agentId, version, result);
913
986
  emitJson({
914
- ...agentSyncJson(agentId, version, result),
987
+ ...base,
988
+ ok: base.ok === true && residual.length === 0,
989
+ residualDrift: residual,
915
990
  ...healedPointers,
916
991
  projectCompile: projectCompile
917
992
  ? {
@@ -928,6 +1003,7 @@ async function runSync(agentSpec, repoArg, opts) {
928
1003
  return;
929
1004
  // ---------- 6. Detailed output ----------
930
1005
  printSyncDetail(result, agentId, version, cwd);
1006
+ printResidual(residual, errLog);
931
1007
  if (projectCompile?.compiled) {
932
1008
  const linkInfo = projectCompile.symlinks.length > 0
933
1009
  ? ` (+ ${projectCompile.symlinks.join(', ')})`
@@ -109,6 +109,13 @@ async function handleSync(opts) {
109
109
  parts.push(chalk.dim('nothing new'));
110
110
  }
111
111
  console.log(parts.join(chalk.dim(' · ')));
112
+ // The per-session data can upload cleanly while the aggregated console shard
113
+ // fails to refresh (e.g. the sessions DB is locked by a running app). That used
114
+ // to be silent, so the console sat stale with no signal here (PHNX-3401).
115
+ if (result.indexError) {
116
+ console.log(chalk.yellow(' ⚠ console index not refreshed') +
117
+ chalk.dim(` (${result.indexError}) — dashboard may be stale; it will retry next sync`));
118
+ }
112
119
  }
113
120
  async function handleStatus() {
114
121
  const ledger = readSyncLedger();
@@ -11,7 +11,7 @@ import { installVersion, removeVersion, listInstalledVersions, isVersionInstalle
11
11
  import { carryForwardSettings } from '../lib/settings-manifest.js';
12
12
  import { createShim, createVersionedAlias, supportsIsolatedInstall, isIsolationProtected, CONFIG_ENV_ISOLATED_AGENTS, removeShim, shimExists, getShimsDir, getShimPath, getPathShadowingExecutable, isShimsInPath, getPathSetupInstructions, addShimsToPath, switchConfigSymlink, switchHomeFileSymlinks, } from '../lib/installations/shims.js';
13
13
  import { isInteractiveTerminal, isPromptCancelled, requireInteractiveSelection } from './utils.js';
14
- import { tryAutoPull } from '../lib/git.js';
14
+ import { tryAutoPullSystemRepo } from '../lib/git.js';
15
15
  import { getAgentsDir, getTrashVersionsDir } from '../lib/state.js';
16
16
  import { setHelpSections } from '../lib/help.js';
17
17
  import { updateSessionFilePaths } from '../lib/session/db.js';
@@ -630,10 +630,18 @@ export function registerVersionsCommands(program) {
630
630
  useCmd.action(async (agentArg, versionArg, options) => {
631
631
  try {
632
632
  const skipPrompts = options.yes || !isInteractiveTerminal();
633
- // Auto-pull ~/.agents/.system if it's a git repo with remote (silent on success)
633
+ // Auto-pull ~/.agents/.system if it's a git repo tracking the EXPECTED
634
+ // system remote (silent on success). The system repo ships hooks that run
635
+ // as shell on tool events, so pulling from a repointed origin would be
636
+ // remote code execution — tryAutoPullSystemRepo refuses that (PHNX-2957).
634
637
  const agentsDir = getAgentsDir();
635
- const pullResult = await tryAutoPull(agentsDir);
636
- if (pullResult.pulled) {
638
+ const pullResult = await tryAutoPullSystemRepo(agentsDir);
639
+ if (pullResult.refused) {
640
+ console.error(chalk.red(`Refusing to auto-sync ~/.agents/.system: its origin (${pullResult.actualRemote}) is not the expected system repo.`));
641
+ console.error(chalk.gray('The system repo ships hooks that run on tool events; a fast-forward from an unexpected origin is not applied. ' +
642
+ 'Re-point it (git -C ~/.agents/.system remote set-url origin <expected>) or set AGENTS_SYSTEM_REPO, then re-run `agents setup --force`.'));
643
+ }
644
+ else if (pullResult.pulled) {
637
645
  console.log(chalk.gray('Synced ~/.agents/.system from remote'));
638
646
  }
639
647
  // Support both "claude 2.0.65" and "claude@2.0.65" formats
@@ -4,7 +4,7 @@ import { termLink } from '../lib/format.js';
4
4
  import ora from 'ora';
5
5
  import * as fs from 'fs';
6
6
  import * as path from 'path';
7
- import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
7
+ import { AGENTS, ALL_AGENT_IDS, accountDisplayLabel, getAllCliStates, getUnmanagedCliState, getAccountInfo, credentialPresence, resolveAgentName, formatAgentError, agentLabel, colorAgent, } from '../lib/agents.js';
8
8
  import { ambientClaudeToken, loginHint } from '../lib/signin-badge.js';
9
9
  import { machineId } from '../lib/machine-id.js';
10
10
  import { authCacheKey, readAuthHealthCache } from '../lib/auth-health.js';
@@ -24,7 +24,7 @@ import { listNativeAccounts } from '../lib/account-registry.js';
24
24
  import { isGitRepo, getGitSyncStatus } from '../lib/git.js';
25
25
  import { getCentralRulesFileName } from '../lib/rules/rules.js';
26
26
  import { composeRulesFromState } from '../lib/rules/compose.js';
27
- import { getConfiguredRunStrategy } from '../lib/accounting/rotate.js';
27
+ import { getConfiguredRunStrategy, isLaunchableSignedIn } from '../lib/accounting/rotate.js';
28
28
  import { resolveRunDefaults } from '../lib/run-defaults.js';
29
29
  import { resolveConfiguredModel } from '../lib/models.js';
30
30
  import { listProfiles, profileExists, profileSummary, readProfile } from '../lib/profiles.js';
@@ -1293,6 +1293,11 @@ export async function collectAgentsJson(filterAgentId, resourceSections, opts) {
1293
1293
  isolated: isVersionIsolated(agentId, version),
1294
1294
  isIsolatedDefault: getIsolatedDefault(agentId) === version,
1295
1295
  signedIn: info.signedIn,
1296
+ // The strict per-version launch truth (vs the display `signedIn` above,
1297
+ // which inherits the active/global HOME login). The same primitive
1298
+ // `collectRunCandidates` uses locally, so remote `--device auto` placement
1299
+ // is gated on identical launchability (PHNX-3466).
1300
+ launchable: isLaunchableSignedIn(info.signedIn, credentialPresence(agentId, home)),
1296
1301
  authVerdict: authCache[authCacheKey(host, agentId, version)]?.verdict ?? null,
1297
1302
  email: info.email,
1298
1303
  accountId: info.accountId,
package/dist/index.d.ts CHANGED
@@ -15,7 +15,7 @@
15
15
  * - `__secrets-get` / `__secrets-ping` / `__secrets-lock` (SYNC_* tokens)
16
16
  * - `__shim`
17
17
  * - `__claude-statusline`
18
- * - `__usage-ingest`
18
+ * - `__usage-ingest` / `__usage-export`
19
19
  * - `__daemon-run`
20
20
  *
21
21
  * The tokens are imported from the leaf module sync-commands.ts — the SAME
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * - `__secrets-get` / `__secrets-ping` / `__secrets-lock` (SYNC_* tokens)
16
16
  * - `__shim`
17
17
  * - `__claude-statusline`
18
- * - `__usage-ingest`
18
+ * - `__usage-ingest` / `__usage-export`
19
19
  * - `__daemon-run`
20
20
  *
21
21
  * The tokens are imported from the leaf module sync-commands.ts — the SAME
@@ -84,6 +84,11 @@ if (process.argv[2] === '__usage-ingest') {
84
84
  const { runUsageIngest } = await import('./lib/accounting/usage-ingest.js');
85
85
  process.exit(await runUsageIngest());
86
86
  }
87
+ if (process.argv[2] === '__usage-export') {
88
+ const { exportClaudeUsageCacheRows } = await import('./lib/accounting/usage.js');
89
+ process.stdout.write(JSON.stringify({ v: 1, rows: exportClaudeUsageCacheRows() }));
90
+ process.exit(0);
91
+ }
87
92
  if (process.argv[2] === '__daemon-run') {
88
93
  const { runDaemon, log: daemonLog } = await import('./lib/daemon/daemon.js');
89
94
  // RUSH-2418: the daemon is the one always-on process here, and it ran with no
@@ -63,11 +63,15 @@ export declare function unbindAccount(nameOrId: string, target: string): void;
63
63
  /** Every target bound to `accountId`: this box's device-doc bindings merged over
64
64
  * the fleet-shared central bindings (PHNX-3315). */
65
65
  export declare function accountBindings(accountId: string, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>): string[];
66
+ export interface AccountSelection {
67
+ id: string;
68
+ source: 'explicit' | 'binding' | 'default';
69
+ }
66
70
  /** Explicit selection wins over a configured per-harness default. */
67
71
  export declare function resolveAccountSelection(explicit: string | undefined, agent: AgentId, meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, opts?: {
68
72
  useDefault?: boolean;
69
73
  target?: string;
70
- }): string | undefined;
74
+ }): AccountSelection | undefined;
71
75
  export interface AddAccountOptions {
72
76
  baseUrl?: string;
73
77
  }
@@ -20,6 +20,7 @@ import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import * as crypto from 'node:crypto';
22
22
  import * as yaml from 'yaml';
23
+ import chalk from 'chalk';
23
24
  import { atomicWriteFileSync } from './fs-atomic.js';
24
25
  import { getUserAgentsDir, readMeta, updateMeta } from './state.js';
25
26
  import { deleteKeychainToken, getKeychainToken, hasKeychainToken } from './secrets/index.js';
@@ -307,18 +308,19 @@ export function accountBindings(accountId, meta) {
307
308
  /** Explicit selection wins over a configured per-harness default. */
308
309
  export function resolveAccountSelection(explicit, agent, meta, opts = {}) {
309
310
  if (explicit)
310
- return explicit;
311
+ return { id: explicit, source: 'explicit' };
311
312
  // This box's device-doc bindings win over the fleet-shared central bindings
312
313
  // (PHNX-3315), so a per-box account attachment resolves without touching the
313
314
  // shared file. Defaults are genuinely fleet-shared and stay central.
314
315
  const bindings = { ...meta.accounts?.bindings, ...meta.deviceAccounts?.bindings };
315
316
  const bound = opts.target ? bindings[opts.target] : undefined;
316
317
  if (bound)
317
- return bound;
318
+ return { id: bound, source: 'binding' };
318
319
  const deviceScoped = bindings[agent];
319
320
  if (deviceScoped)
320
- return deviceScoped;
321
- return opts.useDefault === false ? undefined : meta.accounts?.defaults?.[agent];
321
+ return { id: deviceScoped, source: 'binding' };
322
+ const defaulted = opts.useDefault === false ? undefined : meta.accounts?.defaults?.[agent];
323
+ return defaulted ? { id: defaulted, source: 'default' } : undefined;
322
324
  }
323
325
  function profileConsumers(name, base) {
324
326
  const dir = path.join(base, 'profiles');
@@ -371,19 +373,30 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
371
373
  const rows = nativeRowsForNameOrId(meta, oldName);
372
374
  if (rows.length) {
373
375
  assertUniqueUnifiedName(newName, meta, doc, new Set(rows.map(account => account.id)));
374
- // Sweep every row for the identity (PHNX-3206) in its owning store (PHNX-3315).
376
+ // Sweep every row for the identity (PHNX-3206) in its owning store (PHNX-3315)
377
+ // and any per-harness default that points to the old name or row ids.
375
378
  const rowScope = rows[0].scope;
379
+ const idsToRename = new Set([oldName, ...rows.map(row => row.id)]);
376
380
  updateMeta(current => {
381
+ const defaults = { ...current.accounts?.defaults };
382
+ for (const [agent, value] of Object.entries(defaults)) {
383
+ if (idsToRename.has(value))
384
+ defaults[agent] = newName;
385
+ }
386
+ const next = { ...current, accounts: { ...current.accounts, defaults } };
377
387
  if (rowScope === 'device') {
378
388
  const native = { ...current.deviceAccounts?.native };
379
389
  for (const row of rows)
380
390
  native[row.id] = { ...native[row.id], name: newName };
381
- return { ...current, deviceAccounts: { ...current.deviceAccounts, native } };
391
+ next.deviceAccounts = { ...current.deviceAccounts, native };
382
392
  }
383
- const native = { ...current.accounts?.native };
384
- for (const row of rows)
385
- native[row.id] = { ...native[row.id], name: newName };
386
- return { ...current, accounts: { ...current.accounts, native } };
393
+ else {
394
+ const native = { ...current.accounts?.native };
395
+ for (const row of rows)
396
+ native[row.id] = { ...native[row.id], name: newName };
397
+ next.accounts = { ...next.accounts, native };
398
+ }
399
+ return next;
387
400
  });
388
401
  return;
389
402
  }
@@ -391,6 +404,15 @@ export function renameAccount(oldName, newName, base = getUserAgentsDir()) {
391
404
  if (!account)
392
405
  throw new Error(`Unknown account '${oldName}'.`);
393
406
  assertUniqueUnifiedName(newName, meta, doc);
407
+ const idsToRename = new Set([account.name, account.id]);
408
+ updateMeta(current => {
409
+ const defaults = { ...current.accounts?.defaults };
410
+ for (const [agent, value] of Object.entries(defaults)) {
411
+ if (idsToRename.has(value))
412
+ defaults[agent] = newName;
413
+ }
414
+ return { ...current, accounts: { ...current.accounts, defaults } };
415
+ });
394
416
  renameBundle(account.name, newName); // moves metadata + secret, preserves ACCOUNT_ID
395
417
  renameProfileConsumers(account.name, newName, base);
396
418
  }
@@ -422,7 +444,7 @@ export function removeAccount(name, base = getUserAgentsDir()) {
422
444
  if (!account)
423
445
  throw new Error(`Unknown account '${name}'.`);
424
446
  const bindings = accountBindings(account.id, meta);
425
- const defaults = Object.entries(meta.accounts?.defaults ?? {}).filter(([, id]) => id === account.id).map(([agent]) => agent);
447
+ const defaults = Object.entries(meta.accounts?.defaults ?? {}).filter(([, value]) => value === account.id || value === account.name).map(([agent]) => agent);
426
448
  if (bindings.length || defaults.length) {
427
449
  const refs = [...bindings.map(target => `binding ${target}`), ...defaults.map(agent => `default ${agent}`)];
428
450
  throw new Error(`Account '${account.name}' is still referenced by: ${refs.join(', ')}. Detach or clear those references before removing it.`);
@@ -505,9 +527,20 @@ export function resolveSpawnAccount(explicit, agent, version, meta, opts = {}) {
505
527
  const selection = resolveAccountSelection(explicit, agent, meta, { useDefault: opts.useDefault, target });
506
528
  if (!selection)
507
529
  return null;
508
- const unified = findUnifiedAccount(selection, meta);
509
- if (!unified)
510
- throw new Error(`Unknown account '${selection}'.`);
530
+ const unified = findUnifiedAccount(selection.id, meta);
531
+ if (!unified) {
532
+ // A stale per-harness default is a preference, not a hard requirement: the
533
+ // machine stays runnable by falling back to balanced rotation. Bindings and
534
+ // explicit --account are intentional, so they still fail loud.
535
+ if (selection.source === 'default') {
536
+ process.stderr.write(chalk.yellow(`[agents] default account '${selection.id}' for ${agent} no longer exists on this machine; falling back to balanced selection. Clear the stale default with: agents accounts clear-default ${agent}\n`));
537
+ return null;
538
+ }
539
+ const remedy = selection.source === 'binding'
540
+ ? `Detach the stale binding with: agents accounts detach ${selection.id} ${target}`
541
+ : `Add an account named '${selection.id}' or remove the --account override.`;
542
+ throw new Error(`Unknown account '${selection.id}' for ${agent} harness. ${remedy}`);
543
+ }
511
544
  if (unified.kind === 'native') {
512
545
  if (unified.agent !== agent) {
513
546
  throw new Error(`Account '${unified.name}' is a ${unified.agent} login and cannot authenticate the ${agent} harness.`);
@@ -10,15 +10,26 @@
10
10
  * floor, so an account racing toward its 5h cap loses priority before it maxes.
11
11
  */
12
12
  export declare const PROJECTION_HORIZON_MIN = 30;
13
+ /**
14
+ * The weight an account with NO usage snapshot draws. Absence of a usage signal
15
+ * is NOT capacity (specifications.md GWT-E5c, SING-1a): a null snapshot means
16
+ * "unverifiable", not "empty" — on a worker box every account reads null
17
+ * (setup-token lacks the `user:profile` scope, RUSH-2392), so scoring null as
18
+ * full capacity made the blind pool's top pick an account that was actually
19
+ * weekly-exhausted (PHNX-3392). Floored at 1, never 0: an all-unverified pool
20
+ * must still draw a pick rather than strand the launch.
21
+ */
22
+ export declare const UNVERIFIED_WEIGHT = 1;
13
23
  /**
14
24
  * Weight one candidate by remaining routing capacity, deprioritized by how soon
15
25
  * it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
16
- * an account with no live snapshot is treated as full-capacity (100) since there
17
- * is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
18
- * projection on the 5h session window) then scales that base: >= horizon (or
19
- * unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
20
- * so a launch avoids an account projected to cap soon, not just a 100%-maxed
21
- * one. Pure + exported so the deprioritization is unit-tested directly (a
22
- * weighted-random draw is not).
26
+ * an account with no live snapshot is NOT full-capacity — absence of a usage
27
+ * signal is not evidence of headroom, so it draws `UNVERIFIED_WEIGHT` and any
28
+ * verified-healthy account outranks it (GWT-E5c). `minutesToLimit` (the daemon's
29
+ * burn-rate projection on the 5h session window) then scales that base: >=
30
+ * horizon (or unknown) keeps full weight, and closer-to-cap scales toward the
31
+ * floor of 1 — so a launch avoids an account projected to cap soon, not just a
32
+ * 100%-maxed one. Pure + exported so the deprioritization is unit-tested
33
+ * directly (a weighted-random draw is not).
23
34
  */
24
35
  export declare function capacityWeight(usedPercent: number | null, minutesToLimit: number | null): number;
@@ -10,19 +10,30 @@
10
10
  * floor, so an account racing toward its 5h cap loses priority before it maxes.
11
11
  */
12
12
  export const PROJECTION_HORIZON_MIN = 30;
13
+ /**
14
+ * The weight an account with NO usage snapshot draws. Absence of a usage signal
15
+ * is NOT capacity (specifications.md GWT-E5c, SING-1a): a null snapshot means
16
+ * "unverifiable", not "empty" — on a worker box every account reads null
17
+ * (setup-token lacks the `user:profile` scope, RUSH-2392), so scoring null as
18
+ * full capacity made the blind pool's top pick an account that was actually
19
+ * weekly-exhausted (PHNX-3392). Floored at 1, never 0: an all-unverified pool
20
+ * must still draw a pick rather than strand the launch.
21
+ */
22
+ export const UNVERIFIED_WEIGHT = 1;
13
23
  /**
14
24
  * Weight one candidate by remaining routing capacity, deprioritized by how soon
15
25
  * it is projected to cap. The base is weekly headroom (`max(1, 100 - used)`);
16
- * an account with no live snapshot is treated as full-capacity (100) since there
17
- * is no signal to deprioritize it. `minutesToLimit` (the daemon's burn-rate
18
- * projection on the 5h session window) then scales that base: >= horizon (or
19
- * unknown) keeps full weight, and closer-to-cap scales toward the floor of 1 —
20
- * so a launch avoids an account projected to cap soon, not just a 100%-maxed
21
- * one. Pure + exported so the deprioritization is unit-tested directly (a
22
- * weighted-random draw is not).
26
+ * an account with no live snapshot is NOT full-capacity — absence of a usage
27
+ * signal is not evidence of headroom, so it draws `UNVERIFIED_WEIGHT` and any
28
+ * verified-healthy account outranks it (GWT-E5c). `minutesToLimit` (the daemon's
29
+ * burn-rate projection on the 5h session window) then scales that base: >=
30
+ * horizon (or unknown) keeps full weight, and closer-to-cap scales toward the
31
+ * floor of 1 — so a launch avoids an account projected to cap soon, not just a
32
+ * 100%-maxed one. Pure + exported so the deprioritization is unit-tested
33
+ * directly (a weighted-random draw is not).
23
34
  */
24
35
  export function capacityWeight(usedPercent, minutesToLimit) {
25
- const base = usedPercent === null ? 100 : Math.max(1, 100 - usedPercent);
36
+ const base = usedPercent === null ? UNVERIFIED_WEIGHT : Math.max(1, 100 - usedPercent);
26
37
  if (minutesToLimit === null || !Number.isFinite(minutesToLimit))
27
38
  return base;
28
39
  const factor = Math.max(0, Math.min(1, minutesToLimit / PROJECTION_HORIZON_MIN));
@@ -1,8 +1,9 @@
1
1
  import { type DeviceProfile } from '../devices/registry.js';
2
2
  import { type ConfiguredDeviceRole } from '../device-config.js';
3
- import { type CachedUsageSnapshot } from './usage.js';
3
+ import { type CachedUsageSnapshot, type UsageSnapshot } from './usage.js';
4
4
  /** How long a single peer push may take before it is abandoned for this tick. */
5
5
  export declare const USAGE_PUSH_DEADLINE_MS = 20000;
6
+ export declare const USAGE_PULL_DEADLINE_MS = 20000;
6
7
  /** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
7
8
  export interface UsageSyncPayload {
8
9
  v: 1;
@@ -62,6 +63,33 @@ export interface UsageSyncDeps {
62
63
  message?: string;
63
64
  };
64
65
  }
66
+ export interface UsagePullResult {
67
+ pulledFrom: string | null;
68
+ merged: number;
69
+ skipped: string | null;
70
+ error: string | null;
71
+ }
72
+ export interface UsagePullDeps {
73
+ selfRole?: () => ConfiguredDeviceRole | undefined;
74
+ listDevices?: () => DeviceProfile[];
75
+ listRoles?: () => Record<string, ConfiguredDeviceRole>;
76
+ isPinned?: (name: string) => boolean;
77
+ exportRows?: () => Record<string, CachedUsageSnapshot>;
78
+ readRow?: (usageKey: string) => Pick<UsageSnapshot, 'windows'> | null;
79
+ ingestRows?: (rows: Record<string, CachedUsageSnapshot>) => number;
80
+ /** Read the versioned payload from the primary. Default: ssh `__usage-export`. */
81
+ pull?: (device: DeviceProfile) => {
82
+ ok: boolean;
83
+ stdout?: string;
84
+ message?: string;
85
+ };
86
+ }
87
+ /**
88
+ * Pull usage from the fleet's primary headed device when this worker's local
89
+ * cache is empty or contains an expired row. `personal` is the primary role;
90
+ * a `desktop` is used only when the fleet has no personal device.
91
+ */
92
+ export declare function pullUsageFromPrimary(deps?: UsagePullDeps): UsagePullResult;
65
93
  /**
66
94
  * Push the local identity-keyed usage snapshot to every reachable, pinned,
67
95
  * non-headed peer. A no-op on a non-headed box or when the local cache is empty.
@@ -25,16 +25,17 @@
25
25
  * The planner is pure so tests cover every skip/push branch with no SSH.
26
26
  */
27
27
  import { sshExec } from '../ssh-exec.js';
28
- import { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShellFor } from '../hosts/remote-cmd.js';
28
+ import { buildRemoteAgentsInvocation, buildWindowsStdinAgentsCommand, remoteShellFor, stripClixml } from '../hosts/remote-cmd.js';
29
29
  import { resolveRemoteOsSync } from '../hosts/remote-os.js';
30
30
  import { loadDevicesSync } from '../devices/registry.js';
31
31
  import { sshTargetFor } from '../devices/connect.js';
32
32
  import { isHostPinned } from '../devices/known-hosts.js';
33
33
  import { machineId, normalizeHost } from '../session/sync/config.js';
34
34
  import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
35
- import { exportClaudeUsageCacheRows } from './usage.js';
35
+ import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, readClaudeUsageCache, } from './usage.js';
36
36
  /** How long a single peer push may take before it is abandoned for this tick. */
37
37
  export const USAGE_PUSH_DEADLINE_MS = 20_000;
38
+ export const USAGE_PULL_DEADLINE_MS = 20_000;
38
39
  /**
39
40
  * Decide, per peer, whether to push the local usage snapshot. Pure.
40
41
  *
@@ -84,6 +85,79 @@ function defaultUsagePush(device, payload) {
84
85
  return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
85
86
  return { ok: true };
86
87
  }
88
+ /** Production pull: read the primary's cache through its hidden export verb. */
89
+ function defaultUsagePull(device) {
90
+ const target = sshTargetFor(device);
91
+ const os = resolveRemoteOsSync(device.name);
92
+ const remoteCmd = buildRemoteAgentsInvocation(['__usage-export'], undefined, os);
93
+ const res = sshExec(target, remoteCmd, { timeoutMs: USAGE_PULL_DEADLINE_MS });
94
+ if (res.timedOut)
95
+ return { ok: false, message: 'timed out' };
96
+ if (res.code !== 0)
97
+ return { ok: false, message: res.stderr.trim() || `remote exit ${res.code}` };
98
+ return { ok: true, stdout: res.stdout };
99
+ }
100
+ /**
101
+ * Pull usage from the fleet's primary headed device when this worker's local
102
+ * cache is empty or contains an expired row. `personal` is the primary role;
103
+ * a `desktop` is used only when the fleet has no personal device.
104
+ */
105
+ export function pullUsageFromPrimary(deps = {}) {
106
+ const result = { pulledFrom: null, merged: 0, skipped: null, error: null };
107
+ if ((deps.selfRole ?? selfConfiguredDeviceRole)() !== 'worker') {
108
+ result.skipped = 'this device is not a worker';
109
+ return result;
110
+ }
111
+ const localRows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
112
+ const readRow = deps.readRow ?? readClaudeUsageCache;
113
+ const localEntries = Object.entries(localRows);
114
+ if (localEntries.length > 0 && localEntries.every(([key, row]) => {
115
+ const fresh = readRow(key);
116
+ return fresh !== null && fresh.windows.length === row.windows.length;
117
+ })) {
118
+ result.skipped = 'local usage cache is fresh';
119
+ return result;
120
+ }
121
+ const devices = deps.listDevices?.() ?? Object.values(loadDevicesSync());
122
+ const roles = deps.listRoles?.() ?? (deps.listDevices
123
+ ? listConfiguredDeviceRoles(devices.map((device) => device.name))
124
+ : listConfiguredDeviceRoles());
125
+ const isPinned = deps.isPinned ?? isHostPinned;
126
+ const primary = devices
127
+ .filter((device) => roles[device.name] === 'personal')
128
+ .concat(devices.filter((device) => roles[device.name] === 'desktop'))
129
+ .find((device) => device.tailscale?.online !== false && isPinned(device.name));
130
+ if (!primary) {
131
+ result.error = 'no reachable, pinned personal or desktop device is configured as the usage primary';
132
+ return result;
133
+ }
134
+ const outcome = (deps.pull ?? defaultUsagePull)(primary);
135
+ if (!outcome.ok) {
136
+ result.error = outcome.message ?? 'pull failed';
137
+ return result;
138
+ }
139
+ let payload;
140
+ try {
141
+ // A Windows usage primary's `agents.ps1` shim prepends a PowerShell CLIXML
142
+ // progress banner to stdout, exactly as every other remote-JSON boundary
143
+ // strips (remote-cmd.ts:325). Without this, a headed Windows box (win-mini
144
+ // is a live fleet device) makes every pull fail as "malformed JSON", the
145
+ // worker's cache stays null, and the PHNX-3392 capacity floor silently
146
+ // becomes the only thing standing between a blind pool and an exhausted pick.
147
+ payload = JSON.parse(stripClixml(outcome.stdout ?? ''));
148
+ }
149
+ catch {
150
+ result.error = 'primary returned malformed JSON';
151
+ return result;
152
+ }
153
+ if (!payload || payload.v !== 1 || typeof payload.rows !== 'object' || payload.rows === null || Array.isArray(payload.rows)) {
154
+ result.error = 'primary returned an unrecognized usage-sync payload shape';
155
+ return result;
156
+ }
157
+ result.pulledFrom = primary.name;
158
+ result.merged = (deps.ingestRows ?? ingestPeerClaudeUsageRows)(payload.rows);
159
+ return result;
160
+ }
87
161
  /**
88
162
  * Push the local identity-keyed usage snapshot to every reachable, pinned,
89
163
  * non-headed peer. A no-op on a non-headed box or when the local cache is empty.