@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
@@ -18,10 +18,12 @@ import ora from 'ora';
18
18
  import { getCliVersion } from '../lib/version.js';
19
19
  import { readAndResolveBundleEnv } from '../lib/secrets/bundles.js';
20
20
  import { machineId } from '../lib/session/sync/config.js';
21
+ import { assertRegistrableDeviceName } from '../lib/devices/registry.js';
21
22
  import { isDeviceAuto, resolveDeviceAffinity } from '../lib/smart-launch.js';
23
+ import { isDeviceInteractive, resolveInteractiveDevice, interactiveUnsetError, } from '../lib/devices/interactive-host.js';
22
24
  import { registerFleetCaptureCommand } from './fleet-capture.js';
23
25
  import { registerFleetApplyAlias } from './apply.js';
24
- import { addIgnored, getDevice, loadDevices, loadIgnored, removeDevice, removeIgnored, upsertDevice, writeReachability, } from '../lib/devices/registry.js';
26
+ import { addIgnored, getDevice, loadDevices, loadIgnored, loadIgnoredEntries, removeDevice, removeIgnored, upsertDevice, writeReachability, } from '../lib/devices/registry.js';
25
27
  import { resolveDeviceProfile } from '../lib/devices/resolve-profile.js';
26
28
  import { collectReachabilityWriteBacks, deviceOnlineState } from '../lib/devices/reachability.js';
27
29
  import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson, } from '../lib/devices/tailscale.js';
@@ -47,7 +49,7 @@ import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
47
49
  import { listTasks, resolveTaskRef } from '../lib/hosts/tasks.js';
48
50
  import { reconcileRunningTasks } from '../lib/hosts/reconcile.js';
49
51
  import { stopDispatchedTask } from '../lib/hosts/dispatch.js';
50
- import { terminalWidth, truncateToWidth } from '../lib/session/width.js';
52
+ import { stringWidth, stripAnsi, terminalWidth, truncateToWidth } from '../lib/session/width.js';
51
53
  import { sshExecAsync } from '../lib/ssh-exec.js';
52
54
  import { ALL_AGENT_IDS } from '../lib/agents.js';
53
55
  import { collectLocalHarnessInventory, groupByAccount, renderAccountsMatrix, renderHarnessMatrix, } from '../lib/devices/harness-inventory.js';
@@ -113,30 +115,106 @@ function pctCell(v, width) {
113
115
  return chalk.yellow(s);
114
116
  return chalk.red(s);
115
117
  }
