@phnx-labs/agents-cli 1.20.73 → 1.20.76

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 (209) hide show
  1. package/CHANGELOG.md +989 -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 +31 -1
  13. package/dist/commands/exec.js +31 -3
  14. package/dist/commands/export.d.ts +11 -0
  15. package/dist/commands/export.js +215 -0
  16. package/dist/commands/feed.d.ts +7 -1
  17. package/dist/commands/feed.js +47 -4
  18. package/dist/commands/go.d.ts +9 -2
  19. package/dist/commands/go.js +16 -6
  20. package/dist/commands/harness.d.ts +14 -0
  21. package/dist/commands/harness.js +145 -0
  22. package/dist/commands/import.js +87 -9
  23. package/dist/commands/inspect.js +5 -2
  24. package/dist/commands/profiles.d.ts +20 -0
  25. package/dist/commands/profiles.js +68 -21
  26. package/dist/commands/repo.js +14 -0
  27. package/dist/commands/routines.js +15 -2
  28. package/dist/commands/secrets.js +44 -21
  29. package/dist/commands/sessions-browser.js +1 -1
  30. package/dist/commands/sessions-export.js +19 -5
  31. package/dist/commands/sessions-migrate.d.ts +29 -0
  32. package/dist/commands/sessions-migrate.js +596 -0
  33. package/dist/commands/sessions-picker.js +16 -1
  34. package/dist/commands/sessions.d.ts +36 -13
  35. package/dist/commands/sessions.js +210 -40
  36. package/dist/commands/setup-computer.js +2 -2
  37. package/dist/commands/setup.js +6 -1
  38. package/dist/commands/ssh.js +71 -35
  39. package/dist/commands/status.js +10 -2
  40. package/dist/commands/versions.js +51 -11
  41. package/dist/commands/view.d.ts +5 -0
  42. package/dist/commands/view.js +99 -31
  43. package/dist/commands/watchdog.d.ts +7 -2
  44. package/dist/commands/watchdog.js +73 -57
  45. package/dist/index.js +59 -7
  46. package/dist/lib/activity.d.ts +14 -4
  47. package/dist/lib/activity.js +17 -5
  48. package/dist/lib/actor.d.ts +50 -0
  49. package/dist/lib/actor.js +166 -0
  50. package/dist/lib/agent-spec/provider.js +2 -1
  51. package/dist/lib/agent-spec/resolve.js +19 -5
  52. package/dist/lib/agent-spec/types.d.ts +9 -1
  53. package/dist/lib/agents.d.ts +56 -0
  54. package/dist/lib/agents.js +133 -7
  55. package/dist/lib/cloud/codex.d.ts +2 -0
  56. package/dist/lib/cloud/codex.js +14 -3
  57. package/dist/lib/cloud/session-index.d.ts +32 -0
  58. package/dist/lib/cloud/session-index.js +58 -0
  59. package/dist/lib/cloud/store.d.ts +7 -0
  60. package/dist/lib/cloud/store.js +25 -0
  61. package/dist/lib/config-transfer.d.ts +16 -0
  62. package/dist/lib/config-transfer.js +60 -0
  63. package/dist/lib/daemon.js +18 -2
  64. package/dist/lib/devices/connect.d.ts +15 -1
  65. package/dist/lib/devices/connect.js +15 -1
  66. package/dist/lib/devices/fleet.d.ts +21 -0
  67. package/dist/lib/devices/fleet.js +26 -0
  68. package/dist/lib/devices/health-report.d.ts +38 -0
  69. package/dist/lib/devices/health-report.js +214 -0
  70. package/dist/lib/devices/health.js +4 -1
  71. package/dist/lib/devices/reachability.d.ts +31 -0
  72. package/dist/lib/devices/reachability.js +40 -0
  73. package/dist/lib/devices/registry.d.ts +33 -0
  74. package/dist/lib/devices/registry.js +37 -0
  75. package/dist/lib/devices/resolve-target.d.ts +15 -27
  76. package/dist/lib/devices/resolve-target.js +63 -102
  77. package/dist/lib/devices/sync.d.ts +18 -0
  78. package/dist/lib/devices/sync.js +23 -1
  79. package/dist/lib/devices/tailscale.d.ts +3 -0
  80. package/dist/lib/devices/tailscale.js +1 -0
  81. package/dist/lib/events.d.ts +1 -1
  82. package/dist/lib/exec.d.ts +66 -0
  83. package/dist/lib/exec.js +195 -6
  84. package/dist/lib/export.d.ts +72 -0
  85. package/dist/lib/export.js +269 -0
  86. package/dist/lib/feed-post.d.ts +63 -0
  87. package/dist/lib/feed-post.js +204 -0
  88. package/dist/lib/fleet/apply.d.ts +32 -2
  89. package/dist/lib/fleet/apply.js +97 -10
  90. package/dist/lib/fleet/types.d.ts +11 -0
  91. package/dist/lib/fs-walk.d.ts +13 -0
  92. package/dist/lib/fs-walk.js +16 -7
  93. package/dist/lib/hooks.d.ts +1 -0
  94. package/dist/lib/hooks.js +156 -0
  95. package/dist/lib/hosts/dispatch.d.ts +7 -0
  96. package/dist/lib/hosts/dispatch.js +2 -0
  97. package/dist/lib/hosts/passthrough.js +2 -0
  98. package/dist/lib/hosts/reconnect.d.ts +78 -0
  99. package/dist/lib/hosts/reconnect.js +127 -0
  100. package/dist/lib/hosts/registry.d.ts +75 -14
  101. package/dist/lib/hosts/registry.js +205 -30
  102. package/dist/lib/hosts/remote-cmd.js +1 -0
  103. package/dist/lib/hosts/run-target.d.ts +13 -5
  104. package/dist/lib/hosts/run-target.js +26 -10
  105. package/dist/lib/hosts/session-index.d.ts +18 -1
  106. package/dist/lib/hosts/session-index.js +37 -2
  107. package/dist/lib/hosts/session-marker.d.ts +33 -0
  108. package/dist/lib/hosts/session-marker.js +51 -0
  109. package/dist/lib/hosts/tasks.d.ts +8 -4
  110. package/dist/lib/import.d.ts +25 -0
  111. package/dist/lib/import.js +107 -2
  112. package/dist/lib/isolation-boundary-report.d.ts +10 -0
  113. package/dist/lib/isolation-boundary-report.js +35 -0
  114. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  115. package/dist/lib/menubar/install-menubar.js +45 -1
  116. package/dist/lib/migrate.d.ts +16 -0
  117. package/dist/lib/migrate.js +36 -0
  118. package/dist/lib/models.d.ts +27 -0
  119. package/dist/lib/models.js +54 -1
  120. package/dist/lib/overdue.d.ts +7 -2
  121. package/dist/lib/overdue.js +18 -11
  122. package/dist/lib/picker.js +4 -1
  123. package/dist/lib/platform/process.d.ts +17 -0
  124. package/dist/lib/platform/process.js +70 -0
  125. package/dist/lib/profiles-presets.js +9 -7
  126. package/dist/lib/profiles.d.ts +31 -0
  127. package/dist/lib/profiles.js +70 -0
  128. package/dist/lib/project-resources.js +20 -5
  129. package/dist/lib/pty-server.d.ts +2 -10
  130. package/dist/lib/pty-server.js +4 -38
  131. package/dist/lib/registry.d.ts +1 -1
  132. package/dist/lib/registry.js +48 -8
  133. package/dist/lib/rotate.d.ts +18 -0
  134. package/dist/lib/rotate.js +28 -0
  135. package/dist/lib/routines.d.ts +26 -0
  136. package/dist/lib/routines.js +19 -2
  137. package/dist/lib/runner.js +104 -7
  138. package/dist/lib/secrets/agent.d.ts +66 -1
  139. package/dist/lib/secrets/agent.js +154 -90
  140. package/dist/lib/secrets/bundles.d.ts +34 -0
  141. package/dist/lib/secrets/bundles.js +179 -19
  142. package/dist/lib/secrets/index.d.ts +15 -1
  143. package/dist/lib/secrets/index.js +21 -3
  144. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  145. package/dist/lib/secrets/rc-hygiene.js +154 -0
  146. package/dist/lib/secrets/remote.d.ts +7 -4
  147. package/dist/lib/secrets/remote.js +7 -4
  148. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  149. package/dist/lib/secrets/sync-commands.js +21 -0
  150. package/dist/lib/self-update.d.ts +23 -2
  151. package/dist/lib/self-update.js +86 -5
  152. package/dist/lib/session/active.d.ts +114 -7
  153. package/dist/lib/session/active.js +323 -52
  154. package/dist/lib/session/cloud.js +2 -1
  155. package/dist/lib/session/db.d.ts +48 -0
  156. package/dist/lib/session/db.js +238 -14
  157. package/dist/lib/session/detached.d.ts +30 -0
  158. package/dist/lib/session/detached.js +92 -0
  159. package/dist/lib/session/discover.d.ts +465 -2
  160. package/dist/lib/session/discover.js +1648 -468
  161. package/dist/lib/session/fork.js +2 -1
  162. package/dist/lib/session/hook-sessions.d.ts +43 -0
  163. package/dist/lib/session/hook-sessions.js +135 -0
  164. package/dist/lib/session/linear.d.ts +6 -0
  165. package/dist/lib/session/linear.js +55 -0
  166. package/dist/lib/session/migrate-targets.d.ts +65 -0
  167. package/dist/lib/session/migrate-targets.js +94 -0
  168. package/dist/lib/session/migrations.d.ts +37 -0
  169. package/dist/lib/session/migrations.js +60 -0
  170. package/dist/lib/session/parse.d.ts +18 -0
  171. package/dist/lib/session/parse.js +130 -30
  172. package/dist/lib/session/pid-registry.d.ts +34 -2
  173. package/dist/lib/session/pid-registry.js +49 -2
  174. package/dist/lib/session/render.d.ts +7 -2
  175. package/dist/lib/session/render.js +22 -10
  176. package/dist/lib/session/short-id.d.ts +17 -0
  177. package/dist/lib/session/short-id.js +20 -0
  178. package/dist/lib/session/state.js +6 -2
  179. package/dist/lib/shims.d.ts +27 -1
  180. package/dist/lib/shims.js +88 -6
  181. package/dist/lib/sqlite.d.ts +3 -2
  182. package/dist/lib/sqlite.js +27 -4
  183. package/dist/lib/staleness/detectors/commands.js +1 -1
  184. package/dist/lib/staleness/detectors/workflows.js +13 -2
  185. package/dist/lib/staleness/writers/commands.js +7 -7
  186. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  187. package/dist/lib/startup/command-registry.d.ts +2 -0
  188. package/dist/lib/startup/command-registry.js +5 -0
  189. package/dist/lib/state.d.ts +1 -1
  190. package/dist/lib/state.js +15 -36
  191. package/dist/lib/teams/agents.d.ts +19 -10
  192. package/dist/lib/teams/agents.js +40 -39
  193. package/dist/lib/types.d.ts +48 -2
  194. package/dist/lib/uninstall.js +1 -45
  195. package/dist/lib/usage.d.ts +33 -1
  196. package/dist/lib/usage.js +172 -12
  197. package/dist/lib/versions.d.ts +22 -2
  198. package/dist/lib/versions.js +98 -10
  199. package/dist/lib/watchdog/log.d.ts +43 -0
  200. package/dist/lib/watchdog/log.js +69 -0
  201. package/dist/lib/watchdog/routine.d.ts +44 -0
  202. package/dist/lib/watchdog/routine.js +69 -0
  203. package/dist/lib/watchdog/runner.d.ts +51 -7
  204. package/dist/lib/watchdog/runner.js +239 -64
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +31 -16
  207. package/dist/lib/workflows.d.ts +16 -0
  208. package/dist/lib/workflows.js +110 -1
  209. package/package.json +1 -1
