@phnx-labs/agents-cli 1.22.27 → 1.22.28

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 (87) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +62 -26
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +54 -10
  5. package/dist/commands/hosts.js +4 -3
  6. package/dist/commands/perf.d.ts +10 -0
  7. package/dist/commands/perf.js +14 -6
  8. package/dist/commands/projects.js +24 -10
  9. package/dist/commands/routines.js +31 -4
  10. package/dist/commands/run-account-picker.d.ts +37 -0
  11. package/dist/commands/run-account-picker.js +101 -17
  12. package/dist/commands/secrets.js +86 -2
  13. package/dist/commands/sessions.d.ts +26 -6
  14. package/dist/commands/sessions.js +46 -12
  15. package/dist/commands/teams.js +19 -8
  16. package/dist/lib/activity.js +45 -99
  17. package/dist/lib/claude-account-token.js +38 -11
  18. package/dist/lib/cloud/host.js +1 -0
  19. package/dist/lib/devices/resolve-target.d.ts +1 -0
  20. package/dist/lib/devices/resolve-target.js +9 -2
  21. package/dist/lib/events.d.ts +1 -1
  22. package/dist/lib/events.js +2 -2
  23. package/dist/lib/git.d.ts +9 -3
  24. package/dist/lib/git.js +39 -22
  25. package/dist/lib/hooks/profile.d.ts +12 -1
  26. package/dist/lib/hooks/profile.js +6 -1
  27. package/dist/lib/hooks.js +4 -1
  28. package/dist/lib/hosts/dispatch.d.ts +10 -0
  29. package/dist/lib/hosts/dispatch.js +78 -22
  30. package/dist/lib/hosts/logs.js +11 -1
  31. package/dist/lib/hosts/passthrough.d.ts +5 -0
  32. package/dist/lib/hosts/passthrough.js +5 -2
  33. package/dist/lib/hosts/progress.d.ts +10 -0
  34. package/dist/lib/hosts/progress.js +37 -5
  35. package/dist/lib/hosts/ready.d.ts +41 -2
  36. package/dist/lib/hosts/ready.js +100 -12
  37. package/dist/lib/hosts/reconcile.d.ts +2 -1
  38. package/dist/lib/hosts/reconcile.js +13 -5
  39. package/dist/lib/hosts/reconnect.js +5 -3
  40. package/dist/lib/hosts/registry.js +1 -0
  41. package/dist/lib/hosts/tasks.d.ts +1 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/menubar/snapshot.js +15 -2
  45. package/dist/lib/models.d.ts +18 -11
  46. package/dist/lib/models.js +19 -15
  47. package/dist/lib/perf/db.js +16 -3
  48. package/dist/lib/perf/types.d.ts +12 -1
  49. package/dist/lib/project-probe.d.ts +22 -3
  50. package/dist/lib/project-probe.js +105 -17
  51. package/dist/lib/project-status.d.ts +9 -0
  52. package/dist/lib/project-status.js +15 -0
  53. package/dist/lib/refresh.js +22 -11
  54. package/dist/lib/rotate.d.ts +22 -0
  55. package/dist/lib/rotate.js +26 -0
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/secrets/agent.d.ts +19 -3
  59. package/dist/lib/secrets/agent.js +58 -20
  60. package/dist/lib/secrets/audit.d.ts +1 -1
  61. package/dist/lib/secrets/audit.js +2 -0
  62. package/dist/lib/secrets/bundles.d.ts +3 -3
  63. package/dist/lib/secrets/bundles.js +15 -5
  64. package/dist/lib/secrets/filestore.d.ts +2 -0
  65. package/dist/lib/secrets/filestore.js +4 -0
  66. package/dist/lib/secrets/session-store.d.ts +7 -0
  67. package/dist/lib/secrets/session-store.js +21 -0
  68. package/dist/lib/session/active.d.ts +49 -0
  69. package/dist/lib/session/active.js +175 -11
  70. package/dist/lib/session/bash-command.d.ts +16 -0
  71. package/dist/lib/session/bash-command.js +65 -0
  72. package/dist/lib/session/db.d.ts +4 -0
  73. package/dist/lib/session/db.js +32 -15
  74. package/dist/lib/session/remote-bundle.js +1 -1
  75. package/dist/lib/staleness/detectors/skills.d.ts +2 -0
  76. package/dist/lib/staleness/detectors/skills.js +17 -2
  77. package/dist/lib/staleness/index.d.ts +10 -1
  78. package/dist/lib/staleness/index.js +28 -3
  79. package/dist/lib/teams/agents.d.ts +1 -0
  80. package/dist/lib/teams/agents.js +38 -11
  81. package/dist/lib/teams/remoteWorktree.d.ts +11 -7
  82. package/dist/lib/teams/remoteWorktree.js +29 -27
  83. package/dist/lib/versions.d.ts +1 -0
  84. package/dist/lib/versions.js +40 -22
  85. package/package.json +3 -1
  86. package/dist/lib/exec.bench.d.ts +0 -1
  87. package/dist/lib/exec.bench.js +0 -186
