@phnx-labs/agents-cli 1.22.56 → 1.22.58

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +11 -2
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/accounts.js +7 -3
  7. package/dist/commands/apply.js +10 -2
  8. package/dist/commands/exec.js +1 -1
  9. package/dist/commands/fork.d.ts +23 -10
  10. package/dist/commands/fork.js +115 -58
  11. package/dist/commands/hooks.js +4 -4
  12. package/dist/commands/insights.d.ts +7 -5
  13. package/dist/commands/insights.js +16 -9
  14. package/dist/commands/monitors.js +11 -0
  15. package/dist/commands/perf.d.ts +16 -7
  16. package/dist/commands/perf.js +29 -20
  17. package/dist/commands/prune.js +5 -3
  18. package/dist/commands/routines.d.ts +8 -0
  19. package/dist/commands/routines.js +57 -3
  20. package/dist/commands/rules.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +11 -0
  22. package/dist/commands/sessions-picker.js +16 -0
  23. package/dist/commands/sessions.js +1 -0
  24. package/dist/commands/share.d.ts +14 -0
  25. package/dist/commands/share.js +43 -2
  26. package/dist/commands/ssh.js +24 -14
  27. package/dist/commands/status.js +1 -1
  28. package/dist/commands/sync.js +83 -7
  29. package/dist/commands/traces.js +7 -0
  30. package/dist/commands/trash.d.ts +2 -2
  31. package/dist/commands/trash.js +2 -6
  32. package/dist/commands/versions.d.ts +2 -2
  33. package/dist/commands/versions.js +1 -10
  34. package/dist/commands/view.d.ts +2 -2
  35. package/dist/commands/view.js +7 -6
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +14 -0
  38. package/dist/lib/account-registry.d.ts +5 -1
  39. package/dist/lib/account-registry.js +47 -14
  40. package/dist/lib/accounting/capacity.d.ts +18 -7
  41. package/dist/lib/accounting/capacity.js +19 -8
  42. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  43. package/dist/lib/accounting/usage-ingest.js +75 -0
  44. package/dist/lib/accounting/usage-sync.d.ts +97 -0
  45. package/dist/lib/accounting/usage-sync.js +203 -0
  46. package/dist/lib/accounting/usage.d.ts +48 -2
  47. package/dist/lib/accounting/usage.js +79 -2
  48. package/dist/lib/agent-spec/agents.js +1 -1
  49. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  50. package/dist/lib/analytics/mix-commands.js +50 -73
  51. package/dist/lib/auth-mint.d.ts +11 -1
  52. package/dist/lib/auth-mint.js +21 -6
  53. package/dist/lib/browser/ipc.d.ts +8 -0
  54. package/dist/lib/browser/ipc.js +87 -0
  55. package/dist/lib/browser/service.d.ts +19 -0
  56. package/dist/lib/browser/service.js +96 -11
  57. package/dist/lib/browser/sessions-list.js +10 -1
  58. package/dist/lib/daemon/daemon.js +5 -0
  59. package/dist/lib/daemon/runner.d.ts +3 -0
  60. package/dist/lib/daemon/runner.js +95 -53
  61. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  62. package/dist/lib/daemon/usage-sync-service.js +42 -0
  63. package/dist/lib/daemon-services.d.ts +1 -1
  64. package/dist/lib/daemon-services.js +5 -0
  65. package/dist/lib/device-config.d.ts +17 -6
  66. package/dist/lib/device-config.js +25 -11
  67. package/dist/lib/devices/connect.d.ts +17 -8
  68. package/dist/lib/devices/connect.js +31 -14
  69. package/dist/lib/devices/pool.d.ts +4 -3
  70. package/dist/lib/devices/pool.js +13 -5
  71. package/dist/lib/doctor-diff.js +77 -7
  72. package/dist/lib/exec.d.ts +6 -41
  73. package/dist/lib/exec.js +6 -41
  74. package/dist/lib/fleet/manifest.d.ts +17 -0
  75. package/dist/lib/fleet/manifest.js +26 -0
  76. package/dist/lib/git.d.ts +13 -1
  77. package/dist/lib/git.js +36 -7
  78. package/dist/lib/harness/adapter.d.ts +7 -7
  79. package/dist/lib/harness/adapters/claude.js +3 -2
  80. package/dist/lib/hooks/install.d.ts +27 -11
  81. package/dist/lib/hooks/install.js +42 -17
  82. package/dist/lib/hosts/reconnect.d.ts +52 -203
  83. package/dist/lib/hosts/reconnect.js +64 -284
  84. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  85. package/dist/lib/hosts/remote-cmd.js +22 -0
  86. package/dist/lib/installations/migrate.d.ts +6 -120
  87. package/dist/lib/installations/migrate.js +27 -259
  88. package/dist/lib/installations/shims.d.ts +13 -95
  89. package/dist/lib/installations/shims.js +22 -139
  90. package/dist/lib/installations/store.js +1 -1
  91. package/dist/lib/installations/versions.d.ts +26 -133
  92. package/dist/lib/installations/versions.js +41 -204
  93. package/dist/lib/perf/db.d.ts +1 -1
  94. package/dist/lib/perf/db.js +1 -1
  95. package/dist/lib/plugins/skills.d.ts +8 -1
  96. package/dist/lib/plugins/skills.js +18 -2
  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/reaper.d.ts +28 -70
  108. package/dist/lib/secrets/reaper.js +30 -85
  109. package/dist/lib/secrets/remote.d.ts +42 -129
  110. package/dist/lib/secrets/remote.js +55 -173
  111. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  112. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  113. package/dist/lib/self-heal/registry.js +2 -0
  114. package/dist/lib/self-heal/types.d.ts +1 -1
  115. package/dist/lib/self-update.d.ts +23 -0
  116. package/dist/lib/self-update.js +50 -0
  117. package/dist/lib/session/active.d.ts +16 -32
  118. package/dist/lib/session/active.js +10 -68
  119. package/dist/lib/session/db.d.ts +24 -36
  120. package/dist/lib/session/db.js +143 -44
  121. package/dist/lib/session/discover.d.ts +6 -58
  122. package/dist/lib/session/discover.js +5 -43
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/parse.d.ts +1 -19
  126. package/dist/lib/session/parse.js +2 -15
  127. package/dist/lib/session/tool-calls.d.ts +43 -1
  128. package/dist/lib/session/tool-calls.js +74 -44
  129. package/dist/lib/session/tool-store.d.ts +33 -2
  130. package/dist/lib/session/tool-store.js +56 -3
  131. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  132. package/dist/lib/staleness/writers/sources.js +2 -1
  133. package/dist/lib/startup/command-registry.d.ts +8 -2
  134. package/dist/lib/startup/command-registry.js +12 -4
  135. package/dist/lib/sync-status.d.ts +22 -0
  136. package/dist/lib/sync-status.js +27 -0
  137. package/dist/lib/sync-umbrella.d.ts +9 -0
  138. package/dist/lib/sync-umbrella.js +21 -2
  139. package/dist/lib/traces/insights.d.ts +47 -14
  140. package/dist/lib/traces/insights.js +92 -21
  141. package/dist/lib/traces/phenotype.d.ts +23 -3
  142. package/dist/lib/traces/phenotype.js +72 -24
  143. package/dist/lib/traces/sync.d.ts +15 -0
  144. package/dist/lib/traces/sync.js +104 -19
  145. package/dist/lib/traces/worker-template.js +154 -1
  146. package/package.json +1 -1
