@phnx-labs/agents-cli 1.22.46 → 1.22.49

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 (348) hide show
  1. package/CHANGELOG.md +663 -0
  2. package/LICENSE +102 -182
  3. package/README.md +96 -49
  4. package/dist/bootstrap.js +4 -2
  5. package/dist/cli/command-registry.d.ts +2 -1
  6. package/dist/cli/command-registry.js +6 -2
  7. package/dist/commands/accounts.d.ts +58 -0
  8. package/dist/commands/accounts.js +243 -25
  9. package/dist/commands/artifacts.js +7 -6
  10. package/dist/commands/attach.js +1 -1
  11. package/dist/commands/auth.js +7 -5
  12. package/dist/commands/browser-sessions-picker.js +9 -7
  13. package/dist/commands/browser.js +711 -62
  14. package/dist/commands/config.d.ts +0 -1
  15. package/dist/commands/config.js +26 -24
  16. package/dist/commands/cost.d.ts +1 -1
  17. package/dist/commands/daemon.d.ts +21 -0
  18. package/dist/commands/daemon.js +147 -12
  19. package/dist/commands/detach.d.ts +13 -0
  20. package/dist/commands/detach.js +11 -8
  21. package/dist/commands/exec.js +107 -53
  22. package/dist/commands/feed-watch.d.ts +2 -0
  23. package/dist/commands/feed-watch.js +28 -0
  24. package/dist/commands/feed.js +27 -0
  25. package/dist/commands/feedback.js +3 -19
  26. package/dist/commands/focus.d.ts +14 -0
  27. package/dist/commands/focus.js +48 -5
  28. package/dist/commands/go.d.ts +3 -0
  29. package/dist/commands/go.js +16 -3
  30. package/dist/commands/insights.d.ts +1 -1
  31. package/dist/commands/insights.js +2 -2
  32. package/dist/commands/inspect.js +2 -2
  33. package/dist/commands/lease.d.ts +1 -1
  34. package/dist/commands/lease.js +7 -6
  35. package/dist/commands/mailboxes.js +4 -3
  36. package/dist/commands/menubar.js +11 -4
  37. package/dist/commands/projects.d.ts +16 -1
  38. package/dist/commands/projects.js +58 -64
  39. package/dist/commands/resume.js +1 -1
  40. package/dist/commands/route.js +4 -4
  41. package/dist/commands/routines.js +4 -3
  42. package/dist/commands/run-broadcast.d.ts +1 -1
  43. package/dist/commands/run-broadcast.js +1 -1
  44. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  45. package/dist/commands/sessions-picker-factory.js +1 -1
  46. package/dist/commands/sessions-picker.d.ts +22 -0
  47. package/dist/commands/sessions-picker.js +48 -9
  48. package/dist/commands/sessions-stop.d.ts +24 -0
  49. package/dist/commands/sessions-stop.js +66 -0
  50. package/dist/commands/sessions-trace.d.ts +82 -0
  51. package/dist/commands/sessions-trace.js +387 -0
  52. package/dist/commands/sessions.js +5 -0
  53. package/dist/commands/setup-secrets.js +23 -0
  54. package/dist/commands/share.d.ts +20 -5
  55. package/dist/commands/share.js +152 -52
  56. package/dist/commands/ssh.d.ts +15 -0
  57. package/dist/commands/ssh.js +243 -18
  58. package/dist/commands/teams.js +34 -2
  59. package/dist/commands/traces.d.ts +2 -0
  60. package/dist/commands/traces.js +184 -0
  61. package/dist/commands/view.d.ts +2 -0
  62. package/dist/commands/view.js +32 -62
  63. package/dist/commands/watchdog.js +9 -14
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js +5 -0
  66. package/dist/lib/account-capabilities.d.ts +4 -0
  67. package/dist/lib/account-capabilities.js +13 -9
  68. package/dist/lib/account-registry.d.ts +2 -0
  69. package/dist/lib/account-registry.js +30 -2
  70. package/dist/lib/accounting/rotate.d.ts +9 -0
  71. package/dist/lib/accounting/rotate.js +12 -2
  72. package/dist/lib/accounting/usage.d.ts +84 -6
  73. package/dist/lib/accounting/usage.js +293 -75
  74. package/dist/lib/agent-modes.js +29 -1
  75. package/dist/lib/agent-spec/agents.d.ts +4 -3
  76. package/dist/lib/agent-spec/agents.js +25 -15
  77. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  78. package/dist/lib/analytics/mix-commands.js +2 -2
  79. package/dist/lib/auth-health.d.ts +13 -3
  80. package/dist/lib/auth-health.js +50 -8
  81. package/dist/lib/browser/devices.d.ts +19 -0
  82. package/dist/lib/browser/devices.js +23 -0
  83. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  84. package/dist/lib/browser/drivers/ssh.js +36 -9
  85. package/dist/lib/browser/ipc.d.ts +1 -1
  86. package/dist/lib/browser/ipc.js +40 -9
  87. package/dist/lib/browser/profiles.d.ts +104 -65
  88. package/dist/lib/browser/profiles.js +277 -165
  89. package/dist/lib/browser/registry.d.ts +39 -0
  90. package/dist/lib/browser/registry.js +149 -0
  91. package/dist/lib/browser/remote-control.d.ts +28 -2
  92. package/dist/lib/browser/remote-control.js +35 -3
  93. package/dist/lib/browser/resolve-target.d.ts +68 -0
  94. package/dist/lib/browser/resolve-target.js +250 -0
  95. package/dist/lib/browser/runtime-state.d.ts +44 -11
  96. package/dist/lib/browser/runtime-state.js +98 -15
  97. package/dist/lib/browser/service.d.ts +65 -9
  98. package/dist/lib/browser/service.js +196 -61
  99. package/dist/lib/browser/sessions-list.d.ts +1 -3
  100. package/dist/lib/browser/sessions-list.js +3 -16
  101. package/dist/lib/browser/task-index.d.ts +70 -0
  102. package/dist/lib/browser/task-index.js +180 -0
  103. package/dist/lib/browser/types.d.ts +12 -1
  104. package/dist/lib/byok-usage.js +2 -2
  105. package/dist/lib/claude-statusline.d.ts +29 -0
  106. package/dist/lib/claude-statusline.js +139 -0
  107. package/dist/lib/cloud/host.d.ts +1 -1
  108. package/dist/lib/cloud/host.js +1 -1
  109. package/dist/lib/cloud/rush.js +1 -1
  110. package/dist/lib/codex-policy.d.ts +11 -1
  111. package/dist/lib/codex-policy.js +20 -5
  112. package/dist/lib/computer/computer-rpc.js +2 -2
  113. package/dist/lib/computer/download.d.ts +15 -15
  114. package/dist/lib/computer/download.js +35 -98
  115. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  116. package/dist/lib/computer/ssh-tunnel.js +12 -22
  117. package/dist/lib/config-keys.d.ts +6 -11
  118. package/dist/lib/config-keys.js +18 -19
  119. package/dist/lib/crabbox/cli.d.ts +1 -1
  120. package/dist/lib/crabbox/cli.js +2 -2
  121. package/dist/lib/crabbox/lease.d.ts +9 -0
  122. package/dist/lib/crabbox/lease.js +70 -15
  123. package/dist/lib/crabbox/runtimes.js +1 -1
  124. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  125. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  126. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  127. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  128. package/dist/lib/daemon/daemon.d.ts +3 -0
  129. package/dist/lib/daemon/daemon.js +212 -444
  130. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  131. package/dist/lib/daemon/device-probe-service.js +47 -0
  132. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  133. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  134. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  135. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  136. package/dist/lib/daemon/runner.js +15 -2
  137. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  138. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  139. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  140. package/dist/lib/daemon/self-heal-service.js +48 -0
  141. package/dist/lib/daemon/service.d.ts +102 -0
  142. package/dist/lib/daemon/service.js +98 -0
  143. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  144. package/dist/lib/daemon/session-index-service.js +40 -0
  145. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  146. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  147. package/dist/lib/daemon/supervisor.d.ts +87 -0
  148. package/dist/lib/daemon/supervisor.js +298 -0
  149. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  150. package/dist/lib/daemon/watchdog-service.js +40 -0
  151. package/dist/lib/daemon-health.d.ts +15 -0
  152. package/dist/lib/daemon-health.js +29 -5
  153. package/dist/lib/daemon-services.d.ts +8 -1
  154. package/dist/lib/daemon-services.js +55 -0
  155. package/dist/lib/daemon-ticks.d.ts +13 -2
  156. package/dist/lib/daemon-ticks.js +22 -20
  157. package/dist/lib/device-config.d.ts +25 -10
  158. package/dist/lib/device-config.js +90 -39
  159. package/dist/lib/devices/config-migration.d.ts +7 -0
  160. package/dist/lib/devices/config-migration.js +67 -3
  161. package/dist/lib/devices/health.d.ts +27 -2
  162. package/dist/lib/devices/health.js +39 -9
  163. package/dist/lib/devices/interactive-host.d.ts +15 -0
  164. package/dist/lib/devices/interactive-host.js +53 -0
  165. package/dist/lib/devices/registry.d.ts +61 -4
  166. package/dist/lib/devices/registry.js +94 -38
  167. package/dist/lib/devices/stats-cache.d.ts +26 -0
  168. package/dist/lib/devices/stats-cache.js +59 -7
  169. package/dist/lib/devices/worker-pick.d.ts +48 -0
  170. package/dist/lib/devices/worker-pick.js +113 -0
  171. package/dist/lib/exec.d.ts +70 -14
  172. package/dist/lib/exec.js +110 -38
  173. package/dist/lib/feed/answer.d.ts +29 -0
  174. package/dist/lib/feed/answer.js +145 -0
  175. package/dist/lib/feed/attention.d.ts +83 -0
  176. package/dist/lib/feed/attention.js +0 -0
  177. package/dist/lib/feed/feed.d.ts +109 -0
  178. package/dist/lib/feed/feed.js +154 -3
  179. package/dist/lib/feed/pr-status.d.ts +14 -0
  180. package/dist/lib/feed/pr-status.js +47 -0
  181. package/dist/lib/feed/watch.d.ts +59 -0
  182. package/dist/lib/feed/watch.js +180 -0
  183. package/dist/lib/fleet/capture.js +6 -0
  184. package/dist/lib/fleet/remote-login.js +8 -3
  185. package/dist/lib/fleet/types.d.ts +17 -0
  186. package/dist/lib/harness/adapter.d.ts +11 -0
  187. package/dist/lib/harness/adapters/claude.js +38 -24
  188. package/dist/lib/harness/adapters/codex.js +2 -4
  189. package/dist/lib/harness/adapters/cursor.js +8 -11
  190. package/dist/lib/helper-download.d.ts +108 -0
  191. package/dist/lib/helper-download.js +194 -0
  192. package/dist/lib/helper-versions.d.ts +52 -0
  193. package/dist/lib/helper-versions.js +58 -0
  194. package/dist/lib/hooks/cache.js +19 -0
  195. package/dist/lib/hooks/install.js +2 -2
  196. package/dist/lib/hooks/match.d.ts +3 -0
  197. package/dist/lib/hooks/match.js +25 -0
  198. package/dist/lib/hosts/credentials.d.ts +1 -1
  199. package/dist/lib/hosts/credentials.js +1 -1
  200. package/dist/lib/hosts/dispatch.d.ts +7 -1
  201. package/dist/lib/hosts/dispatch.js +24 -5
  202. package/dist/lib/hosts/passthrough.d.ts +8 -15
  203. package/dist/lib/hosts/passthrough.js +35 -16
  204. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  205. package/dist/lib/hosts/providers/devices.js +1 -1
  206. package/dist/lib/hosts/reconnect.d.ts +179 -21
  207. package/dist/lib/hosts/reconnect.js +266 -45
  208. package/dist/lib/hosts/registry.js +7 -0
  209. package/dist/lib/installations/migrate.d.ts +4 -5
  210. package/dist/lib/installations/migrate.js +6 -7
  211. package/dist/lib/installations/shims.d.ts +1 -1
  212. package/dist/lib/installations/shims.js +23 -13
  213. package/dist/lib/installations/versions.js +25 -2
  214. package/dist/lib/linear-autoclose.d.ts +1 -1
  215. package/dist/lib/linear-autoclose.js +1 -1
  216. package/dist/lib/loop.d.ts +2 -2
  217. package/dist/lib/loop.js +1 -1
  218. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  219. package/dist/lib/menubar/download-menubar.js +60 -0
  220. package/dist/lib/menubar/install-menubar.d.ts +44 -7
  221. package/dist/lib/menubar/install-menubar.js +105 -23
  222. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  223. package/dist/lib/menubar/notify-desktop.js +6 -6
  224. package/dist/lib/model-tiers.d.ts +1 -1
  225. package/dist/lib/open-url.d.ts +60 -2
  226. package/dist/lib/open-url.js +167 -12
  227. package/dist/lib/project-probe.d.ts +1 -1
  228. package/dist/lib/project-probe.js +1 -1
  229. package/dist/lib/project-pull.d.ts +1 -1
  230. package/dist/lib/project-pull.js +2 -2
  231. package/dist/lib/projects.d.ts +1 -1
  232. package/dist/lib/projects.js +1 -1
  233. package/dist/lib/refresh-coordinator.d.ts +1 -1
  234. package/dist/lib/refresh-coordinator.js +1 -1
  235. package/dist/lib/sandbox.js +6 -7
  236. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  237. package/dist/lib/secrets/download-keychain.js +70 -0
  238. package/dist/lib/secrets/install-helper.d.ts +20 -1
  239. package/dist/lib/secrets/install-helper.js +44 -3
  240. package/dist/lib/session/active.d.ts +67 -0
  241. package/dist/lib/session/active.js +86 -2
  242. package/dist/lib/session/db.d.ts +11 -1
  243. package/dist/lib/session/db.js +142 -8
  244. package/dist/lib/session/discover.d.ts +23 -1
  245. package/dist/lib/session/discover.js +44 -10
  246. package/dist/lib/session/highlights.d.ts +49 -1
  247. package/dist/lib/session/highlights.js +95 -0
  248. package/dist/lib/session/host-link.d.ts +16 -2
  249. package/dist/lib/session/host-link.js +7 -1
  250. package/dist/lib/session/parse.js +33 -1
  251. package/dist/lib/session/prompt.d.ts +35 -0
  252. package/dist/lib/session/prompt.js +56 -0
  253. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  254. package/dist/lib/session/remote/remote-list.js +19 -2
  255. package/dist/lib/session/remote/watch.d.ts +1 -1
  256. package/dist/lib/session/remote/watch.js +17 -3
  257. package/dist/lib/session/render.js +7 -3
  258. package/dist/lib/session/session-cache.d.ts +53 -0
  259. package/dist/lib/session/session-cache.js +93 -1
  260. package/dist/lib/session/state.js +1 -1
  261. package/dist/lib/session/tool-calls.d.ts +2 -0
  262. package/dist/lib/session/tool-calls.js +1 -1
  263. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  264. package/dist/lib/session/trajectory-compare.js +116 -0
  265. package/dist/lib/session/trajectory-html.d.ts +19 -0
  266. package/dist/lib/session/trajectory-html.js +770 -0
  267. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  268. package/dist/lib/session/trajectory-lineage.js +195 -0
  269. package/dist/lib/session/trajectory-text.d.ts +37 -0
  270. package/dist/lib/session/trajectory-text.js +284 -0
  271. package/dist/lib/session/trajectory.d.ts +102 -0
  272. package/dist/lib/session/trajectory.js +384 -0
  273. package/dist/lib/session/types.d.ts +15 -0
  274. package/dist/lib/sha256-asset.d.ts +26 -0
  275. package/dist/lib/sha256-asset.js +41 -0
  276. package/dist/lib/share/backend.d.ts +95 -0
  277. package/dist/lib/share/backend.js +139 -0
  278. package/dist/lib/share/delete.d.ts +6 -2
  279. package/dist/lib/share/delete.js +8 -9
  280. package/dist/lib/share/html.d.ts +22 -0
  281. package/dist/lib/share/html.js +88 -0
  282. package/dist/lib/share/provision.d.ts +18 -4
  283. package/dist/lib/share/provision.js +38 -4
  284. package/dist/lib/share/publish.d.ts +39 -15
  285. package/dist/lib/share/publish.js +55 -26
  286. package/dist/lib/share/worker-template.js +171 -11
  287. package/dist/lib/sqlite.js +16 -3
  288. package/dist/lib/ssh-exec.d.ts +65 -0
  289. package/dist/lib/ssh-exec.js +120 -4
  290. package/dist/lib/startup/command-registry.d.ts +3 -1
  291. package/dist/lib/startup/command-registry.js +6 -3
  292. package/dist/lib/startup/dev-build.js +3 -3
  293. package/dist/lib/state.d.ts +2 -2
  294. package/dist/lib/state.js +11 -5
  295. package/dist/lib/subagents-registry.js +1 -1
  296. package/dist/lib/teams/agents.js +9 -0
  297. package/dist/lib/terminal/index.d.ts +1 -1
  298. package/dist/lib/terminal/inject.d.ts +19 -0
  299. package/dist/lib/terminal/inject.js +19 -9
  300. package/dist/lib/terminal/types.d.ts +1 -1
  301. package/dist/lib/terminal/types.js +1 -1
  302. package/dist/lib/tmux/orphan-reap.js +1 -1
  303. package/dist/lib/tmux/session.d.ts +46 -13
  304. package/dist/lib/tmux/session.js +204 -27
  305. package/dist/lib/traces/backend.d.ts +29 -0
  306. package/dist/lib/traces/backend.js +46 -0
  307. package/dist/lib/traces/classify.d.ts +52 -0
  308. package/dist/lib/traces/classify.js +72 -0
  309. package/dist/lib/traces/config.d.ts +10 -0
  310. package/dist/lib/traces/config.js +2 -0
  311. package/dist/lib/traces/provision.d.ts +19 -0
  312. package/dist/lib/traces/provision.js +30 -0
  313. package/dist/lib/traces/sync.d.ts +150 -0
  314. package/dist/lib/traces/sync.js +447 -0
  315. package/dist/lib/traces/worker-template.d.ts +4 -0
  316. package/dist/lib/traces/worker-template.js +159 -0
  317. package/dist/lib/types.d.ts +34 -19
  318. package/dist/lib/types.js +14 -0
  319. package/dist/lib/usage-backoff.d.ts +2 -1
  320. package/dist/lib/usage-backoff.js +60 -19
  321. package/dist/lib/usage-refresh.d.ts +18 -4
  322. package/dist/lib/usage-refresh.js +68 -19
  323. package/dist/lib/watchdog/log.d.ts +1 -1
  324. package/dist/lib/watchdog/log.js +1 -1
  325. package/dist/lib/watchdog/runner.d.ts +36 -45
  326. package/dist/lib/watchdog/runner.js +150 -119
  327. package/dist/lib/watchdog/service.d.ts +0 -1
  328. package/dist/lib/watchdog/service.js +0 -1
  329. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  330. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  331. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  332. package/dist/lib/watchdog/watchdog.js +37 -104
  333. package/dist/lib/workflows.d.ts +1 -1
  334. package/package.json +5 -7
  335. package/dist/commands/usage.d.ts +0 -27
  336. package/dist/commands/usage.js +0 -96
  337. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  338. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  339. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  340. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  341. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  342. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  343. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -24
  344. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  345. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -123
  346. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  347. package/dist/lib/usage-fleet.d.ts +0 -32
  348. package/dist/lib/usage-fleet.js +0 -125
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Device resource probing for `agents devices list`.
3
3
  *
