@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
package/README.md CHANGED
@@ -270,10 +270,15 @@ On a terminal, `agents sessions --active` (and a bare `agents sessions`) open th
270
270
  | `d` | device (cycles) | `--device` |
271
271
  | `p` | this repo ↔ all dirs | `--all` |
272
272
  | `w` | time window | `--since` |
273
+ | `tab` | toggle the preview pane | — |
273
274
  | `⏎` | resume / attach | `resume` / `focus` |
274
275
  | `y` | copy the equivalent command | `--print-cmd` |
275
276
 
276
- Filters **stack** (they AND together), the active set shows in the header, and the highlighted row previews below. Because every hotkey has a flag, the view you build by hand is a real command: press `y` (or run `--print-cmd`) to get the exact `ag sessions …` line — explore interactively, hand the line to an agent. Piped output, `--json`, or `--no-interactive` keep the plain listing for scripts. Peek without opening the pager with `agents sessions <id> --preview`.
277
+ Filters **stack** (they AND together), the active set shows in the header, and the highlighted row **previews below by default** (`tab` hides it) — prompt, activity, last response, plus a links line where the worked-on ticket and the PR the session opened are **clickable** (OSC 8 hyperlinks: the ticket jumps to Linear, the `PR#` to GitHub, in terminals that support them). The Linear workspace is resolved from `LINEAR_WORKSPACE` or the linear-cli config, so tickets stay plain text when it's unknown. Because every hotkey has a flag, the view you build by hand is a real command: press `y` (or run `--print-cmd`) to get the exact `ag sessions …` line — explore interactively, hand the line to an agent. Piped output, `--json`, or `--no-interactive` keep the plain listing for scripts. Peek without opening the pager with `agents sessions <id> --preview`.
278
+
279
+ | before — preview hidden | after — preview open + clickable links |
280
+ | --- | --- |
281
+ | ![sessions browser, preview hidden](assets/demos/sessions-preview-before.png) | ![sessions browser, preview open with a links line](assets/demos/sessions-preview-after.png) |
277
282
 
278
283
  Each live session resolves to `working`, `waiting_input` (with why -- a question, a plan review, or a permission prompt), or `idle`, alongside badges for the PR it opened, the worktree it sits in, and the ticket it's working. `agents sessions focus [id]` attaches the live pane in place -- the tmux split locally or over SSH, or its Ghostty tab -- and falls back to a fresh tab + resume when the terminal is gone.
279
284
 
@@ -292,6 +297,17 @@ agents sessions resume --host zion --tmux # resume on another machine over SSH
292
297
 
293
298
  `agents sessions resume` reopens sessions in whatever terminal you're in -- auto-detected across iTerm, Ghostty, tmux, and the VSCodium agent-terminal, or forced with `--iterm` / `--ghostty` / `--tmux` / `--vscodium`. Back them with **tmux** and the runs turn durable: detach, close your editor, reboot the GUI -- the session is still alive to `agents tmux attach`. The whole `agents tmux` subsystem (persistent multiplexer sessions that survive editor restarts and can be shared with other tools) sits underneath.
294
299
 
300
+ ### Send an agent to the background — and bring it back
301
+
302
+ Running 30 agents and drowning in terminal tabs? `agents sessions detach <id>` stops a session's interactive process and keeps it working **headless** in the background -- it drives its task to done unattended, no tab, lower cost. `agents sessions attach <id>` brings it back: version-pinned resume into a live TUI, the same session and full history (including whatever it did while backgrounded).
303
+
304
+ ```
305
+ agents sessions detach a1b2c3d4 # go headless in the background, keep working
306
+ agents sessions attach a1b2c3d4 # resume it interactively, right here
307
+ ```
308
+
309
+ Both are agent-agnostic -- they route through the same `agents run --resume` path (native resume for Claude/Codex, `/continue` replay for the rest). `agents sessions --active` marks each session's `presence` -- `attached` (you're watching it), `background` (running headless), or `parked` (its background run finished) -- so the menu bar and Factory show where every agent is. In the Factory extension, **Agents: Detach** (`Cmd/Ctrl+K B`) and **Agents: Attach** (`Cmd/Ctrl+K A`) do the same over the focused terminal.
310
+
295
311
  ---
296
312
 
297
313
  ## Control the fleet