@@ -79,10 +79,14 @@ function deviceSummary(d, isSelf = false, stats, isInteractive = false, roles) {
79
79
  const marker = isSelf ? chalk.cyan('▸ ') : ' ';
80
80
  const name = isSelf ? chalk.bold.cyan(d.name.padEnd(16)) : chalk.bold(d.name.padEnd(16));
81
81
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
82
- const interactive = isInteractive ? chalk.yellow(' ★ interactive') : '';
83
82
  // `roles` defaults to a single-device roster so a fleet-wide `role` default
84
83
  // still reaches this device even when it has no per-device doc of its own.
85
- const role = roleTag(d.name, roles ?? listConfiguredDeviceRoles([d.name]));
84
+ const roleMap = roles ?? listConfiguredDeviceRoles([d.name]);
85
+ // `personal` already means "the interactive box you sit at", so the star would
86
+ // just repeat it — show it only when the interactive host is NOT a personal box
87
+ // (e.g. an unmarked or worker box deliberately pinned as the artifact target).
88
+ const interactive = isInteractive && roleMap[d.name] !== 'personal' ? chalk.yellow(' ★ interactive') : '';
89
+ const role = roleTag(d.name, roleMap);
86
90
  return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}${interactive}${role}`;
87
91
  }
88
92
  /** The fleet-wide role mark, rendered for a device row. Empty when unmarked —
@@ -95,7 +99,9 @@ function roleTag(name, roles) {
95
99
  return chalk.green(' worker');
96
100
  if (role === 'personal')
97
101
  return chalk.yellow(' personal');
98
- return chalk.gray(' control');
102
+ if (role === 'desktop')
103
+ return chalk.cyan(' desktop');
104
+ return '';
99
105
  }
100
106
  const HEADROOM_BADGE = {
101
107
  idle: chalk.green('○ idle'),
@@ -257,7 +263,9 @@ export function renderDeviceTable(reg, names, self, statsMap, full = false, inte
257
263
  : '';
258
264
  const badge = HEADROOM_BADGE[headroom(stats)];
259
265
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
260
- const interactive = name === interactiveHost ? chalk.yellow(' ★ interactive') : '';
266
+ // A `personal` role already conveys "your interactive seat"; don't double-
267
+ // render the star on it. Keep it for a non-personal pinned interactive host.
268
+ const interactive = name === interactiveHost && deviceRoles[name] !== 'personal' ? chalk.yellow(' ★ interactive') : '';
261
269
  lines.push(fitDeviceRow(`${marker}${label}${plat} ${specCell(stats, specWidth)}${load}${mem}${disk}${freeTotal} ${badge}${relay}${here}${interactive}`, role, desc, width));
262
270
  }
263
271
  // Fleet capacity summary — total cores, how much RAM is free right now, and
@@ -1449,12 +1457,12 @@ function registerDevicesCommands(program) {
1449
1457
  *
1450
1458
  * A role written here lands in that device's tracked per-device doc
1451
1459
  * (`devices/<name>/agents.yaml` `config.role`) and syncs with repo
1452
- * push/pull. The vocabulary is deliberately `worker | personal` only.
1460
+ * push/pull. The vocabulary is `worker | personal | desktop`.
1453
1461
  */
1454
1462
  const runDevicesRole = async (name, role, opts) => {
1455
1463
  if (!name) {
1456
1464
  if (role)
1457
- throw new Error('Name a device: agents devices role <name> <worker|personal>');
1465
+ throw new Error('Name a device: agents devices role <name> <worker|personal|desktop>');
1458
1466
  const reg = await loadDevices();
1459
1467
  // The full registered roster, not just online — a fleet-wide `role`
1460
1468
  // default must reach a registered device even when it has no per-device
@@ -1475,14 +1483,14 @@ function registerDevicesCommands(program) {
1475
1483
  }
1476
1484
  else {
1477
1485
  for (const [device, r] of marked) {
1478
- const tint = r === 'worker' ? chalk.green : r === 'personal' ? chalk.yellow : chalk.gray;
1486
+ const tint = r === 'worker' ? chalk.green : r === 'personal' ? chalk.yellow : r === 'desktop' ? chalk.cyan : chalk.gray;
1479
1487
  console.log(` ${device.padEnd(20)} ${tint(r)}`);
1480
1488
  }
1481
1489
  }
1482
1490
  console.log();
1483
1491
  console.log(chalk.bold('--device auto picks from: ') + (pool.length > 0 ? pool.join(', ') : chalk.red('nothing — no eligible device')));
1484
1492
  if (mode === 'all')
1485
- console.log(chalk.gray('auto.pool=all — worker marks are ignored (a personal device is still excluded).'));
1493
+ console.log(chalk.gray('auto.pool=all — worker marks are ignored (personal and desktop devices are still excluded).'));
1486
1494
  return;
1487
1495
  }
1488
1496
  await mustGetDevice(name);
@@ -1669,7 +1677,8 @@ function registerDevicesCommands(program) {
1669
1677
  });
1670
1678
  const roleCmd = devicesCmd
1671
1679
  .command('role [name] [role]')
1672
- .description('Show or set what a device is for: worker (agents run here) or personal (you sit here — never picked automatically). ' +
1680
+ .description('Show or set what a device is for: worker (agents run here), personal (you sit here), or desktop (a headed ' +
1681
+ 'always-on box — the release/credential home). Personal and desktop are never picked automatically. ' +
1673
1682
  'Marking any device worker makes `--device auto` an allowlist over the marked workers.')
1674
1683
  .option('--clear', 'remove the mark, returning the device to unmarked')
1675
1684
  .option('--json', 'output machine-readable JSON')
@@ -1688,6 +1697,7 @@ function registerDevicesCommands(program) {
1688
1697
  agents devices role yosemite-s0 worker # agents spin up here
1689
1698
  agents devices role yosemite-s1 worker # …and here; auto now rotates over these two only
1690
1699
  agents devices role zion personal # your laptop — keep automatic placement off it
1700
+ agents devices role mac-mini desktop # headed always-on box — also kept out of auto placement
1691
1701
  agents devices role yosemite-s0 --clear # unmark
1692
1702
  agents devices role --json # machine-readable
1693
1703
  `,