4
- * One SSH round-trip per device gathers load average, memory pressure, and core
4
+ * One SSH round-trip per device gathers load average, memory pressure, disk, and core
5
5
  * count (mac + linux via a POSIX snippet, windows via a CIM one-liner), parsed
6
6
  * into a {@link DeviceStats}. Probes run in parallel with a bounded timeout so
7
7
  * the list stays responsive — a slow or hung box degrades to "no stats" instead
@@ -21,16 +21,20 @@ export const PROBE_TIMEOUT_MS = 2_500;
21
21
  * routinely blows the 2.5s POSIX budget on a relayed connection. */
22
22
  export const WIN_PROBE_TIMEOUT_MS = 6_000;
23
23
  const SEP = '---AGSTAT---';
24
- /** One-shot remote snapshot: load, then memory (mac vm_stat else linux
25
- * meminfo), then core count (linux nproc else mac hw.ncpu). */
26
- const PROBE_SNIPPET = `uptime; echo ${SEP}; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ${SEP}; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null)`;
24
+ /** One-shot remote snapshot: load, memory, core count, then root filesystem. */
25
+ export const PROBE_SNIPPET = `uptime; echo ${SEP}; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ${SEP}; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null); echo ${SEP}; df -Pk / 2>/dev/null | tail -1`;
27
26
  /** Windows equivalent, one labeled line via CIM. PowerShell 5.1-safe: no `||`
28
27
  * chaining, plain string concatenation. `LoadPercentage` is $null on some
29
28
  * hosts/VMs, which concatenates to an empty field — the parser treats that as
30
29
  * "no load signal" and headroom falls back to memory pressure alone.
31
30
  * wrapRemoteCommand base64-encodes this for powershell-shell devices, so the
32
31
  * quoting survives ssh intact. */