@@ -319,6 +335,7 @@ agents feed --flat # one row per agent (legacy)
319
335
  agents feed --host mac-mini # scope the view to one or more hosts
320
336
  agents feed --local # skip the SSH fan-out
321
337
  agents feed --json # blocks stamped with their outcome key
338
+ agents feed post "halfway done" # agent status post (auto session identity)
322
339
  ```
323
340
 
324
341
  Top-level questions and waiting notifications publish one atomic open-block record per session, including the mailbox id, host, runtime, and every answer option. The default view collapses agents under the **outcome** they serve (Linear ticket, PR, worktree slug, or Unassigned) so a 1,100-agent fleet reads as dozens of deliverables. Answered, resumed, and stopped blocks clear automatically; Task subagents are excluded. The rendered reply command uses the same mailbox id with `agents message`, so the decision routes back to the agent that asked it.
@@ -436,7 +453,8 @@ agents sync --host gpu-box # make the remote machine current
436
453
  agents doctor --devices # readiness matrix for every registered device
437
454
  agents doctor --devices --json # machine-readable fleet readiness
438
455
  agents doctor --device mac-mini # same matrix, scoped to one device
439
- agents fleet status # warnings rollup + health/sync/version/Auth matrix (cache-first)
456
+ agents fleet status # online/offline rollup + NEEDS ATTENTION + OS-grouped rows (cache-first)
457
+ agents fleet status --verbose # full per-device auth/CLI/sync/version grid
440
458
  agents fleet status --live # force a live resource probe (alias of --refresh)
441
459
  agents fleet status --json --strict # scriptable fleet health gate
442
460
  agents check --devices # CI drift gate across every registered device
@@ -468,13 +486,21 @@ this machine locally plus any device missing from the cache; pass `--refresh` (o
468
486
  shorter `--live`) to force a full live probe of every box. Cache-served output notes its
469
487
  age (`updated 2m ago — pass --refresh (--live) for a live probe`).
470
488
 
471
- `agents fleet status` adds an **Auth column** which agent accounts are actually
472
- logged in, per device, read from the auth-health cache (no network). Four buckets keep
473
- it from crying wolf: `●live` (verified), `·present` (signed in but the agent has no
474
- live-probe endpoint e.g. codex/grok benign), `◐degraded` (soft/self-healing:
475
- expired-but-refreshing, rate-limited), and `○revoked` (server rejected re-login now).
476
- Only `○` means a real re-login is needed. Run `agents fleet ping` to force a live
477
- re-verification across the fleet.
489
+ `agents fleet status` answers "is my fleet OK?" at a glance: a one-line rollup
490
+ (`● N online · ○ M offline`), a short **NEEDS ATTENTION** list where every item names
491
+ the command that fixes it (offline `check the box`, config drift or a stark CLI gap →
492
+ `agents apply <box>`, version skew `agents upgrade --fleet`), then quiet per-device
493
+ rows grouped by OS (macOS / Linux / Windows) showing just `name · capacity · load/mem ·
494
+ version`, with this machine flagged `▸ this machine`. A healthy fleet reads in a few
495
+ lines; orphaned versions are demoted to a one-line `agents prune` nudge in the footer.
496
+
497
+ Pass `--verbose` for the full per-device grid — the **Auth column** (which agent accounts
498
+ are actually logged in, per device, read from the auth-health cache — no network), plus
499
+ the CLI-readiness and sync-drift columns. The Auth column has four buckets so it never
500
+ cries wolf: `●live` (verified), `·present` (signed in but the agent has no live-probe
501
+ endpoint — e.g. codex/grok — benign), `◐degraded` (soft/self-healing: expired-but-refreshing,
502
+ rate-limited), and `○revoked` (server rejected — re-login now). Only `○` means a real
503
+ re-login is needed. Run `agents fleet ping` to force a live re-verification across the fleet.
478
504
 
479
505
  **Hosts** (`agents hosts`) are git-synced dispatch targets in `agents.yaml`; **devices** (`agents devices`) are your Tailscale machines in a local registry. Both ride SSH and feed one host pool: devices appear in `agents hosts list` and capability routing without a second enrollment. On `--host` runs every `agents run` option is either forwarded (`--effort --env --timeout --loop …`), rejected loud (`--secrets` never crosses SSH implicitly), or consumed locally — nothing silently drops. See [docs/00-concepts.md](apps/cli/docs/00-concepts.md#devices--hosts).
480
506
 
@@ -1218,7 +1244,7 @@ Agents are defined in [src/lib/agents.ts](apps/cli/src/lib/agents.ts) -- each is
1218
1244
  | [`apps/cli`](apps/cli) | **The CLI** (this README) — version management, config sync, sessions, teams, cloud, browser, computer, secrets. |
1219
1245
  | [`apps/factory`](apps/factory) | **Factory** — a VS Code extension that spawns agent terminals as tabs and adds the Factory Floor dashboard. A separate product with its own publish identity. |
1220
1246
  | [`native/computer-mac`](native/computer-mac) · [`native/computer-win`](native/computer-win) | Native backends behind `agents computer` — Swift (macOS Accessibility + screen capture) and C#/.NET (Windows UI Automation). |
1221
- | [`packages/session-tracker`](packages/session-tracker) | The `SessionStart` hook that writes live-session state the CLI reads back. |
1247
+ | [`packages/session-tracker`](packages/session-tracker) | The `SessionStart` hook that writes live-session state the **Factory extension** reads back (`apps/factory/src/core/liveSession.ts`) — not the CLI, which reads transcripts. |
1222
1248
 
1223
1249
  ## Contributing
1224
1250
 
package/dist/bin/agents CHANGED
Binary file
@@ -18,7 +18,8 @@ import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
18
18
  import { ensureDevicesRegistered } from '../lib/devices/sync.js';
19
19
  import { readFleetFile, resolveDesired } from '../lib/fleet/manifest.js';
20
20
  import { snapshotAuth, materializeAuth, parseAuthBundle, KEYCHAIN_BOUND_ON_MAC } from '../lib/fleet/auth-sync.js';
21
- import { agentIdOf, diffFleet, probeDevice, runFleetApply, pool, sourceHome, } from '../lib/fleet/apply.js';
21
+ import { agentIdOf, diffFleet, probeDevice, runFleetApply, pool, sourceHome, expandAllSpecs, rosterNeedsVersions, } from '../lib/fleet/apply.js';
22
+ import { listInstalledVersions } from '../lib/versions.js';
22
23
  /** Version of the running agents-cli — the fleet target version. */
23
24
  function localCliVersion() {
24
25
  try {
@@ -92,7 +93,7 @@ function renderPlan(plan) {
92
93
  const agentsCell = row.probe.reachable
93
94
  ? (() => {
94
95
  const add = row.actions.filter((a) => a.kind === 'add-agent');
95
- return add.length === 0 ? chalk.green(`ok ${row.desired.agents.length}/${row.desired.agents.length}`) : chalk.cyan('+ ' + add.map((a) => a.agent).join(','));
96
+ return add.length === 0 ? chalk.green(`ok ${row.desired.agents.length}/${row.desired.agents.length}`) : chalk.cyan('+ ' + add.map((a) => a.spec ?? a.agent).join(','));
96
97
  })()
97
98
  : chalk.gray('-');
98
99
  const configCell = row.probe.reachable
@@ -178,6 +179,15 @@ async function runApply(opts) {
178
179
  if (desired.length === 0)
179
180
  throw new Error(`Device '${opts.device}' is not a target in this manifest.`);
180
181
  }
182
+ // --agent overrides the roster for the targeted device(s): install exactly these
183
+ // specs instead of the manifest's. `claude@all` expands to every claude version
184
+ // installed on THIS machine, so a fresh box inherits the same version set. Pair
185
+ // with --device to seed one box; without it, every targeted device gets the set.
186
+ if (opts.agent && opts.agent.length > 0) {
187
+ const specs = expandAllSpecs(opts.agent, (id) => listInstalledVersions(id));
188
+ desired = desired.map((d) => ({ ...d, agents: specs }));
189
+ console.log(chalk.gray(`Roster override (--agent): ${specs.join(', ')}`));
190
+ }
181
191
  if (opts.login === false)
182
192
  desired = desired.map((d) => ({ ...d, login: 'skip' }));
183
193
  if (desired.length === 0) {
@@ -200,8 +210,9 @@ async function runApply(opts) {
200
210
  };
201
211
  // Probe every target device in parallel.
202
212
  const nameToProfile = new Map(desired.map((d) => [d.device, registry[d.device]]));
213
+ const withVersions = rosterNeedsVersions(desired);
203
214
  console.log(chalk.gray(`Probing ${desired.length} device(s)…`));
204
- const probeList = await pool(desired, 6, async (d) => probeDevice(nameToProfile.get(d.device)));
215
+ const probeList = await pool(desired, 6, async (d) => probeDevice(nameToProfile.get(d.device), { withVersions }));
205
216
  const probes = new Map(probeList.map((p) => [p.device, p]));
206
217
  const targetCliVersion = localCliVersion();
207
218
  let plan = diffFleet(desired, probes, { targetCliVersion, sourceAuth, secretsBundles: manifest.secrets?.bundles });
@@ -280,6 +291,7 @@ export function configureApplyCommand(cmd) {
280
291
  .option('--dry-run', 'Alias for --plan')
281
292
  .option('-y, --yes', 'Skip the confirmation prompt')
282
293
  .option('--device <name>', 'Scope the apply to a single device')
294
+ .option('--agent <specs...>', 'Override the roster for targeted device(s): install these specs instead of the manifest\'s. Use `claude@all` to replicate every version installed on this machine.')
283
295
  .option('--only <dims>', 'Limit to dimensions: comma list of agents,config,login')
284
296
  .option('--no-login', 'Do not propagate logins')
285
297
  .addOption(new Option('--recv-auth', 'internal: receive an auth bundle on stdin').hideHelp())
@@ -309,6 +321,12 @@ export function registerApplyCommand(program) {
309
321
 
310
322
  # One device only
311
323
  agents apply --device yosemite-s1 -y
324
+
325
+ # Replicate every claude version on this machine onto a fresh box
326
+ agents apply --agent claude@all --device yosemite-s0 -y
327
+
328
+ # Install a specific pinned version on one device
329
+ agents apply --agent claude@2.1.207 --device yosemite-s0
312
330
  `,