@@ -1699,12 +1709,12 @@ function registerDevicesCommands(program) {
1699
1709
 
1700
1710
  Effect on '--device auto' (agents run, teams, agents ssh auto, and the AGI
1701
1711
  EXT launch commands, which all resolve placement through the CLI):
1702
- no device marked -> every online device, as before
1703
- any worker marked -> ONLY the marked workers
1704
- personal -> never picked, under either state
1712
+ no device marked -> every online device, as before
1713
+ any worker marked -> ONLY the marked workers
1714
+ personal / desktop -> never picked, under either state
1705
1715
 
1706
- Turn the allowlist off with 'agents config set auto.pool all'; a personal
1707
- box stays excluded, since that is what the mark is for.
1716
+ Turn the allowlist off with 'agents config set auto.pool all'; personal and
1717
+ desktop boxes stay excluded, since that is what the mark is for.
1708
1718
  `,
1709
1719
  });
1710
1720
  const describeCmd = devicesCmd
@@ -78,7 +78,7 @@ export function registerStatusCommand(syncCmd) {
78
78
  // A non-git / partial ~/.agents is its own drift state, not a per-agent
79
79
  // "N missing" — surface it distinctly so the real problem isn't buried (PHNX-3301).
80
80
  if (status.user.notGitRepo) {
81
- console.log(` ${'~/.agents (user repo)'.padEnd(28)} ${chalk.yellow('not a git repo — will adopt on next `agents repo sync user`')}`);
81
+ console.log(` ${'~/.agents (user repo)'.padEnd(28)} ${chalk.yellow('not a git repo — will adopt on next `agents sync` (or `agents repo sync user`)')}`);
82
82
  }
83
83
  if (status.agents.length === 0) {
84
84
  console.log(chalk.gray(' (no installed agent versions)'));
@@ -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();
@@ -9,13 +9,13 @@
9
9
  import type { Command } from 'commander';
10
10
  /**
11
11
  * Restore a soft-deleted version back into ~/.agents/.history/versions/.
12
- * Shared by `agents trash restore` and the top-level `agents restore` alias.
12
+ * Backs the top-level `agents restore` command.
13
13
  * Exits the process with a non-zero code on any failure.
14
14
  */
15
15
  export declare function restoreVersion(target: string): void;
16
16
  /**
17
17
  * Register the top-level `agents restore` command — a shorthand for
18
- * `agents trash restore` so users can undo a `remove`/`prune` directly.
18
+ * `agents restore` so users can undo a `remove`/`prune` directly.
19
19
  */
20
20
  export declare function registerRestoreCommand(program: Command): void;
21
21
  export declare function registerTrashCommands(program: Command): void;
@@ -112,7 +112,7 @@ function humanSize(bytes) {
112
112
  }
113
113
  /**
114
114
  * Restore a soft-deleted version back into ~/.agents/.history/versions/.
115
- * Shared by `agents trash restore` and the top-level `agents restore` alias.
115
+ * Backs the top-level `agents restore` command.
116
116
  * Exits the process with a non-zero code on any failure.
117
117
  */
118
118
  export function restoreVersion(target) {
@@ -157,7 +157,7 @@ export function restoreVersion(target) {
157
157
  }
158
158
  /**
159
159
  * Register the top-level `agents restore` command — a shorthand for
160
- * `agents trash restore` so users can undo a `remove`/`prune` directly.
160
+ * `agents restore` so users can undo a `remove`/`prune` directly.
161
161
  */
162
162
  export function registerRestoreCommand(program) {
163
163
  program
@@ -196,8 +196,4 @@ export function registerTrashCommands(program) {
196
196
  console.log();
197
197
  console.log(chalk.gray('Restore with: agents restore <agent>@<version>'));
198
198
  });
199
- trash
200
- .command('restore <target>')
201
- .description('Restore a soft-deleted version (e.g. "claude@2.1.110") back to ~/.agents/.history/versions/')
202
- .action((target) => restoreVersion(target));
203
199
  }
@@ -2,10 +2,10 @@
2
2
  * Version management commands for installing, switching, and removing agent CLIs.
3
3
  *
4
4
  * Implements `agents add`, `agents prune`, `agents remove` (alias),
5
- * `agents use`, and the deprecated `agents list`. Handles npm-based installation,
5
+ * and `agents use`. Handles npm-based installation,
6
6
  * shim creation, config symlink
7
7
  * switching, resource sync prompts, and project-level version pinning.
8
8
  */
9
9
  import type { Command } from 'commander';
10
- /** Register `agents add`, `agents prune`, `agents remove`, `agents use`, and `agents list` (deprecated). */
10
+ /** Register `agents add`, `agents prune`, `agents remove`, and `agents use`. */
11
11
  export declare function registerVersionsCommands(program: Command): void;
@@ -1,4 +1,3 @@
1
- import { addHostOption } from '../lib/hosts/option.js';
2
1
  import chalk from 'chalk';
3
2
  import ora from 'ora';
4
3
  import * as fs from 'fs';
@@ -6,7 +5,6 @@ import * as path from 'path';
6
5
  import { select, confirm, checkbox } from '@inquirer/prompts';
7
6
  import { AGENTS, MANAGED_AGENT_IDS, accountOrgBadge, getAccountEmail, getAccountInfo, agentLabel, warnAgentDeprecated, isAgentHardDeprecated, hardDeprecationError, isSelfUpdatingAgent, } from '../lib/agents.js';
8
7
  import { formatUsageSummary, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, isUsageHeadlessScopeError, } from '../lib/accounting/usage.js';
9
- import { viewAction } from './view.js';
10
8
  import { resolveConfiguredModel, formatAgentIdentity } from '../lib/models.js';
11
9
  import { readManifest, writeManifest, createDefaultManifest } from '../lib/manifest.js';
12
10
  import { installVersion, removeVersion, listInstalledVersions, isVersionInstalled, isLatestInstalled, isOldestInstalled, getGlobalDefault, setGlobalDefault, markVersionIsolated, setIsolatedDefault, isVersionIsolated, getVersionHomePath, getVersionDir, syncResourcesToVersion, parseAgentSpec, promptResourceSelection, promptNewResourceSelection, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, printTrashFooter, } from '../lib/installations/versions.js';
@@ -288,7 +286,7 @@ function configureVersionPruneCommand(cmd, commandName) {
288
286
  });
289
287
  cmd.action((specs, options) => versionPruneAction(specs, options, commandName));
290
288
  }
291
- /** Register `agents add`, `agents prune`, `agents remove`, `agents use`, and `agents list` (deprecated). */
289
+ /** Register `agents add`, `agents prune`, `agents remove`, and `agents use`. */
292
290
  export function registerVersionsCommands(program) {
293
291
  const addCmd = program
294
292
  .command('add <specs...>')
@@ -943,11 +941,4 @@ export function registerVersionsCommands(program) {
943
941
  throw err;
944
942
  }
945
943
  });
946
- // Deprecated: use `agents view` instead
947
- addHostOption(program.command('list [agent]'))
948
- .description('List installed agent CLI versions')
949
- .action(async (agentArg) => {
950
- console.log(chalk.red('Deprecated: "agents list" is now "agents view"\n'));
951
- await viewAction(agentArg);
952
- });
953
944
  }
@@ -72,8 +72,8 @@ export declare function renderHarnessBlocks(harnesses: ProfileSummary[], install
72
72
  /**
73
73
  * The USAGE-READ-2 decision, isolated from its runtime inputs so it can be
74
74
  * tested directly: a usage read may fall through to the interactive OAuth login
75
- * ONLY for a foreground human render on a `personal` device. Both conditions are
76
- * required — role alone is not sufficient.
75
+ * ONLY for a foreground human render on a headed device (`personal` or
76
+ * `desktop`). Both conditions are required — role alone is not sufficient.
77
77
  */
78
78
  export declare function allowInteractiveUsageLogin(role: ConfiguredDeviceRole | undefined, isTTY: boolean): boolean;
79
79
  /** Build the set of resource sections to include in `--json` from the
@@ -9,7 +9,7 @@ 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';
11
11
  import { agentReportsUsage, classifyUsageErrorKind, deriveUsageStatusFromSnapshot, formatUsageSection, formatUsageSummary, formatUsageStatusBadge, getUsageBenignState, getUsageInfoForIdentity, getUsageInfoByIdentity, getUsageLookupKey, isUsageHeadlessScopeError, usageErrorForDisplay, } from '../lib/accounting/usage.js';
12
- import { selfConfiguredDeviceRole } from '../lib/device-config.js';
12
+ import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../lib/device-config.js';
13
13
  import { readManifest } from '../lib/manifest.js';
14
14
  import { listInstalledVersions, listInstalledVersionDirs, getGlobalDefault, getVersionHomePath, getVersionDir, removeVersion, printTrashFooter, reconcileStaleLatestForAgent, isGlobalBinaryAgent, getLiveVersion, isVersionIsolated, getIsolatedDefault, } from '../lib/installations/versions.js';
15
15
  import { ensureVersionedAliasCurrent, removeShim, } from '../lib/installations/shims.js';
@@ -320,17 +320,18 @@ function renderHostClisSection(cwd) {
320
320
  /**
321
321
  * The USAGE-READ-2 decision, isolated from its runtime inputs so it can be
322
322
  * tested directly: a usage read may fall through to the interactive OAuth login
323
- * ONLY for a foreground human render on a `personal` device. Both conditions are
324
- * required — role alone is not sufficient.
323
+ * ONLY for a foreground human render on a headed device (`personal` or
324
+ * `desktop`). Both conditions are required — role alone is not sufficient.
325
325
  */
326
326
  export function allowInteractiveUsageLogin(role, isTTY) {
327
- return role === 'personal' && isTTY === true;
327
+ return isHeadedDeviceRole(role) && isTTY === true;
328
328
  }
329
329
  /**
330
330
  * Whether this `agents view` invocation may fall through to the interactive
331
331
  * OAuth login for a usage read (USAGE-READ-2). True only for a foreground human
332
- * render on a `personal` device: the interactive login is the sole credential
333
- * carrying the `user:profile` scope the usage endpoint needs, and a human
332
+ * render on a headed device (`personal` or `desktop`): the interactive login is
333
+ * the sole credential carrying the `user:profile` scope the usage endpoint needs,
334
+ * and a human
334
335
  * running one command is not the unattended-loop revocation risk RUSH-1822
335
336
  * fixed. The `--json` path never reaches these render functions (it returns
336
337
  * early via `collectAgentsJson`), and a non-TTY (piped/scripted) run is excluded
package/dist/index.d.ts CHANGED
@@ -15,6 +15,7 @@
15
15
  * - `__secrets-get` / `__secrets-ping` / `__secrets-lock` (SYNC_* tokens)
16
16
  * - `__shim`
17
17
  * - `__claude-statusline`
18
+ * - `__usage-ingest` / `__usage-export`
18
19
  * - `__daemon-run`
19
20
  *
20
21
  * The tokens are imported from the leaf module sync-commands.ts — the SAME
package/dist/index.js CHANGED
@@ -15,6 +15,7 @@
15
15
  * - `__secrets-get` / `__secrets-ping` / `__secrets-lock` (SYNC_* tokens)
16
16
  * - `__shim`
17
17
  * - `__claude-statusline`
18
+ * - `__usage-ingest` / `__usage-export`
18
19
  * - `__daemon-run`
19
20
  *
20
21
  * The tokens are imported from the leaf module sync-commands.ts — the SAME
@@ -75,6 +76,19 @@ if (process.argv[2] === '__claude-statusline') {
75
76
  const { runClaudeStatusLine } = await import('./lib/claude-statusline.js');
76
77
  process.exit(await runClaudeStatusLine());
77
78
  }
79
+ // Fleet usage-sync receiver: a headed peer pipes its identity-keyed usage rows to
80
+ // stdin, we merge them newest-wins into this box's cache (PHNX-3392). Above the
81
+ // bootstrap line for the same reason as __claude-statusline — no update check, no
82
+ // detached sync fork, and nothing writes to stdout to corrupt the caller's view.
83
+ if (process.argv[2] === '__usage-ingest') {
84
+ const { runUsageIngest } = await import('./lib/accounting/usage-ingest.js');
85
+ process.exit(await runUsageIngest());
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
+ }
78
92
  if (process.argv[2] === '__daemon-run') {
79
93
  const { runDaemon, log: daemonLog } = await import('./lib/daemon/daemon.js');
80
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.`);