118
+ /** Floor for the `spec` column; the real width is measured from the rows.
119
+ *
120
+ * A fixed width cannot work here: `fmtBytes` emits an optional decimal, so a
121
+ * spec string runs from `8c 16G 256G` (11) to `10c 23.5G 460G` (14) depending
122
+ * purely on the hardware behind it. Anything narrower than the widest row
123
+ * pushes load/mem/disk/headroom out of alignment row-to-row AND against the
124
+ * header — which defeats the scannability this column exists for. */
125
+ const SPEC_WIDTH_MIN = 12;
126
+ /** The static hardware as one compact cell — `12c 64G 1T`: cores, total RAM,
127
+ * total root disk via fmtBytes. `—` only when no probe has ever seen the box.
128
+ *
129
+ * Deliberately NOT gated on `reachable`: hardware does not change while a
130
+ * machine is down, so an offline device keeps rendering the spec from its last
131
+ * successful probe (`retainHardwareFacts`, RUSH-3096) rather than blanking a
132
+ * fact that is still true. The volatile columns beside it — load, mem, disk
133
+ * used — stay `—`, and `fleetCapacity` still counts only reachable boxes, so
134
+ * a down machine never contributes to usable capacity.
135
+ *
136
+ * Unpadded; {@link renderDeviceTable} pads to the measured column width. */
137
+ function specText(stats) {
138
+ if (!stats?.ncpu)
139
+ return '—';
140
+ return `${stats.ncpu}c ${fmtBytes(stats.memTotalBytes)} ${fmtBytes(stats.diskTotalBytes)}`;
141
+ }
142
+ /** The widest spec string across the rows actually being rendered, floored at
143
+ * SPEC_WIDTH_MIN so a short fleet still lines up with the header. */
144
+ function specColumnWidth(names, statsMap) {
145
+ return Math.max(SPEC_WIDTH_MIN, ...names.map((n) => stringWidth(specText(statsMap.get(n)))));
146
+ }
147
+ /** Colour + pad one spec cell to the measured column width. */
148
+ function specCell(stats, width) {
149
+ const text = specText(stats);
150
+ return (text === '—' ? chalk.gray : chalk.greenBright)(text.padEnd(width));
151
+ }
152
+ /** The one-line `description` config value per device (absent when unset). */
153
+ function listDeviceDescriptions(names) {
154
+ const out = {};
155
+ for (const n of names) {
156
+ const v = getConfigValue('description', { device: n }).value;
157
+ if (typeof v === 'string' && v.length > 0)
158
+ out[n] = v;
159
+ }
160
+ return out;
161
+ }
162
+ /**
163
+ * Fit a device row to the terminal width. The description is the FIRST thing
164
+ * to truncate, then the role tag; the fixed columns (device, platform, spec,
165
+ * load/mem/disk numbers, headroom, and the ▸/★/←/relay markers) never
166
+ * truncate. `role` and `desc` arrive fully rendered (ansi + leading gap).
167
+ */
168
+ function fitDeviceRow(fixed, role, desc, width) {
169
+ const w = (s) => stringWidth(stripAnsi(s));
170
+ let over = w(fixed) + w(role) + w(desc) - width;
171
+ if (over > 0 && desc) {
172
+ // `desc` is ' ' + gray(text) — truncate the text, keep the gap.
173
+ const text = stripAnsi(desc).slice(2);
174
+ const budget = w(text) - over;
175
+ desc = budget > 0 ? ' ' + chalk.gray(truncateToWidth(text, budget)) : '';
176
+ over = w(fixed) + w(role) + w(desc) - width;
177
+ }
178
+ // A partial role word ("work…") reads as a bug — when it can't fit whole,
179
+ // it drops entirely. The numbers are never touched.
180
+ if (over > 0 && role)
181
+ role = '';
182
+ return fixed + role + desc;
183
+ }
116
184
  /**
117
185
  * Render the device list. When `statsMap` is provided, resource columns are
118
- * appended — normalized load, memory, a headroom badge, and (in `full` mode)
119
- * core count and free/total memory so it's obvious which boxes have room.
186
+ * appended — a `spec` cell (cores / total RAM / total disk), normalized load,
187
+ * memory and disk-used percentages, a headroom badge, and (in `full` mode)
188
+ * free/total memory — with the per-device role and `description` riding the
189
+ * tail, so it's obvious which boxes have room and what each box is for.
120
190
  * Without it (probe skipped) the classic reachability line is used. A fleet
121
191
  * capacity summary is appended whenever stats were gathered.
122
192
  */