33
- const WIN_PROBE_SNIPPET = `$os = Get-CimInstance Win32_OperatingSystem; $cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; Write-Output ('AGWINSTAT load=' + $cpu + ' freeKb=' + $os.FreePhysicalMemory + ' totalKb=' + $os.TotalVisibleMemorySize + ' ncpu=' + $env:NUMBER_OF_PROCESSORS)`;
32
+ export const WIN_PROBE_SNIPPET = `$os = Get-CimInstance Win32_OperatingSystem; $cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; $disk = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='C:'"; Write-Output ('AGWINSTAT load=' + $cpu + ' freeKb=' + $os.FreePhysicalMemory + ' totalKb=' + $os.TotalVisibleMemorySize + ' ncpu=' + $env:NUMBER_OF_PROCESSORS + ' diskFreeKb=' + ($disk.FreeSpace / 1KB) + ' diskTotalKb=' + ($disk.Size / 1KB))`;
33
+ export function localProbeInvocation(platform) {
34
+ return platform === 'win32'
35
+ ? { file: 'powershell', args: ['-NoProfile', '-Command', WIN_PROBE_SNIPPET] }
36
+ : { file: 'sh', args: ['-c', PROBE_SNIPPET] };
37
+ }
34
38
  /** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
35
39
  export function fmtBytes(bytes) {
36
40
  if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0)
@@ -99,12 +103,28 @@ export function parseNcpu(out) {
99
103
  const n = parseInt(out.trim().split(/\s+/)[0] ?? '', 10);
100
104
  return Number.isFinite(n) && n > 0 ? { ncpu: n } : {};
101
105
  }
102
- /** Assemble a DeviceStats from the three snippet sections. */
106
+ /** Parse the final data row from `df -k /`, deriving usage from block counts. */
107
+ export function parseDf(out) {
108
+ const columns = out.trim().split(/\s+/);
109
+ if (columns.length < 4)
110
+ return {};
111
+ const totalKb = Number(columns[1]);
112
+ const freeKb = Number(columns[3]);
113
+ if (!Number.isFinite(totalKb) || totalKb <= 0 || !Number.isFinite(freeKb) || freeKb < 0)
114
+ return {};
115
+ return {
116
+ diskTotalBytes: totalKb * 1024,
117
+ diskFreeBytes: freeKb * 1024,
118
+ diskUsedPercent: Math.max(0, Math.min(100, ((totalKb - freeKb) / totalKb) * 100)),
119
+ };
120
+ }
121
+ /** Assemble a DeviceStats from the four snippet sections. */
103
122
  export function parseProbeOutput(host, stdout, fetchedAt) {
104
- const [uptimePart = '', memPart = '', ncpuPart = ''] = stdout.split(SEP);
123
+ const [uptimePart = '', memPart = '', ncpuPart = '', diskPart = ''] = stdout.split(SEP);
105
124
  const { loadAvg1 } = parseUptime(uptimePart);
106
125
  const mem = memPart.includes('MemTotal') ? parseLinuxMemInfo(memPart) : parseVmStat(memPart);
107
126
  const { ncpu } = parseNcpu(ncpuPart);
127
+ const disk = parseDf(diskPart);
108
128
  const loadPercent = loadAvg1 !== undefined && ncpu ? (loadAvg1 / ncpu) * 100 : undefined;
109
129
  return {
110
130
  host,
@@ -115,6 +135,8 @@ export function parseProbeOutput(host, stdout, fetchedAt) {
115
135
  memPercent: mem.memPercent,
116
136
  memTotalBytes: mem.memTotalBytes,
117
137
  memFreeBytes: mem.memFreeBytes,
138
+ ...disk,
139
+ specsFetchedAt: fetchedAt,
118
140
  fetchedAt,
119
141
  };
120
142
  }
@@ -122,13 +144,16 @@ export function parseProbeOutput(host, stdout, fetchedAt) {
122
144
  * (e.g. CIM unavailable) keeps `reachable: true` — ssh answered — with no
123
145
  * numbers, mirroring how garbage POSIX output degrades. */
124
146
  export function parseWinProbeOutput(host, stdout, fetchedAt) {
125
- const m = stdout.match(/AGWINSTAT load=([0-9.]*) freeKb=([0-9]+) totalKb=([0-9]+) ncpu=([0-9]+)/);
147
+ const m = stdout.match(/AGWINSTAT load=([0-9.]*) freeKb=([0-9]+) totalKb=([0-9]+) ncpu=([0-9]+)(?: diskFreeKb=([0-9.]+) diskTotalKb=([0-9.]+))?/);
126
148
  if (!m)
127
149
  return { host, reachable: true, fetchedAt };
128
150
  const loadPercent = m[1] === '' ? undefined : parseFloat(m[1]);
129
151
  const freeKb = parseInt(m[2], 10);
130
152
  const totalKb = parseInt(m[3], 10);
131
153
  const ncpu = parseInt(m[4], 10);
154
+ const diskFreeKb = m[5] === undefined ? undefined : Number(m[5]);
155
+ const diskTotalKb = m[6] === undefined ? undefined : Number(m[6]);
156
+ const hasDisk = diskTotalKb !== undefined && diskTotalKb > 0 && diskFreeKb !== undefined && diskFreeKb >= 0;
132
157
  return {
133
158
  host,
134
159
  reachable: true,
@@ -137,6 +162,10 @@ export function parseWinProbeOutput(host, stdout, fetchedAt) {
137
162
  memPercent: totalKb > 0 ? Math.max(0, Math.min(100, ((totalKb - freeKb) / totalKb) * 100)) : undefined,
138
163
  memTotalBytes: totalKb > 0 ? totalKb * 1024 : undefined,
139
164
  memFreeBytes: totalKb > 0 ? freeKb * 1024 : undefined,
165
+ diskTotalBytes: hasDisk ? diskTotalKb * 1024 : undefined,
166
+ diskFreeBytes: hasDisk ? diskFreeKb * 1024 : undefined,
167
+ diskUsedPercent: hasDisk ? Math.max(0, Math.min(100, ((diskTotalKb - diskFreeKb) / diskTotalKb) * 100)) : undefined,
168
+ specsFetchedAt: fetchedAt,
140
169
  fetchedAt,
141
170
  };
142
171
  }
@@ -208,8 +237,9 @@ export function probeDeviceStats(device, opts = {}) {
208
237
  export function probeLocalStats(host, opts = {}) {
209
238
  const fetchedAt = opts.now ?? Date.now();
210
239
  const isWin = process.platform === 'win32';
240
+ const invocation = localProbeInvocation(process.platform);
211
241
  return new Promise((resolve) => {
212
- execFile(isWin ? 'powershell' : 'sh', isWin ? ['-NoProfile', '-Command', WIN_PROBE_SNIPPET] : ['-c', PROBE_SNIPPET], { encoding: 'utf-8', timeout: opts.timeoutMs ?? (isWin ? WIN_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS) }, (err, stdout) => {
242
+ execFile(invocation.file, invocation.args, { encoding: 'utf-8', timeout: opts.timeoutMs ?? (isWin ? WIN_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS) }, (err, stdout) => {
213
243
  if (err || !stdout)
214
244
  return resolve({ host, reachable: false, fetchedAt });
215
245
  resolve(isWin ? parseWinProbeOutput(host, stdout, fetchedAt) : parseProbeOutput(host, stdout, fetchedAt));
@@ -0,0 +1,15 @@
1
+ /** The reserved `--device` value meaning the box the human is at. */
2
+ export declare const INTERACTIVE_DEVICE_SENTINEL = "interactive";
3
+ /** True when a host flag value is the interactive sentinel. */
4
+ export declare function isDeviceInteractive(value: string | undefined | null): boolean;
5
+ /**
6
+ * The device pinned as `interactive.host`, or null when unset.
7
+ *
8
+ * Callers MUST refuse on null rather than falling back to the local machine.
9
+ * The whole point of the sentinel is reaching a screen someone is watching;
10
+ * silently running on a headless worker instead is the exact failure it exists
11
+ * to prevent, and it would fail invisibly.
12
+ */
13
+ export declare function resolveInteractiveDevice(): string | null;
14
+ /** The actionable error for an unset pin — shared so every call site says the same thing. */
15
+ export declare function interactiveUnsetError(): string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The `interactive` device sentinel: "wherever the human is sitting".
3
+ *
4
+ * `--device auto` means "pick a box by load". This means the opposite — one
5
+ * specific box, the one whose screen a person is actually looking at, pinned as
6
+ * `interactive.host`.
7
+ *
8
+ * It exists because a skill cannot teach a host name. Guidance that says
9
+ * "deliver it to zion" is wrong on every other fleet and stale the moment the
10
+ * pin changes, so agents were left to infer the target or to skip the step. A
11
+ * fixed token is something a SKILL.md can state literally and have be correct
12
+ * everywhere.
13
+ *
14
+ * Resolution happens at the dispatch site, before `isSelfHost`, exactly like
15
+ * `auto` — so a pin naming this machine runs locally instead of self-SSHing, and
16
+ * the forwarded argv has the routing flag stripped, which is what keeps this
17
+ * from recursing.
18
+ */
19
+ import { getConfigValue } from '../device-config.js';
20
+ import { RESERVED_DEVICE_NAMES } from './registry.js';
21
+ /** The reserved `--device` value meaning the box the human is at. */
22
+ export const INTERACTIVE_DEVICE_SENTINEL = 'interactive';
23
+ /** True when a host flag value is the interactive sentinel. */
24
+ export function isDeviceInteractive(value) {
25
+ return typeof value === 'string' && value.trim().toLowerCase() === INTERACTIVE_DEVICE_SENTINEL;
26
+ }
27
+ /**
28
+ * The device pinned as `interactive.host`, or null when unset.
29
+ *
30
+ * Callers MUST refuse on null rather than falling back to the local machine.
31
+ * The whole point of the sentinel is reaching a screen someone is watching;
32
+ * silently running on a headless worker instead is the exact failure it exists
33
+ * to prevent, and it would fail invisibly.
34
+ */
35
+ export function resolveInteractiveDevice() {
36
+ const pinned = getConfigValue('interactive.host').value;
37
+ if (typeof pinned !== 'string' || !pinned.trim())
38
+ return null;
39
+ const host = pinned.trim();
40
+ // Defensive only. `interactive.host` rejects every reserved sentinel at WRITE
41
+ // time (assertValidDeviceName), so a pin of "interactive" or "auto" cannot be
42
+ // stored in the first place — which is the right layer, because refusing on
43
+ // read could only ever say "none is set" and send the user back to the command
44
+ // they just ran. This catches a config written by an older version.
45
+ if (RESERVED_DEVICE_NAMES.has(host.toLowerCase()))
46
+ return null;
47
+ return host;
48
+ }
49
+ /** The actionable error for an unset pin — shared so every call site says the same thing. */
50
+ export function interactiveUnsetError() {
51
+ return (`--device interactive needs an interactive host pinned, and none is set.\n` +
52
+ ` Set it on the machine you sit at: agents config set interactive.host <device>`);
53
+ }
@@ -1,3 +1,5 @@
1
+ import type { Meta } from '../types.js';
2
+ import type { IgnoredDeviceEntry } from '../fleet/types.js';
1
3
  /** Operating-system family of a device, used to pick the remote shell. */
2
4
  export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
3
5
  /** Remote shell dialect derived from the platform. */
@@ -84,8 +86,36 @@ export interface DeviceProfile {
84
86
  export declare function isDialableDevice(d: DeviceProfile): boolean;
85
87
  /** Map of device name to profile. */
86
88
  export type DeviceRegistry = Record<string, DeviceProfile>;
87
- /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
89
+ /**
90
+ * `--device` values that mean "resolve me", not "connect to a box with this
91
+ * name". A real device registered under one of these would be unreachable,
92
+ * and pinning `interactive.host` to one is a misconfiguration that has to fail
93
+ * at WRITE time — otherwise the read side can only report "none is set", which
94
+ * tells the user to run the command they just ran.
95
+ */
96
+ export declare const RESERVED_DEVICE_NAMES: Set<string>;
97
+ /**
98
+ * Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.).
99
+ *
100
+ * SHAPE ONLY. Safe on read paths, which must keep working for a name that is
101
+ * already registered — including one this version would refuse to create.
102
+ */
88
103
  export declare function assertValidDeviceName(name: string): void;
104
+ /**
105
+ * Throw if `name` cannot be used for a NEW device: bad shape, or a reserved
106
+ * routing sentinel.
107
+ *
108
+ * Deliberately separate from {@link assertValidDeviceName}, and called from
109
+ * exactly two kinds of place: `agents devices add <name>`, and the config keys
110
+ * that point AT a device (`interactive.host`).
111
+ *
112
+ * Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
113
+ * receive tailnet node names the user never typed — `devices sync` upserts every
114
+ * observed node in a loop with no per-node catch, so one node named `auto` would
115
+ * abort the entire sync and register nothing after it. A name the fleet OBSERVES
116
+ * is not a name anyone CHOSE; only the second kind is policy's business.
117
+ */
118
+ export declare function assertRegistrableDeviceName(name: string): void;
89
119
  /** Map a Tailscale `OS` field to our platform enum. */
90
120
  export declare function platformFromOs(os: string | undefined): DevicePlatform;
91
121
  /** The remote shell a platform speaks. */
@@ -132,12 +162,39 @@ export declare function upsertDevice(name: string, input: DeviceInput): Promise<
132
162
  export declare function writeReachability(updates: Record<string, DeviceReachability>): Promise<string[]>;
133
163
  /** Remove a device. Returns false if it was not registered. */
134
164
  export declare function removeDevice(name: string): Promise<boolean>;
135
- /** Load the set of ignored node names. Missing file => empty set. A malformed
136
- * file is a hard error for the same reason the registry is: silently returning
137
- * [] would let the next write wipe the user's dismissals. */
165
+ /**
166
+ * The ignore-list: tailscale node names the user explicitly dismissed from
167
+ * auto-discovery. A dismissed node is deliberately NOT a device (it never
168
+ * enters the registry), so it has no per-device doc — its home is the central,
169
+ * TRACKED `~/.agents/agents.yaml` under a `fleet.ignored` list, which syncs
170
+ * fleet-wide via `agents repo push/pull`: a dismissal on one box stops the
171
+ * suggestion on every box (RUSH-3062). Auto-discovery (`runDeviceSync`'s
172
+ * pending diff) subtracts this set, so an ignored node never re-surfaces as a
173
+ * suggestion. Writes go through `updateMeta` — the central-config write path
174
+ * (withMetaLock + atomic write) — so two agents writing concurrently cannot
175
+ * corrupt it, and no second lock is hand-rolled here.
176
+ */
177
+ export type { IgnoredDeviceEntry } from '../fleet/types.js';
178
+ /**
179
+ * The full ignore-list entries — who dismissed a node, when, and on which box —
180
+ * the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
181
+ * []. A malformed block is a hard error for the same reason the registry is:
182
+ * silently returning [] would let the next write wipe the user's dismissals.
183
+ */
184
+ export declare function loadIgnoredEntries(meta?: Meta): IgnoredDeviceEntry[];
185
+ /** Load the set of ignored node names. Same corruption contract as
186
+ * {@link loadIgnoredEntries}. */
138
187
  export declare function loadIgnored(): Promise<Set<string>>;
139
188
  /** True if `name` is on the ignore-list. */
140
189
  export declare function isIgnored(name: string): Promise<boolean>;
190
+ /**
191
+ * Union `names` into the meta's ignore-list, stamping new entries with
192
+ * `ignoredAt` and THIS machine's id. Existing entries keep their original
193
+ * who/when, so re-adding a name is a true no-op. Returns the input unchanged
194
+ * when no name is new. Exported for the one-shot legacy-store migration in
195
+ * lib/devices/config-migration.ts.
196
+ */
197
+ export declare function withIgnoredAdded(meta: Meta, names: string[], ignoredAt: string): Meta;
141
198
  /** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
142
199
  export declare function addIgnored(name: string): Promise<Set<string>>;
143
200
  /** Remove a node name from the ignore-list (un-ignore). Returns false if it was
@@ -17,8 +17,9 @@ import * as fs from 'fs/promises';
17
17
  import * as fsSync from 'fs';
18
18
  import * as path from 'path';
19
19
  import lockfile from 'proper-lockfile';
20
- import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
20
+ import { getDevicesRegistryPath, readMeta, updateMeta } from '../state.js';
21
21
  import { atomicWriteJsonSync } from '../fs-atomic.js';
22
+ import { machineId } from '../machine-id.js';
22
23
  /**
23
24
  * Whether a fan-out should dial this device, honouring the preference stated on
24
25
  * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
@@ -62,12 +63,46 @@ function registryPath() {
62
63
  /** Valid logical device name: the ssh-alias charset, so it renders into an
63
64
  * unambiguous `Host` stanza and is safe as an ssh target. */
64
65
  const DEVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
65
- /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
66
+ /**
67
+ * `--device` values that mean "resolve me", not "connect to a box with this
68
+ * name". A real device registered under one of these would be unreachable,
69
+ * and pinning `interactive.host` to one is a misconfiguration that has to fail
70
+ * at WRITE time — otherwise the read side can only report "none is set", which
71
+ * tells the user to run the command they just ran.
72
+ */
73
+ export const RESERVED_DEVICE_NAMES = new Set(['auto', 'interactive', 'all']);
74
+ /**
75
+ * Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.).
76
+ *
77
+ * SHAPE ONLY. Safe on read paths, which must keep working for a name that is
78
+ * already registered — including one this version would refuse to create.
79
+ */
66
80
  export function assertValidDeviceName(name) {
67
81
  if (!DEVICE_NAME_RE.test(name)) {
68
82
  throw new Error(`Invalid device name ${JSON.stringify(name)}. Use letters, digits, '.', '_', '-' (no spaces) — e.g. 'win-mini'.`);
69
83
  }
70
84
  }
85
+ /**
86
+ * Throw if `name` cannot be used for a NEW device: bad shape, or a reserved
87
+ * routing sentinel.
88
+ *
89
+ * Deliberately separate from {@link assertValidDeviceName}, and called from
90
+ * exactly two kinds of place: `agents devices add <name>`, and the config keys
91
+ * that point AT a device (`interactive.host`).
92
+ *
93
+ * Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
94
+ * receive tailnet node names the user never typed — `devices sync` upserts every
95
+ * observed node in a loop with no per-node catch, so one node named `auto` would
96
+ * abort the entire sync and register nothing after it. A name the fleet OBSERVES
97
+ * is not a name anyone CHOSE; only the second kind is policy's business.
98
+ */
99
+ export function assertRegistrableDeviceName(name) {
100
+ assertValidDeviceName(name);
101
+ if (RESERVED_DEVICE_NAMES.has(name.trim().toLowerCase())) {
102
+ throw new Error(`${JSON.stringify(name)} is a reserved --device value, not a device name. ` +
103
+ `Reserved: ${[...RESERVED_DEVICE_NAMES].join(', ')}.`);
104
+ }
105
+ }
71
106
  /** Map a Tailscale `OS` field to our platform enum. */
72
107
  export function platformFromOs(os) {
73
108
  switch ((os ?? '').toLowerCase()) {
@@ -246,55 +281,76 @@ export async function removeDevice(name) {
246
281
  return true;
247
282
  });
248
283
  }
249
- function ignoredPath() {
250
- return getDevicesIgnoredPath();
284
+ /**
285
+ * The full ignore-list entries — who dismissed a node, when, and on which box —
286
+ * the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
287
+ * []. A malformed block is a hard error for the same reason the registry is:
288
+ * silently returning [] would let the next write wipe the user's dismissals.
289
+ */
290
+ export function loadIgnoredEntries(meta = readMeta()) {
291
+ const raw = meta.fleet?.ignored;
292
+ if (raw === undefined)
293
+ return [];
294
+ if (!Array.isArray(raw) ||
295
+ raw.some((e) => !e ||
296
+ typeof e.name !== 'string' ||
297
+ typeof e.ignoredAt !== 'string' ||
298
+ typeof e.ignoredOn !== 'string')) {
299
+ throw new Error(`Device ignore-list corrupted in agents.yaml (fleet.ignored): expected a list of { name, ignoredAt, ignoredOn } entries. Inspect and repair ~/.agents/agents.yaml.`);
300
+ }
301
+ return raw;
251
302
  }
252
- /** Load the set of ignored node names. Missing file => empty set. A malformed
253
- * file is a hard error for the same reason the registry is: silently returning
254
- * [] would let the next write wipe the user's dismissals. */
303
+ /** Load the set of ignored node names. Same corruption contract as
304
+ * {@link loadIgnoredEntries}. */
255
305
  export async function loadIgnored() {
256
- const p = ignoredPath();
257
- let raw;
258
- try {
259
- raw = await fs.readFile(p, 'utf-8');
260
- }
261
- catch (err) {
262
- if (err && err.code === 'ENOENT')
263
- return new Set();
264
- throw err;
265
- }
266
- try {
267
- const parsed = JSON.parse(raw);
268
- return new Set(Array.isArray(parsed.ignored) ? parsed.ignored : []);
269
- }
270
- catch (err) {
271
- throw new Error(`Device ignore-list corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
272
- }
306
+ return new Set(loadIgnoredEntries().map((e) => e.name));
273
307
  }
274
308
  /** True if `name` is on the ignore-list. */
275
309
  export async function isIgnored(name) {
276
310
  return (await loadIgnored()).has(name);
277
311
  }
312
+ /**
313
+ * Union `names` into the meta's ignore-list, stamping new entries with
314
+ * `ignoredAt` and THIS machine's id. Existing entries keep their original
315
+ * who/when, so re-adding a name is a true no-op. Returns the input unchanged
316
+ * when no name is new. Exported for the one-shot legacy-store migration in
317
+ * lib/devices/config-migration.ts.
318
+ */
319
+ export function withIgnoredAdded(meta, names, ignoredAt) {
320
+ const entries = loadIgnoredEntries(meta); // throws on a corrupted block — never wipe it
321
+ const have = new Set(entries.map((e) => e.name));
322
+ const fresh = names.filter((n) => !have.has(n));
323
+ if (fresh.length === 0)
324
+ return meta;
325
+ const fleet = (meta.fleet ?? { devices: {} });
326
+ const ignored = [
327
+ ...entries,
328
+ ...fresh.map((name) => ({ name, ignoredAt, ignoredOn: machineId() })),
329
+ ].sort((a, b) => a.name.localeCompare(b.name));
330
+ const nextFleet = { ...fleet, ignored };
331
+ return { ...meta, fleet: nextFleet };
332
+ }
278
333
  /** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
279
334
  export async function addIgnored(name) {
280
335
  assertValidDeviceName(name);
281
- const p = ignoredPath();
282
- return withRegistryLock(p, async () => {
283
- const set = await loadIgnored();
284
- set.add(name);
285
- atomicWriteJsonSync(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
286
- return set;
287
- });
336
+ const meta = updateMeta((m) => withIgnoredAdded(m, [name], new Date().toISOString()));
337
+ return new Set(loadIgnoredEntries(meta).map((e) => e.name));
288
338
  }
289
339
  /** Remove a node name from the ignore-list (un-ignore). Returns false if it was
290
340
  * not ignored. */
291
341
  export async function removeIgnored(name) {
292
- const p = ignoredPath();
293
- return withRegistryLock(p, async () => {
294
- const set = await loadIgnored();
295
- if (!set.delete(name))
296
- return false;
297
- atomicWriteJsonSync(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
298
- return true;
342
+ let removed = false;
343
+ updateMeta((m) => {
344
+ const fleet = m.fleet;
345
+ if (!fleet?.ignored)
346
+ return m;
347
+ const entries = loadIgnoredEntries(m);
348
+ const next = entries.filter((e) => e.name !== name);
349
+ if (next.length === entries.length)
350
+ return m;
351
+ removed = true;
352
+ const nextFleet = { ...fleet, ignored: next };
353
+ return { ...m, fleet: nextFleet };
299
354
  });
355
+ return removed;
300
356
  }
@@ -8,8 +8,32 @@ import type { DeviceProfile } from './registry.js';
8
8
  * rewrites the cache, unreachable boxes included).
9
9
  */
10
10
  export declare const STATS_STALE_MS: number;
11
+ /** Static hardware totals are valid for seven days. */
12
+ export declare const SPECS_STALE_MS: number;
11
13
  /** True when a cached row is still within {@link STATS_STALE_MS}. */
12
14
  export declare function isFreshDeviceStats(stats: DeviceStats, now?: number): boolean;
15
+ /** True when cached core, RAM-total, and disk-total facts remain current. */
16
+ export declare function isFreshDeviceSpecs(stats: DeviceStats, now?: number): boolean;
17
+ /**
18
+ * Carry a device's last successfully-probed hardware facts onto a row whose
19
+ * probe just came back unreachable (RUSH-3096).
20
+ *
21
+ * A failed probe knows nothing about the box — `probeDeviceStats` resolves
22
+ * `{ host, reachable: false, fetchedAt }` — and that bare row then replaced the
23
+ * cached one, so `agents devices list` rendered an offline device with an empty
24
+ * `spec` cell and forgot its cores/RAM/disk until it came back up. Cores, total
25
+ * RAM, and root-disk capacity do not change while a machine is down, so they
26
+ * survive the failure; `loadPercent`, `memPercent`, and the free-byte counts are
27
+ * current-state readings and stay absent rather than render a stale number as
28
+ * live.
29
+ *
30
+ * `specsFetchedAt` keeps the moment the facts were actually observed while
31
+ * `fetchedAt` advances to this attempt, so the row reads "this hardware, seen
32
+ * then; unreachable, as of now" instead of backdating a reading that never
33
+ * happened. A reachable probe is returned untouched — its own reading is the
34
+ * truth, including a fact the box has stopped reporting.
35
+ */
36
+ export declare function retainHardwareFacts(probed: DeviceStats, prior: DeviceStats | undefined): DeviceStats;
13
37
  /** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
14
38
  export declare function readStatsCache(): Record<string, DeviceStats>;
15
39
  /**
@@ -30,6 +54,8 @@ export interface FleetStatsResult {
30
54
  export interface LoadFleetStatsOptions {
31
55
  /** Skip the cache and live-probe every device (the `--refresh`/`--live` path). */
32
56
  forceRefresh?: boolean;
57
+ /** Read only static hardware facts, whose cache lifetime is seven days. */
58
+ specsOnly?: boolean;
33
59
  /** Device name of THIS machine — always probed locally (no ssh), never cached-served. */
34
60
  selfName?: string;
35
61
  /** Injectable probes + cache IO for tests (default to the real ssh/local/disk ones). */
@@ -37,10 +37,55 @@ const CACHE_FILE = '.fleet-stats.json';
37
37
  * rewrites the cache, unreachable boxes included).
38
38
  */
39
39
  export const STATS_STALE_MS = 3 * 60_000;
40
+ /** Static hardware totals are valid for seven days. */
41
+ export const SPECS_STALE_MS = 7 * 24 * 60 * 60_000;
40
42
  /** True when a cached row is still within {@link STATS_STALE_MS}. */
41
43
  export function isFreshDeviceStats(stats, now = Date.now()) {
42
44
  return now - stats.fetchedAt <= STATS_STALE_MS;
43
45
  }
46
+ /** True when cached core, RAM-total, and disk-total facts remain current. */
47
+ export function isFreshDeviceSpecs(stats, now = Date.now()) {
48
+ const fetchedAt = stats.specsFetchedAt ?? stats.fetchedAt;
49
+ return now - fetchedAt <= SPECS_STALE_MS;
50
+ }
51
+ /**
52
+ * Carry a device's last successfully-probed hardware facts onto a row whose
53
+ * probe just came back unreachable (RUSH-3096).
54
+ *
55
+ * A failed probe knows nothing about the box — `probeDeviceStats` resolves
56
+ * `{ host, reachable: false, fetchedAt }` — and that bare row then replaced the
57
+ * cached one, so `agents devices list` rendered an offline device with an empty
58
+ * `spec` cell and forgot its cores/RAM/disk until it came back up. Cores, total
59
+ * RAM, and root-disk capacity do not change while a machine is down, so they
60
+ * survive the failure; `loadPercent`, `memPercent`, and the free-byte counts are
61
+ * current-state readings and stay absent rather than render a stale number as
62
+ * live.
63
+ *
64
+ * `specsFetchedAt` keeps the moment the facts were actually observed while
65
+ * `fetchedAt` advances to this attempt, so the row reads "this hardware, seen
66
+ * then; unreachable, as of now" instead of backdating a reading that never
67
+ * happened. A reachable probe is returned untouched — its own reading is the
68
+ * truth, including a fact the box has stopped reporting.
69
+ */
70
+ export function retainHardwareFacts(probed, prior) {
71
+ if (probed.reachable || !prior)
72
+ return probed;
73
+ const ncpu = probed.ncpu ?? prior.ncpu;
74
+ const memTotalBytes = probed.memTotalBytes ?? prior.memTotalBytes;
75
+ const diskTotalBytes = probed.diskTotalBytes ?? prior.diskTotalBytes;
76
+ if (ncpu === probed.ncpu &&
77
+ memTotalBytes === probed.memTotalBytes &&
78
+ diskTotalBytes === probed.diskTotalBytes) {
79
+ return probed;
80
+ }
81
+ return {
82
+ ...probed,
83
+ ncpu,
84
+ memTotalBytes,
85
+ diskTotalBytes,
86
+ specsFetchedAt: prior.specsFetchedAt ?? prior.fetchedAt,
87
+ };
88
+ }
44
89
  function cacheFilePath() {
45
90
  return path.join(getCacheDir(), CACHE_FILE);
46
91
  }
@@ -88,18 +133,22 @@ export async function loadFleetStats(devices, opts = {}) {
88
133
  const writeCache = opts.writeCache ?? writeStatsCache;
89
134
  const self = opts.selfName;
90
135
  const now = opts.now ?? Date.now();
91
- const cache = opts.forceRefresh ? {} : readCache();
136
+ // Always read the cache, even under --refresh. It is not *served* then, but it
137
+ // is the only record of each box's hardware, and a failed probe must not erase
138
+ // it — see {@link retainHardwareFacts}.
139
+ const cache = readCache();
92
140
  const stats = new Map();
93
141
  const toProbe = [];
94
142
  let servedFromCache = false;
95
143
  for (const d of devices) {
96
- if (d.name === self) {
144
+ if (d.name === self && !opts.specsOnly) {
97
145
  // This machine is always probed locally — cheap, no ssh, always live.
98
146
  toProbe.push(d);
99
147
  continue;
100
148
  }
101
- const cached = cache[d.name];
102
- if (cached && isFreshDeviceStats(cached, now)) {
149
+ const cached = opts.forceRefresh ? undefined : cache[d.name];
150
+ const cacheFresh = cached && (opts.specsOnly ? isFreshDeviceSpecs(cached, now) : isFreshDeviceStats(cached, now));
151
+ if (cached && cacheFresh) {
103
152
  stats.set(d.name, cached);
104
153
  servedFromCache = true;
105
154
  }
@@ -113,8 +162,11 @@ export async function loadFleetStats(devices, opts = {}) {
113
162
  const probed = await probeFleet(toProbe, { selfName: self });
114
163
  const fresh = {};
115
164
  for (const [name, s] of probed) {
116
- stats.set(name, s);
117
- fresh[name] = s;
165
+ // A box that just failed to answer keeps the hardware it was last seen
166
+ // with, so this call and the cache it writes both carry the spec.
167
+ const row = retainHardwareFacts(s, cache[name]);
168
+ stats.set(name, row);
169
+ fresh[name] = row;
118
170
  }
119
171
  if (Object.keys(fresh).length > 0)
120
172
  writeCache(fresh);
@@ -123,7 +175,7 @@ export async function loadFleetStats(devices, opts = {}) {
123
175
  // list (e.g. self not registered as an ssh target) — matches the old callers'
124
176
  // explicit local fallback.
125
177
  if (self && !stats.has(self)) {
126
- stats.set(self, await probeLocal(self));
178
+ stats.set(self, retainHardwareFacts(await probeLocal(self), cache[self]));
127
179
  }
128
180
  let oldest = null;
129
181
  for (const s of stats.values()) {
@@ -0,0 +1,48 @@
1
+ import { type DevicePlacementSignal } from '../teams/scheduler.js';
2
+ /** Why a candidate was dropped, for the fail-loud error message. */
3
+ export interface WorkerExclusion {
4
+ device: string;
5
+ reason: 'unreachable' | 'overloaded' | 'wrong-platform' | 'interactive';
6
+ }
7
+ export interface WorkerPickPlan {
8
+ /** The chosen device name. Never the local box unless it is an auto-pool member. */
9
+ device: string;
10
+ /** True when the pick IS this machine (the caller should run in place). */
11
+ isLocal: boolean;
12
+ candidates: Array<{
13
+ device: string;
14
+ loadPercent?: number;
15
+ headroom?: string;
16
+ }>;
17
+ excluded: WorkerExclusion[];
18
+ }
19
+ export interface WorkerPickOptions {
20
+ /** Restrict to these platforms. Defaults to POSIX (`linux`, `macos`). */
21
+ platforms?: string[];
22
+ /** Override the candidate pool (tests). */
23
+ eligibleHosts?: string[];
24
+ localMachine?: string;
25
+ /** Injected probe (tests). */
26
+ probe?: (pool: string[]) => Promise<Map<string, DevicePlacementSignal>>;
27
+ }
28
+ /**
29
+ * Platforms a bun/vitest offload can actually run on.
30
+ *
31
+ * Windows is excluded by policy, not by accident: `AGENTS.md` states Windows is
32
+ * not a required PR or ordinary-release platform and `tests.yml` runs it only as
33
+ * a `continue-on-error` post-merge smoke. Silently shipping the tree to
34
+ * `win-mini` would produce a confusing failure for a platform whose result
35
+ * nobody gates on.
36
+ */
37
+ export declare const POSIX_PLATFORMS: readonly ["linux", "macos"];
38
+ /**
39
+ * Resolve the least-loaded eligible worker.
40
+ *
41
+ * Throws — never returns a degraded answer. An offload helper that quietly
42
+ * decided "no worker, run here" would recreate exactly the failure the offload
43
+ * exists to prevent: the operator believes the work went to the fleet while
44
+ * their laptop is pinned. Callers surface the thrown message verbatim.
45
+ */
46
+ export declare function resolveWorkerDevice(opts?: WorkerPickOptions): Promise<WorkerPickPlan>;
47
+ /** The one no-worker message, naming every candidate and why it was dropped. */
48
+ export declare function formatNoWorkerError(excluded: WorkerExclusion[], platforms: Set<string>): string;