@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -13,10 +13,13 @@ import * as os from 'os';
13
13
  import { getDaemonDir as getDaemonDirRoot } from './state.js';
14
14
  import { isAlive, killTree, backgroundSpawnOptions } from './platform/index.js';
15
15
  import { listJobs as listAllJobs } from './routines.js';
16
+ import { syncAllProjectRoutines } from './routines-project.js';
16
17
  import { JobScheduler } from './scheduler.js';
17
18
  import { MonitorEngine } from './monitors/engine.js';
18
19
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
19
20
  import { detectOverdueJobs, notifyOverdue } from './overdue.js';
21
+ import { notifyDesktop } from './menubar/notify-desktop.js';
22
+ import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
20
23
  import { BrowserService } from './browser/service.js';
21
24
  import { BrowserIPCServer } from './browser/ipc.js';
22
25
  import { readAndResolveBundleEnv } from './secrets/bundles.js';
@@ -315,10 +318,36 @@ export async function runDaemon() {
315
318
  // secrets-agent and is otherwise absent (leaving the daemon on its existing
316
319
  // interactive OAuth session), matching the detached-start path. Never blocks.
317
320
  if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
318
- const oauthToken = readDaemonClaudeOAuthToken();
321
+ const bundleEnv = readDaemonClaudeBundleEnv();
322
+ const oauthToken = (bundleEnv[DAEMON_OAUTH_KEY] ?? '').trim();
319
323
  if (oauthToken) {
320
324
  process.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
321
- log('INFO', 'Loaded Claude OAuth token from secrets bundle for routine runs');
325
+ // Also inject each per-account CLAUDE_CODE_OAUTH_TOKEN_<slug> present in the
326
+ // bundle, so a routine authenticates its rotation-pinned account via that
327
+ // account's own long-lived, non-rotating setup-token (runner.ts
328
+ // buildRoutineSpawnEnv) instead of the interactive session that rotates and
329
+ // logs the fleet out (Claude Code #25609 / #56339).
330
+ let perAccount = 0;
331
+ for (const [k, v] of Object.entries(bundleEnv)) {
332
+ if (k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && (v ?? '').trim() && !process.env[k]) {
333
+ process.env[k] = v.trim();
334
+ perAccount++;
335
+ }
336
+ }
337
+ log('INFO', `Loaded Claude OAuth token from secrets bundle for routine runs${perAccount ? ` (+${perAccount} per-account)` : ''}`);
338
+ }
339
+ else {
340
+ // No token available (e.g. a headless macOS daemon whose keychain was
341
+ // locked at start resolves broker-only and gets nothing). Historically
342
+ // this was silent, so every Claude routine the daemon spawned failed auth
343
+ // with no signal. Make it loud: WARN in the log and fire a desktop alert.
344
+ log('WARN', 'No Claude OAuth token available — Claude routine runs will fail auth on this host. ' +
345
+ 'Restart the daemon with the keychain unlocked, or unlock the `claude` secrets bundle.');
346
+ notifyDesktop({
347
+ title: 'agents daemon: no Claude credential',
348
+ body: 'Claude routines will fail auth on this host. Restart the daemon with the keychain unlocked.',
349
+ action: 'routines:list',
350
+ });
322
351
  }
323
352
  }
324
353
  // Reap any stray duplicate daemon of this install that slipped past the start
@@ -363,14 +392,50 @@ export async function runDaemon() {
363
392
  ? `workflow: ${config.workflow}`
364
393
  : `agent: ${config.agent}`;
365
394
  log('INFO', `Triggering job '${config.name}' (${jobLabel})`);
395
+ // RUSH-2030: branded desktop notification on start (agent/workflow routines;
396
+ // suppressed for command housekeeping). Finish/output is fired from the
397
+ // onFinish hook below — executeJobDetached finalizes the run in-process, so
398
+ // the monitor tick never sees the live transition. Never let a notification
399
+ // failure break the trigger.
366
400
  try {
367
- const meta = await executeJobDetached(config);
401
+ notifyRoutineStart(config);
402
+ }
403
+ catch { /* best-effort */ }
404
+ try {
405
+ const meta = await executeJobDetached(config, {
406
+ onFinish: (final) => {
407
+ try {
408
+ notifyRoutineFinish(final);
409
+ }
410
+ catch { /* best-effort */ }
411
+ },
412
+ });
368
413
  log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
369
414
  }
370
415
  catch (err) {
371
- log('ERROR', `Job '${config.name}' failed to spawn: ${err.message}`);
416
+ const message = err.message;
417
+ log('ERROR', `Job '${config.name}' failed to spawn: ${message}`);
418
+ // RUSH-2030: the START ping already fired unconditionally above. A pre-spawn
419
+ // failure produces no run record and thus no onFinish, so send a synthetic
420
+ // "failed to start" finish here — otherwise the user is left with an orphaned
421
+ // "Routine started" and never told it failed.
422
+ try {
423
+ notifyRoutineStartFailed(config, message);
424
+ }
425
+ catch { /* best-effort */ }
372
426
  }
373
427
  });