@@ -320,8 +320,8 @@ export const AGENTS = {
320
320
  instructionsFile: 'AGENTS.md',
321
321
  format: 'markdown',
322
322
  variableSyntax: '$ARGUMENTS',
323
- supportsHooks: false,
324
- capabilities: { hooks: false, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '1.1.1' }, skills: true, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'] },
323
+ supportsHooks: true,
324
+ capabilities: { hooks: { since: '0.3.130' }, mcp: true, mcpHttp: false, mcpHeaders: false, allowlist: { since: '1.1.1' }, skills: true, commands: true, plugins: true, subagents: true, rules: { file: 'AGENTS.md' }, workflows: false, memory: false, modes: ['plan', 'edit'] },
325
325
  },
326
326
  openclaw: {
327
327
  id: 'openclaw',
@@ -479,6 +479,8 @@ export const AGENTS = {
479
479
  // ~/.grok/hooks/ (+ project .grok/hooks/) — events PreToolUse, PostToolUse, etc.
480
480
  // Plugins live in ~/.grok/plugins/ with marketplaces. Permissions: --allow/--deny
481
481
  // CLI flags or [permission] TOML block in ~/.grok/config.toml.
482
+ // Workflows (native Rhai orchestration) enabled by default as of v0.2.111 —
483
+ // projected into ~/.grok/workflows/<name>.rhai (under GROK_HOME isolation).
482
484
  grok: {
483
485
  id: 'grok',
484
486
  name: 'Grok',
@@ -487,8 +489,12 @@ export const AGENTS = {
487
489
  npmPackage: '',
488
490
  installScript: 'curl -fsSL https://x.ai/cli/install.sh | bash',
489
491
  configDir: path.join(HOME, '.grok'),
490
- commandsDir: '', // Grok primarily uses skills + slash commands from skills
491
- commandsSubdir: '',
492
+ // Grok discovers file-based slash commands from ~/.agents/commands/ (the
493
+ // cross-agent compat dir it mirrors from Claude Code) as well as the legacy
494
+ // ~/.claude/commands/ symlink. We write there directly so the per-agent
495
+ // install path and the central user repo stay in sync.
496
+ commandsDir: path.join(HOME, '.agents', 'commands'),
497
+ commandsSubdir: path.join('..', '.agents', 'commands'),
492
498
  skillsDir: path.join(HOME, '.grok', 'skills'),
493
499
  hooksDir: path.join(HOME, '.grok', 'hooks'),
494
500
  instructionsFile: 'AGENTS.md',
@@ -502,11 +508,13 @@ export const AGENTS = {
502
508
  mcpHeaders: false,
503
509
  allowlist: true, // maps to Grok's granular Bash/Edit/Write/Read/Grep/WebFetch/MCPTool rules
504
510
  skills: true,
505
- commands: false, // covered by skills
511
+ commands: true, // Grok >= 0.2.111: file-based slash commands from ~/.agents/commands/ (docs) + ~/.claude/commands/ (Claude Code compat)
506
512
  plugins: true,
507
513
  subagents: true, // ~/.grok/agents/*.md (Claude-compatible agent defs)
508
514
  rules: { file: 'AGENTS.md' },
509
- workflows: false,
515
+ // Native workflows (`.rhai` under ~/.grok/workflows/) shipped on-by-default
516
+ // in v0.2.111 (2026-07-22). See transformWorkflowForGrok in workflows.ts.
517
+ workflows: { since: '0.2.111' },
510
518
  memory: true,
511
519
  modes: ['plan', 'edit', 'skip'],
512
520
  // grok's `--permission-mode plan` silently stalls a headless `-p` run at
@@ -583,6 +591,24 @@ export const AGENTS = {
583
591
  // `.factory-plugin/` (it also reads `.claude-plugin/` for compatibility) — set
584
592
  // pluginManifestDir so syncPluginToVersion mirrors the manifest into it, the
585
593
  // same pattern codex uses with `.codex-plugin`.
594
+ //
595
+ // Workflows / Factory Missions (RUSH-1864, probed droid v0.177.0; ticket cited
596
+ // v0.161.0 — self-updating, no pinnable semver): Factory ships genuine multi-
597
+ // step orchestration ("Missions") via `/missions` (interactive) and
598
+ // `droid exec --mission` (headless; optional `-f <prompt.md>` is a *prompt*
599
+ // file, not a named mission template). Docs:
600
+ // https://docs.factory.ai/cli/features/missions and
601
+ // https://docs.factory.ai/docs/droid-exec/overview (Mission Mode).
602
+ //
603
+ // Mission state may land under `~/.factory/missions/<sessionId>/` at runtime
604
+ // (`getMissionsDir` = join(home, ".factory", "missions") + per-session
605
+ // mission.md / features.json / progress_log.jsonl) — that is **session
606
+ // runtime state**, not a discovery directory of installable named workflows.
607
+ // There is no discoverMission / template registry, and a cold install has no
608
+ // `~/.factory/missions/` until a mission is run. agents-cli's workflows
609
+ // capability means "sync WORKFLOW.md into an auto-discovered dir" — Droid
610
+ // has no such dir, so workflows stays false (invoke-only; do not fabricate a
611
+ // writer target).
586
612
  droid: {
587
613
  id: 'droid',
588
614
  name: 'Droid',
@@ -615,6 +641,7 @@ export const AGENTS = {
615
641
  plugins: true,
616
642
  subagents: true,
617
643
  rules: { file: 'AGENTS.md' },
644
+ // Factory Missions are invoke-only — no installable discovery dir (RUSH-1864).
618
645
  workflows: false,
619
646
  memory: false,
620
647
  modes: ['plan', 'edit', 'auto', 'skip'],
@@ -860,7 +887,20 @@ export async function getCliState(agentId) {
860
887
  }
861
888
  }
862
889
  }
863
- // Non-version-managed: single PATH lookup + cached version read
890
+ return getUnmanagedCliState(agentId);
891
+ }
892
+ /**
893
+ * Resolve the agent's OWN install — the one agents-cli does not manage — by plain
894
+ * PATH lookup, ignoring the version dirs entirely.
895
+ *
896
+ * Callers that specifically mean "the user's own globally-installed CLI" must use
897
+ * this rather than `getCliState`, whose managed fast path reports an installed
898
+ * version (any version dir, in readdir order) and would therefore hand back an
899
+ * isolated copy — a copy that is deliberately unreachable from PATH — labelled as
900
+ * the global install.
901
+ */
902
+ export async function getUnmanagedCliState(agentId) {
903
+ const agent = AGENTS[agentId];
864
904
  // Special case for grok: it manages its own binaries in ~/.grok/downloads/
865
905
  if (agentId === 'grok') {
866
906
  const grokBin = resolveGrokBinary();
@@ -1322,6 +1362,85 @@ function isValidOpenCodeCredential(value) {
1322
1362
  default: return false;
1323
1363
  }
1324
1364
  }
1365
+ /**
1366
+ * Whether a Claude version home's credential file is present but carries no
1367
+ * token — the "must have a real credential" floor (see
1368
+ * `isValidOpenCodeCredential`) applied to claude.
1369
+ *
1370
+ * A FAILED OAuth refresh leaves exactly this state behind: Claude Code rewrites
1371
+ * `.claude/.credentials.json` with `accessToken: ""`, `refreshToken: ""` and
1372
+ * `expiresAt: 0`, keeping only the descriptive fields (`subscriptionType`,
1373
+ * `rateLimitTier`, `refreshTokenExpiresAt`). Everything we derive from
1374
+ * `.claude.json` — email, plan — still looks healthy, so the install reported
1375
+ * `signedIn: true`, `agents view` drew usage bars for it, and balanced rotation
1376
+ * (whose `authValid` is just "email present") kept picking it — every pick dying
1377
+ * at spawn on "OAuth session expired and could not be refreshed".
1378
+ *
1379
+ * Only decidable off macOS: there the login Keychain is the canonical store and
1380
+ * this file is not authoritative, and probing the Keychain would raise an
1381
+ * authorization sheet per installed version on every `agents run` — the reason
1382
+ * rotation stopped calling `isClaudeAuthValid` at all. Off macOS the file IS the
1383
+ * only store, so a token-less file is proof of signed-out. `platform` is a
1384
+ * parameter so both branches are testable on any host.
1385
+ *
1386
+ * Sync, no Keychain, no network — safe on the `agents run` hot path.
1387
+ */
1388
+ export function isClaudeCredentialFileBlank(base, platform = process.platform) {
1389
+ if (platform === 'darwin')
1390
+ return false;
1391
+ try {
1392
+ const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
1393
+ const oauth = JSON.parse(raw).claudeAiOauth;
1394
+ if (!oauth)
1395
+ return false;
1396
+ const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
1397
+ return !nonEmpty(oauth.accessToken) && !nonEmpty(oauth.refreshToken);
1398
+ }
1399
+ catch {
1400
+ // No file (a Keychain-backed home, or never logged in here) or an
1401
+ // unreadable/corrupt one: not positive evidence of a blank credential, so
1402
+ // leave the existing signal alone rather than declaring a working install
1403
+ // signed out.
1404
+ return false;
1405
+ }
1406
+ }
1407
+ /**
1408
+ * Whether a Claude version home holds its OWN on-disk credential — a non-blank
1409
+ * `.claude/.credentials.json` (the store Claude Code writes on login off macOS)
1410
+ * or a `.claude/.oauth_token` file (the keychain-less shim fallback, shims.ts).
1411
+ *
1412
+ * RUSH-1979: the routines daemon injects one ambient `CLAUDE_CODE_OAUTH_TOKEN`
1413
+ * (RUSH-1759) so a token-less default account still authenticates. But Claude —
1414
+ * and the Linux shim's own `-z CLAUDE_CODE_OAUTH_TOKEN` guard — both prefer that
1415
+ * env var over a pinned account's config dir, so when balanced rotation pins a
1416
+ * specific account's `CLAUDE_CONFIG_DIR` the ambient token shadows it: the pool
1417
+ * is inert and every fire 401s on the one stale token. A routine spawn drops the
1418
+ * ambient token when the pinned account has its own credential (this returns
1419
+ * true) so the account authenticates itself; when it does not (the RUSH-1759
1420
+ * default), the injected token is kept.
1421
+ *
1422
+ * Off-darwin only, mirroring {@link isClaudeCredentialFileBlank}: on macOS the
1423
+ * login Keychain is the canonical store and probing it raises an auth sheet, and
1424
+ * there the daemon injects no token to shadow (broker-only, usually absent), so
1425
+ * returning false changes nothing. Sync, no Keychain, no network.
1426
+ */
1427
+ export function claudeHomeHasOwnCredential(base, platform = process.platform) {
1428
+ if (platform === 'darwin')
1429
+ return false;
1430
+ if (fs.existsSync(path.join(base, '.claude', '.oauth_token')))
1431
+ return true;
1432
+ try {
1433
+ const raw = fs.readFileSync(path.join(base, '.claude', '.credentials.json'), 'utf-8');
1434
+ const oauth = JSON.parse(raw).claudeAiOauth;
1435
+ if (!oauth)
1436
+ return false;
1437
+ const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0;
1438
+ return nonEmpty(oauth.accessToken) || nonEmpty(oauth.refreshToken);
1439
+ }
1440
+ catch {
1441
+ return false;
1442
+ }
1443
+ }
1325
1444
  export async function getAccountInfo(agentId, home) {
1326
1445
  const base = home || os.homedir();
1327
1446
  const empty = {
@@ -1358,6 +1477,13 @@ export async function getAccountInfo(agentId, home) {
1358
1477
  const accountId = normalizeIdentityPart(oa?.accountUuid);
1359
1478
  const organizationId = normalizeIdentityPart(oa?.organizationUuid);
1360
1479
  const email = oa?.emailAddress || null;
1480
+ // Credential floor: a blanked credential file means this home cannot
1481
+ // authenticate, whatever `.claude.json` still says. Report it signed out
1482
+ // so `agents view` prompts a re-login and rotation routes around it,
1483
+ // instead of handing runs to an install that dies at spawn.
1484
+ if (email && isClaudeCredentialFileBlank(base)) {
1485
+ return { ...empty, lastActive };
1486
+ }
1361
1487
  const accountKey = buildIdentityKey(agentId, [
1362
1488
  ['account', accountId],
1363
1489
  ['org', organizationId],
@@ -24,3 +24,5 @@ export declare class CodexCloudProvider implements CloudProvider {
24
24
  cancel(_taskId: string): Promise<void>;
25
25
  message(_taskId: string, _content: string): Promise<void>;
26
26
  }
27
+ /** Extract a task ID from codex CLI output (JSON field, key:value line, or UUID pattern). */
28
+ export declare function extractTaskId(output: string): string | undefined;
@@ -127,8 +127,19 @@ export class CodexCloudProvider {
127
127
  if (code !== 0) {
128
128
  throw new Error(`codex cloud exec failed: ${stderr || stdout}`);
129
129
  }
130
- // Parse task ID from output. Codex typically prints the task ID.
131
- const taskId = extractTaskId(stdout) ?? `codex-${Date.now()}`;
130
+ // The task id is the ONLY handle to the just-created execution — status,
131
+ // list, and the session-index reconcile all key on it. Codex prints it to
132
+ // stdout (JSON or a task_/id: line); some builds route it to stderr, so scan
133
+ // both. A synthetic `codex-<ts>` id was the old fallback here — it can never
134
+ // match the real execution, so it silently broke every follow-up. If the id
135
+ // genuinely can't be parsed, fail loudly and point at `agents cloud list`
136
+ // (which recovers the newest execution) rather than persist a bogus id.
137
+ const taskId = extractTaskId(stdout) ?? extractTaskId(stderr);
138
+ if (!taskId) {
139
+ throw new Error('codex cloud exec did not report a task id — the run may have dispatched, but ' +
140
+ 'without the id it cannot be tracked. Find it with `agents cloud list --provider codex`, ' +
141
+ `then \`agents cloud status <id>\`.\nRaw output: ${(stdout || stderr).trim().slice(0, 400)}`);
142
+ }
132
143
  const now = new Date().toISOString();
133
144
  return {
134
145
  id: taskId,
@@ -232,7 +243,7 @@ export class CodexCloudProvider {
232
243
  }
233
244
  }
234
245
  /** Extract a task ID from codex CLI output (JSON field, key:value line, or UUID pattern). */
235
- function extractTaskId(output) {
246
+ export function extractTaskId(output) {
236
247
  // Try JSON first
237
248
  try {
238
249
  const data = JSON.parse(output);
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Register cloud-dispatched tasks into the LOCAL session index, keyed by the
3
+ * real execution id, so a launch is mappable to a session immediately.
4
+ *
5
+ * The cloud store (`tasks.db`) and the session index (`sessions` table) were two
6
+ * disjoint stores: a `agents cloud run` wrote only the store, and the session
7
+ * index learned about the run only later, via `discoverCloudSessions` hitting the
8
+ * proxy. Until that discovery ran there was no launch→session mapping at all —
9
+ * the run was orphaned in `agents sessions` exactly like the non-Claude host runs.
10
+ *
11
+ * This mirrors hosts/session-index.ts: the transcript is remote (fetched on
12
+ * demand by session/cloud.ts), so the row is registered with an EMPTY `file_path`
13
+ * — the sentinel db.js's stale-row filter treats as "always live" — and a
14
+ * `[cloud/<status>]` label. The row is written at dispatch and refreshed on every
15
+ * status poll so the label tracks the task's lifecycle. Non-session-agent cloud
16
+ * providers (a task whose agent has no transcript format) are skipped: there is
17
+ * nothing to resolve or resume by id.
18
+ */
19
+ import type { CloudTask } from './types.js';
20
+ /** Context a cloud task needs to become a session row — the LOCAL dir it was launched from. */
21
+ export interface CloudSessionContext {
22
+ /** Local directory `agents cloud run` was invoked from. Defaults to process.cwd(). */
23
+ cwd?: string;
24
+ }
25
+ /**
26
+ * Register (or refresh) a cloud task in the local session index. No-op when the
27
+ * task's agent isn't a known session agent, or its id isn't a usable execution id
28
+ * (never a fabricated `codex-<ts>` — codex.ts fails loud instead of minting one,
29
+ * but guard here too so a bad id can never seed a bogus row). Best-effort: a
30
+ * failed index write must never break the dispatch/poll it rides on.
31
+ */
32
+ export declare function registerCloudSession(task: CloudTask, ctx?: CloudSessionContext): void;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Register cloud-dispatched tasks into the LOCAL session index, keyed by the
3
+ * real execution id, so a launch is mappable to a session immediately.
4
+ *
5
+ * The cloud store (`tasks.db`) and the session index (`sessions` table) were two
6
+ * disjoint stores: a `agents cloud run` wrote only the store, and the session
7
+ * index learned about the run only later, via `discoverCloudSessions` hitting the
8
+ * proxy. Until that discovery ran there was no launch→session mapping at all —
9
+ * the run was orphaned in `agents sessions` exactly like the non-Claude host runs.
10
+ *
11
+ * This mirrors hosts/session-index.ts: the transcript is remote (fetched on
12
+ * demand by session/cloud.ts), so the row is registered with an EMPTY `file_path`
13
+ * — the sentinel db.js's stale-row filter treats as "always live" — and a
14
+ * `[cloud/<status>]` label. The row is written at dispatch and refreshed on every
15
+ * status poll so the label tracks the task's lifecycle. Non-session-agent cloud
16
+ * providers (a task whose agent has no transcript format) are skipped: there is
17
+ * nothing to resolve or resume by id.
18
+ */
19
+ import { upsertSession } from '../session/db.js';
20
+ import { SESSION_AGENTS } from '../session/types.js';
21
+ import { deriveShortId } from '../session/short-id.js';
22
+ /** The execution-id charset the session index will accept as a row id. */
23
+ const EXECUTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
24
+ /**
25
+ * Register (or refresh) a cloud task in the local session index. No-op when the
26
+ * task's agent isn't a known session agent, or its id isn't a usable execution id
27
+ * (never a fabricated `codex-<ts>` — codex.ts fails loud instead of minting one,
28
+ * but guard here too so a bad id can never seed a bogus row). Best-effort: a
29
+ * failed index write must never break the dispatch/poll it rides on.
30
+ */
31
+ export function registerCloudSession(task, ctx = {}) {
32
+ if (!SESSION_AGENTS.includes(task.agent))
33
+ return;
34
+ if (!task.id || !EXECUTION_ID_RE.test(task.id))
35
+ return;
36
+ try {
37
+ upsertSession({
38
+ id: task.id,
39
+ shortId: deriveShortId(task.id),
40
+ agent: task.agent,
41
+ timestamp: task.createdAt,
42
+ lastActivity: task.updatedAt,
43
+ cwd: ctx.cwd ?? process.cwd(),
44
+ project: task.repo ?? task.repos?.[0],
45
+ // Remote transcript — no local file yet. Empty file_path is the sentinel
46
+ // the DB stale-filter treats as "always live" (see hosts/session-index.ts).
47
+ filePath: '',
48
+ topic: task.prompt.split('\n')[0]?.slice(0, 120) || undefined,
49
+ // Mirrors session/cloud.ts's discovered label, so the dispatch-time row and
50
+ // a later proxy-discovered row read the same in `agents sessions`.
51
+ label: `[cloud/${task.status}]${task.branch ? ` ${task.branch}` : ''}`,
52
+ prUrl: task.prUrl,
53
+ }, '');
54
+ }
55
+ catch {
56
+ /* index write is best-effort; the task is already persisted in the cloud store */
57
+ }
58
+ }
@@ -6,6 +6,13 @@
6
6
  * remote provider each time.
7
7
  */
8
8
  import type { CloudTask, CloudProviderId, CloudTaskStatus } from './types.js';
9
+ /**
10
+ * Release the tasks.db connection so the file (and its WAL sidecars) can be
11
+ * removed. The mirror of `closeDB()` in `../session/db.ts`, which this module
12
+ * had no counterpart for — on Windows an open handle makes the file
13
+ * un-unlinkable, so anything tearing down a temp home must close this too.
14
+ */
15
+ export declare function closeStore(): void;
9
16
  /** Persist a task snapshot, replacing any existing row with the same ID. */
10
17
  export declare function insertTask(task: CloudTask): void;
11
18
  /** Update a task's status and optionally patch summary, PR URL, or branch. */
@@ -9,6 +9,7 @@ import * as fs from 'fs';
9
9
  import * as path from 'path';
10
10
  import Database from '../sqlite.js';
11
11
  import { getCloudDir } from '../state.js';
12
+ import { registerCloudSession } from './session-index.js';
12
13
  const CLOUD_DIR = getCloudDir();
13
14
  const DB_PATH = path.join(CLOUD_DIR, 'tasks.db');
14
15
  const SCHEMA = `
@@ -41,12 +42,29 @@ function db() {
41
42
  _db.exec(SCHEMA);
42
43
  return _db;
43
44
  }
45
+ /**
46
+ * Release the tasks.db connection so the file (and its WAL sidecars) can be
47
+ * removed. The mirror of `closeDB()` in `../session/db.ts`, which this module
48
+ * had no counterpart for — on Windows an open handle makes the file
49
+ * un-unlinkable, so anything tearing down a temp home must close this too.
50
+ */
51
+ export function closeStore() {
52
+ if (_db) {
53
+ _db.close();
54
+ _db = null;
55
+ }
56
+ }
44
57
  /** Persist a task snapshot, replacing any existing row with the same ID. */
45
58
  export function insertTask(task) {
46
59
  db().prepare(`
47
60
  INSERT OR REPLACE INTO tasks (id, provider, status, agent, prompt, repo, branch, pr_url, summary, created_at, updated_at)
48
61
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
49
62
  `).run(task.id, task.provider, task.status, task.agent ?? null, task.prompt, task.repo ?? null, task.branch ?? null, task.prUrl ?? null, task.summary ?? null, task.createdAt, task.updatedAt);
63
+ // Every cloud dispatch flows through here, so this is the one chokepoint that
64
+ // reconciles the cloud store with the session index: register a session row
65
+ // keyed by the real execution id so the launch is mappable to a session
66
+ // immediately, not only after a later proxy discovery (see session-index.ts).
67
+ registerCloudSession(task);
50
68
  }
51
69
  /** Update a task's status and optionally patch summary, PR URL, or branch. */
52
70
  export function updateTaskStatus(id, status, extra) {
@@ -67,6 +85,13 @@ export function updateTaskStatus(id, status, extra) {
67
85
  }
68
86
  params.push(id);
69
87
  db().prepare(`UPDATE tasks SET ${sets.join(', ')} WHERE id = ?`).run(...params);
88
+ // Keep the session index in lockstep with the store on every poll: refresh the
89
+ // row so its `[cloud/<status>]` label tracks the task's lifecycle (and picks up
90
+ // a newly-opened PR url). Reads the just-written row so the label reflects the
91
+ // update we just made, not a stale snapshot.
92
+ const updated = getTaskById(id);
93
+ if (updated)
94
+ registerCloudSession(updated);
70
95
  }
71
96
  /** Fetch a single task by its provider-assigned ID, or null if not found locally. */
72
97
  export function getTaskById(id) {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Move `source` onto `dest` across possibly-different volumes. `renameSync` is
3
+ * atomic but throws EXDEV when `~/.agents` lives on a different filesystem than
4
+ * `$HOME`; fall back to copy-then-remove so the restore still completes. The
5
+ * source is removed only after the copy succeeds, so a mid-copy failure never
6
+ * destroys the sole surviving copy.
7
+ */
8
+ export declare function moveDirCrossDevice(source: string, dest: string): void;
9
+ /**
10
+ * Copy `source` to `dest`, dropping any symlink whose target resolves back into
11
+ * `~/.agents`. Managed resources (skills/commands) are synced into a version home
12
+ * as symlinks into `~/.agents`; copying them verbatim would leave the result full
13
+ * of links that dangle the moment `~/.agents` is disposed. Stripping them yields a
14
+ * clean, self-contained copy — which is the entire point of an export.
15
+ */
16
+ export declare function copyDirStrippingAgentsSymlinks(source: string, dest: string, agentsDir: string): void;
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Primitives for moving an agent config directory across the agents-cli boundary.
3
+ *
4
+ * Both helpers were part of {@link ./uninstall.ts} and are unchanged — teardown was
5
+ * simply the first caller. `agents export` needs the same two operations (relocate a
6
+ * directory that may sit on another volume; copy one without dragging `~/.agents`
7
+ * symlinks along), so they live here rather than being duplicated or imported out of
8
+ * a module named for teardown.
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ /**
13
+ * Move `source` onto `dest` across possibly-different volumes. `renameSync` is
14
+ * atomic but throws EXDEV when `~/.agents` lives on a different filesystem than
15
+ * `$HOME`; fall back to copy-then-remove so the restore still completes. The
16
+ * source is removed only after the copy succeeds, so a mid-copy failure never
17
+ * destroys the sole surviving copy.
18
+ */
19
+ export function moveDirCrossDevice(source, dest) {
20
+ try {
21
+ fs.renameSync(source, dest);
22
+ }
23
+ catch (err) {
24
+ if (err.code !== 'EXDEV')
25
+ throw err;
26
+ fs.cpSync(source, dest, { recursive: true });
27
+ fs.rmSync(source, { recursive: true, force: true });
28
+ }
29
+ }
30
+ /**
31
+ * Copy `source` to `dest`, dropping any symlink whose target resolves back into
32
+ * `~/.agents`. Managed resources (skills/commands) are synced into a version home
33
+ * as symlinks into `~/.agents`; copying them verbatim would leave the result full
34
+ * of links that dangle the moment `~/.agents` is disposed. Stripping them yields a
35
+ * clean, self-contained copy — which is the entire point of an export.
36
+ */
37
+ export function copyDirStrippingAgentsSymlinks(source, dest, agentsDir) {
38
+ const inside = agentsDir + path.sep;
39
+ fs.cpSync(source, dest, {
40
+ recursive: true,
41
+ // `force: true` is Node's default, but Bun drops it when a `filter` is supplied —
42
+ // existing files are then silently left alone. `dist/bin/agents` is bun-compiled,
43
+ // so this is a production path, not just a test artifact. State it explicitly.
44
+ force: true,
45
+ filter: (src) => {
46
+ try {
47
+ const st = fs.lstatSync(src);
48
+ if (st.isSymbolicLink()) {
49
+ const tgt = path.resolve(path.dirname(src), fs.readlinkSync(src));
50
+ if (tgt === agentsDir || tgt.startsWith(inside))
51
+ return false;
52
+ }
53
+ }
54
+ catch {
55
+ /* unreadable entry — let cpSync surface it on the real copy */
56
+ }
57
+ return true;
58
+ },
59
+ });
60
+ }
@@ -16,7 +16,7 @@ import { listJobs as listAllJobs } from './routines.js';
16
16
  import { JobScheduler } from './scheduler.js';
17
17
  import { MonitorEngine } from './monitors/engine.js';
18
18
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
19
- import { detectOverdueJobs, notifyOverdue } from './overdue.js';
19
+ import { detectOverdueJobs, notifyOverdue, notifyDesktop } from './overdue.js';
20
20
  import { BrowserService } from './browser/service.js';
21
21
  import { BrowserIPCServer } from './browser/ipc.js';
22
22
  import { readAndResolveBundleEnv } from './secrets/bundles.js';
@@ -320,6 +320,15 @@ export async function runDaemon() {
320
320
  process.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
321
321
  log('INFO', 'Loaded Claude OAuth token from secrets bundle for routine runs');
322
322
  }
323
+ else {
324
+ // No token available (e.g. a headless macOS daemon whose keychain was
325
+ // locked at start resolves broker-only and gets nothing). Historically
326
+ // this was silent, so every Claude routine the daemon spawned failed auth
327
+ // with no signal. Make it loud: WARN in the log and fire a desktop alert.
328
+ log('WARN', 'No Claude OAuth token available — Claude routine runs will fail auth on this host. ' +
329
+ 'Restart the daemon with the keychain unlocked, or unlock the `claude` secrets bundle.');
330
+ notifyDesktop('agents daemon: no Claude credential', 'Claude routines will fail auth on this host. Restart the daemon with the keychain unlocked.');
331
+ }
323
332
  }
324
333
  // Reap any stray duplicate daemon of this install that slipped past the start
325
334
  // lock or was orphaned by a hard-crash — before it can double-fire jobs.
@@ -623,9 +632,16 @@ export async function runDaemon() {
623
632
  checkingLaunchHealth = true;
624
633
  try {
625
634
  const { healBrokenDefaultLaunches } = await import('./versions.js');
626
- const repaired = await healBrokenDefaultLaunches((m) => log('INFO', `launch-health: ${m}`));
635
+ // Unattended pass: repair the current default in place, but NEVER repoint
636
+ // the global default (allowDefaultSwitch: false). A background default
637
+ // switch installs a fresh version home → a fresh empty Claude credential
638
+ // scope, i.e. a silent logout uncorrelated with anything the user did.
639
+ const { repaired, unhealed } = await healBrokenDefaultLaunches((m) => log('INFO', `launch-health: ${m}`), { allowDefaultSwitch: false });
627
640
  if (repaired.length)
628
641
  log('INFO', `launch-health: repaired ${repaired.join(', ')}`);
642
+ if (unhealed.length) {
643
+ 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\``);
644
+ }
629
645
  }
630
646
  catch (err) {
631
647
  log('ERROR', `launch-health check failed: ${err.message}`);
@@ -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
  /**