123
- function renderDeviceTable(reg, names, self, statsMap, full = false, interactiveHost) {
193
+ export function renderDeviceTable(reg, names, self, statsMap, full = false, interactiveHost, opts = {}) {
124
194
  if (!statsMap) {
125
195
  const roles = listConfiguredDeviceRoles(names);
126
196
  return names.map((n) => deviceSummary(reg[n], n === self, undefined, n === interactiveHost, roles));
127
197
  }
128
198
  const deviceRoles = listConfiguredDeviceRoles(names);
199
+ const specWidth = specColumnWidth(names, statsMap);
200
+ const descriptions = listDeviceDescriptions(names);
201
+ const width = opts.width ?? terminalWidth();
129
202
  const lines = [];
130
203
  const head = ' ' +
131
204
  chalk.gray('device'.padEnd(16)) +
132
205
  chalk.gray('platform'.padEnd(8)) +
133
206
  ' ' +
134
- (full ? chalk.gray('cores'.padStart(6)) : '') +
207
+ chalk.gray('spec'.padEnd(specWidth)) +
135
208
  chalk.gray('load'.padStart(5)) +
136
209
  chalk.gray('mem'.padStart(6)) +
210
+ chalk.gray('disk'.padStart(5)) +
137
211
  (full ? ' ' + chalk.gray('free/total'.padEnd(12)) : '') +
138
212
  ' ' +
139
- chalk.gray('headroom');
213
+ chalk.gray('headroom') +
214
+ ' ' +
215
+ chalk.gray('role') +
216
+ ' ' +
217
+ chalk.gray('description');
140
218
  lines.push(head);
141
219
  for (const name of names) {
142
220
  // Effective profile: the operator's central config (ssh.*/platform/user)
@@ -147,18 +225,30 @@ function renderDeviceTable(reg, names, self, statsMap, full = false, interactive
147
225
  const label = isSelf ? chalk.bold.cyan(name.padEnd(16)) : chalk.bold(name.padEnd(16));
148
226
  const plat = String(d.platform).padEnd(8);
149
227
  const stats = statsMap.get(name);
228
+ const role = roleTag(name, deviceRoles);
229
+ const desc = descriptions[name] ? ' ' + chalk.gray(descriptions[name]) : '';
150
230
  // Prefer this run's live probe, then the written-back verdict, over the
151
231
  // stale tailscale.online snapshot — so a reachable box never renders
152
232
  // "offline" while its live load/mem sit one column over (RUSH-1965).
153
233
  const offline = deviceOnlineState(d, stats) === 'offline';
154
234
  if (offline) {
155
- lines.push(`${marker}${label}${plat} ${chalk.gray('offline')}`);
235
+ // The spec cell rides the offline row: hardware is what the box IS, not
236
+ // what it is doing, so it stays legible while the machine is down
237
+ // (RUSH-3096). The live columns are omitted rather than dashed — there is
238
+ // no current load/mem/disk to report for a box that did not answer.
239
+ //
240
+ // The explicit gap is load-bearing: `specCell` pads to the MEASURED
241
+ // column width, so the widest spec on the fleet pads to nothing and the
242
+ // marker would collide with it ("455Goffline"). An online row gets its
243
+ // separation for free from `pctCell`'s right-alignment; this one has no
244
+ // numeric cell to lean on.
245
+ lines.push(fitDeviceRow(`${marker}${label}${plat} ${specCell(stats, specWidth)} ${chalk.gray('offline')}`, role, desc, width));
156
246
  continue;
157
247
  }
158
248
  const relay = !isSelf && d.tailscale?.online && !d.tailscale.direct ? chalk.yellow(' relay') : '';
159
- const cores = full ? chalk.gray(String(stats?.ncpu ?? '—').padStart(6)) : '';
160
249
  const load = pctCell(stats?.loadPercent, 5);
161
250
  const mem = pctCell(stats?.memPercent, 6);
251
+ const disk = pctCell(stats?.diskUsedPercent, 5);
162
252
  const freeTotal = full
163
253
  ? ' ' +
164
254
  (stats?.reachable && stats.memTotalBytes
@@ -168,13 +258,21 @@ function renderDeviceTable(reg, names, self, statsMap, full = false, interactive
168
258
  const badge = HEADROOM_BADGE[headroom(stats)];
169
259
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
170
260
  const interactive = name === interactiveHost ? chalk.yellow(' ★ interactive') : '';
171
- lines.push(`${marker}${label}${plat} ${cores}${load}${mem}${freeTotal} ${badge}${relay}${here}${interactive}${roleTag(name, deviceRoles)}`);
261
+ lines.push(fitDeviceRow(`${marker}${label}${plat} ${specCell(stats, specWidth)}${load}${mem}${disk}${freeTotal} ${badge}${relay}${here}${interactive}`, role, desc, width));
172
262
  }
173
- // Fleet capacity summary — total cores + how much RAM is free right now.
263
+ // Fleet capacity summary — total cores, how much RAM is free right now, and
264
+ // total free root disk across the reachable fleet.
174
265
  const cap = fleetCapacity(statsMap.values());
175
266
  if (cap.reachable > 0) {
176
267
  const freePct = cap.memTotalBytes > 0 ? Math.round((cap.memFreeBytes / cap.memTotalBytes) * 100) : 0;
177
- lines.push(chalk.gray(` Fleet capacity: ${cap.cores} cores · ${fmtBytes(cap.memFreeBytes)} free / ${fmtBytes(cap.memTotalBytes)} RAM (${freePct}% free) across ${cap.reachable} reachable device${cap.reachable === 1 ? '' : 's'}`));
268
+ let diskFreeBytes = 0;
269
+ for (const s of statsMap.values())
270
+ if (s.reachable)
271
+ diskFreeBytes += s.diskFreeBytes ?? 0;
272
+ lines.push(chalk.gray(` Fleet capacity: ${cap.cores} cores · ${fmtBytes(cap.memFreeBytes)} free / ${fmtBytes(cap.memTotalBytes)} RAM (${freePct}% free) · ${fmtBytes(diskFreeBytes)} disk free across ${cap.reachable} reachable device${cap.reachable === 1 ? '' : 's'}`));
273
+ }
274
+ if (opts.ignoredCount) {
275
+ lines.push(chalk.gray(` ${opts.ignoredCount} ignored node${opts.ignoredCount === 1 ? '' : 's'} not listed — 'agents devices ignored'`));
178
276
  }
179
277
  return lines;
180
278
  }
@@ -975,6 +1073,7 @@ function registerDevicesCommands(program) {
975
1073
  agents devices sync # pick which tailscale nodes to keep (TTY)
976
1074
  agents devices sync --yes # register all non-ignored nodes
977
1075
  agents devices ignore ipad165 # dismiss a node so it's never re-suggested
1076
+ agents devices ignored # list dismissed nodes (when / which machine)
978
1077
 
979
1078
  Inspect:
980
1079
  agents devices list # what's registered (★ = interactive host)
@@ -990,6 +1089,7 @@ function registerDevicesCommands(program) {
990
1089
  agents devices config win-mini ssh.auth password
991
1090
  agents devices config worker ssh.identity-file ~/.ssh/worker_ed25519
992
1091
  agents devices config mac-mini auto-launch.enabled off
1092
+ agents devices describe mark-1 "gpu box — cuda 12.4" # one-line purpose, shown in the list
993
1093
  agents devices config mac-mini interactive.host zion # where agents show YOU artifacts
994
1094
  agents devices render --write # write ~/.ssh/config.d/agents include
995
1095
 
@@ -1002,10 +1102,10 @@ function registerDevicesCommands(program) {
1002
1102
  registerLeaseCommand(devicesCmd);
1003
1103
  registerSnapshotCommand(devicesCmd);
1004
1104
  registerCommandGroups(devicesCmd, [
1005
- { title: 'Discover & register', names: ['sync', 'register', 'add', 'ignore', 'unignore', 'rm'] },
1105
+ { title: 'Discover & register', names: ['sync', 'register', 'add', 'ignore', 'unignore', 'ignored', 'remove'] },
1006
1106
  { title: 'Inspect', names: ['list', 'show', 'status', 'ping', 'harnesses', 'accounts', 'snapshot'] },
1007
1107
  { title: 'Disposable devices', names: ['lease'] },
1008
- { title: 'Configure a device', names: ['config', 'render'] },
1108
+ { title: 'Configure a device', names: ['config', 'describe', 'render'] },
1009
1109
  { title: 'Fleet operations', names: ['update', 'run', 'login', 'capture', 'apply'] },
1010
1110
  ]);
1011
1111
  devicesCmd
@@ -1085,6 +1185,47 @@ function registerDevicesCommands(program) {
1085
1185
  setDeviceDiscoveryStatus(name, undefined);
1086
1186
  console.log(chalk.green(`No longer ignoring '${name}'`) + chalk.gray(' — run `agents devices sync` to register it.'));
1087
1187
  });
1188
+ const ignoredCmd = devicesCmd
1189
+ .command('ignored')
1190
+ .description('List dismissed tailscale nodes — what was dismissed, when, and on which machine.')
1191
+ .option('--json', 'output machine-readable JSON')
1192
+ .action((opts) => {
1193
+ try {
1194
+ const entries = loadIgnoredEntries();
1195
+ if (opts.json) {
1196
+ process.stdout.write(JSON.stringify(entries, null, 2) + '\n');
1197
+ return;
1198
+ }
1199
+ if (entries.length === 0) {
1200
+ console.log(chalk.gray("No ignored nodes. 'agents devices ignore <name>' dismisses one — it won't be re-suggested."));
1201
+ return;
1202
+ }
1203
+ console.log(chalk.bold(`Ignored nodes (${entries.length})`));
1204
+ for (const e of entries) {
1205
+ const age = formatCheckedAge(Date.parse(e.ignoredAt));
1206
+ console.log(` ${chalk.bold(e.name.padEnd(24))} ${chalk.gray(`${age} · dismissed on ${e.ignoredOn}`)}`);
1207
+ }
1208
+ console.log(chalk.gray("Undo one with 'agents devices unignore <name>'."));
1209
+ }
1210
+ catch (err) {
1211
+ console.error(chalk.red(err.message));
1212
+ process.exit(1);
1213
+ }
1214
+ });
1215
+ setHelpSections(ignoredCmd, {
1216
+ examples: `
1217
+ agents devices ignored # what was dismissed, when, and on which machine
1218
+ agents devices ignored --json # machine-readable [{ name, ignoredAt, ignoredOn }]
1219
+ agents devices unignore old-laptop # undo a dismissal
1220
+ `,
1221
+ notes: `
1222
+ Dismissals live in the tracked central agents.yaml (fleet.ignored) and
1223
+ sync with 'agents repo push/pull', so a node dismissed on one box stays
1224
+ dismissed everywhere. An ignored node is not a device — it never enters
1225
+ the registry, so 'agents devices list' never shows it; this command is
1226
+ where dismissals are visible.
1227
+ `,
1228
+ });
1088
1229
  // ─── devices config (unified settings surface) ────────────────────────────
1089
1230
  //
1090
1231
  // ONE command for every per-device setting: `agents devices config <name>
@@ -1461,6 +1602,8 @@ function registerDevicesCommands(program) {
1461
1602
  `,
1462
1603
  notes: `
1463
1604
  Keys: role (worker|personal), see 'agents devices role',
1605
+ description (one line saying what the box is for — see
1606
+ 'agents devices describe'; renders in the devices list tail),
1464
1607
  agents.max-concurrent, scheduler.enabled, daemon.enabled,
1465
1608
  watchdog.enabled, tmux.enabled, browser.remote-control,
1466
1609
  browser.task-idle-minutes, browser.profile,
@@ -1529,6 +1672,40 @@ function registerDevicesCommands(program) {
1529
1672
  box stays excluded, since that is what the mark is for.
1530
1673
  `,
1531
1674
  });
1675
+ const describeCmd = devicesCmd
1676
+ .command('describe <name> [text...]')
1677
+ .description('Show or set the one-line description of what a device is FOR ("gpu box — cuda 12.4"). ' +
1678
+ 'Rendered as the tail column of `agents devices list` and synced fleet-wide. ' +
1679
+ 'Same key as `agents devices config <name> description` — one store, two names.')
1680
+ .option('--unset', 'remove the description (the device falls back to the fleet default / unset)')
1681
+ .option('--json', 'output machine-readable JSON')
1682
+ .action(async (name, textParts, opts) => {
1683
+ try {
1684
+ // Thin sugar over the 'description' config key — the same engine that
1685
+ // backs `agents devices config <name> description`, not a second path.
1686
+ await runDevicesConfig(name, 'description', textParts ?? [], opts);
1687
+ }
1688
+ catch (err) {
1689
+ console.error(chalk.red(err.message));
1690
+ process.exit(1);
1691
+ }
1692
+ });
1693
+ setHelpSections(describeCmd, {
1694
+ examples: `
1695
+ agents devices describe mark-1 "gpu box — cuda 12.4" # set it (80 chars, one line)
1696
+ agents devices describe mark-1 # read it back
1697
+ agents devices describe mark-1 --unset # remove it
1698
+ agents devices describe mark-1 --json # machine-readable
1699
+ agents devices config mark-1 description "gpu box" # equivalent — same store
1700
+ `,
1701
+ notes: `
1702
+ The description is the device-scope 'description' config key: stored in
1703
+ the tracked per-device doc (devices/<name>/agents.yaml config: block),
1704
+ synced fleet-wide with 'agents repo push/pull', and rendered as the tail
1705
+ column of 'agents devices list'. It replaces on each set — for appended
1706
+ long-form scratch use 'agents devices config <name> notes "…"'.
1707
+ `,
1708
+ });
1532
1709
  /** Deprecation notice for a retired subcommand — STDERR only, so a --json consumer's stdout stays parseable. */
1533
1710
  const configTombstoneNotice = (retired, replacement) => {
1534
1711
  console.error(chalk.yellow(`Deprecated: "agents devices ${retired}" is now "agents devices ${replacement}". Running that for you.\n`));
@@ -1753,6 +1930,7 @@ function registerDevicesCommands(program) {
1753
1930
  process.stdout.write(JSON.stringify(names.map((name) => {
1754
1931
  const config = deviceConfigJson(name);
1755
1932
  const health = statsMap?.get(name);
1933
+ const description = getConfigValue('description', { device: name }).value;
1756
1934
  return {
1757
1935
  ...resolveDeviceProfile(reg[name]),
1758
1936
  interactive: name === interactiveHost,
@@ -1760,6 +1938,7 @@ function registerDevicesCommands(program) {
1760
1938
  // marked (absent when unmarked), `autoPool` is the answer that
1761
1939
  // matters to a caller — may `--device auto` pick this box.
1762
1940
  ...(jsonRoles[name] ? { role: jsonRoles[name] } : {}),
1941
+ ...(typeof description === 'string' && description ? { description } : {}),
1763
1942
  autoPool: autoPool.has(name),
1764
1943
  ...(config ? { config } : {}),
1765
1944
  ...(health ? { health: { ...health, headroom: headroom(health) } } : {}),
@@ -1768,7 +1947,10 @@ function registerDevicesCommands(program) {
1768
1947
  return;
1769
1948
  }
1770
1949
  console.log(chalk.bold(`Devices (${names.length})`));
1771
- for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full, interactiveHost))
1950
+ // Dismissed nodes are not devices (never in the registry) surface their
1951
+ // count under the table so a "missing" node is explainable from the list.
1952
+ const ignoredCount = loadIgnoredEntries().length;
1953
+ for (const line of renderDeviceTable(reg, names, self, statsMap, opts.full, interactiveHost, { ignoredCount }))
1772
1954
  console.log(line);
1773
1955
  if (freshness?.servedFromCache && freshness.oldestFetchedAt != null) {
1774
1956
  console.log(chalk.gray(` updated ${formatCheckedAge(freshness.oldestFetchedAt)} — pass --refresh (--live) for a live probe`));
@@ -1789,7 +1971,7 @@ function registerDevicesCommands(program) {
1789
1971
  devicesCmd
1790
1972
  .command('list')
1791
1973
  .alias('ls')
1792
- .description('List registered devices with platform, address, reachability, and live resource headroom.')
1974
+ .description('List registered devices with platform, spec (cores/RAM/disk), live load/mem/disk headroom, role, and description.')
1793
1975
  .option('--json', 'output effective device profiles, config, and health as a JSON array')
1794
1976
  .option('--no-stats', 'skip the live resource probe (instant; names/addresses only)')
1795
1977
  .option('--refresh', 'force a live probe of every device, bypassing the cache')
@@ -1832,6 +2014,36 @@ function registerDevicesCommands(program) {
1832
2014
  await runFleetPing({ ...opts, verbose });
1833
2015
  });
1834
2016
  const csvList = (s) => s ? s.split(',').map((x) => x.trim()).filter(Boolean) : undefined;
2017
+ devicesCmd
2018
+ .command('pick')
2019
+ .description('Print the device automatic placement would choose for offloaded machine work (the suite, a build) — least-loaded, reachable, POSIX, never the box you are sitting at. Writes just the name to stdout so scripts can consume it.')
2020
+ .option('--json', 'output the pick plus every candidate and exclusion reason')
2021
+ .option('--platform <list>', 'comma-separated platforms to allow (default: linux,macos)')
2022
+ .action(async (opts) => {
2023
+ const { resolveWorkerDevice } = await import('../lib/devices/worker-pick.js');
2024
+ let plan;
2025
+ try {
2026
+ plan = await resolveWorkerDevice({ platforms: csvList(opts.platform) });
2027
+ }
2028
+ catch (err) {
2029
+ // Fail loud with the resolver's own message. Never print a device name
2030
+ // we did not actually pick: a caller reading stdout would ship a tree to it.
2031
+ console.error(err instanceof Error ? err.message : String(err));
2032
+ process.exitCode = 1;
2033
+ return;
2034
+ }
2035
+ if (opts.json) {
2036
+ console.log(JSON.stringify(plan, null, 2));
2037
+ return;
2038
+ }
2039
+ // stdout is the name alone (`box="$(agents devices pick)"`); the human
2040
+ // detail goes to stderr so it never pollutes that capture.
2041
+ const detail = plan.candidates
2042
+ .map((c) => `${c.device}:${c.loadPercent === undefined ? '?' : `${Math.round(c.loadPercent)}%`}`)
2043
+ .join(' ');
2044
+ console.error(chalk.gray(`[agents] worker=${plan.device}${plan.isLocal ? ' (this machine)' : ''} candidates: ${detail}`));
2045
+ console.log(plan.device);
2046
+ });
1835
2047
  const harnessInvOpts = (opts) => ({
1836
2048
  agents: csvList(opts.agents),
1837
2049
  devices: csvList(opts.device ?? opts.devices),
@@ -1924,6 +2136,10 @@ email) into a single row. Use \`agents devices harnesses\` for the per-install v
1924
2136
  .option('--platform <platform>', 'windows | linux | macos')
1925
2137
  .action(async (name, target, opts) => {
1926
2138
  try {
2139
+ // The one place a device name is CHOSEN rather than observed, so the one
2140
+ // place the reserved-sentinel policy belongs. upsertDevice itself stays
2141
+ // shape-only — `devices sync` feeds it tailnet node names in a loop.
2142
+ assertRegistrableDeviceName(name);
1927
2143
  const { host, user } = splitUserHost(target);
1928
2144
  const isIp = /^\d{1,3}(\.\d{1,3}){3}$/.test(host);
1929
2145
  const d = await upsertDevice(name, {
@@ -1987,8 +2203,8 @@ email) into a single row. Use \`agents devices harnesses\` for the per-install v
1987
2203
  }
1988
2204
  });
1989
2205
  devicesCmd
1990
- .command('rm <name>')
1991
- .alias('remove')
2206
+ .command('remove <name>')
2207
+ .alias('rm')
1992
2208
  .description('Remove a device from the registry.')
1993
2209
  .action(async (name) => {
1994
2210
  const ok = await removeDevice(name);
@@ -2190,6 +2406,15 @@ the target when it exists, else the remote home. Same portable-cwd rule as
2190
2406
  // nothing to resolve "self" to, and mis-report the pick as "Unknown
2191
2407
  // device").
2192
2408
  let target = name;
2409
+ if (isDeviceInteractive(name)) {
2410
+ const pinned = resolveInteractiveDevice();
2411
+ if (!pinned) {
2412
+ console.error(chalk.red(interactiveUnsetError()));
2413
+ process.exit(1);
2414
+ }
2415
+ process.stderr.write(chalk.gray(`[agents] device=interactive → ${pinned}\n`));
2416
+ target = pinned;
2417
+ }
2193
2418
  if (isDeviceAuto(name)) {
2194
2419
  const plan = resolveDeviceAffinity({});
2195
2420
  if (!plan.host) {
@@ -18,6 +18,7 @@ import { createTeam, ensureTeam, getTeam, loadTeams, removeTeam, teamExists, } f
18
18
  import { setHelpSections } from '../lib/help.js';
19
19
  import { createWorktree, commitsBehindDefault, isGitRepo, hasUncommittedChanges, removeWorktree, worktreeCheckoutExists, worktreeExists, } from '../lib/teams/worktree.js';
20
20
  import { resolveHost } from '../lib/hosts/registry.js';
21
+ import { isDeviceInteractive, resolveInteractiveDevice, interactiveUnsetError, } from '../lib/devices/interactive-host.js';
21
22
  import { isDeviceAuto, resolveDeviceAuto } from '../lib/smart-launch.js';
22
23
  import { sshTargetFor } from '../lib/hosts/types.js';
23
24
  import { ensureHostReady } from '../lib/hosts/ready.js';
@@ -1369,7 +1370,22 @@ export function registerTeamsCommands(program) {
1369
1370
  // trim, drop blanks, dedupe (preserving first-seen order).
1370
1371
  const rawPool = [opts.devices, opts.hosts].filter(Boolean).join(',');
1371
1372
  const devices = [];
1372
- for (const d of rawPool.split(',').map((s) => s.trim()).filter(Boolean)) {
1373
+ for (const raw of rawPool.split(',').map((s) => s.trim()).filter(Boolean)) {
1374
+ // Resolve `interactive` to the concrete device HERE, the same way the
1375
+ // single `--device` pin does. A pool is a set of specific machines the
1376
+ // scheduler picks between, so persisting the sentinel would let the
1377
+ // pool's membership change silently the next time `interactive.host`
1378
+ // is re-pinned — and every later friction message would quote
1379
+ // "interactive" instead of a box the user can act on.
1380
+ let d = raw;
1381
+ if (isDeviceInteractive(d)) {
1382
+ const pinned = resolveInteractiveDevice();
1383
+ if (!pinned) {
1384
+ dieFriction('teams', 'pool-device-interactive-unset', interactiveUnsetError());
1385
+ }
1386
+ process.stderr.write(chalk.gray(`[teams] pool device=interactive → ${pinned}\n`));
1387
+ d = pinned;
1388
+ }
1373
1389
  if (!devices.includes(d))
1374
1390
  devices.push(d);
1375
1391
  }
@@ -1444,7 +1460,7 @@ export function registerTeamsCommands(program) {
1444
1460
  .alias('a')
1445
1461
  .description("Add a teammate to work on a task. Runs in background; returns immediately. Use 'status' to check in.")
1446
1462
  .option('-n, --name <name>', 'Friendly name for this teammate (e.g. alice). Required if using --after. Unique within team.')
1447
- .option('-m, --mode <mode>', `Permissions: plan (read-only) | edit (can write files) | auto (smart classifier auto-approves safe ops) | skip (bypass all permission prompts). 'full' accepted as alias for skip. Teammates run headless: plan works headless on claude/codex/cursor/droid/opencode; kimi/grok/antigravity have no headless plan mode and auto-downgrade a plan request to auto.`, 'edit')
1463
+ .option('-m, --mode <mode>', `Permissions: plan (read-only) | edit (can write files) | auto (more autonomous than edit, mechanism per-harness see 'agents modes <agent>') | skip (bypass all permission prompts). 'full' accepted as alias for skip. Teammates run headless: plan works headless on claude/codex/cursor/droid/opencode; kimi/grok/antigravity have no headless plan mode and auto-downgrade a plan request to auto.`, 'edit')
1448
1464
  .option('-e, --effort <effort>', `Reasoning intensity: ${VALID_EFFORTS.join('|')}`, 'medium')
1449
1465
  .option('--model <model>', 'Cost tier (cheap|default|best|ultra) or a concrete id (e.g. claude-opus-4-8); tiers resolve per harness+version to a supported model')
1450
1466
  .option('--env <key=value>', 'Set an environment variable for this teammate (repeatable for multiple vars)', (val, prev) => [...prev, val], [])
@@ -1507,6 +1523,22 @@ export function registerTeamsCommands(program) {
1507
1523
  process.stderr.write(chalk.gray(`[teams] device=auto → ${picked === machineId() ? 'local' : picked}\n`));
1508
1524
  explicitDevice = picked;
1509
1525
  }
1526
+ // `interactive` needs the same up-front resolution, and for a sharper
1527
+ // reason: `teams add`/`create` bail out of the fleet passthrough
1528
+ // (passthrough.ts, the teams subcommand guard), so the sentinel block
1529
+ // there never runs and this is the only resolver. Left literal, the
1530
+ // local-machine check below compares 'interactive' against the machine id,
1531
+ // takes the remote branch on the pinned box itself, and pins the teammate
1532
+ // to an SSH hop into this same machine — skipping three local preflights
1533
+ // on the way.
1534
+ if (explicitDevice && isDeviceInteractive(explicitDevice)) {
1535
+ const pinned = resolveInteractiveDevice();
1536
+ if (!pinned) {
1537
+ dieFriction('teams', 'device-interactive-unset', interactiveUnsetError());
1538
+ }
1539
+ process.stderr.write(chalk.gray(`[teams] device=interactive → ${pinned === machineId() ? 'local' : pinned}\n`));
1540
+ explicitDevice = pinned;
1541
+ }
1510
1542
  // Distributed teams: --device <name> PINS this teammate to a machine over
1511
1543
  // SSH. Resolve + validate the placement here so a bad target fails at `add`
1512
1544
  // time, not silently at launch. Persisted (hostName/hostTarget/repoPath) so
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerTracesCommands(program: Command): void;