313
331
  });
314
332
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * `agents sessions attach <id>` — bring a backgrounded (or parked) agent session
3
+ * back to the foreground: stop its headless continuation, then resume it
4
+ * interactively in this terminal. The resume is version-pinned native resume
5
+ * (claude/codex) or a `/continue` replay (others) — the same session, full
6
+ * history, the exact inverse of `agents sessions detach`.
7
+ */
8
+ import type { Command } from 'commander';
9
+ export declare function registerAttachCommand(program: Command): void;
10
+ export declare function attachAction(id: string): Promise<void>;
@@ -0,0 +1,41 @@
1
+ import chalk from 'chalk';
2
+ import { discoverSessions } from '../lib/session/discover.js';
3
+ import { resumeSessionInPlace } from './sessions.js';
4
+ import { readDetachRecord, clearDetachRecord, isHeadlessAlive } from '../lib/session/detached.js';
5
+ export function registerAttachCommand(program) {
6
+ program
7
+ .command('attach')
8
+ .argument('<id>', 'Short or full id of the backgrounded/parked session to resume interactively')
9
+ .description('Bring a backgrounded agent to the foreground — resume it interactively here')
10
+ .action(async (id) => {
11
+ await attachAction(id);
12
+ });
13
+ }
14
+ export async function attachAction(id) {
15
+ const q = id.toLowerCase();
16
+ // Rich meta carries the pinned version + origin cwd the resume needs.
17
+ const metas = await discoverSessions({ all: true, since: '90d', limit: 2000 });
18
+ const meta = metas.find((m) => m.id === id) ?? metas.find((m) => m.id.toLowerCase().startsWith(q));
19
+ if (!meta) {
20
+ console.error(chalk.red(`No session matching "${id}".`));
21
+ console.error(chalk.gray(' See your sessions: agents sessions'));
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+ // If it's backgrounded, stop the headless continuation first so two processes
26
+ // don't resume the same transcript at once.
27
+ const rec = readDetachRecord(meta.id);
28
+ if (rec) {
29
+ if (isHeadlessAlive(rec)) {
30
+ try {
31
+ process.kill(rec.headlessPid, 'SIGTERM');
32
+ }
33
+ catch {
34
+ /* already gone */
35
+ }
36
+ }
37
+ clearDetachRecord(meta.id);
38
+ }
39
+ console.log(chalk.gray(`Attaching ${meta.agent} ${meta.id.slice(0, 8)} — resuming interactively…`));
40
+ await resumeSessionInPlace(meta);
41
+ }
@@ -644,8 +644,8 @@ function registerSetupCommand(program) {
644
644
  console.log('');
645
645
  console.log('Next steps:');
646
646
  console.log(' 1. Grant TCC permissions (one-time):');
647
- console.log(' System Settings > Privacy & Security > Accessibility — add Computer Helper.app');
648
- console.log(' System Settings > Privacy & Security > Screen Recording — add Computer Helper.app');
647
+ console.log(' System Settings > Privacy & Security > Accessibility — add Agents Computer');
648
+ console.log(' System Settings > Privacy & Security > Screen Recording — add Agents Computer');
649
649
  console.log(' 2. Whitelist the apps the daemon may drive. Add a YAML under ~/.agents/permissions/groups/:');
650
650
  console.log(' name: computer');
651
651
  console.log(' allow:');
@@ -687,7 +687,7 @@ function registerStartCommand(program) {
687
687
  }
688
688
  if (!trusted) {
689
689
  console.log('');
690
- console.log('Grant Accessibility + Screen Recording to Computer Helper.app, then run `agents computer start` again.');
690
+ console.log('Grant Accessibility + Screen Recording to Agents Computer, then run `agents computer start` again.');
691
691
  }
692
692
  });