428
+ // Materialise opted-in project routines into the user layer on every start
429
+ // so a fresh daemon picks up project YAML without a separate sync step.
430
+ try {
431
+ const result = syncAllProjectRoutines();
432
+ const n = result.projects.reduce((acc, p) => acc + p.synced.length, 0);
433
+ if (n > 0)
434
+ log('INFO', `Project routines sync: ${n} job(s) from ${result.projects.length} project(s)`);
435
+ }
436
+ catch (err) {
437
+ log('WARN', `Project routines sync failed: ${err.message}`);
438
+ }
374
439
  scheduler.loadAll();
375
440
  const scheduled = scheduler.listScheduled();
376
441
  log('INFO', `Loaded ${scheduled.length} jobs`);
@@ -623,9 +688,16 @@ export async function runDaemon() {
623
688
  checkingLaunchHealth = true;
624
689
  try {
625
690
  const { healBrokenDefaultLaunches } = await import('./versions.js');
626
- const repaired = await healBrokenDefaultLaunches((m) => log('INFO', `launch-health: ${m}`));
691
+ // Unattended pass: repair the current default in place, but NEVER repoint
692
+ // the global default (allowDefaultSwitch: false). A background default
693
+ // switch installs a fresh version home → a fresh empty Claude credential
694
+ // scope, i.e. a silent logout uncorrelated with anything the user did.
695
+ const { repaired, unhealed } = await healBrokenDefaultLaunches((m) => log('INFO', `launch-health: ${m}`), { allowDefaultSwitch: false });
627
696
  if (repaired.length)
628
697
  log('INFO', `launch-health: repaired ${repaired.join(', ')}`);
698
+ if (unhealed.length) {
699
+ log('WARN', `launch-health: ${unhealed.join(', ')} won't launch and will not be auto-switched — choose a version with \`agents use <agent> <version>\` or \`agents add <agent>@latest\``);
700
+ }
629
701
  }
630
702
  catch (err) {
631
703
  log('ERROR', `launch-health check failed: ${err.message}`);
@@ -705,6 +777,19 @@ export async function runDaemon() {
705
777
  const brokerSelfHealInterval = setInterval(() => { void runBrokerSelfHeal(); }, 60_000);
706
778
  const handleReload = () => {
707
779
  log('INFO', 'Reloading jobs (SIGHUP)');
780
+ // Refresh user-layer copies of opted-in project routines BEFORE the
781
+ // scheduler reloads, so YAML edits under `<project>/.agents/routines/`
782
+ // take effect on the next fire without a manual `routines sync`.
783
+ try {
784
+ const result = syncAllProjectRoutines();
785
+ const n = result.projects.reduce((acc, p) => acc + p.synced.length, 0);
786
+ if (n > 0 || result.missing.length > 0) {
787
+ log('INFO', `Project routines sync: ${n} updated, ${result.missing.length} missing roots`);
788
+ }
789
+ }
790
+ catch (err) {
791
+ log('WARN', `Project routines sync failed: ${err.message}`);
792
+ }
708
793
  scheduler.reloadAll();
709
794
  const reloaded = scheduler.listScheduled();
710
795
  log('INFO', `Reloaded ${reloaded.length} jobs`);
@@ -758,17 +843,26 @@ export async function runDaemon() {
758
843
  * token leaves the daemon on its existing interactive OAuth session.
759
844
  */
760
845
  export function readDaemonClaudeOAuthToken(opts = {}) {
846
+ const token = (readDaemonClaudeBundleEnv(opts)[DAEMON_OAUTH_KEY] ?? '').trim();
847
+ return token.length > 0 ? token : null;
848
+ }
849
+ /**
850
+ * Read the FULL `claude` bundle env — the main `CLAUDE_CODE_OAUTH_TOKEN` plus any
851
+ * per-account `CLAUDE_CODE_OAUTH_TOKEN_<slug>` setup-tokens. Same resolution and
852
+ * never-throws contract as {@link readDaemonClaudeOAuthToken}; returns `{}` when
853
+ * the bundle can't be read (broker-only headless miss, absent bundle, etc.).
854
+ */
855
+ export function readDaemonClaudeBundleEnv(opts = {}) {
761
856
  try {
762
857
  const allowPrompt = opts.allowPrompt ?? Boolean(process.stdin.isTTY);
763
858
  const { env } = readAndResolveBundleEnv(DAEMON_OAUTH_BUNDLE, {
764
859
  caller: 'daemon',
765
860
  agentOnly: !allowPrompt,
766
861
  });
767
- const token = (env[DAEMON_OAUTH_KEY] ?? '').trim();
768
- return token.length > 0 ? token : null;
862
+ return env;
769
863
  }
770
864
  catch {
771
- return null;
865
+ return {};
772
866
  }
773
867
  }
774
868
  /** Escape a string for safe inclusion in an XML <string> node. */
@@ -996,11 +1090,20 @@ function startDaemonLocked(agentsBin) {
996
1090
  * the daemon then passes it to every routine run it spawns. An already-set
997
1091
  * value (e.g. inherited from launchd) is left untouched.
998
1092
  */
999
- export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readDaemonClaudeOAuthToken()) {
1093
+ export function buildDetachedDaemonEnv(baseEnv = process.env, bundleEnv = readDaemonClaudeBundleEnv()) {
1000
1094
  const env = { ...baseEnv };
1001
1095
  if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
1002
- if (oauthToken)
1003
- env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
1096
+ const main = (bundleEnv[DAEMON_OAUTH_KEY] ?? '').trim();
1097
+ if (main)
1098
+ env.CLAUDE_CODE_OAUTH_TOKEN = main;
1099
+ }
1100
+ // Per-account setup-tokens — same rationale as the runDaemon startup injection:
1101
+ // a routine authenticates its rotation-pinned account via that account's own
1102
+ // long-lived, non-rotating token. An already-set value wins.
1103
+ for (const [k, v] of Object.entries(bundleEnv)) {
1104
+ if (k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && (v ?? '').trim() && !env[k]) {
1105
+ env[k] = v.trim();
1106
+ }
1004
1107
  }
1005
1108
  return env;
1006
1109
  }
@@ -3,6 +3,14 @@ import { type DeviceProfile } from './registry.js';
3
3
  export declare const ASKPASS_BUNDLE_ENV = "AGENTS_SSH_BUNDLE";
4
4
  /** Env var the askpass shim reads to know which key in the bundle is the password. */
5
5
  export declare const ASKPASS_KEY_ENV = "AGENTS_SSH_KEY";
6
+ /**
7
+ * Env var that forces the askpass bundle resolve to be broker-only (`agentOnly`)
8
+ * regardless of TTY. A read-only stats probe (`agents devices` load/mem columns)
9
+ * sets this so an uncached password-auth device resolves from the already-unlocked
10
+ * secrets broker or degrades to an unreachable row — never a foreground Touch ID
11
+ * sheet. See {@link buildSshInvocation}'s `agentOnly` option and `runAskpass`. (RUSH-1970)
12
+ */
13
+ export declare const ASKPASS_AGENT_ONLY_ENV = "AGENTS_SSH_AGENT_ONLY";
6
14
  /**
7
15
  * Build the `user@host` (or bare `host`) ssh target for a device and validate
8
16
  * it against the shared injection guard. Throws if the device has no address.
@@ -51,8 +59,14 @@ export interface SshHostKeyOptions {
51
59
  * Host-key checking runs against the CLI-managed known_hosts store (never the
52
60
  * user's `~/.ssh/known_hosts`): strict once `hostKey.pinned` is set, else
53
61
  * `accept-new` to learn+pin the key on first connect (RUSH-1767).
62
+ *
63
+ * `opts.agentOnly` marks the connection as a read-only probe: for password auth
64
+ * it sets {@link ASKPASS_AGENT_ONLY_ENV} in the overlay so the askpass resolve
65
+ * stays broker-only and never pops a foreground biometric (RUSH-1970).
54
66
  */
55
- export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions): {
67
+ export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions, opts?: {
68
+ agentOnly?: boolean;
69
+ }): {
56
70
  args: string[];
57
71
  env: Record<string, string>;
58
72
  };
@@ -25,6 +25,14 @@ import { hostNameFor } from './ssh-config.js';
25
25
  export const ASKPASS_BUNDLE_ENV = 'AGENTS_SSH_BUNDLE';
26
26
  /** Env var the askpass shim reads to know which key in the bundle is the password. */
27
27
  export const ASKPASS_KEY_ENV = 'AGENTS_SSH_KEY';
28
+ /**
29
+ * Env var that forces the askpass bundle resolve to be broker-only (`agentOnly`)
30
+ * regardless of TTY. A read-only stats probe (`agents devices` load/mem columns)
31
+ * sets this so an uncached password-auth device resolves from the already-unlocked
32
+ * secrets broker or degrades to an unreachable row — never a foreground Touch ID
33
+ * sheet. See {@link buildSshInvocation}'s `agentOnly` option and `runAskpass`. (RUSH-1970)
34
+ */
35
+ export const ASKPASS_AGENT_ONLY_ENV = 'AGENTS_SSH_AGENT_ONLY';
28
36
  /**
29
37
  * Build the `user@host` (or bare `host`) ssh target for a device and validate
30
38
  * it against the shared injection guard. Throws if the device has no address.
@@ -83,8 +91,12 @@ export function wrapRemoteCommand(device, cmd) {
83
91
  * Host-key checking runs against the CLI-managed known_hosts store (never the
84
92
  * user's `~/.ssh/known_hosts`): strict once `hostKey.pinned` is set, else
85
93
  * `accept-new` to learn+pin the key on first connect (RUSH-1767).
94
+ *
95
+ * `opts.agentOnly` marks the connection as a read-only probe: for password auth
96
+ * it sets {@link ASKPASS_AGENT_ONLY_ENV} in the overlay so the askpass resolve
97
+ * stays broker-only and never pops a foreground biometric (RUSH-1970).
86
98
  */
87
- export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
99
+ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, opts = {}) {
88
100
  const target = sshTargetFor(device);
89
101
  const remote = wrapRemoteCommand(device, cmd);
90
102
  const env = {};
@@ -100,6 +112,8 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}) {
100
112
  env.SSH_ASKPASS_REQUIRE = 'force';
101
113
  env[ASKPASS_BUNDLE_ENV] = device.auth.bundle;
102
114
  env[ASKPASS_KEY_ENV] = device.auth.bundleKey ?? 'password';
115
+ if (opts.agentOnly)
116
+ env[ASKPASS_AGENT_ONLY_ENV] = '1';
103
117
  args.push('-o', 'PreferredAuthentications=password', '-o', 'PubkeyAuthentication=no', '-o', 'NumberOfPasswordPrompts=1');
104
118
  }
105
119
  else {
@@ -8,6 +8,7 @@
8
8
  * the remaining devices.
9
9
  */
10
10
  import { type DeviceProfile, type DeviceRegistry } from './registry.js';
11
+ import type { DeviceStats } from './health.js';
11
12
  export type FleetSkipReason = 'offline' | 'no-address' | 'control';
12
13
  /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
14
  export declare const FLEET_VERSION_RE: RegExp;
@@ -55,6 +56,26 @@ export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
55
56
  * surface — so their `skip` reason still flows through as an `unreachable` row.
56
57
  */
57
58
  export declare function remoteFleetTargets(planned: FleetTarget[], self: string): FleetTarget[];
59
+ /**
60
+ * Decide whether a fleet-health target should skip the expensive version+doctor
61
+ * dials (`agents fleet status`). The cheap stats probe (~2.5s, same registry
62
+ * address) has already tried this box one step earlier. If it came back
63
+ * unreachable, dialing `agents --version` (15s) + `agents doctor --json` (30s)
64
+ * would almost certainly fail the same way — just 45s slower — so one
65
+ * genuinely-offline box would stall the whole matrix (the ~60s hang, RUSH-1964).
66
+ *
67
+ * We trust the stats verdict on the DEFAULT path, not only under
68
+ * `--refresh`/`--live`: `probeDeviceStats` and the version/doctor dials share
69
+ * one ssh path (`fleetDialTarget`), so reachability is not per-probe. The
70
+ * verdict is either freshly probed this run or daemon-warmed (~3min) with the
71
+ * live write-back from RUSH-1965, so a box that came online in the last few
72
+ * minutes is the only false-negative window — it renders `unreachable` until the
73
+ * next stats warm, which beats letting it hang the status glance for 45s.
74
+ *
75
+ * An existing skip (control/offline/no-address from {@link planFleetTargets})
76
+ * always wins — those are classified before any probe.
77
+ */
78
+ export declare function fleetHealthSkip(currentSkip: FleetSkipReason | string | undefined, stats: DeviceStats | undefined): FleetSkipReason | string | undefined;
58
79
  /** Human label for a skip reason. */
59
80
  export declare function skipLabel(reason: FleetSkipReason): string;
60
81
  /**
@@ -115,5 +136,20 @@ export interface RunFleetOptions {
115
136
  * recorded as `failed` so one bad device never aborts the rest.
116
137
  */
117
138
  export declare function runFleet(targets: FleetTarget[], cmd: string[], opts?: RunFleetOptions): FleetRunResult[];
139
+ export interface FanOutDeviceOptions {
140
+ /**
141
+ * Per-device deadline in milliseconds. When set, any probe that does not
142
+ * settle within this window is abandoned via `Promise.race` against a
143
+ * rejection timer and recorded as a `failed` result with the message
144
+ * `'timed out'`. There is no AbortController — the underlying probe
145
+ * continues running in the background; cancellation of the in-flight work
146
+ * is the caller's responsibility. In practice `probeRemoteAuth` relies on
147
+ * `sshExecAsync`'s own 15 s timer to kill the ssh child independently.
148
+ * The per-device ssh timeout passed directly to {@link sshExecAsync} is
149
+ * the first line of defence; this acts as a hard backstop so one slow
150
+ * device can never stall the entire fan-out past its budget.
151
+ */
152
+ perDeviceTimeoutMs?: number;
153
+ }
118
154
  /** Run one async probe per device in parallel, preserving input order. */
119
- export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>): Promise<FanOutDeviceResult<T>[]>;
155
+ export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>, opts?: FanOutDeviceOptions): Promise<FanOutDeviceResult<T>[]>;
@@ -54,6 +54,32 @@ export function planFleetTargets(reg) {
54
54
  export function remoteFleetTargets(planned, self) {
55
55
  return planned.filter((t) => t.device.name !== self && t.skip !== 'control');
56
56
  }
57
+ /**
58
+ * Decide whether a fleet-health target should skip the expensive version+doctor
59
+ * dials (`agents fleet status`). The cheap stats probe (~2.5s, same registry
60
+ * address) has already tried this box one step earlier. If it came back
61
+ * unreachable, dialing `agents --version` (15s) + `agents doctor --json` (30s)
62
+ * would almost certainly fail the same way — just 45s slower — so one
63
+ * genuinely-offline box would stall the whole matrix (the ~60s hang, RUSH-1964).
64
+ *
65
+ * We trust the stats verdict on the DEFAULT path, not only under
66
+ * `--refresh`/`--live`: `probeDeviceStats` and the version/doctor dials share
67
+ * one ssh path (`fleetDialTarget`), so reachability is not per-probe. The
68
+ * verdict is either freshly probed this run or daemon-warmed (~3min) with the
69
+ * live write-back from RUSH-1965, so a box that came online in the last few
70
+ * minutes is the only false-negative window — it renders `unreachable` until the
71
+ * next stats warm, which beats letting it hang the status glance for 45s.
72
+ *
73
+ * An existing skip (control/offline/no-address from {@link planFleetTargets})
74
+ * always wins — those are classified before any probe.
75
+ */
76
+ export function fleetHealthSkip(currentSkip, stats) {
77
+ if (currentSkip)
78
+ return currentSkip;
79
+ if (stats?.reachable === false)
80
+ return 'unreachable';
81
+ return undefined;
82
+ }
57
83
  /** Human label for a skip reason. */
58
84
  export function skipLabel(reason) {
59
85
  switch (reason) {
@@ -186,7 +212,7 @@ export function runFleet(targets, cmd, opts = {}) {
186
212
  return results;
187
213
  }
188
214
  /** Run one async probe per device in parallel, preserving input order. */
189
- export async function fanOutDevices(targets, probe) {
215
+ export async function fanOutDevices(targets, probe, opts = {}) {
190
216
  return Promise.all(targets.map(async (target) => {
191
217
  if (target.skip) {
192
218
  return {
@@ -196,10 +222,18 @@ export async function fanOutDevices(targets, probe) {
196
222
  };
197
223
  }
198
224
  try {
225
+ let probePromise = probe(target);
226
+ if (opts.perDeviceTimeoutMs) {
227
+ const timeoutMs = opts.perDeviceTimeoutMs;
228
+ probePromise = Promise.race([
229
+ probePromise,
230
+ new Promise((_, reject) => setTimeout(() => reject(new Error('timed out')), timeoutMs)),
231
+ ]);
232
+ }
199
233
  return {
200
234
  name: target.name,
201
235
  status: 'ok',
202
- value: await probe(target),
236
+ value: await probePromise,
203
237
  };
204
238
  }
205
239
  catch (err) {
@@ -1,5 +1,6 @@
1
1
  import { type DeviceStats } from './health.js';
2
2
  import { type HostAuthSummary } from '../auth-health.js';
3
+ import type { OnlineState } from './reachability.js';
3
4
  export interface FleetCliStatus {
4
5
  installed: boolean;
5
6
  path: string | null;
@@ -32,6 +33,13 @@ export interface FleetHealthRow {
32
33
  /** Cached auth-health rollup for this host (the Auth column). Undefined when
33
34
  * the host has never been probed (`agents fleet ping`) or the cache is cold. */
34
35
  auth?: HostAuthSummary;
36
+ /** Resolved online/offline verdict (from {@link deviceOnlineState}). Populated
37
+ * by `runFleetStatus` for the summary view; undefined in the raw grid. */
38
+ online?: OnlineState;
39
+ /** When this box was last seen reachable (ISO), for the "last seen …" note on
40
+ * an offline row. Sourced from the registry's tailscale snapshot / reachability
41
+ * verdict. Undefined when never recorded. */
42
+ lastSeen?: string;
35
43
  }
36
44
  export interface FleetWarning {
37
45
  kind: 'unreachable' | 'drift' | 'cli' | 'version-skew';
@@ -55,3 +63,33 @@ export declare function renderFleetMatrix(report: FleetHealthReport): string[];
55
63
  * table carries no timestamped data at all (nothing to date).
56
64
  */
57
65
  export declare function freshnessFooter(rows: FleetHealthRow[], now?: number): string | null;
66
+ /** Collapse a long dev build (`0.0.0-dev.<sha>[-dirty]`) to `dev`/`dev-dirty`;
67
+ * released semver is shown verbatim. Keeps the version column narrow and stops
68
+ * a single dev box from widening every row. */
69
+ export declare function shortVersion(version: string | null | undefined): string;
70
+ /** OS bucket label for grouping. Anything unrecognized falls under "Other". */
71
+ export declare function platformGroupLabel(platform: string | undefined): 'macOS' | 'Linux' | 'Windows' | 'Other';
72
+ export interface FleetAttentionItem {
73
+ /** Leading mark: `○` offline, `⚠` config/CLI/version issue. */
74
+ glyph: 'offline' | 'warn';
75
+ subject: string;
76
+ detail: string;
77
+ /** The exact command (or instruction) that fixes it. */
78
+ fix: string;
79
+ }
80
+ /**
81
+ * The actionable problems only — each with the command that fixes it. Order:
82
+ * offline boxes, CLI gaps, active-version config drift, then version skew.
83
+ * A healthy fleet returns `[]` (the caller prints an all-clear line).
84
+ */
85
+ export declare function buildFleetAttentionItems(report: FleetHealthReport, now?: number): FleetAttentionItem[];
86
+ /**
87
+ * The default `agents fleet status` view (RUSH-1966): a one-line rollup, a
88
+ * NEEDS ATTENTION list of only the actionable problems (each with its fix
89
+ * command), quiet per-device rows grouped by OS, and an honest footer. The full
90
+ * auth/CLI/sync grid moves behind `--verbose` ({@link renderFleetMatrix}).
91
+ */
92
+ export declare function renderFleetSummary(report: FleetHealthReport, opts?: {
93
+ self?: string;
94
+ now?: number;
95
+ }): string[];
@@ -226,3 +226,217 @@ export function freshnessFooter(rows, now = Date.now()) {
226
226
  return null;
227
227
  return ` updated ${parts.join(' · ')} — pass --refresh (--live) for a live probe`;
228
228
  }
229
+ // ---------------------------------------------------------------------------
230
+ // Summary view (default): rollup + NEEDS ATTENTION + OS groups + footer.
231
+ // The full grid above is kept for `--verbose`. (RUSH-1966)
232
+ // ---------------------------------------------------------------------------
233
+ /** Collapse a long dev build (`0.0.0-dev.<sha>[-dirty]`) to `dev`/`dev-dirty`;
234
+ * released semver is shown verbatim. Keeps the version column narrow and stops
235
+ * a single dev box from widening every row. */
236
+ export function shortVersion(version) {
237
+ if (!version)
238
+ return '—';
239
+ const m = version.match(/-dev\b/);
240
+ if (m)
241
+ return /dirty/.test(version) ? 'dev-dirty' : 'dev';
242
+ return version;
243
+ }
244
+ /** OS bucket label for grouping. Anything unrecognized falls under "Other". */
245
+ export function platformGroupLabel(platform) {
246
+ const p = (platform ?? '').toLowerCase();
247
+ if (p === 'macos' || p === 'darwin')
248
+ return 'macOS';
249
+ if (p === 'linux')
250
+ return 'Linux';
251
+ if (p.startsWith('win'))
252
+ return 'Windows';
253
+ return 'Other';
254
+ }
255
+ const GROUP_ORDER = ['macOS', 'Linux', 'Windows', 'Other'];
256
+ /** Non-fresh sync rows for the ACTIVE (default) version only — the drift that a
257
+ * running install actually feels, not stale/cold counts across old orphans. */
258
+ function activeDriftRows(row) {
259
+ return row.sync.filter((s) => s.isDefault && s.status !== 'fresh');
260
+ }
261
+ function isOffline(row) {
262
+ // Prefer the resolved verdict; fall back to a probe error/skip when the caller
263
+ // didn't populate it (e.g. a raw report). Only a positive 'online' is "up".
264
+ if (row.online)
265
+ return row.online !== 'online';
266
+ return Boolean(row.error || row.skipped);
267
+ }
268
+ /** A box with a real offline verdict — as opposed to `unknown` (registered but
269
+ * never addressed/probed). Only a genuine offline is a "check the box" item;
270
+ * an unknown box is unconfigured, not down. */
271
+ function isGenuinelyOffline(row) {
272
+ return row.online === 'offline' || (!row.online && Boolean(row.error || row.skipped));
273
+ }
274
+ /** A CLI count is only worth flagging when it's stark — the box is missing more
275
+ * than two-thirds of the known agent CLIs (e.g. 1/9), which signals a
276
+ * broken/half-set-up box. A normal partial install (a box that just doesn't run
277
+ * every agent) is not a problem, so this deliberately does NOT fire at 4/9 or
278
+ * 6/9. The full count stays visible under `--verbose`. */
279
+ function starkCliGap(row) {
280
+ const { installed, total } = installedCliCount(row);
281
+ return total > 0 && installed * 3 < total ? { installed, total } : null;
282
+ }
283
+ /**
284
+ * The actionable problems only — each with the command that fixes it. Order:
285
+ * offline boxes, CLI gaps, active-version config drift, then version skew.
286
+ * A healthy fleet returns `[]` (the caller prints an all-clear line).
287
+ */
288
+ export function buildFleetAttentionItems(report, now = Date.now()) {
289
+ const items = [];
290
+ // 1) Genuinely-offline boxes (not `unknown`/unconfigured), each individually.
291
+ for (const row of report.devices) {
292
+ if (!isGenuinelyOffline(row))
293
+ continue;
294
+ const seen = row.lastSeen ? ` · last seen ${formatCheckedAge(Date.parse(row.lastSeen), now)}` : '';
295
+ items.push({ glyph: 'offline', subject: row.name, detail: `offline${seen}`, fix: 'check the box' });
296
+ }
297
+ // 2) Boxes that need `agents apply` — merge config drift and a stark CLI gap
298
+ // into ONE item per box (both are fixed by the same command, so don't
299
+ // double-list). An offline box's config/CLI is unknowable, so skip it.
300
+ for (const row of report.devices) {
301
+ if (isOffline(row))
302
+ continue;
303
+ const reasons = [];
304
+ if (activeDriftRows(row).length > 0)
305
+ reasons.push('config drift');
306
+ const stark = starkCliGap(row);
307
+ if (stark)
308
+ reasons.push(`only ${stark.installed} of ${stark.total} agent CLIs installed`);
309
+ if (reasons.length > 0) {
310
+ items.push({ glyph: 'warn', subject: row.name, detail: reasons.join(' · '), fix: `agents apply ${row.name}` });
311
+ }
312
+ }
313
+ // 3) Version skew across the fleet — one line.
314
+ const skew = report.warnings.find((w) => w.kind === 'version-skew');
315
+ if (skew) {
316
+ const counts = new Map();
317
+ for (const row of report.devices) {
318
+ if (!row.version)
319
+ continue;
320
+ const v = shortVersion(row.version);
321
+ counts.set(v, (counts.get(v) ?? 0) + 1);
322
+ }
323
+ const summary = Array.from(counts.entries())
324
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
325
+ .map(([v, n]) => `${n}× ${v}`)
326
+ .join(' · ');
327
+ items.push({ glyph: 'warn', subject: 'version skew', detail: summary, fix: 'agents upgrade --fleet' });
328
+ }
329
+ return items;
330
+ }
331
+ /** Right-aligned `<content>` on the same line as `left`, clamped so it never
332
+ * overlaps the left text on a narrow terminal. */
333
+ function alignRight(left, right, width) {
334
+ const gap = width - stringWidth(left) - stringWidth(right);
335
+ return gap > 1 ? `${left}${' '.repeat(gap)}${right}` : `${left} ${right}`;
336
+ }
337
+ function headroomWord(row) {
338
+ if (isOffline(row))
339
+ return chalk.gray(row.online === 'unknown' ? 'unknown' : 'offline');
340
+ return headroomLabel(row);
341
+ }
342
+ function loadMemCell(row) {
343
+ if (isOffline(row) || !row.stats?.reachable)
344
+ return chalk.gray('—');
345
+ const load = row.stats.loadPercent === undefined ? '—' : `${Math.round(row.stats.loadPercent)}%`;
346
+ const mem = row.stats.memPercent === undefined ? '—' : `${Math.round(row.stats.memPercent)}%`;
347
+ return `${load} / ${mem}`;
348
+ }
349
+ function versionCell(row) {
350
+ if (isOffline(row))
351
+ return chalk.gray('—');
352
+ return shortVersion(row.version);
353
+ }
354
+ /**
355
+ * The default `agents fleet status` view (RUSH-1966): a one-line rollup, a
356
+ * NEEDS ATTENTION list of only the actionable problems (each with its fix
357
+ * command), quiet per-device rows grouped by OS, and an honest footer. The full
358
+ * auth/CLI/sync grid moves behind `--verbose` ({@link renderFleetMatrix}).
359
+ */
360
+ export function renderFleetSummary(report, opts = {}) {
361
+ const now = opts.now ?? Date.now();
362
+ const width = terminalWidth();
363
+ const rows = report.devices;
364
+ if (rows.length === 0)
365
+ return [chalk.gray('No registered devices. Run `agents devices` to register some.')];
366
+ const online = rows.filter((r) => !isOffline(r)).length;
367
+ const offline = rows.length - online;
368
+ const oldestStats = oldestAcross(rows, (r) => r.stats?.fetchedAt);
369
+ const cachedAge = oldestStats != null ? `cached ${formatCheckedAge(oldestStats, now)}` : null;
370
+ const rollupRight = [opts.self, cachedAge].filter(Boolean).join(' · ');
371
+ const rollupLeft = `${chalk.bold('Fleet')} ${chalk.green('●')} ${online} online ${chalk.gray('○')} ${offline} offline`;
372
+ const lines = [rollupRight ? alignRight(rollupLeft, chalk.gray(rollupRight), width) : rollupLeft, ''];
373
+ const items = buildFleetAttentionItems(report, now);
374
+ if (items.length === 0) {
375
+ lines.push(chalk.green('Everything looks healthy.'), '');
376
+ }
377
+ else {
378
+ lines.push(chalk.bold(`NEEDS ATTENTION (${items.length})`));
379
+ const subjW = Math.max(...items.map((i) => i.subject.length));
380
+ const detailW = Math.max(...items.map((i) => stringWidth(i.detail)));
381
+ for (const it of items) {
382
+ const glyph = it.glyph === 'offline' ? chalk.gray('○') : chalk.yellow('⚠');
383
+ const subject = it.glyph === 'offline' ? chalk.gray(it.subject) : it.subject;
384
+ lines.push(` ${glyph} ${padToWidth(subject, subjW)} ${padToWidth(it.detail, detailW)} ${chalk.cyan(`→ ${it.fix}`)}`);
385
+ }
386
+ lines.push('');
387
+ }
388
+ // Per-device rows, grouped by OS. Within a group, this machine floats to the
389
+ // top; the rest stay alphabetical.
390
+ const nameW = Math.min(18, Math.max(6, ...rows.map((r) => r.name.length)));
391
+ const headW = Math.max(...rows.map((r) => stringWidth(headroomWord(r))));
392
+ const loadW = Math.max(...rows.map((r) => stringWidth(loadMemCell(r))));
393
+ const verW = Math.max(7, ...rows.map((r) => stringWidth(versionCell(r))));
394
+ const grouped = new Map();
395
+ for (const r of rows) {
396
+ const g = platformGroupLabel(r.platform);
397
+ (grouped.get(g) ?? grouped.set(g, []).get(g)).push(r);
398
+ }
399
+ for (const group of GROUP_ORDER) {
400
+ const members = grouped.get(group);
401
+ if (!members || members.length === 0)
402
+ continue;
403
+ members.sort((a, b) => (a.name === opts.self ? -1 : b.name === opts.self ? 1 : 0) || a.name.localeCompare(b.name));
404
+ lines.push(chalk.bold(group));
405
+ for (const row of members) {
406
+ const isSelf = row.name === opts.self;
407
+ const prefix = isSelf ? chalk.cyan('▸') : ' ';
408
+ const marks = [];
409
+ if (!isOffline(row)) {
410
+ const stark = starkCliGap(row);
411
+ if (stark)
412
+ marks.push(chalk.yellow(`⚠ CLIs ${stark.installed}/${stark.total}`));
413
+ }
414
+ else if (isGenuinelyOffline(row) && row.lastSeen) {
415
+ marks.push(chalk.gray(`last seen ${formatCheckedAge(Date.parse(row.lastSeen), now)}`));
416
+ }
417
+ if (isSelf)
418
+ marks.push(chalk.cyan('← this machine'));
419
+ const note = marks.length ? ` ${marks.join(' ')}` : '';
420
+ lines.push(` ${prefix} ${padToWidth(truncateToWidth(row.name, nameW), nameW)} ` +
421
+ `${padToWidth(headroomWord(row), headW)} ` +
422
+ `${padToWidth(loadMemCell(row), loadW)} ` +
423
+ `${padToWidth(versionCell(row), verW)}` +
424
+ note);
425
+ }
426
+ lines.push('');
427
+ }
428
+ // Footer: orphan-version nudge (a `prune` concern, not drift), then an honest
429
+ // freshness line naming the cache age and what --live/--verbose add.
430
+ const orphaned = rows.filter((r) => r.orphans.length > 0).length;
431
+ if (orphaned > 0) {
432
+ const subject = orphaned === 1 ? '1 device carries' : `${orphaned} devices carry`;
433
+ lines.push(chalk.gray(`${subject} orphaned versions · run ${chalk.cyan('agents prune')} to reclaim disk`));
434
+ }
435
+ const freshBits = [
436
+ cachedAge,
437
+ 'pass --live to re-probe auth + reachability',
438
+ 'pass --verbose for the auth/CLI/sync columns',
439
+ ].filter(Boolean);
440
+ lines.push(chalk.gray(freshBits.join(' · ')));
441
+ return lines;
442
+ }