@phnx-labs/agents-cli 1.20.74 → 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 (199) hide show
  1. package/CHANGELOG.md +893 -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 +31 -3
  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 +15 -2
  26. package/dist/commands/secrets.js +44 -21
  27. package/dist/commands/sessions-browser.js +1 -1
  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.js +16 -1
  32. package/dist/commands/sessions.d.ts +36 -13
  33. package/dist/commands/sessions.js +210 -40
  34. package/dist/commands/setup-computer.js +2 -2
  35. package/dist/commands/ssh.js +71 -35
  36. package/dist/commands/status.js +10 -2
  37. package/dist/commands/versions.js +15 -6
  38. package/dist/commands/view.d.ts +5 -0
  39. package/dist/commands/view.js +24 -4
  40. package/dist/commands/watchdog.d.ts +7 -2
  41. package/dist/commands/watchdog.js +73 -57
  42. package/dist/index.js +58 -7
  43. package/dist/lib/activity.d.ts +14 -4
  44. package/dist/lib/activity.js +17 -5
  45. package/dist/lib/actor.d.ts +50 -0
  46. package/dist/lib/actor.js +166 -0
  47. package/dist/lib/agent-spec/provider.js +2 -1
  48. package/dist/lib/agent-spec/resolve.js +19 -5
  49. package/dist/lib/agent-spec/types.d.ts +9 -1
  50. package/dist/lib/agents.d.ts +21 -0
  51. package/dist/lib/agents.js +70 -6
  52. package/dist/lib/cloud/codex.d.ts +2 -0
  53. package/dist/lib/cloud/codex.js +14 -3
  54. package/dist/lib/cloud/session-index.d.ts +32 -0
  55. package/dist/lib/cloud/session-index.js +58 -0
  56. package/dist/lib/cloud/store.d.ts +7 -0
  57. package/dist/lib/cloud/store.js +25 -0
  58. package/dist/lib/config-transfer.js +4 -0
  59. package/dist/lib/daemon.js +18 -2
  60. package/dist/lib/devices/connect.d.ts +15 -1
  61. package/dist/lib/devices/connect.js +15 -1
  62. package/dist/lib/devices/fleet.d.ts +21 -0
  63. package/dist/lib/devices/fleet.js +26 -0
  64. package/dist/lib/devices/health-report.d.ts +38 -0
  65. package/dist/lib/devices/health-report.js +214 -0
  66. package/dist/lib/devices/health.js +4 -1
  67. package/dist/lib/devices/reachability.d.ts +31 -0
  68. package/dist/lib/devices/reachability.js +40 -0
  69. package/dist/lib/devices/registry.d.ts +33 -0
  70. package/dist/lib/devices/registry.js +37 -0
  71. package/dist/lib/devices/resolve-target.d.ts +15 -27
  72. package/dist/lib/devices/resolve-target.js +63 -102
  73. package/dist/lib/devices/sync.d.ts +18 -0
  74. package/dist/lib/devices/sync.js +23 -1
  75. package/dist/lib/devices/tailscale.d.ts +3 -0
  76. package/dist/lib/devices/tailscale.js +1 -0
  77. package/dist/lib/events.d.ts +1 -1
  78. package/dist/lib/exec.d.ts +66 -0
  79. package/dist/lib/exec.js +195 -6
  80. package/dist/lib/feed-post.d.ts +63 -0
  81. package/dist/lib/feed-post.js +204 -0
  82. package/dist/lib/fleet/apply.d.ts +32 -2
  83. package/dist/lib/fleet/apply.js +97 -10
  84. package/dist/lib/fleet/types.d.ts +11 -0
  85. package/dist/lib/fs-walk.d.ts +13 -0
  86. package/dist/lib/fs-walk.js +16 -7
  87. package/dist/lib/hooks.d.ts +1 -0
  88. package/dist/lib/hooks.js +156 -0
  89. package/dist/lib/hosts/dispatch.d.ts +7 -0
  90. package/dist/lib/hosts/dispatch.js +2 -0
  91. package/dist/lib/hosts/passthrough.js +2 -0
  92. package/dist/lib/hosts/reconnect.d.ts +78 -0
  93. package/dist/lib/hosts/reconnect.js +127 -0
  94. package/dist/lib/hosts/registry.d.ts +75 -14
  95. package/dist/lib/hosts/registry.js +205 -30
  96. package/dist/lib/hosts/remote-cmd.js +1 -0
  97. package/dist/lib/hosts/run-target.d.ts +13 -5
  98. package/dist/lib/hosts/run-target.js +26 -10
  99. package/dist/lib/hosts/session-index.d.ts +18 -1
  100. package/dist/lib/hosts/session-index.js +37 -2
  101. package/dist/lib/hosts/session-marker.d.ts +33 -0
  102. package/dist/lib/hosts/session-marker.js +51 -0
  103. package/dist/lib/hosts/tasks.d.ts +8 -4
  104. package/dist/lib/import.d.ts +2 -0
  105. package/dist/lib/import.js +35 -2
  106. package/dist/lib/menubar/install-menubar.d.ts +20 -0
  107. package/dist/lib/menubar/install-menubar.js +45 -1
  108. package/dist/lib/migrate.d.ts +16 -0
  109. package/dist/lib/migrate.js +36 -0
  110. package/dist/lib/models.d.ts +27 -0
  111. package/dist/lib/models.js +54 -1
  112. package/dist/lib/overdue.d.ts +7 -2
  113. package/dist/lib/overdue.js +18 -11
  114. package/dist/lib/picker.js +4 -1
  115. package/dist/lib/platform/process.d.ts +17 -0
  116. package/dist/lib/platform/process.js +70 -0
  117. package/dist/lib/profiles-presets.js +9 -7
  118. package/dist/lib/profiles.d.ts +31 -0
  119. package/dist/lib/profiles.js +70 -0
  120. package/dist/lib/pty-server.d.ts +2 -10
  121. package/dist/lib/pty-server.js +4 -38
  122. package/dist/lib/registry.d.ts +1 -1
  123. package/dist/lib/registry.js +48 -8
  124. package/dist/lib/rotate.d.ts +18 -0
  125. package/dist/lib/rotate.js +28 -0
  126. package/dist/lib/routines.d.ts +26 -0
  127. package/dist/lib/routines.js +19 -2
  128. package/dist/lib/runner.js +104 -7
  129. package/dist/lib/secrets/agent.d.ts +66 -1
  130. package/dist/lib/secrets/agent.js +154 -90
  131. package/dist/lib/secrets/bundles.d.ts +34 -0
  132. package/dist/lib/secrets/bundles.js +179 -19
  133. package/dist/lib/secrets/index.d.ts +15 -1
  134. package/dist/lib/secrets/index.js +21 -3
  135. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  136. package/dist/lib/secrets/rc-hygiene.js +154 -0
  137. package/dist/lib/secrets/remote.d.ts +7 -4
  138. package/dist/lib/secrets/remote.js +7 -4
  139. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  140. package/dist/lib/secrets/sync-commands.js +21 -0
  141. package/dist/lib/self-update.d.ts +23 -2
  142. package/dist/lib/self-update.js +86 -5
  143. package/dist/lib/session/active.d.ts +114 -7
  144. package/dist/lib/session/active.js +323 -52
  145. package/dist/lib/session/cloud.js +2 -1
  146. package/dist/lib/session/db.d.ts +48 -0
  147. package/dist/lib/session/db.js +238 -14
  148. package/dist/lib/session/detached.d.ts +30 -0
  149. package/dist/lib/session/detached.js +92 -0
  150. package/dist/lib/session/discover.d.ts +465 -2
  151. package/dist/lib/session/discover.js +1648 -468
  152. package/dist/lib/session/fork.js +2 -1
  153. package/dist/lib/session/hook-sessions.d.ts +43 -0
  154. package/dist/lib/session/hook-sessions.js +135 -0
  155. package/dist/lib/session/linear.d.ts +6 -0
  156. package/dist/lib/session/linear.js +55 -0
  157. package/dist/lib/session/migrate-targets.d.ts +65 -0
  158. package/dist/lib/session/migrate-targets.js +94 -0
  159. package/dist/lib/session/migrations.d.ts +37 -0
  160. package/dist/lib/session/migrations.js +60 -0
  161. package/dist/lib/session/parse.d.ts +18 -0
  162. package/dist/lib/session/parse.js +130 -30
  163. package/dist/lib/session/pid-registry.d.ts +34 -2
  164. package/dist/lib/session/pid-registry.js +49 -2
  165. package/dist/lib/session/render.d.ts +7 -2
  166. package/dist/lib/session/render.js +22 -10
  167. package/dist/lib/session/short-id.d.ts +17 -0
  168. package/dist/lib/session/short-id.js +20 -0
  169. package/dist/lib/session/state.js +6 -2
  170. package/dist/lib/shims.d.ts +1 -1
  171. package/dist/lib/shims.js +20 -6
  172. package/dist/lib/sqlite.d.ts +3 -2
  173. package/dist/lib/sqlite.js +27 -4
  174. package/dist/lib/staleness/detectors/commands.js +1 -1
  175. package/dist/lib/staleness/detectors/workflows.js +13 -2
  176. package/dist/lib/staleness/writers/commands.js +7 -7
  177. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  178. package/dist/lib/startup/command-registry.d.ts +1 -0
  179. package/dist/lib/startup/command-registry.js +3 -0
  180. package/dist/lib/state.d.ts +1 -1
  181. package/dist/lib/state.js +1 -32
  182. package/dist/lib/teams/agents.d.ts +19 -10
  183. package/dist/lib/teams/agents.js +40 -39
  184. package/dist/lib/types.d.ts +37 -2
  185. package/dist/lib/usage.d.ts +33 -1
  186. package/dist/lib/usage.js +172 -12
  187. package/dist/lib/versions.d.ts +9 -2
  188. package/dist/lib/versions.js +37 -8
  189. package/dist/lib/watchdog/log.d.ts +43 -0
  190. package/dist/lib/watchdog/log.js +69 -0
  191. package/dist/lib/watchdog/routine.d.ts +44 -0
  192. package/dist/lib/watchdog/routine.js +69 -0
  193. package/dist/lib/watchdog/runner.d.ts +51 -7
  194. package/dist/lib/watchdog/runner.js +239 -64
  195. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  196. package/dist/lib/watchdog/watchdog.js +31 -16
  197. package/dist/lib/workflows.d.ts +16 -0
  198. package/dist/lib/workflows.js +110 -1
  199. package/package.json +1 -1
@@ -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
  /**
@@ -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();