package/CHANGELOG.md CHANGED
@@ -1,5 +1,54 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.28
4
+
5
+ - **`agents run` / `agents teams` `--device`/`--host`: fail loud when a pinned harness version is not installed on the target (RUSH-2313).** A concrete pin like `codex@0.145.0` is checked against the remote `agents view --json` listing during `ensureHostReady` *before* the run is marked dispatched. Missing pins exit non-zero naming the box, the pin, what is installed there, and `agents ssh <box> -- agents add <agent>@<ver>` — so detached fleet drains no longer print `Dispatched` and then die only in the remote log. Aliases (`@latest` / …) still resolve on the remote; a bare agent name still only warns. Source: `apps/cli/src/lib/hosts/ready.ts`, `dispatch.ts`, `teams/agents.ts`.
6
+
7
+ - **fix: Windows-host e2e suites can resolve `win-mini` again under the hermetic device registry.** `tests/setup.ts` (RUSH-2042 / #1572) redirects `AGENTS_DEVICES_DIR` to an empty fork-private directory so unit fixtures never leak into the real fleet registry. The live `ssh-tunnel.e2e` / `browser/drivers/ssh.e2e` suites still need a real `DeviceProfile` for `AGENTS_TEST_WIN_HOST`, so after #1572 every `tests-windows-host-e2e.yml` run failed immediately with `Unknown device 'win-mini'` — even on a tailnet-joined runner that could ssh to the box. The setup file now seeds the private registry from the real fleet entry (or synthesizes one from `ssh -G <host>`) when `AGENTS_TEST_WIN_HOST` is set; the real registry is never written. Source: `apps/cli/tests/seed-e2e-win-host.ts`, `apps/cli/tests/setup.ts`.
8
+
9
+ - **OpenCode timeout-sample spool dir is Windows-safe (#1869).** The generated
10
+ `agents-cli-hooks.ts` plugin used `PERF_SPOOL.slice(0, PERF_SPOOL.lastIndexOf("/"))`
11
+ to mkdir the perf spool parent. On Windows `getPerfDir()` is backslash-separated,
12
+ so `lastIndexOf("/")` returned -1 and the slice dropped one character — the
13
+ sample write was fail-silent and never landed. The plugin now imports
14
+ `node:path` and uses `path.dirname(PERF_SPOOL)`. Source: `apps/cli/src/lib/hooks.ts`.
15
+
16
+ - **Bash-command classifier: single-source tool registry + broader unwrap (#1889).** The embedded Python activity-log hook (`ACTIVITY_LOG_HOOK_SCRIPT` in `activity.ts`) hand-duplicated `bash-command.ts`'s `TOOL_REGISTRY` and `VALUE_FLAGS` with a "keep them in sync" comment — the two had already drifted (Python was missing `rmdir`, and `agents`/`linear` two-level tools). Both tables are now generated from the TypeScript source (`pythonToolRegistryLiteral` / `pythonValueFlagsLiteral`) at module load. Separately, `unwrapCommand` (and the matching Python `_unwrap_command`) now peels `export VAR=…`, `set -euo pipefail`, `for`/`until` loop bodies, `if`/`then` branches, and `(command)` subshell prefixes that previously classified as `other`. Source: `apps/cli/src/lib/session/bash-command.ts`, `apps/cli/src/lib/activity.ts`.
17
+
18
+ - **Active-session polls skip re-parsing quiet transcripts (#2047).** `computeLiveSignals` (the per-session tail/parse behind `agents sessions --active` and the menu-bar badge tick) now memoizes by transcript path + mtime + `pidAlive` inside the process, so a 30s poll no longer re-tails every live session whose file has not changed. Positive Claude transcript-path resolutions are also memoized while the file still exists, avoiding a full walk of every Claude version-home `projects/` tree per pid per tick. Source: `apps/cli/src/lib/session/active.ts`.
19
+
20
+ - perf(sessions): throttle headless `ps`/`lsof` scan + memoize process table across an active-session poll (#2047)
21
+
22
+ - **`agents routines devices --set` / `--clear` no longer abort when a fleet peer is offline (#2118).** Pinning a routine used to fan out pause/resume to every registered device and throw on the first unreachable one — often *after* the pin had already succeeded on the target — so a single asleep laptop made fleet pins unusable and the error looked like the pin failed. Offline peers are now skipped with a warning; the command exits non-zero only when a *selected* target device cannot be reached. Source: `apps/cli/src/commands/routines.ts`.
23
+
24
+ - **`agents models claude` no longer lists a per-cloud bare-minor id as a plain catalog entry (#2233).** The id-scan fallback (`scanClaudeCatalogIds`, used when the structured alias/perCloud maps yield fewer than two models) used to surface short forms like `claude-opus-4-1` that only appear as a `foundry:` / cloud-scoped field next to a real firstParty id (`claude-opus-4-1-20250805`). `dropBareLegacyIds` now drops **any** id that is a dash-boundary prefix of a more-specific sibling also present in the scan — covering both the bare-major `.includes("claude-opus-4")` artifacts (#1892) and these bare-minor cloud-metadata values — while keeping genuine bare currents with no sibling (`claude-sonnet-5`) and not collapsing `claude-opus-4-1` into `claude-opus-4-10`. Source: `apps/cli/src/lib/models.ts`.
25
+
26
+ - **Menu-bar dropdown density toggle removed.** The `Density: Auto/Rich/Compact` footer item and the whole rich/compact rendering fork are gone; the dropdown now always renders the rich rows (session/work titles, expanded Routines and Recent sections). Removes the `menubarDensity` UserDefaults key and `MENUBAR_DENSITY` env override, and updates the menu-bar docs to match. Source: `apps/cli/menubar/Sources/MenubarHelper/StatusItemController.swift`, `apps/cli/docs/menubar.md`.
27
+
28
+ - **The macOS secrets broker can now hold an explicit key subset under its own lease id and expiry (RUSH-2255).** `agents secrets lease <bundle> --keys K1,K2 --for 8h` authenticates once, stores only those resolved values in broker memory and the restart session, and reports lease metadata without exposing values. Unknown, missing, and expired keys fail closed. Source: `apps/cli/src/{commands/secrets.ts,lib/secrets/{agent,lease,session-store}.ts}`.
29
+
30
+ - **Scoped leases can now be listed and revoked by id (RUSH-2256).** `agents secrets leases` shows bundle, keys, and remaining lifetime; `agents secrets revoke <lease-id>` wipes exactly that broker grant and its restart session. Source: `apps/cli/src/{commands/secrets.ts,lib/secrets/{agent,session-store}.ts}`.
31
+
32
+ - **Fleet devices accept an explicit SSH private-key path (RUSH-2265).** `agents devices set <name> --auth key --identity-file <path>` stores the path on that device and every shared SSH invocation passes it to OpenSSH with `-i`, so devices no longer depend on whichever key the ambient ssh agent happens to offer. Switching a device from password to key auth also removes stale password-bundle metadata from the registry.
33
+
34
+ - **Detached agent dispatch now runs on Windows OpenSSH hosts (RUSH-2267).** Headless `agents run … --host <windows>` launches through a hidden PowerShell process, preserves actor/session/env context, and uses durable Windows-native log, follow, reconcile, stop, and cleanup operations.
35
+
36
+ - **`agents repo pull` fast-forwards a clean behind-only checkout instead of failing with "Cannot rebase onto multiple branches" (RUSH-2282).** After fetch, a 1-behind tree integrates via `merge --ff-only` against the tracking ref; only genuinely diverged histories enter rebase. Avoids re-running `git pull --rebase` on a multi-entry `FETCH_HEAD` (common after bare fetch when the remote has several branches, or under concurrent fleet fetch). Source: `apps/cli/src/lib/git.ts` `pullRepo`.
37
+
38
+ - **`agents perf` splits intentional deny (exit 2) from crashes (exit 1) — hook health is readable again (RUSH-2294).** Deny-by-design guards (`ask-user-question-guard`, `plan-html-reminder`, `git-guard`, …) exit 2 on purpose; the warehouse previously counted any nonzero exit as `errorCount`, so a working guard read as a "92%-error" hook. Exit 0 = allow, exit 2 = `blockCount`/`blockRate`, exit 1 / other = `errorCount`/`errorRate`. The hooks table column is now `ERR/BLOCK/TO` (`err:…% block:…% to:…%`). JSON rows gain `blockCount`/`blockRate`. Source: `apps/cli/src/lib/perf/db.ts`, `apps/cli/src/lib/hooks/profile.ts`, `apps/cli/src/commands/perf.ts`.
39
+
40
+ - **Claude launches reuse an unchanged file-backed setup-token instead of decrypting it on every exec (RUSH-2317).** The process-lifetime cache is isolated by version home and invalidates from the encrypted credential file's identity, ctime, mtime, and size; token rotation is picked up on the next launch, missing per-account tokens are negatively cached, and plaintext tokens remain memory-only. Source: `apps/cli/src/lib/claude-account-token.ts`, `apps/cli/src/lib/secrets/filestore.ts`.
41
+
42
+ - **Repeated full session listings reuse stable transcript membership (RUSH-2318).** The process briefly caches settled transcript directories by mtime+size, rereads recently changed directories, and expires every entry within the filesystem timestamp-precision window, avoiding burst-time repeat reads without hiding filesystem-only creates or deletes. Source: `apps/cli/src/lib/session/db.ts`.
43
+
44
+ - **`agents sync --yes` drops ~1s/agent of dead work and reuses still-fresh fingerprints (RUSH-2320).** Measured on a real install: `getActuallySyncedResources` was ~1055 ms/agent on the unattended path that never reads it; `buildManifest` re-hashed every file (~716 ms) after a no-op force sync; the guard-hit path spent ~12 ms building inventories it then discarded. Unattended sync now skips the interactive inventory, the skills detector is stat-first on size before any content read, `buildManifest` carries still-fresh fingerprints from the previous manifest, and `syncResourcesToVersion` runs its no-change guard before pattern expansion (accepting a caller-supplied `available` inventory so multi-version fan-out does not re-scan). Source: `apps/cli/src/lib/refresh.ts`, `apps/cli/src/lib/versions.ts`, `apps/cli/src/lib/staleness/`.
45
+
46
+ - **fix: `agents run <agent>` no longer dead-ends when every account is logged out — it launches so you can sign in.** A harness with one installed, signed-out version had no reachable login path at all: `agents run cursor` exited with `no healthy cursor account under strategy 'balanced' — excluded: 2026.07.23 (signed_out)`, the `agents run cursor@` account picker marked the logged-out row `disabled` and offered only `No usable accounts — cancel`, and `agents use cursor` only set a default. The zero-healthy guard (RUSH-2132) treated a missing login like an exhausted account, but they are opposites — a throttled account must not be launched, while a signed-out one is fixed precisely BY launching, since the harness's own TUI is the login surface. On a human-facing terminal run (a real TTY and no `--json`), a single sign-in-recoverable account now launches directly (naming the version and the login command) and several open the account picker with auth-blocked rows selectable and labelled `launch to sign in`; `rate_limited` / `out_of_credits` still fail loud, and off a TTY — or under `--json` — both classes keep the exact watchdog-parsed error, now with the harness's login command alongside `--strategy pinned`. Source: `apps/cli/src/lib/rotate.ts` (`isSignInRecoverable`, `signInRecoverableCandidates`), `apps/cli/src/commands/run-account-picker.ts` (`pickSignInLaunchVersion`), `apps/cli/src/commands/exec.ts`. (RUSH-2334)
47
+
48
+ - **`sessions --active` no longer shows retained dead/queued rows, and every process row now carries its PID (RUSH-2336).** Bare `--active` (CLI table/JSON, the interactive browser, `focus`, and the menu bar) previously kept a row alive as long as its pid wasn't *known* dead — so a queued-but-not-started row, or a process of genuinely unverified liveness, could still show up. The canonical selector (`isRunningLiveSession`) now excludes `queued`/`closed`/`crashed` outright (still reachable via `--queued`/`--closed`/`--crashed`) and requires a real process row to positively verify its machine, a positive pid, and `pidAlive === true`; a cloud row stays active on its provider + task id alone. Every process-backed `--active --json` row now guarantees `machine`/`pid`/`pidAlive: true`, and the human CLI row and the menu bar's session detail both show the matching `machine:pid` (or `provider · taskId` for cloud) locator. Source: `apps/cli/src/commands/sessions.ts`, `apps/cli/src/lib/menubar/snapshot.ts`, `apps/cli/menubar/Sources/MenubarHelper/`.
49
+
50
+ - **`agents projects status` card scans faster: grouped warnings, a fleet health summary, and a truthful `dead` label (RUSH-2337).** On a busy fleet the card printed each host's git drift twice — once in the inline `fleet` table, then again as one 2-line block per host in the warnings footer (18 lines for 3 facts) — and read `dead 41 finished or lost (41 crashed)`, which contradicts itself when every dead session is a crash. Now: (1) the warnings footer **groups by root cause** — all behind hosts collapse to one warning listing each with its count (`4 hosts behind origin/main — mac-mini ↓172, yosemite-m2 ↓217, …`) under one shared remediation, dirty/missing the same, a lone host keeps its full sentence, grouped per probed path so two repos never merge (mirrors doctor's `emitGroup`); (2) a one-line **fleet health summary** (`6/13 clean · 4 behind · 4 dirty · 1 missing`) sits above the per-host table, which keeps its branch/drift detail; (3) the `dead` row names the status directly when singular (`dead 41 crashed`); (4) the fleet-wide rollup carries a `fleet snapshot · as of HH:MM` stamp; (5) `linear` shows a completion percent (`468/547 done (86%)`). Source: apps/cli/src/lib/project-probe.ts, apps/cli/src/lib/project-status.ts, apps/cli/src/commands/projects.ts, apps/cli/docs/11-projects.md.
51
+
3
52
  ## 1.22.27
4
53
 
5
54
  - **`agents sessions --teams` groups sessions by team (RUSH-1997).** Instead of one flat list with a `[team/handle]` tag, `--teams` now prints a report grouped by team: each team names its spawner (the orchestrator session that created it) and spawn time, and every teammate row shows its mode + handle. Team-flagged spawns that carry no teammate `meta.json` — headless `agents run` sessions, or teammates whose team record aged out — sink into a trailing `(no team)` bucket, so a real `agents teams` teammate and a bare SDK spawn are never shown as the same thing. `--teams --flat`/`--tree` keep the plain inline table, and a search query keeps the interactive picker. `TeamOrigin` (also on `--teams --json`) now carries the teammate's `startedAt` spawn time and a `source` (`meta` = teammate, `entrypoint` = bare spawn). Source: `apps/cli/src/lib/session/team-filter.ts`, `apps/cli/src/commands/sessions.ts`.
package/README.md CHANGED
@@ -11,7 +11,7 @@
11
11
  <a href="https://github.com/phnx-labs/agents-cli"><img src="https://img.shields.io/badge/github-phnx--labs%2Fagents--cli-blue?style=flat-square" alt="github" /></a>
12
12
  </p>
13
13
 
14
- **The missing toolchain for CLI coding agents.** Run any agent on your existing subscription. Spawn parallel teams in isolated terminals or dispatch to the cloud for a PR. Watch live state across the fleet, nudge stalled runs, and message agents mid-flight. Schedule routines and set monitors that fire an agent when a source changes, drive browsers and Electron apps, store secrets behind Touch ID, and file tickets from a menu-bar bar — all from one CLI.
14
+ **A framework for running a distributed agent factory.** Dispatch Claude, Codex, Antigravity, Grok, and more across your own machines, in parallel, on your existing subscriptions. Measure every run with `agents perf` / `agents insights`, fold what you learn back into `AGENTS.md` and skills, then put the loop on a schedule with routines and monitors. Spawn parallel teams in isolated terminals or dispatch to the cloud for a PR. Watch live state across the fleet, nudge stalled runs, and message agents mid-flight. Store secrets behind Touch ID, drive real browsers and Electron apps, and steer the whole fleet from a menu bar — all from one CLI.
15
15
 
16
16
  <p align="center">
17
17
  <a href="https://github.com/anthropics/claude-code" title="Claude Code"><img src="assets/harnesses/anthropic.svg" height="32" alt="Claude Code" /></a>
@@ -60,20 +60,25 @@ Source: [github.com/phnx-labs/agents-cli](https://github.com/phnx-labs/agents-cl
60
60
 
61
61
  Also available as `ag` -- all commands work with both `agents` and `ag`.
62
62
 
63
- - [Pin versions per project](#pin-versions-per-project)
63
+ - [Factory loop](#factory-loop)
64
64
  - [One config, every agent](#one-config-every-agent)
65
65
  - [Run any agent](#run-any-agent)
66
66
  - [Sessions across agents](#sessions-across-agents)
67
67
  - [Control the fleet](#control-the-fleet)
68
68
  - [Sync the fleet](#sync-the-fleet)
69
- - [Run open models through Claude Code](#run-open-models-through-claude-code)
69
+ - [Pin versions per project](#pin-versions-per-project)
70
+ - [Run open models through Claude Code](#run-open-models-through-claude-code-experimental)
71
+ - [Run on your own machines](#run-on-your-own-machines)
70
72
  - [Teams](#teams)
71
73
  - [Cloud](#cloud)
72
74
  - [Workflows](#workflows)
75
+ - [Plugins](#plugins)
76
+ - [Make it yours](#make-it-yours)
73
77
  - [Browser](#browser)
74
78
  - [Secrets](#secrets)
75
79
  - [Routines](#routines)
76
80
  - [Monitors](#monitors)
81
+ - [Share](#share)
77
82
  - [PTY](#pty)
78
83
  - [Portable setup](#portable-setup)
79
84
  - [Menu bar](#menu-bar)
@@ -84,34 +89,35 @@ Also available as `ag` -- all commands work with both `agents` and `ag`.
84
89
 
85
90
  ---
86
91
 
87
- ## Pin versions per project
88
-
89
- ```bash
90
- # This project needs claude@2.0.65 -- newer versions changed tool calling.
91
- agents use claude@2.0.65 -p
92
-
93
- # The monorepo uses codex@0.116.0 across the team.
94
- agents use codex@0.116.0 -p
95
- ```
96
-
97
- This creates an `agents.yaml` at the project root:
98
-
99
- ```yaml
100
- # agents.yaml (commit this to your repo)
101
- agents:
102
- claude: "2.0.65"
103
- codex: "0.116.0"
104
- ```
92
+ ## Factory loop
105
93
 
106
- Think `requirements.txt` for CLI coding agents, on steroids. A shim reads `agents.yaml` from the project root and routes `claude` / `codex` / `antigravity` / `grok` (and others) to the right version automatically. Each version gets its own isolated home -- switching backs up config and re-syncs resources.
94
+ The same loop whether it's one agent on your laptop or a fleet across a dozen machines: dispatch work, measure what happened, fold the lesson back into the harness, then put it on a schedule.
107
95
 
108
96
  ```bash
109
- agents add claude@2.0.65 # Install a specific version
110
- agents add codex@latest # Install latest
111
- agents add codex@oldest # Install the oldest published version
112
- agents view # See everything installed
97
+ # Dispatch a team across the fleet -- each teammate in its own worktree
98
+ agents teams create checkout --devices yosemite-s0,yosemite-s1
99
+ agents teams add checkout claude "Owns: app/payments/*" --name payments
100
+ agents teams add checkout codex "Write tests for the new code" --name qa --after payments
101
+ agents teams start checkout --watch
102
+
103
+ # Measure what happened -- latency, friction, dead-weight skills
104
+ agents perf commands --days 7 # slowest CLI entrypoints
105
+ agents insights --since 30d # friction, harness comparison, ranked actions
106
+
107
+ # Fold the lesson back into the harness -- every agent picks it up next run
108
+ agents rules add ~/lessons/payments-review.md --agents claude
109
+ agents skills add ~/skills/payments-review --agents claude
110
+
111
+ # Put it on a schedule so it runs itself
112
+ agents routines add nightly-payments-audit \
113
+ --schedule "0 2 * * *" --agent claude --prompt "Audit payments/* for regressions"
114
+
115
+ # Steer the fleet this loop runs on from the menu bar
116
+ agents menubar setup
113
117
  ```
114
118
 
119
+ `agents perf` reads a disposable warehouse at `~/.agents/.cache/perf/perf.db` -- hook, command, and run timing rollups, deletable any time. `agents insights` (alias `agents sessions insights`) is deterministic and offline: it caches per-session facets, compares harnesses, and ranks actions by evidence count -- no model call unless you pass `--narrative`. Routines put any of this on a cron ([Routines](#routines)); monitors fire it on a change instead of a clock ([Monitors](#monitors)); the menu bar is the always-on control surface for the fleet these commands drive ([Menu bar](#menu-bar)).
120
+
115
121
  ---
116
122
 
117
123
  ## One config, every agent
@@ -464,6 +470,36 @@ See [docs/fleet.md](apps/cli/docs/fleet.md) for the manifest schema and reconcil
464
470
 
465
471
  ---
466
472
 
473
+ ## Pin versions per project
474
+
475
+ ```bash
476
+ # This project needs claude@2.0.65 -- newer versions changed tool calling.
477
+ agents use claude@2.0.65 -p
478
+
479
+ # The monorepo uses codex@0.116.0 across the team.
480
+ agents use codex@0.116.0 -p
481
+ ```
482
+
483
+ This creates an `agents.yaml` at the project root:
484
+
485
+ ```yaml
486
+ # agents.yaml (commit this to your repo)
487
+ agents:
488
+ claude: "2.0.65"
489
+ codex: "0.116.0"
490
+ ```
491
+
492
+ Think `requirements.txt` for CLI coding agents, on steroids. A shim reads `agents.yaml` from the project root and routes `claude` / `codex` / `antigravity` / `grok` (and others) to the right version automatically. Each version gets its own isolated home -- switching backs up config and re-syncs resources.
493
+
494
+ ```bash
495
+ agents add claude@2.0.65 # Install a specific version
496
+ agents add codex@latest # Install latest
497
+ agents add codex@oldest # Install the oldest published version
498
+ agents view # See everything installed
499
+ ```
500
+
501
+ ---
502
+
467
503
  ## Run open models through Claude Code (experimental)
468
504
 
469
505
  > **Note:** Profiles are experimental, but available by default — no enable step needed.
package/dist/bin/agents CHANGED
Binary file
@@ -1795,7 +1795,7 @@ export function registerRunCommand(program) {
1795
1795
  await warnUnpushedWork(resumeExec.cwd ?? process.cwd());
1796
1796
  process.exit(resumeExit);
1797
1797
  }
1798
- const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, implicitModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive, inferredInteractiveWithoutTty }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveHostSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports, capableAgents }, { shareRuntimeEnv },] = await Promise.all([
1798
+ const [{ buildExecCommand, parseExecEnv, execAgent, runWithFallback, normalizeMode, resolveMode, implicitModeFor, headlessPlanStallCommand, nativeResume, resolveInteractive, inferredInteractiveWithoutTty }, { ALL_AGENT_IDS, ACCOUNT_INSPECTION_AGENT_IDS, agentLabel, supportsAccountInspection }, { profileExists, resolveProfileForRun }, { readAndResolveBundleEnv, describeBundle, assertRemoteBundleFlagsUnsupported }, { splitBundleRef, resolveHostSshTarget, remoteResolveEnv }, { getConfiguredRunStrategy, normalizeRunStrategy, resolveRunVersion, rotationFailoverChain, shouldArmRotationFailover, RUN_STRATEGIES, collectHarnessCandidates, pickHarnessWeighted, classifyHarnessCandidates, formatHarnessPickBanner, formatNoHealthyHarnessError, formatNoHealthyAccountError, signInRecoverableCandidates }, { getGlobalDefault, getVersionHomePath, resolveVersion, resolveVersionAlias, ensureAgentRunnable }, { buildDiscoveredPlugin, loadPluginManifest, syncPluginToVersion }, { parseWorkflowFrontmatter, resolveWorkflowRef, resolveAllowedSubagents, pruneStaleWorkflowSubagents, ensureSubagentDispatchTool }, { resolveRunDefaults }, { getMcpServersByName, buildWorkflowMcpConfig }, { supports, capableAgents }, { shareRuntimeEnv },] = await Promise.all([
1799
1799
  import('../lib/exec.js'),
1800
1800
  import('../lib/agents.js'),
1801
1801
  import('../lib/profiles.js'),
@@ -2302,6 +2302,9 @@ export function registerRunCommand(program) {
2302
2302
  // synthesize a same-agent fallback chain from the other healthy accounts
2303
2303
  // (issue #348). Stays null unless a non-pinned strategy actually rotated.
2304
2304
  let rotationResult = null;
2305
+ // Set when the zero-healthy path already announced a deliberate
2306
+ // launch-to-sign-in, so the login preflight below does not repeat it.
2307
+ let signInLaunch = false;
2305
2308
  if (options.strategy && !explicitStrategy) {
2306
2309
  console.error(chalk.red(`Invalid strategy: ${options.strategy}. Use ${RUN_STRATEGIES.join(', ')}.`));
2307
2310
  process.exit(1);
@@ -2328,15 +2331,53 @@ export function registerRunCommand(program) {
2328
2331
  try {
2329
2332
  const resolved = await resolveRunVersion(agent, strategy, cwd);
2330
2333
  if (resolved.exhausted) {
2331
- // Fail loud (RUSH-2132): the old behavior warned "found no
2332
- // usable version; falling back to defaults" and launched the
2333
- // pinned default anyway — the exact move that loops a rotate
2334
- // into an exhausted account. The message text is a contract
2335
- // the Factory watchdog tail-detects; do not reword it.
2336
- console.error(chalk.red(formatNoHealthyAccountError(agent, strategy, resolved.exhausted)));
2337
- process.exit(1);
2334
+ // Zero healthy accounts splits two ways, and conflating them is what
2335
+ // stranded a logged-out harness with no way in at all (RUSH-2334):
2336
+ //
2337
+ // - THROTTLED (rate_limited / out_of_credits) -> fail loud (RUSH-2132).
2338
+ // The old behavior warned "found no usable version; falling back to
2339
+ // defaults" and launched the pinned default anyway — the exact move
2340
+ // that loops a rotate into an exhausted account. Only a window reset
2341
+ // clears it. The message text is a contract the Factory watchdog
2342
+ // tail-detects; do not reword it.
2343
+ // - NEEDS A SIGN-IN (signed_out / revoked) -> launching IS the fix,
2344
+ // because the harness's own TUI is the login surface. So on a TTY we
2345
+ // carry the user into that login instead of erroring. Exiting here
2346
+ // made `agents run <agent>`, `agents run <agent>@`, and `agents use`
2347
+ // all dead-end with no reachable way to authenticate.
2348
+ const recoverable = signInRecoverableCandidates(resolved.exhausted);
2349
+ const { signInLaunchDecision } = await import('./run-account-picker.js');
2350
+ const decision = signInLaunchDecision({
2351
+ recoverable: recoverable.length,
2352
+ tty: isInteractiveTerminal(),
2353
+ json: options.json === true,
2354
+ });
2355
+ if (decision === 'launch') {
2356
+ const { pickSignInLaunchVersion } = await import('./run-account-picker.js');
2357
+ const signInVersion = await pickSignInLaunchVersion(agent, recoverable, !!options.quiet);
2358
+ // A cancelled prompt launches nothing — same contract as the
2359
+ // trailing-@ account picker above.
2360
+ if (!signInVersion)
2361
+ return;
2362
+ version = signInVersion;
2363
+ // We just told the user this account is logged out and why we're
2364
+ // launching it, so suppress the downstream login preflight — it
2365
+ // would print a second, near-identical "looks logged out" warning.
2366
+ signInLaunch = true;
2367
+ }
2368
+ else {
2369
+ console.error(chalk.red(formatNoHealthyAccountError(agent, strategy, resolved.exhausted)));
2370
+ if (recoverable.length > 0) {
2371
+ // Off a TTY nobody can complete a login, so we still exit — but
2372
+ // name the actual fix rather than only offering --strategy pinned,
2373
+ // which would just pin the same unauthenticated account.
2374
+ const { loginHint } = await import('../lib/signin-badge.js');
2375
+ console.error(chalk.gray(`To sign in: ${loginHint(agent)} — or run \`agents run ${agent}\` from a terminal.`));
2376
+ }
2377
+ process.exit(1);
2378
+ }
2338
2379
  }
2339
- if (resolved.version) {
2380
+ else if (resolved.version) {
2340
2381
  version = resolved.version;
2341
2382
  rotationResult = resolved.rotation;
2342
2383
  if (resolved.rotation && !options.quiet) {
@@ -2428,7 +2469,10 @@ export function registerRunCommand(program) {
2428
2469
  json: options.json,
2429
2470
  quiet: options.quiet,
2430
2471
  authCheckDisabled: options.authCheck === false || process.env.AGENTS_NO_AUTH_CHECK === '1',
2431
- rotated: !!rotationResult || accountPickerRequested,
2472
+ // `signInLaunch` means the zero-healthy path already reported this exact
2473
+ // account as logged out and named the login command, so re-probing here
2474
+ // only prints a second, near-identical warning.
2475
+ rotated: !!rotationResult || accountPickerRequested || signInLaunch,
2432
2476
  });
2433
2477
  if (preflight) {
2434
2478
  try {
@@ -13,7 +13,7 @@ import { isInteractiveTerminal } from './utils.js';
13
13
  import { assertValidSshTarget } from '../lib/ssh-exec.js';
14
14
  import { getProvider, listAllHosts, resolveHost } from '../lib/hosts/registry.js';
15
15
  import { getDevice } from '../lib/devices/registry.js';
16
- import { sshTargetFor } from '../lib/hosts/types.js';
16
+ import { hostIdentityArgs, sshTargetFor } from '../lib/hosts/types.js';
17
17
  import { listSshConfigHosts, listKnownHosts, isSshConfigHost } from '../lib/hosts/ssh-config.js';
18
18
  import { probeHost, remoteAgentsVersion, bootstrapAgentsCli, localCliVersion, } from '../lib/hosts/ready.js';
19
19
  import { resolveRemoteOsSync } from '../lib/hosts/remote-os.js';
@@ -190,14 +190,15 @@ async function doCheck(name) {
190
190
  const target = sshTargetFor(host);
191
191
  const os = host.os ?? resolveRemoteOsSync(name);
192
192
  process.stdout.write(`Probing ${chalk.cyan(name)} (${target})… `);
193
- const probe = probeHost(target, os);
193
+ const identityArgs = hostIdentityArgs(host);
194
+ const probe = probeHost(target, os, identityArgs);
194
195
  if (!probe.reachable) {
195
196
  console.log(chalk.red('unreachable'));
196
197
  process.exitCode = 1;
197
198
  return;
198
199
  }
199
200
  console.log(chalk.green('reachable') + chalk.gray(probe.os ? ` · ${probe.os}` : ''));
200
- const ver = remoteAgentsVersion(target, probe.os ?? os);
201
+ const ver = remoteAgentsVersion(target, probe.os ?? os, identityArgs);
201
202
  console.log(` agents-cli: ${ver ? chalk.green(ver) : chalk.yellow('not installed')}`);
202
203
  }
203
204
  async function doRemove(name) {
@@ -23,6 +23,16 @@ interface PerfGlobalOpts {
23
23
  }
24
24
  /** Map warehouse rows shaped like hook.fire into the existing HookProfileRow UI. */
25
25
  export declare function asHookRows(rows: PerfAggregateRow[]): HookProfileRow[];
26
+ /**
27
+ * `err:12% block:40% to:4%` when any rate is present, else ''.
28
+ * Exit 2 (intentional deny) is `block:`, not `err:` — so a deny-by-design
29
+ * guard no longer reads as a crashing hook in the table.
30
+ */
31
+ export declare function formatRateColumn(r: {
32
+ errorRate?: number;
33
+ blockRate?: number;
34
+ timeoutRate?: number;
35
+ }): string;
26
36
  export declare function renderHookTable(rows: HookProfileRow[], warnMs: number): void;
27
37
  /**
28
38
  * Prefer SQLite samples; fall back to the legacy daily JSONL so existing
@@ -43,6 +43,8 @@ export function asHookRows(rows) {
43
43
  cacheMissPct: r.cacheMissPct ?? 0,
44
44
  errorCount: r.errorCount ?? 0,
45
45
  errorRate: r.errorRate,
46
+ blockCount: r.blockCount ?? 0,
47
+ blockRate: r.blockRate,
46
48
  timeoutRate: r.timeoutRate,
47
49
  project: r.project,
48
50
  }));
@@ -57,11 +59,17 @@ function printTable(headers, widths, lines, slowFlags) {
57
59
  console.log(slowFlags[i] ? chalk.yellow(line) : line);
58
60
  }
59
61
  }
60
- /** `err:12% to:4%` when either rate is present, else ''. */
61
- function formatRateColumn(r) {
62
+ /**
63
+ * `err:12% block:40% to:4%` when any rate is present, else ''.
64
+ * Exit 2 (intentional deny) is `block:`, not `err:` — so a deny-by-design
65
+ * guard no longer reads as a crashing hook in the table.
66
+ */
67
+ export function formatRateColumn(r) {
62
68
  const parts = [];
63
69
  if (r.errorRate)
64
70
  parts.push(`err:${Math.round(r.errorRate * 100)}%`);
71
+ if (r.blockRate)
72
+ parts.push(`block:${Math.round(r.blockRate * 100)}%`);
65
73
  if (r.timeoutRate)
66
74
  parts.push(`to:${Math.round(r.timeoutRate * 100)}%`);
67
75
  return parts.join(' ');
@@ -73,7 +81,7 @@ export function renderHookTable(rows, warnMs) {
73
81
  console.log(chalk.gray('Hooks write via cache/matches shims into the spool; run a session or resync hooks.'));
74
82
  return;
75
83
  }
76
- const widths = { hook: 36, n: 5, p50: 7, p95: 7, p99: 7, mean: 7, max: 7, cache: 22, rate: 14 };
84
+ const widths = { hook: 36, n: 5, p50: 7, p95: 7, p99: 7, mean: 7, max: 7, cache: 22, rate: 22 };
77
85
  const pad = (s, w) => (s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length));
78
86
  const header = [
79
87
  pad('HOOK', widths.hook),
@@ -84,7 +92,7 @@ export function renderHookTable(rows, warnMs) {
84
92
  pad('MEAN', widths.mean),
85
93
  pad('MAX', widths.max),
86
94
  pad('CACHE', widths.cache),
87
- pad('ERR/TIMEOUT', widths.rate),
95
+ pad('ERR/BLOCK/TO', widths.rate),
88
96
  ].join(' ');
89
97
  console.log(chalk.bold(header));
90
98
  console.log(chalk.gray('─'.repeat(header.length)));
@@ -112,8 +120,8 @@ function renderLabelTable(title, rows, warnMs, limit) {
112
120
  console.log(chalk.gray(`No ${title} samples yet.`));
113
121
  return;
114
122
  }
115
- const widths = [40, 5, 7, 7, 7, 7, 7, 14];
116
- printTable(['LABEL', 'N', 'P50', 'P95', 'P99', 'MEAN', 'MAX', 'ERR/TIMEOUT'], widths, sliced.map((r) => [
123
+ const widths = [40, 5, 7, 7, 7, 7, 7, 22];
124
+ printTable(['LABEL', 'N', 'P50', 'P95', 'P99', 'MEAN', 'MAX', 'ERR/BLOCK/TO'], widths, sliced.map((r) => [
117
125
  r.label,
118
126
  String(r.n),
119
127
  formatMs(r.p50Ms),
@@ -20,9 +20,9 @@ import { machineId } from '../lib/machine-id.js';
20
20
  import { getActiveSessions } from '../lib/session/active.js';
21
21
  import { gatherRemoteActive } from '../lib/session/remote-active.js';
22
22
  import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
23
- import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, workspaceWarnings, } from '../lib/project-probe.js';
23
+ import { formatFleetSummary, formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, workspaceWarnings, } from '../lib/project-probe.js';
24
24
  import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, validateProjectDef, } from '../lib/projects.js';
25
- import { rollupSessionsByProject, withDefaultMachine, isDeadStatus, liveDeadSplit, enrichProjectSignals, formatProjectMembersByHost, formatProjectWarnings, } from '../lib/project-status.js';
25
+ import { rollupSessionsByProject, withDefaultMachine, isDeadStatus, formatDeadSummary, liveDeadSplit, enrichProjectSignals, formatProjectMembersByHost, formatProjectWarnings, } from '../lib/project-status.js';
26
26
  import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
27
27
  import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
28
28
  import { checkRepoSlug } from '../lib/project-doctor.js';
@@ -302,8 +302,7 @@ warnWorkspaces = []) {
302
302
  if (split.dead > 0) {
303
303
  // Wreckage is worth a number of its own — 19 crashed sessions is a thing to
304
304
  // go fix, not a throughput signal to fold into the headline.
305
- const deadDetail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
306
- console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${deadDetail})`)}`);
305
+ console.log(` ${chalk.dim('dead')} ${formatDeadSummary(split)}`);
307
306
  }
308
307
  // Live only, grouped by host when machine stamps exist so "who is on which
309
308
  // box" is visible. Flat collapse hid that when harness×status matched across hosts.
@@ -327,7 +326,8 @@ warnWorkspaces = []) {
327
326
  if (ships.length)
328
327
  console.log(` ${chalk.dim('ships')} ${ships.join(' · ')}`);
329
328
  if (linear) {
330
- console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
329
+ const pct = linear.total > 0 ? ` ${chalk.dim(`(${Math.round((linear.done / linear.total) * 100)}%)`)}` : '';
330
+ console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done${pct} · ${linear.inProgress} in progress`);
331
331
  }
332
332
  for (const line of formatMilestoneLines(linear?.milestones ?? [], linear?.nextMilestone, nowMs, milestoneLimit)) {
333
333
  console.log(line);
@@ -346,13 +346,18 @@ warnWorkspaces = []) {
346
346
  console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
347
347
  }
348
348
  if (fleet) {
349
- const lines = formatFleetWorkspaces(fleet);
350
- if (lines.length === 0) {
349
+ const table = formatFleetWorkspaces(fleet);
350
+ if (table.length === 0) {
351
351
  console.log(` ${chalk.dim('fleet')} ${chalk.gray('no workspace paths (set root or repos[].path)')}`);
352
352
  }
353
- lines.forEach((line, i) => {
354
- console.log(` ${chalk.dim((i === 0 ? 'fleet' : '').padEnd(5))} ${line}`);
355
- });
353
+ else {
354
+ // A compact health line first (scan without reading every host), then the
355
+ // full per-host table under it. The grouped footer carries the actionable
356
+ // subset; this line + the table carry the whole picture.
357
+ [formatFleetSummary(fleet), ...table].forEach((line, i) => {
358
+ console.log(` ${chalk.dim((i === 0 ? 'fleet' : '').padEnd(5))} ${line}`);
359
+ });
360
+ }
356
361
  }
357
362
  if (remote?.artifacts) {
358
363
  const last = remote.lastArtifact ? ` ${chalk.dim(`· last: ${remote.lastArtifact}`)}` : '';
@@ -639,6 +644,15 @@ export function registerProjectsCommands(program) {
639
644
  }
640
645
  // Compact rollup shows the next milestone; `view` shows every declared one.
641
646
  const milestoneLimit = detail ? Number.POSITIVE_INFINITY : 1;
647
+ // Stamp the fleet-wide rollup with when it was taken, so a scrollback isn't
648
+ // mistaken for a live snapshot. A single named `view` skips it (one card, and
649
+ // its own detail makes the freshness obvious).
650
+ if (!detail) {
651
+ const t = new Date(nowMs);
652
+ const hhmm = `${String(t.getHours()).padStart(2, '0')}:${String(t.getMinutes()).padStart(2, '0')}`;
653
+ console.log(chalk.dim(`fleet snapshot · as of ${hhmm}`));
654
+ console.log('');
655
+ }
642
656
  for (const d of defs) {
643
657
  renderCard(d, roll.get(d.name), remote.get(d.name), fleetFor(d), linear.get(d.name), nowMs, milestoneLimit, focus.get(d.name) ?? [], detail,
644
658
  // Always feed workspace rows into the warnings footer so behind/dirty
@@ -1560,8 +1560,17 @@ export function registerRoutinesCommands(program) {
1560
1560
  const unknown = [...selectedSet].filter((device) => !all.includes(device));
1561
1561
  if (unknown.length > 0)
1562
1562
  throw new Error(`Unknown device${unknown.length === 1 ? '' : 's'}: ${unknown.join(', ')}`);
1563
+ // --set/--clear fan out to every registered device so peers outside the
1564
+ // new set get paused. An asleep/offline peer must not abort the whole
1565
+ // pin: the target may already be applied, and an unreachable box cannot
1566
+ // be running the routine (it picks up the enabled set on next sync).
1567
+ // Exit non-zero only when a *selected* device could not be enabled
1568
+ // (github.com/phnx-labs/agents-cli#2118).
1569
+ const skipped = [];
1570
+ const failedTargets = [];
1563
1571
  for (const device of all) {
1564
1572
  const action = selectedSet.has(device) ? 'resume' : 'pause';
1573
+ const isTarget = selectedSet.has(device);
1565
1574
  if (device === normalizeHost(machineId())) {
1566
1575
  setJobEnabled(name, action === 'resume');
1567
1576
  if (isDaemonRunning())
@@ -1570,12 +1579,30 @@ export function registerRoutinesCommands(program) {
1570
1579
  }
1571
1580
  const launch = getCliLaunch(['routines', action, name, '--host', device]);
1572
1581
  const result = spawnSync(launch.command, launch.args, { stdio: 'inherit', env: process.env });
1573
- if ((result.status ?? 1) !== 0)
1574
- throw new Error(`Could not ${action} '${name}' on ${device}`);
1582
+ if ((result.status ?? 1) !== 0) {
1583
+ if (isTarget) {
1584
+ failedTargets.push(device);
1585
+ }
1586
+ else {
1587
+ skipped.push({ device, action });
1588
+ }
1589
+ }
1575
1590
  }
1591
+ for (const s of skipped) {
1592
+ console.log(chalk.yellow(`Skipped ${s.action} of '${name}' on ${s.device} (unreachable; will sync when online)`));
1593
+ }
1594
+ if (failedTargets.length > 0) {
1595
+ const offlineNote = skipped.length > 0
1596
+ ? ` (also skipped offline: ${skipped.map((s) => s.device).join(', ')})`
1597
+ : '';
1598
+ throw new Error(`Could not enable '${name}' on: ${failedTargets.join(', ')}${offlineNote}`);
1599
+ }
1600
+ const offlineNote = skipped.length > 0
1601
+ ? ` (${skipped.length} offline device${skipped.length === 1 ? '' : 's'} skipped)`
1602
+ : '';
1576
1603
  console.log(chalk.green(selected.length === 0
1577
- ? `Routine '${name}' disabled on every registered device`
1578
- : `Routine '${name}' enabled on: ${selected.join(', ')}`));
1604
+ ? `Routine '${name}' disabled on every registered device${offlineNote}`
1605
+ : `Routine '${name}' enabled on: ${selected.join(', ')}${offlineNote}`));
1579
1606
  };
1580
1607
  if (options.clear) {
1581
1608
  await applyDevices([]);
@@ -4,11 +4,48 @@ export interface RunAccountChoice {
4
4
  name: string;
5
5
  value: string;
6
6
  disabled?: string;
7
+ /** Can serve a run right now: signed in, authenticated, and under quota. */
7
8
  ready: boolean;
9
+ /**
10
+ * Selectable, but picking it launches the harness so you can authenticate
11
+ * first (RUSH-2334). Mutually exclusive with `ready`; never `disabled`.
12
+ */
13
+ signInRequired: boolean;
8
14
  }
9
15
  /** Human-readable remaining capacity for every window the provider exposes. */
10
16
  export declare function formatAccountLimits(candidate: RotateCandidate): string;
11
17
  /** Build aligned picker rows with usable accounts first and unsafe rows disabled. */
12
18
  export declare function buildRunAccountChoices(candidates: RotateCandidate[], globalDefault: string | null): RunAccountChoice[];
19
+ /**
20
+ * Whether a zero-healthy run may recover by launching for a login, or must keep
21
+ * failing loud. Three inputs, all of which have to hold:
22
+ *
23
+ * - `recoverable` — at least one excluded account is only auth-blocked. An
24
+ * all-throttled set is never launched (RUSH-2132): only a window reset clears it.
25
+ * - `tty` — a login needs a human present; off a TTY nobody can complete one.
26
+ * - `json` — `--json` marks a MACHINE consumer, which must never be handed a
27
+ * picker or dropped into a login TUI. This mirrors the canonical
28
+ * `Surface.interactive = tty && !json` in `commands/utils.ts`; a `--json` caller
29
+ * gets the parseable fail-loud error instead.
30
+ */
31
+ export declare function signInLaunchDecision(input: {
32
+ recoverable: number;
33
+ tty: boolean;
34
+ json: boolean;
35
+ }): 'launch' | 'fail-loud';
36
+ /**
37
+ * Choose which installed version to launch so the user can authenticate, when a
38
+ * strategy found zero healthy accounts but at least one is merely signed out
39
+ * (RUSH-2334). Returns the version to launch, or null if the user cancelled.
40
+ *
41
+ * A single candidate does NOT prompt — a one-item picker is pure noise, and the
42
+ * only thing to decide has one answer. Several candidates fall through to the
43
+ * normal account picker, which shows every account with its state so the choice
44
+ * is informed (throttled rows stay disabled there).
45
+ *
46
+ * Callers MUST have already confirmed an interactive terminal: off a TTY there
47
+ * is nobody to complete the login, and the run should fail loud instead.
48
+ */
49
+ export declare function pickSignInLaunchVersion(agent: AgentId, recoverable: RotateCandidate[], quiet?: boolean): Promise<string | null>;
13
50
  /** Prompt for one safe installed account/version. A cancelled picker launches nothing. */
14
51
  export declare function pickRunAccountCandidate(agent: AgentId): Promise<RotateCandidate | null>;