@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
@@ -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) {
@@ -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
+ }
@@ -182,7 +182,10 @@ export function probeDeviceStats(device, opts = {}) {
182
182
  // remote login shell, which evaluates the snippet's `;`/`||` directly — no
183
183
  // `sh -c` wrapper needed (and a wrapper would only re-quote the first token).
184
184
  // For powershell devices it base64-encodes the snippet instead.
185
- ({ args, env } = buildSshInvocation(device, [isWin ? WIN_PROBE_SNIPPET : PROBE_SNIPPET], shim));
185
+ // agentOnly: this is a read-only stats probe. A password-auth device must
186
+ // resolve its bundle broker-only — never force a foreground Touch ID sheet
187
+ // just to render the load/mem columns of `agents devices` (RUSH-1970).
188
+ ({ args, env } = buildSshInvocation(device, [isWin ? WIN_PROBE_SNIPPET : PROBE_SNIPPET], shim, {}, { agentOnly: true }));
186
189
  }
187
190
  catch {
188
191
  return Promise.resolve({ host, reachable: false, fetchedAt });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * One reachability resolver for the fleet render (RUSH-1965).
3
+ *
4
+ * "Is this device online?" had three disagreeing answers: the live SSH probe
5
+ * (`DeviceStats.reachable`, display-only), the persisted registry verdict, and
6
+ * the cached `tailscale.online` snapshot that the online/offline word actually
7
+ * read. A box reachable every day but whose tailscale snapshot was stale (or a
8
+ * `via:"manual"` device that never got a tailscale field at all) rendered
9
+ * "offline forever". This module is the single ordering the render + the
10
+ * write-back both go through, so the word and the persisted truth agree.
11
+ */
12
+ import type { DeviceProfile, DeviceReachability, DeviceRegistry } from './registry.js';
13
+ import type { DeviceStats } from './health.js';
14
+ export type OnlineState = 'online' | 'offline' | 'unknown';
15
+ /**
16
+ * Resolve a device's online/offline state, preferring the freshest signal.
17
+ *
18
+ * Precedence: a live stat from THIS run (the probe that just ran) > the
19
+ * persisted reachability verdict written back by a prior probe > the cached
20
+ * `tailscale.online` snapshot. 'unknown' only when nothing at all is known.
21
+ */
22
+ export declare function deviceOnlineState(d: DeviceProfile, stats?: DeviceStats): OnlineState;
23
+ /** Build the reachability verdict to persist from a fresh probe stat. Pure. */
24
+ export declare function reachabilityFromStats(d: DeviceProfile, stats: DeviceStats): DeviceReachability;
25
+ /**
26
+ * Collect the reachability verdicts to write back for a fleet's freshly-read
27
+ * stats map. Only devices present in the registry are included (a stat for a
28
+ * name we don't track is dropped rather than resurrected). Pure — the caller
29
+ * hands the result to {@link writeReachability}.
30
+ */
31
+ export declare function collectReachabilityWriteBacks(reg: DeviceRegistry, statsMap: Map<string, DeviceStats>): Record<string, DeviceReachability>;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Resolve a device's online/offline state, preferring the freshest signal.
3
+ *
4
+ * Precedence: a live stat from THIS run (the probe that just ran) > the
5
+ * persisted reachability verdict written back by a prior probe > the cached
6
+ * `tailscale.online` snapshot. 'unknown' only when nothing at all is known.
7
+ */
8
+ export function deviceOnlineState(d, stats) {
9
+ if (stats)
10
+ return stats.reachable ? 'online' : 'offline';
11
+ if (d.reachability)
12
+ return d.reachability.reachable ? 'online' : 'offline';
13
+ if (d.tailscale)
14
+ return d.tailscale.online ? 'online' : 'offline';
15
+ return 'unknown';
16
+ }
17
+ /** Build the reachability verdict to persist from a fresh probe stat. Pure. */
18
+ export function reachabilityFromStats(d, stats) {
19
+ return {
20
+ reachable: stats.reachable,
21
+ via: d.address?.via,
22
+ checkedAt: new Date(stats.fetchedAt).toISOString(),
23
+ };
24
+ }
25
+ /**
26
+ * Collect the reachability verdicts to write back for a fleet's freshly-read
27
+ * stats map. Only devices present in the registry are included (a stat for a
28
+ * name we don't track is dropped rather than resurrected). Pure — the caller
29
+ * hands the result to {@link writeReachability}.
30
+ */
31
+ export function collectReachabilityWriteBacks(reg, statsMap) {
32
+ const out = {};
33
+ for (const [name, stats] of statsMap) {
34
+ const d = reg[name];
35
+ if (!d)
36
+ continue;
37
+ out[name] = reachabilityFromStats(d, stats);
38
+ }
39
+ return out;
40
+ }
@@ -40,6 +40,24 @@ export interface DeviceTailscale {
40
40
  relay?: string;
41
41
  lastSeen?: string;
42
42
  }
43
+ /**
44
+ * Verdict of the last live SSH reachability probe (RUSH-1965).
45
+ *
46
+ * The live `agents devices` probe already learns whether a box answered right
47
+ * now; this persists that answer so the online/offline word is read from a
48
+ * fresh probe instead of the potentially-stale {@link DeviceTailscale.online}
49
+ * snapshot (which only `agents devices sync` / the daemon ever writes). A
50
+ * `via:"manual"` device — which never gets a tailscale peer entry — gets a
51
+ * reachability verdict this way too, so it stops rendering "offline forever".
52
+ */
53
+ export interface DeviceReachability {
54
+ /** Whether the last live probe reached the device. */
55
+ reachable: boolean;
56
+ /** Transport the verdict came through — the address kind used to dial it. */
57
+ via?: DeviceAddress['via'];
58
+ /** ISO-8601 timestamp of the probe that produced this verdict. */
59
+ checkedAt: string;
60
+ }
43
61
  /** A single registered device. */
44
62
  export interface DeviceProfile {
45
63
  name: string;
@@ -49,6 +67,10 @@ export interface DeviceProfile {
49
67
  address: DeviceAddress;
50
68
  auth: DeviceAuth;
51
69
  tailscale?: DeviceTailscale;
70
+ /** Last live SSH-probe reachability verdict (RUSH-1965). Preferred over the
71
+ * cached {@link DeviceTailscale.online} snapshot when rendering online/offline,
72
+ * because the live probe reflects whether the box answered right now. */
73
+ reachability?: DeviceReachability;
52
74
  /** What the device is for. Absent means `worker` (see {@link DeviceRole}). */
53
75
  role?: DeviceRole;
54
76
  createdAt: string;
@@ -88,6 +110,7 @@ export interface DeviceInput {
88
110
  address?: DeviceAddress;
89
111
  auth?: DeviceAuth;
90
112
  tailscale?: DeviceTailscale;
113
+ reachability?: DeviceReachability;
91
114
  role?: DeviceRole;
92
115
  }
93
116
  /**
@@ -96,6 +119,16 @@ export interface DeviceInput {
96
119
  * platform so the two can never drift. Returns the resulting profile.
97
120
  */
98
121
  export declare function upsertDevice(name: string, input: DeviceInput): Promise<DeviceProfile>;
122
+ /**
123
+ * Persist live reachability verdicts for many devices in one locked pass
124
+ * (RUSH-1965). A no-op verdict (same `reachable` and not newer than what's
125
+ * stored) is skipped, so a cache-served `agents devices` render doesn't churn
126
+ * the registry every call; only a flip or a fresher probe writes. Unlike
127
+ * {@link upsertDevice} this does NOT bump `updatedAt` — reachability is
128
+ * transient liveness (like the tailscale snapshot), not a profile edit. Unknown
129
+ * device names are ignored. Returns the names actually updated.
130
+ */
131
+ export declare function writeReachability(updates: Record<string, DeviceReachability>): Promise<string[]>;
99
132
  /** Remove a device. Returns false if it was not registered. */
100
133
  export declare function removeDevice(name: string): Promise<boolean>;
101
134
  /** Load the set of ignored node names. Missing file => empty set. A malformed
@@ -178,6 +178,7 @@ export async function upsertDevice(name, input) {
178
178
  address: input.address ?? prev?.address ?? { via: 'manual' },
179
179
  auth: input.auth ?? prev?.auth ?? { method: 'key' },
180
180
  tailscale: input.tailscale ?? prev?.tailscale,
181
+ reachability: input.reachability ?? prev?.reachability,
181
182
  role: input.role ?? prev?.role,
182
183
  createdAt: prev?.createdAt ?? now,
183
184
  updatedAt: now,
@@ -187,6 +188,42 @@ export async function upsertDevice(name, input) {
187
188
  return merged;
188
189
  });
189
190
  }
191
+ /**
192
+ * Persist live reachability verdicts for many devices in one locked pass
193
+ * (RUSH-1965). A no-op verdict (same `reachable` and not newer than what's
194
+ * stored) is skipped, so a cache-served `agents devices` render doesn't churn
195
+ * the registry every call; only a flip or a fresher probe writes. Unlike
196
+ * {@link upsertDevice} this does NOT bump `updatedAt` — reachability is
197
+ * transient liveness (like the tailscale snapshot), not a profile edit. Unknown
198
+ * device names are ignored. Returns the names actually updated.
199
+ */
200
+ export async function writeReachability(updates) {
201
+ const names = Object.keys(updates);
202
+ if (names.length === 0)
203
+ return [];
204
+ const p = registryPath();
205
+ return withRegistryLock(p, async () => {
206
+ const reg = await loadDevices();
207
+ const changed = [];
208
+ for (const name of names) {
209
+ const prev = reg[name];
210
+ if (!prev)
211
+ continue; // never resurrect a device the user removed
212
+ const next = updates[name];
213
+ const cur = prev.reachability;
214
+ if (cur &&
215
+ cur.reachable === next.reachable &&
216
+ Date.parse(cur.checkedAt) >= Date.parse(next.checkedAt)) {
217
+ continue; // unchanged verdict, no fresher timestamp — skip the write
218
+ }
219
+ reg[name] = { ...prev, reachability: next };
220
+ changed.push(name);
221
+ }
222
+ if (changed.length > 0)
223
+ await saveDevices(reg);
224
+ return changed;
225
+ });
226
+ }
190
227
  /** Remove a device. Returns false if it was not registered. */
191
228
  export async function removeDevice(name) {
192
229
  const p = registryPath();
@@ -1,4 +1,6 @@
1
- import { type DeviceProfile, type DeviceRegistry } from './registry.js';
1
+ import { splitUserHost } from '../hosts/registry.js';
2
+ import { type DeviceProfile } from './registry.js';
3
+ export { splitUserHost };
2
4
  /** A dialable peer: the ssh target, the machine id used to tag its rows, a
3
5
  * display name, and the OS family that picks the remote shell dialect. */
4
6
  export interface ResolvedSshTarget {
@@ -7,35 +9,21 @@ export interface ResolvedSshTarget {
7
9
  name: string;
8
10
  os?: string;
9
11
  }
10
- /** Split a `user@host` / `host` token into its login user (if any) and host part. */
11
- export declare function splitUserHost(token: string): {
12
- user?: string;
13
- host: string;
14
- };
15
- /**
16
- * Resolve one `--host`/`--device` token to a concrete ssh target through the
17
- * registry. Registry hit → the device's real address + platform (so the machine
18
- * id, route, and OS all match the auto-discovery sweep), with any `user@`
19
- * overriding the login account. Miss → a literal `user@host` fallback, its OS
20
- * taken from the host overlay if enrolled. Returns undefined only when the token
21
- * fails the shared ssh-target injection guard.
22
- */
23
- export declare function resolveSshTarget(token: string, reg: DeviceRegistry): ResolvedSshTarget | undefined;
24
12
  /**
25
13
  * Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
26
- * grammar as {@link resolveSshTarget}, but returns the whole profile (auth,
27
- * shell, tailscale metadata) `buildSshInvocation` needs not just a target
28
- * string. A registered `name` or `user@device` yields that profile (with the
29
- * login user overridden by any `user@`); an ad-hoc `user@host`/`host` literal
30
- * yields a synthesized key-auth profile. A bare unregistered alias (no `@`/dot)
31
- * returns undefined so the caller reports "Unknown device" rather than dialing a
32
- * literal the strict behaviour the interactive wrapper has always had.
14
+ * grammar as the fan-out, but returns the whole profile (auth, shell, tailscale
15
+ * metadata) `buildSshInvocation` needs. A registered `name` / `user@device`
16
+ * yields that profile (login user overridden by any `user@`); an ad-hoc
17
+ * `user@host` / IP / FQDN literal yields a synthesized key-auth profile. A bare
18
+ * unregistered alias (no `@`/dot) or an ssh_config-only alias, which `agents
19
+ * ssh` has never dialed — returns undefined so the caller reports "Unknown
20
+ * device" rather than dialing a literal.
33
21
  */
34
- export declare function resolveDeviceTarget(token: string, reg: DeviceRegistry): DeviceProfile | undefined;
22
+ export declare function resolveDeviceTarget(token: string): Promise<DeviceProfile | undefined>;
35
23
  /**
36
- * Resolve an explicit `--host`/`--device` list to dialable targets, reading the
37
- * registry once. A token that fails the injection guard is skipped with a
38
- * stderr note (never fatal — one bad token must not blank the fan-out). Shared
39
- * by every cross-machine fan-out so they can never diverge onto two routes.
24
+ * Resolve an explicit `--host`/`--device` list to dialable targets. A token that
25
+ * fails the injection guard or names nothing reachable is skipped with a stderr
26
+ * note (never fatal — one bad token must not blank the fan-out). Shared by every
27
+ * cross-machine fan-out so they can never diverge onto two routes.
40
28
  */
41
29
  export declare function resolveExplicitTargets(hosts: string[]): Promise<ResolvedSshTarget[]>;