693
693
  }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Pure core of `agents sessions detach` — the nudge, the resumed-run argv, and the id
3
+ * resolver. Kept import-light (only a type import) so it is unit-tested without
4
+ * loading the live-session discovery graph.
5
+ */
6
+ import type { ActiveSession } from '../lib/session/active.js';
7
+ /**
8
+ * The prompt the backgrounded run resumes with. Its whole job is to stop a
9
+ * now-unwatched agent from stalling on a confirmation nobody will answer: it
10
+ * tells the agent it is headless and to drive to done, or to stop and state a
11
+ * blocker (which surfaces as a waiting session the user can `attach`).
12
+ */
13
+ export declare const BACKGROUND_NUDGE: string;
14
+ /**
15
+ * Build the argv for the headless continuation `detach` spawns. Agent-agnostic
16
+ * and version-pinned by construction — it goes through the same `agents run
17
+ * --resume` path `attach` reverses.
18
+ */
19
+ export declare function buildBackgroundArgv(agent: string, sessionId: string, cwd?: string): string[];
20
+ /** What to do with a resolved session, given which machine we're on. */
21
+ export type DetachTarget = {
22
+ kind: 'local';
23
+ sessionId: string;
24
+ } | {
25
+ kind: 'remote';
26
+ machine: string;
27
+ sessionId: string;
28
+ } | {
29
+ kind: 'refuse';
30
+ reason: string;
31
+ };
32
+ /**
33
+ * Decide how to detach a resolved session. Cloud and team sessions are refused
34
+ * (they have their own lifecycles); a session on another machine is delegated to
35
+ * that host over SSH — never stopped/resumed locally, since its pid and tmux
36
+ * socket only mean something where it actually runs. Pure, so every branch is
37
+ * unit-tested without a live pool. Mirrors `focus`/`jumpTo`'s remote branch.
38
+ */
39
+ export declare function resolveDetachTarget(s: ActiveSession, self: string): DetachTarget;
40
+ /**
41
+ * Resolve `<id>` to exactly one live session by prefix. Mirrors `focus`'s match
42
+ * so the two verbs accept the same ids.
43
+ */
44
+ export declare function resolveOne(activeById: Map<string, ActiveSession>, id: string): ActiveSession | {
45
+ error: string;
46
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The prompt the backgrounded run resumes with. Its whole job is to stop a
3
+ * now-unwatched agent from stalling on a confirmation nobody will answer: it
4
+ * tells the agent it is headless and to drive to done, or to stop and state a
5
+ * blocker (which surfaces as a waiting session the user can `attach`).
6
+ */
7
+ export const BACKGROUND_NUDGE = "You've been sent to the background — nobody is watching this session now. " +
8
+ 'Continue the current task and drive it to completion end-to-end. ' +
9
+ "Don't ask for confirmation; make the reasonable call and keep going. " +
10
+ 'If you genuinely cannot proceed safely, stop and state the blocker plainly in one message.';
11
+ /**
12
+ * Build the argv for the headless continuation `detach` spawns. Agent-agnostic
13
+ * and version-pinned by construction — it goes through the same `agents run
14
+ * --resume` path `attach` reverses.
15
+ */
16
+ export function buildBackgroundArgv(agent, sessionId, cwd) {
17
+ const argv = ['run', agent, BACKGROUND_NUDGE, '--resume', sessionId, '--headless'];
18
+ if (cwd)
19
+ argv.push('--cwd', cwd);
20
+ return argv;
21
+ }
22
+ /**
23
+ * Decide how to detach a resolved session. Cloud and team sessions are refused
24
+ * (they have their own lifecycles); a session on another machine is delegated to
25
+ * that host over SSH — never stopped/resumed locally, since its pid and tmux
26
+ * socket only mean something where it actually runs. Pure, so every branch is
27
+ * unit-tested without a live pool. Mirrors `focus`/`jumpTo`'s remote branch.
28
+ */
29
+ export function resolveDetachTarget(s, self) {
30
+ if (s.context === 'cloud') {
31
+ return { kind: 'refuse', reason: 'Cloud sessions run remotely and cannot be detached from here.' };
32
+ }
33
+ if (s.context === 'teams') {
34
+ return {
35
+ kind: 'refuse',
36
+ reason: 'That session is managed by its team — stop it with `agents teams`, not `detach`.',
37
+ };
38
+ }
39
+ const sessionId = s.sessionId ?? '';
40
+ if (!sessionId) {
41
+ return { kind: 'refuse', reason: 'That session has no id to resume, so it cannot be detached.' };
42
+ }
43
+ if (s.machine && s.machine !== self) {
44
+ return { kind: 'remote', machine: s.machine, sessionId };
45
+ }
46
+ return { kind: 'local', sessionId };
47
+ }
48
+ /**
49
+ * Resolve `<id>` to exactly one live session by prefix. Mirrors `focus`'s match
50
+ * so the two verbs accept the same ids.
51
+ */
52
+ export function resolveOne(activeById, id) {
53
+ const q = id.toLowerCase();
54
+ const matches = [...activeById.values()].filter((s) => (s.sessionId ?? '').toLowerCase().startsWith(q));
55
+ if (matches.length === 0)
56
+ return { error: `No live session matching "${id}".` };
57
+ if (matches.length > 1) {
58
+ return { error: `"${id}" is ambiguous (${matches.length} live matches). Use more of the id.` };
59
+ }
60
+ return matches[0];
61
+ }
@@ -0,0 +1,5 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerDetachCommand(program: Command): void;
3
+ export declare function detachAction(id: string, opts?: {
4
+ local?: boolean;
5
+ }): Promise<void>;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * `agents sessions detach <id>` — send a live agent session to the background:
3
+ * stop its interactive process and continue it headless, unattended, so it drives
4
+ * its task to completion without holding a terminal. Bring it back with
5
+ * `agents sessions attach`. A sibling of `sessions focus`/`resume` on the session
6
+ * lifecycle axis.
7
+ *
8
+ * Agent-agnostic and version-pinned by construction: it re-invokes
9
+ * `agents run <agent> "<nudge>" --resume <id> --headless`, which resolves the
10
+ * session's originating version and uses the agent's native resume (claude/codex)
11
+ * or a `/continue` replay (others) — the same path `attach` reverses.
12
+ */
13
+ import { spawn } from 'node:child_process';
14
+ import { openSync, closeSync, mkdirSync } from 'node:fs';
15
+ import path from 'node:path';
16
+ import chalk from 'chalk';
17
+ import { gatherLiveTargets } from './go.js';
18
+ import { killSession } from '../lib/tmux/index.js';
19
+ import { getDefaultSocketPath } from '../lib/tmux/paths.js';
20
+ import { getAgentsInvocation } from '../lib/daemon.js';
21
+ import { captureProcessStartTime } from '../lib/pty-server.js';
22
+ import { writeDetachRecord } from '../lib/session/detached.js';
23
+ import { getLogsDir } from '../lib/state.js';
24
+ import { runOnPeer } from '../lib/session/remote-list.js';
25
+ import { buildBackgroundArgv, resolveDetachTarget, resolveOne } from './detach-core.js';
26
+ export function registerDetachCommand(program) {
27
+ program
28
+ .command('detach')
29
+ .argument('<id>', 'Short or full id of the live session to background')
30
+ .option('--local', 'Only this machine (skip the cross-host sweep)')
31
+ .description('Send a live agent to the background — stop its terminal, keep it working headless')
32
+ .action(async (id, opts) => {
33
+ await detachAction(id, opts);
34
+ });
35
+ }
36
+ /**
37
+ * Stop the interactive process so it can't fight the headless resume over the
38
+ * same transcript. tmux-hosted sessions are killed by session (closes the pane
39
+ * and ends the agent); a plain process is SIGTERM'd by pid. Either way we then
40
+ * wait for the process to actually exit before returning, so the headless
41
+ * continuation never starts writing the transcript while the old process is
42
+ * still flushing it.
43
+ */
44
+ async function stopInteractive(s) {
45
+ if (s.tmuxTarget) {
46
+ const name = s.tmuxTarget.split(':')[0];
47
+ await killSession(name, getDefaultSocketPath());
48
+ }
49
+ else if (s.pid && s.pid > 0) {
50
+ try {
51
+ process.kill(s.pid, 'SIGTERM');
52
+ }
53
+ catch {
54
+ return; /* already gone — nothing to wait on */
55
+ }
56
+ }
57
+ if (s.pid && s.pid > 0)
58
+ await waitForExit(s.pid);
59
+ }
60
+ /**
61
+ * Poll until `pid` is gone (SIGTERM already sent), escalating to SIGKILL past the
62
+ * deadline so a stuck process can't wedge the detach. Bounded so `detach` never
63
+ * hangs waiting on a process that ignores signals.
64
+ */
65
+ async function waitForExit(pid, timeoutMs = 5000) {
66
+ const deadline = Date.now() + timeoutMs;
67
+ for (;;) {
68
+ try {
69
+ process.kill(pid, 0);
70
+ }
71
+ catch {
72
+ return; /* gone */
73
+ }
74
+ if (Date.now() >= deadline) {
75
+ try {
76
+ process.kill(pid, 'SIGKILL');
77
+ }
78
+ catch {
79
+ /* raced to exit */
80
+ }
81
+ return;
82
+ }
83
+ await new Promise((r) => setTimeout(r, 100));
84
+ }
85
+ }
86
+ /**
87
+ * Where a backgrounded continuation's stdout/stderr lands, by convention, so a
88
+ * crash after detach is debuggable — there is no terminal watching it, and
89
+ * presence only tells you the pid is gone, not whether the run finished or died.
90
+ */
91
+ function backgroundLogPath(sessionId) {
92
+ return path.join(getLogsDir(), `detach-${sessionId.slice(0, 8)}.log`);
93
+ }
94
+ /**
95
+ * Spawn the headless continuation detached, resolving to its pid for tracking.
96
+ * Its output is redirected to `logFile` rather than discarded, so a fast-failing
97
+ * background run leaves a trail.
98
+ */
99
+ function spawnHeadless(command, args, logFile) {
100
+ return new Promise((resolve, reject) => {
101
+ mkdirSync(path.dirname(logFile), { recursive: true });
102
+ const fd = openSync(logFile, 'a');
103
+ const child = spawn(command, args, { detached: true, stdio: ['ignore', fd, fd], env: process.env });
104
+ child.once('spawn', () => {
105
+ const pid = child.pid ?? 0;
106
+ child.unref();
107
+ try {
108
+ closeSync(fd);
109
+ }
110
+ catch { /* fd handed to the child */ }
111
+ resolve(pid);
112
+ });
113
+ child.once('error', (err) => {
114
+ try {
115
+ closeSync(fd);
116
+ }
117
+ catch { /* never opened for the child */ }
118
+ reject(err instanceof Error ? err : new Error(String(err)));
119
+ });
120
+ });
121
+ }
122
+ export async function detachAction(id, opts = {}) {
123
+ const { self, activeById } = await gatherLiveTargets(!!opts.local, { includeCloud: true });
124
+ const resolved = resolveOne(activeById, id);
125
+ if ('error' in resolved) {
126
+ console.error(chalk.red(resolved.error));
127
+ process.exitCode = 1;
128
+ return;
129
+ }
130
+ const s = resolved;
131
+ const target = resolveDetachTarget(s, self);
132
+ // Cloud, team, and id-less sessions can't be backgrounded from here.
133
+ if (target.kind === 'refuse') {
134
+ console.error(chalk.red(target.reason));
135
+ process.exitCode = 1;
136
+ return;
137
+ }
138
+ const short = target.sessionId.slice(0, 8);
139
+ // A session on another host: its pid and tmux socket only mean something where
140
+ // it runs, so detach it *there* over SSH — never kill/resume locally. Mirrors
141
+ // focus/jumpTo's remote branch.
142
+ if (target.kind === 'remote') {
143
+ console.log(chalk.gray(`${short} lives on ${target.machine} — detaching it there over SSH…`));
144
+ const rc = await runOnPeer(['sessions', 'detach', target.sessionId, '--local'], target.machine);
145
+ if (rc === 'no-target') {
146
+ console.error(chalk.red(`Can't reach ${target.machine} to detach ${short}.`));
147
+ process.exitCode = 1;
148
+ }
149
+ return;
150
+ }
151
+ // Local: stop the interactive process, then continue it headless, detached —
152
+ // version-pinned via the existing `agents run --resume` path.
153
+ const sessionId = target.sessionId;
154
+ const agent = s.kind;
155
+ await stopInteractive(s);
156
+ const logFile = backgroundLogPath(sessionId);
157
+ const inv = getAgentsInvocation(buildBackgroundArgv(agent, sessionId, s.cwd));
158
+ const pid = await spawnHeadless(inv.command, inv.args, logFile);
159
+ // Record it so `attach` and `agents ls --active` know it's backgrounded.
160
+ writeDetachRecord({
161
+ sessionId,
162
+ agent,
163
+ cwd: s.cwd,
164
+ headlessPid: pid,
165
+ headlessStartTime: captureProcessStartTime(pid),
166
+ detachedAtMs: Date.now(),
167
+ });
168
+ console.log(chalk.green(`◒ Backgrounded ${agent} ${short}`) +
169
+ chalk.gray(` — running headless (pid ${pid}). Bring it back: agents sessions attach ${short}`));
170
+ console.log(chalk.gray(` logs: ${logFile}`));
171
+ }
@@ -22,6 +22,7 @@ import { listCliStatus } from '../lib/cli-resources.js';
22
22
  import { setHelpSections } from '../lib/help.js';
23
23
  import { heal, healChangedAnything } from '../lib/heal.js';
24
24
  import { blocksLocalScripts, getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
25
+ import { scanUserRcFiles, rcSecretWarningLines } from '../lib/secrets/rc-hygiene.js';
25
26
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
26
27
  import * as fs from 'fs';
27
28
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
@@ -177,6 +178,10 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn) {
177
178
  // generated `agents.ps1` launcher — postinstall diagnoses it interactively,
178
179
  // but a non-interactive install never sees that. Surface it here too.
179
180
  renderExecPolicyAdvisory();
181
+ // Credentials exported from a shell rc file leak into every process's
182
+ // environment (readable via /proc/<pid>/environ) — the RUSH-1968 class. Flag
183
+ // them here so the master passphrase never silently lives in `~/.zshenv` again.
184
+ renderRcHygieneAdvisory();
180
185
  }
181
186
  // ─── windows execution-policy advisory ─────────────────────────────────────────
182
187
  /**
@@ -199,6 +204,19 @@ export function execPolicyWarningLines(platform, policy) {
199
204
  'The agents.cmd shim still works regardless of the policy.',
200
205
  ];
201
206
  }
207
+ function renderRcHygieneAdvisory() {
208
+ const findings = scanUserRcFiles();
209
+ const lines = rcSecretWarningLines(findings);
210
+ if (lines.length === 0)
211
+ return;
212
+ console.log();
213
+ console.log(chalk.bold('Secrets in shell config'));
214
+ const [headline, ...rest] = lines;
215
+ console.log(` ${chalk.yellow('warn ')} ${headline}`);
216
+ for (const line of rest) {
217
+ console.log(chalk.gray(` ${line}`));
218
+ }
219
+ }
202
220
  function renderExecPolicyAdvisory() {
203
221
  // Only probe the policy on Windows — getEffectiveExecutionPolicy() spawns
204
222
  // powershell, which is a wasted (doomed) process on POSIX where the advisory