@phnx-labs/agents-cli 1.20.76 → 1.20.78

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 (119) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,269 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.20.78
4
+
5
+ - **`agents sessions <uuid>` now resolves a remote session exactly, across the
6
+ fleet.** A full session id absent from the local disk used to fall back to an
7
+ FTS content search — and because a UUID appears verbatim in other sessions'
8
+ transcripts (a watchdog `/continue <uuid>` reference), that surfaced a list of
9
+ unrelated "matches" instead of the one session, which actually lived on another
10
+ machine. A UUID is now treated as an identifier: on a local miss the CLI fans
11
+ the id lookup out to the online fleet (the existing `gatherRemoteList` SSH
12
+ sweep), and when exactly one machine holds it, renders that session's summary
13
+ from the owning peer via `runOnPeer` (instead of `Session transcript not
14
+ available`). Same id on more than one box surfaces a machine-labeled conflict to
15
+ disambiguate with `--device <host>`; a UUID found nowhere prints a clear "no
16
+ session on this machine" message. There is **no** fuzzy/content fallback for a
17
+ UUID anywhere — the peer's `--json` answer id-resolves too, so a content
18
+ mentioner can never masquerade as the session. `--local` still restricts the
19
+ lookup to the local machine, and a peer already answering a parent's sweep
20
+ (`AGENTS_SESSIONS_LOCAL=1`) never re-fans-out. Source:
21
+ `resolveSessionAcrossFleet` / `fleetHitsById` / `shouldFanOutForId` in
22
+ `apps/cli/src/commands/sessions.ts` (wired into `renderOneSession`), and the
23
+ id-only `--json` resolution at the sessions listing seam. (RUSH-2024)
24
+
25
+ - **`agents doctor --devices` now detects cross-device harness divergence (RUSH-2027).** The umbrella fleet diagnostic compares each registered device's installed harness inventory — resources (commands, skills, hooks, rules, mcp, permissions, subagents, plugins, promptcuts, workflows), per-agent installed versions, and `.agents`/`.system` config-repo state (branch, HEAD, dirty) — against the local machine as the baseline, and flags anything present on one box but missing on another. A plugin like `swarm` installed on `zion` but absent on `yosemite-s0` now surfaces as a clear warning (`yosemite-s0 is missing plugin 'swarm' (present on zion)`) instead of only being discovered at runtime as `Unknown command: /swarm:run`. Agent-version gaps (`yosemite-s0 is missing claude@2.1.220`) and diverged config repos are reported too. Read-only by default — it never installs or syncs; `--json` carries a stable `fleet` divergence block for the VS Code extension to consume. `agents fleet status` gained the same per-device divergence warning in its rollup. Every device's top-level `doctor --json` now emits a `fleet` inventory field so the comparison needs no extra probe. Source: `apps/cli/src/lib/devices/fleet-divergence.ts` (comparator), `apps/cli/src/lib/devices/fleet-inventory.ts` (`collectLocalFleetInventory`), `apps/cli/src/commands/doctor.ts` (`runDevicesDoctor`, `renderFleetDivergence`, `--json` `fleet` field), `apps/cli/src/lib/devices/health-report.ts` (`buildFleetHealthReport` divergence warning), `apps/cli/src/lib/git.ts` (`readRepoState`).
26
+
27
+ - **`agents doctor` now shows repo-behind notices; they no longer appear on stderr during normal commands (RUSH-2048).** `printPendingUpdateNotices()` — which wrote "agents-cli: ~/.agents/ is N commits behind origin/main" to stderr on every CLI invocation — is replaced by `readRepoBehindMarkers()`, which returns the same data without printing. `agents doctor` reads these markers and renders a "Repo updates" section showing which repos are behind and the `agents repo pull <alias>` fix command. `agents doctor --json` emits a `repos` array so menubar helpers and other consumers can read the same data. Markers persist on disk until the next background fetch overwrites them, so the notice stays visible until the user acts. Source: `apps/cli/src/lib/auto-pull.ts`, `apps/cli/src/commands/doctor.ts`, `apps/cli/src/index.ts`.
28
+
29
+ - **Session affinity data + host affinity resolver (RUSH-2049).** Sessions index
30
+ persists `machine` (schema v18) so affinity can `GROUP BY machine`.
31
+ `queryAffinityRollup` returns launch counts by device (and harness/joint for
32
+ analytics). Host affinity sampling lives in `smart-launch.ts` as
33
+ `resolveDeviceAffinity` / `applyDeviceAutoToOptions` (weight ∝ launches^α;
34
+ online hosts with no history still explore at weight 1). Account pick stays
35
+ the existing balanced strategy (live session/week rate-limit windows).
36
+ **User-facing host pick shipped as `--device auto` / `--host auto` in
37
+ RUSH-2059** (not a public `--smart` flag and not harness auto-pick). Source:
38
+ `apps/cli/src/lib/session/db.ts`, `origin-machine.ts`, `smart-launch.ts`.
39
+
40
+ - **`agents sessions --all` now widens every non-status filter, not just the
41
+ directory (RUSH-2055).** `--all` used to only drop the current-project scope; it
42
+ now also drops the 30-day window cap, so one flag means "all values for every
43
+ non-status filter" — all directories AND all time. `--active` still composes as a
44
+ status filter, and `-a` / `--device` / `--since` still narrow their own axis (an
45
+ explicit `--since` overrides the all-time default). Applies to both the bare
46
+ listing and `--active`. Source: `apps/cli/src/commands/sessions-browser.ts`.
47
+
48
+ - **Device affinity is `--device auto` (not `--smart` / harness `auto`) (RUSH-2059).**
49
+ Host pick from 14d usage affinity is a special value on the existing host flags:
50
+ `agents run claude --device auto` or `--host auto`. The harness is always the
51
+ agent you type — never auto-selected. Deprecated hidden `--smart` maps to
52
+ `--device auto` for one release. Extension New Agent unpinned launches use
53
+ `--device auto`. Banner: `device=auto → <host> (affinity …) · accounts=balanced`.
54
+ Source: `apps/cli/src/commands/exec.ts`, `smart-launch.ts`,
55
+ `apps/factory/src/core/agents.ts`.
56
+
57
+ - **`agents sessions --active` no longer hides most of your running sessions.** On a TTY, `--active` opens the interactive browser, and the browser resolved "running" two ways that both dropped live sessions: its live scan called the local-only `getActiveSessions()` instead of the fleet sweep the static view uses, and it treated running as an *intersection* with the transcript index (`pool.filter(r => live.has(r.id))`) rather than a source of rows. Together they meant every session on another machine was invisible, as was any local one the index didn't already carry — a fleet with 32 live sessions across 7 machines showed 4. The browser now shares one gather with the static view (`gatherActiveSessions`) and folds live sessions the index lacks in as their own rows, keyed by session id, cloud task id, or `machine:pid` so two id-less sessions never collapse into one. Picking a row that has no session id yet reports where the process is instead of trying to open a transcript that doesn't exist. Source: `apps/cli/src/commands/sessions.ts` (`gatherActiveSessions`, `isIdlessLiveRow`), `apps/cli/src/commands/sessions-browser.ts` (`liveRowKey`, `indexLiveRows`, `liveSessionToMeta`, `mergeLiveIntoPool`).
58
+ - **The session browser now shows which program each running session is in.** A new host column names the terminal or editor hosting the session — `codium`, `ghostty`, `tmux`, or `tmux→ghostty` when a tmux session is being watched through another app (a bare `tmux` means it is running detached) — so a session in the list can actually be found. The column is live-only and appears just in the running view, since transcript metadata carries no host. The id column also truncates now, so a row named by a 7-digit pid can no longer shunt every later column out of alignment. Source: `apps/cli/src/commands/sessions.ts` (`liveHostLabel`, `formatPickerLabel`, `PickerColumns.showHost`).
59
+
60
+ - **The routines daemon holds no Claude credential and injects no token.** A
61
+ scheduled or daemon-fired Claude run now authenticates exactly like an
62
+ interactive `agents run claude` on the same machine: through the rotation-pinned
63
+ account's own `CLAUDE_CONFIG_DIR` login (`.credentials.json`), which Claude Code
64
+ refreshes per-device. The daemon previously read a token from the `claude`
65
+ secrets bundle and injected it into every routine spawn — first as one ambient
66
+ `CLAUDE_CODE_OAUTH_TOKEN` (RUSH-1759), then also as per-account
67
+ `CLAUDE_CODE_OAUTH_TOKEN_<account>` setup-tokens — which shadowed each account's
68
+ own on-disk login and made the daemon a second, competing credential store. Both
69
+ paths are removed, along with the sandbox `ENV_ALLOWLIST` entry that forwarded
70
+ them; a sandboxed routine now strips `CLAUDE_CODE_OAUTH_TOKEN` from its
71
+ environment and falls through to the per-account login. A box whose interactive
72
+ login has expired is skipped up front by the auth-health preflight with a
73
+ `re-login required` hint instead of running on an injected fallback — log in once
74
+ on that box (`agents run claude`) to restore it; no daemon restart is needed. This
75
+ keeps the daemon out of the credential entirely, which is what avoids the
76
+ fleet-wide rotation logout (a shared/injected token was the cause, not the fix).
77
+ Removed: `readDaemonClaudeOAuthToken` / `readDaemonClaudeBundleEnv` /
78
+ `buildDetachedDaemonEnv` (`daemon.ts`), `resolveAccountSetupToken` and
79
+ `apps/cli/src/lib/secrets/account-token.ts`, `claudeHomeHasOwnCredential`
80
+ (`agents.ts`). Source: `apps/cli/src/lib/daemon.ts`, `runner.ts`, `sandbox.ts`,
81
+ `agents.ts`.
82
+
83
+ - **`agents sessions` no longer over-counts test results from arbitrary stdout.** The catch-up digest scraped any `\d+ pass`-shaped substring anywhere in a command's output, so a `442 passwords generated` log, a `git status: 442 files` line, or a `442 passes/sec` benchmark was reported as `Tests ✓ tests 442 pass`. It also treated any command merely containing a runner token as a test run, so npm-script sub-targets like `bun test:setup`, `npm run test:watch`, or `pnpm test:ci` were counted. Test-run classification now matches only real invocations (`bun/npm/yarn/pnpm test` bare, `vitest`, `jest`, `mocha`, `pytest`, `go test`, `cargo test`, `tsc`) and rejects `:sub-target` scripts, and pass/fail counts are read only from each runner's authoritative summary construct — vitest's ` Tests N passed` / ` Tests N failed | M passed` row, jest's `Tests:` line, pytest's `=== N passed[, M failed] in Xs ===` rule, bun's ` N pass`/` N fail` block closed by `Ran N tests`, and mocha's ` N passing`/` N failing`. A verdict is reported only when a real summary matched, so an ambiguous blob now shows nothing instead of a fabricated pass count. Source: `apps/cli/src/lib/session/digest.ts` (`TEST_RUNNERS` classification with `(?![:\w-])` guard, new `parseSummaryLine`, `parseTestOutput`); consumed by `apps/cli/src/lib/session/render.ts` (`renderTestsLine`) and `apps/cli/src/commands/sessions-picker.ts`.
84
+
85
+ - **Readable `Dirs:` line in `agents sessions`.** The session preview's touched-directories line no longer renders raw Claude project-slugs (`-home-me--agents-…`) or nested worktree paths. Paths under a git worktree collapse to `⧉ <slug>/<remainder>`; a Claude project-slug is matched in slug space (its cwd/`.`-encoding is lossy, so it is never decoded to a fake path) — a slug worktree shows `⧉ <name>` and a slug pointing at the session's own cwd (internal projects-storage scratch) is dropped; real paths still relativize against the session cwd and home (`~`). Source: `apps/cli/src/commands/sessions-picker.ts`.
86
+
87
+ - **`release.sh` is now a zero-config, self-routing release — runnable from any fleet box with an empty environment.** No routing/secret environment variables: `SIGN_HOST`, `SECRET_HOST`, `SIGN_HOST_REPO`, `FORCE_REMOTE_SIGN`, the `PREFERRED_SIGN_HOSTS` list, the `zion` fallback, and the `agents devices` fleet discovery are all gone. The release has three self-selected homes: git/gh orchestration on the invoking box, the Linux test suite on a **dynamic crabbox** (`scripts/sandbox.sh` selects an available Hetzner VM for the repo's `.crabbox.yaml` profile or warms one — never a hardcoded instance), and build + sign + notarize + `npm publish` + computer-helper on the **`mac-mini` home base** (the one hardcoded name, `RELEASE_HOME_BASE`). The script detects its own host (`scutil --get LocalHostName` / `hostname -s`) and runs the privileged phase on the home base — locally if already there, else over ssh — always by checking out the `v<version>` tag into a throwaway worktree and running **that worktree's** `release.sh --home-base-phase`, so the publishing script is the one carried by the release tag, never the home base's stale on-disk checkout; the worktree is removed on exit on success or failure. The npm token is resolved on the home base and never borrowed to the trigger box. A new shared `scripts/headless-sign-context.sh` factors the headless keychain-unlock + `AGENTS_SECRETS_PASSPHRASE` preamble (no Touch ID) used by both the on-home-base publish and `remote-sign-mac.sh`. A phase tracker (`[n/N]`, N=6 for a normal release, 4 for a catch-up publish) labels each phase with the box it runs on and a ✓/✗ result; a crabbox test failure prints the failing tests + the captured log path and halts before any PR/publish. Idempotency/catch-up/tree-verification guards are preserved. Source: `apps/cli/scripts/release.sh`, `apps/cli/scripts/remote-sign-mac.sh`, `apps/cli/scripts/headless-sign-context.sh`.
88
+
89
+ - **`agents secrets unlock` now grants globally, so one Touch ID actually covers everything.** An unlock was silently scoped to the ambient `AGENTS_AGENT_NAME`: typed in a plain shell it was stored under a literal `cli` harness, while a read from inside an agent looked under *its* harness (`claude`, `codex`, …). The two never met, so a valid 7-day grant was invisible to every agent for its whole life — `agents secrets exec <bundle>` reported "not unlocked in the secrets agent" while the bundle sat unexpired in the store, and each miss cost another Touch ID or blocked a headless run outright. An unlock with no `--for` is now a global grant that every harness and a plain shell can read; `--for <agent>` still narrows it to one harness, and readers resolve own-harness → global so a narrow grant wins where it applies. The broker's in-memory store and the durable session store share one scope chain, so behavior is identical before and after a daemon restart. Grants already written under the old `cli` scope migrate to global on the next broker start — an unlock you already paid Touch ID for keeps working across the upgrade instead of going unreadable. Source: `apps/cli/src/lib/secrets/scope.ts` (`GLOBAL_HARNESS`, `bundleScopeChain`), `apps/cli/src/lib/secrets/agent.ts` (`get` handler), `apps/cli/src/lib/secrets/session-store.ts` (`resolveSession`, `cli`→global migration), `apps/cli/src/lib/secrets/bundles.ts` (`readAndResolveBundleEnv`), `apps/cli/src/commands/secrets.ts` (`unlock --for`).
90
+
91
+ - **`agents sessions export <id>` now resolves a short id the same way `sessions
92
+ <id>` does — by id only, never fuzzy content.** The id-only fix landed for the
93
+ `sessions` view but `sessions export` still gated its index lookup on
94
+ `isCompleteSessionId`, so a bare hex short-id like `d3470b57` absent from the
95
+ discovered pool skipped the index and fell through to the text query — bundling
96
+ every transcript that merely MENTIONED the id into the export. The one canonical
97
+ id-shaped test, `looksLikeSessionId`, now lives beside `isCompleteSessionId` in
98
+ `lib/session/discover.ts` and is shared: `sessions export` resolves any id-shaped
99
+ selector through the index (exact -> prefix -> `findSessionsById`) and reports
100
+ "No session with id …" on a miss instead of shipping the mentioner. Source:
101
+ `apps/cli/src/lib/session/discover.ts`, `apps/cli/src/commands/sessions-export.ts`.
102
+
103
+ - **Resolve a Claude transcript across every version home, not just the live `~/.claude`.** A session launched under an earlier agent version keeps its transcript under that version's home; resolving only the `~/.claude` symlink (which repoints to the newest installed version) meant that installing a new version silently hid every still-running older-version session — no `sessionFile`, so `agents sessions` rendered it `unknown` and the watchdog skipped it as "no activity timestamp". `findClaudeSessionFile` now searches all version-home project roots via `getAgentSessionDirs`, newest mtime winning. Source: `apps/cli/src/lib/session/active.ts`.
104
+
105
+ ## 1.20.77
106
+
107
+ - **Interactive `agents run --host` now tracks the real session for every agent,
108
+ not just Claude.** Codex, Kimi, Grok, and Gemini coin their own session id and
109
+ reject a caller-supplied one, so an interactive host run of any of them showed a
110
+ stale/absent id locally — `agents sessions` couldn't surface it and a dropped
111
+ link couldn't auto-reconnect it (RUSH-2033 fixed only the Claude `--session-id`
112
+ path). The launcher now forwards one correlation key it controls
113
+ (`AGENT_LAUNCH_ID`); the remote `agents run` adopts that key
114
+ (`resolveLaunchId`), so its SessionStart hook records the agent's real session id
115
+ under it. After the stream the launcher does one ssh read of the remote hook
116
+ record, resolves the real id by launch id (`resolveRemoteSessionId` /
117
+ `pickRemoteSessionId`), registers it in the local session index, and reconnects
118
+ against it on a dropped link. Claude still forces its own id up front and is
119
+ unchanged. Source: `apps/cli/src/lib/hosts/remote-session-id.ts`,
120
+ `resolveLaunchId` in `apps/cli/src/lib/exec.ts`, and the interactive `--host`
121
+ branch in `apps/cli/src/commands/exec.ts`. (RUSH-2034)
122
+
123
+ - **Project routines can opt into daemon firing with source tracking, sync, and
124
+ host placement (RUSH-2035).** Project YAML under `<project>/.agents/routines/*.yml`
125
+ stays inspection-only until `agents routines enable-project` (with interactive /
126
+ `--yes` approval) records the project on `meta.routines.projects` and materialises
127
+ copies into `~/.agents/routines/` with a `source:` block (`projectPath`, git
128
+ `repo`/`branch`/`commit`). `agents routines sync` (and daemon start/SIGHUP reload)
129
+ refreshes those copies when project YAML changes; `disable-project` / `projects`
130
+ manage the allowlist. New `hostStrategy: local|host|fleet|cloud` (CLI `--placement`)
131
+ chooses where the job body runs: local, a named `--run-on` host, one online fleet
132
+ device per fire (no cross-device double-fire — off-box strategies auto-pin
133
+ `devices`), or the agent's native cloud provider. `--host` remains the remote-
134
+ management passthrough. List/JSON surfaces source repo/branch and strategy. Source:
135
+ `apps/cli/src/lib/routines.ts`, `routines-project.ts`, `routines-placement.ts`,
136
+ `runner.ts`, `daemon.ts`, `commands/routines.ts`, `docs/03-routines.md`.
137
+
138
+ - **Factory interactive launches default to `--mode auto` (RUSH-2038).** The Factory VS Code extension no longer inherits the CLI's `plan` default for interactive terminal launches. Codex, Claude, Gemini, Cursor, OpenCode, and Antigravity now start in `auto` (writable-but-gated) when opened from Factory without an explicit mode, so the agent can edit files instead of stalling in a read-only sandbox. Source: `apps/factory/src/core/agents.ts`.
139
+
140
+ - **Codex approval blocks now notify you.** A headless or terminal Codex agent
141
+ blocked on an approval prompt used to stall silently — the feed/notification path
142
+ only fired for Claude. Codex emits `PermissionRequest` (not Claude's
143
+ `Notification`), which the `feed-publish` hook now handles: it publishes an
144
+ approval-class block with a high cost-of-delay and a `deny` safe-default, so the
145
+ blocked agent surfaces on `agents feed` and `agents feed --dispatch` pages the
146
+ phone as urgent. A Codex approval card clears once the approved tool runs, via a
147
+ matcher-less `PostToolUse` clear hook registered **for Codex only** — so Claude's
148
+ card lifetime (its `permission_prompt`/`idle_prompt`/`elicitation_dialog`
149
+ notification blocks persist until `Stop`/`SessionEnd`) and per-tool overhead are
150
+ exactly as before. The other feed hooks are now registered for Codex too, not
151
+ Claude only. The Factory extension bridges the same waiting state to an
152
+ edge-triggered VS Code notification with a "Focus terminal" action. Claude's path
153
+ is unchanged. Source: `FEED_PUBLISH_HOOK_SCRIPT` / `ensureFeedPublishHook` in
154
+ `apps/cli/src/lib/feed.ts`, `apps/factory/src/core/waitingNotifier.ts`.
155
+ (RUSH-2039)
156
+
157
+ - **`agents fleet ping` now completes within ~15 s per device and ~30 s total, even when several fleet devices are offline or slow (RUSH-2041).** The per-device remote auth probe timeout was lowered from 60 s to 15 s (matching the `fleet status` version-probe budget, which is enough for the ~8 s provider-fetch inside the local auth probe). `fanOutDevices` gained an optional `perDeviceTimeoutMs` that races each probe against a deadline and records it as `failed: timed out` instead of hanging. `runFleetPing` now also wraps the entire fan-out in a 30 s hard cap so the command can never outlast a reasonable budget. Offline devices are now reported promptly as failed/timed-out rather than left hanging in the spinner. Source: `apps/cli/src/lib/devices/fleet.ts` (`fanOutDevices`, `FanOutDeviceOptions`), `apps/cli/src/commands/ssh.ts` (`probeRemoteAuth`, `runFleetPing`).
158
+
159
+ - **`agents sessions` surfaces checklist progress in every list/preview (RUSH-2045).** The picker preview, `--active` rows (local + cross-machine), flat `doing` cell, and metadata-only previews now show compact `✓done/total · current step` from `SessionMeta.todos` / `ActiveSession.todos`, plus the originating prompt and a directories-touched activity line. Active/cross-machine rows also show label + clickable project/ticket alongside the agent short id. Covers interactive, headless, teams, and sub-agent sessions that share the preview infra. Source: `apps/cli/src/commands/sessions-picker.ts`, `apps/cli/src/commands/sessions.ts`.
160
+
161
+ - **Checklist completions emit a feed event (RUSH-2046).** When an agent marks a
162
+ task-checklist item done, the `11-activity-log.py` hook now appends a
163
+ `task.completed` milestone to the session activity log (and `checklist.created`
164
+ the first time a checklist appears), so completions show in `agents feed` and the
165
+ unified `agents events` stream with the item subject and running `N/M`. Detection
166
+ folds the transcript across harnesses — Claude `TaskUpdate`/`TodoWrite`, Grok
167
+ `todo_write`, Codex `update_plan` — so a completion is recognized regardless of
168
+ which agent produced it. Source: `apps/cli/src/lib/activity.ts` (incl. the embedded
169
+ hook), `apps/cli/src/lib/events.ts`, `apps/cli/src/commands/feed.ts`.
170
+
171
+ - **Actor provenance now survives the SSH hop.** A run dispatched to another host
172
+ (`agents run --host`, a remote `agents teams` supervisor, or any `--host`
173
+ passthrough) used to drop the resolved actor at the SSH boundary, so the remote
174
+ re-resolved it from the *originating* box's `SSH_CONNECTION` and mis-credited the
175
+ work to the shared machine or `UNRESOLVED@<host>`. The dispatch layer now forwards
176
+ `AGENTS_ACTOR*` / `GIT_*` across the wire (POSIX `export` and Windows `$env:`
177
+ alike), so the remote inherits the origin identity instead of re-resolving. A
178
+ caller-supplied env value still wins on collision (mirrors `buildExecEnv`).
179
+ Source: `withActorEnv` in `apps/cli/src/lib/hosts/dispatch.ts`, wired into
180
+ `launchDetached` / `runInteractiveOnHost` and the `--host` passthrough. (RUSH-2028)
181
+
182
+ - **A Linux-driven release now auto-discovers its macOS sign host instead of
183
+ hardcoding `mac-mini`.** `scripts/remote-sign-mac.sh` previously defaulted
184
+ `SIGN_HOST` to `mac-mini`, so a release from a Linux box failed outright whenever
185
+ that one appliance was offline — the recurring reason a release stalled and a
186
+ human had to finish it by hand. With `SIGN_HOST` unset the script now reads
187
+ `agents devices list --json`, keeps the reachable/online macOS devices, and picks
188
+ the first that answers `ssh` in preference order `mac-mini` → `zion` → any other
189
+ online Mac. `mac-mini` stays first because it signs headlessly (no Touch ID);
190
+ `zion` (the interactive Mac) is the fallback. An explicit `SIGN_HOST=<host>` still
191
+ pins one and skips discovery, and when no reachable Mac qualifies the script fails
192
+ with the ordered list it tried rather than hanging on a dead host. Source:
193
+ `apps/cli/scripts/remote-sign-mac.sh`.
194
+
195
+ - **An agent launch never raises a Touch ID sheet.** On macOS, starting an agent
196
+ terminal or firing a routine could pop several biometric prompts in a row, because
197
+ each keychain read runs in its own helper process and the biometric assertion never
198
+ reuses across processes. Two causes: `interactiveUnlock` defaulted to true whenever
199
+ an agent name was present, which let an agent-initiated read fall through the
200
+ `agentOnly` guard; and `isHeadlessSecretsContext` recognized the `headless` and
201
+ `teams` runtimes but not `terminal`, which is what an interactive run sets. Agent
202
+ launches now resolve broker-only and a locked bundle fails fast naming
203
+ `agents secrets unlock <bundle>`; `AGENTS_SECRETS_NO_PROMPT=1` is no longer needed
204
+ as a workaround. `agents secrets get/export/exec` typed in a **plain shell** still
205
+ prompts — it carries no `AGENTS_RUNTIME`, so the guard does not apply. Run beneath
206
+ an agent it refuses, because there the agent is the caller. This narrows the
207
+ agent-triggered approval added in RUSH-2032, which is unreleased.
208
+
209
+ - **`release.sh` now borrows the npm token from a primary device when the local box
210
+ has none, so a Linux-driven release stops asking a human to approve a token.**
211
+ Token resolution was env → local `npmjs.com` bundle → *die*. On a fleet box whose
212
+ own keychain holds no npm token, that dead end pushed agents to hand-move a
213
+ credential between machines (and correctly get gated on it). A third step now
214
+ resolves the bundle **ephemerally from a primary device over SSH** —
215
+ `agents secrets exec npmjs.com --host <host>`, which resolves on the remote and
216
+ injects into the run only, never storing the token locally. It tries `SECRET_HOST`
217
+ first, then `zion`, then `mac-mini`, and fails with the list it tried if none
218
+ answer. Combined with the sign-host auto-discovery, a Linux box can now cut a full
219
+ release end-to-end given a reachable Mac for signing and any reachable device that
220
+ holds the npm token. Source: `apps/cli/scripts/release.sh`.
221
+
222
+ - **Branded, actionable daemon notifications on the routine lifecycle (RUSH-2030).**
223
+ Daemon desktop notifications (overdue routines, config heal, the no-credential
224
+ warning) now route through the `MenubarHelper.app` companion instead of raw
225
+ AppleScript, so they carry the agents-cli mark rather than the generic Script
226
+ Editor icon; they degrade to `osascript`/`notify-send` only when the helper is
227
+ not installed. The daemon also notifies when a routine **starts** and
228
+ **finishes** (success/failure, with the report's first line or the error reason
229
+ folded in), suppressing command-housekeeping start/success pings to avoid spam.
230
+ Clicking a finish notification opens the run report/log; start/overdue open the
231
+ runs folder. Source: `apps/cli/src/lib/menubar/notify-desktop.ts`,
232
+ `apps/cli/src/lib/routine-notify.ts`, `apps/cli/src/lib/daemon.ts`,
233
+ `apps/cli/menubar/Sources/MenubarHelper/PromptPanel.swift`.
234
+
235
+ - **`agents secrets status` now suggests which bundles to unlock.** It reads the existing `secrets.get` audit events and surfaces bundles you keep getting a Touch ID prompt for — read from the keychain (not served silently by the broker/session) 3+ times in the last 7 days and not currently held — with a ready `agents secrets unlock <name>` command. `never`/no-ACL bundles (which never prompt) are excluded, and the hint is best-effort so it never breaks `status`. Source: `apps/cli/src/lib/secrets/unlock-hints.ts`, `apps/cli/src/commands/secrets.ts`.
236
+
237
+ - **`agents sessions <id>` with a short/partial id resolves by id only — no more
238
+ "Multiple sessions match" from fuzzy content.** A complete UUID already resolved
239
+ by id, but a bare hex short-id like `d3470b57` was not caught by
240
+ `isCompleteSessionId`, so it fell through to the ranked content search and
241
+ surfaced every transcript that merely MENTIONED the string (a resume prompt
242
+ echoes the parent id into the body of many later sessions) — a real view id
243
+ returned a list of unrelated sessions. Any id-shaped query — complete id OR hex
244
+ short-id/prefix (`looksLikeSessionId`) — now resolves through the index by id in
245
+ both `resolveSessionQuery` and the `renderOneSession` content-widen gate, and
246
+ reports "no session found" when nothing matches instead of content-searching.
247
+ Free-text phrases keep the ranked search path. Source:
248
+ `apps/cli/src/commands/sessions.ts`.
249
+
250
+ - **`agents sessions --active` attributes the initiating device for SSH-launched
251
+ sessions.** A session started by ssh'ing into a box (common for tmux-hosted
252
+ runs) used to render as `local` with no origin, because the tmux discovery path
253
+ stamped a `transport:'local'` placeholder that made provenance enrichment skip
254
+ it. Enrichment now probes the pane process's env and upgrades the row to `ssh`
255
+ with the real origin, then resolves the SSH client IP against the device
256
+ registry into `provenance.origin` (`{ device, user? }`). Both the flat listing
257
+ and the interactive browser read `ssh←<device>` (e.g. `ssh←zion`); an
258
+ unregistered IP stays bare `ssh`. Answers "which box launched this session"
259
+ without scraping `ps`/`who`/`tailscale`. Source:
260
+ `apps/cli/src/lib/session/active.ts`, `apps/cli/src/lib/session/provenance.ts`,
261
+ `apps/cli/src/commands/sessions.ts`, `apps/cli/src/commands/sessions-browser.ts`.
262
+
263
+ - **`agents sessions --include user` / `--first` / `--last` now count genuine user turns, not harness-injected scaffolding (#1550).** A Claude session opened with a `!`-prefix command (e.g. `j <dir>`) stores `<bash-input>`/`<bash-stdout>` as `role=user` records, and `<system-reminder>`/`<task-notification>`/`<command-*>`/hook-feedback/skill bodies land the same way — so `--include user --first 3` returned the jump command and its shell output before the real ask, and every consumer of `--include user` (the `verify-work-complete` Stop hook's "original request" self-audit, `session-recall`) inherited the noise. `parseSession` now flags these injected `role=user` events `_synthetic` at its central post-parse chokepoint via one shared classifier (`isSyntheticUserMessage`), so turn slicing (`applyTurnSlice`) and role filtering (`roleOfEvent`) skip them; they stay in the default/`--markdown` stream for full fidelity. Claude-specific in practice — Codex/Gemini/OpenCode/Grok/Kimi/Rush route shell output to `tool_result`, never to `role=user`, and Droid's `<system-reminder>` was already dropped — but the classifier is cross-harness by construction. Source: `apps/cli/src/lib/session/{prompt,parse,render,types}.ts`.
264
+
265
+ - **Routine/daemon Claude runs authenticate the rotation-pinned account via its own long-lived setup-token — fixes fleet-wide daily logout.** Claude Code's interactive OAuth session uses single-use *rotating* refresh tokens: when one fleet machine refreshes, the server invalidates that account's token on every other machine, so unattended boxes 401 and drop (Claude Code #25609/#56339). A `claude setup-token` is a 1-year, non-rotating token that sidesteps this. The daemon now injects every `CLAUDE_CODE_OAUTH_TOKEN_<account>` present in the `claude` bundle (not just the one ambient token), and a routine spawn selects the token matching the account its version-home is pinned to (`runner.ts` `buildRoutineSpawnEnv` → `resolveAccountSetupToken`), so each unattended account authenticates with its own setup-token instead of the rotating interactive session. Works on macOS too, where the prior drop-based path was inert. Inert (no behavior change) until per-account setup-tokens are stored in the no-ACL `claude` bundle. Interactive `agents run` and remote `--host` dispatch are unchanged (out of scope; noted for follow-up). Source: `apps/cli/src/lib/secrets/account-token.ts`, `apps/cli/src/lib/runner.ts`, `apps/cli/src/lib/daemon.ts`, `apps/cli/src/lib/sandbox.ts`.
266
+
3
267
  ## 1.20.76
4
268
 
5
269
  - **The routines daemon can read a `never`/no-ACL secrets bundle headlessly again — fixes a false "no Claude credential" alert.** The headless secrets guard (`readAndResolveBundleEnv`'s `agentOnly` branch) threw for every keychain-backed bundle absent from the broker, but a `never`/no-ACL bundle carries no biometry ACL — its reads raise no Touch ID sheet, so blocking it served no purpose. That wrongly blocked the automation-only `claude` bundle the routines daemon reads at startup (`readDaemonClaudeOAuthToken`), leaving every scheduled Claude routine token-less, and — on the new auth-failure alert path — firing "no Claude credential" on each daemon start even when the bundle was configured correctly. The guard now exempts `never`/no-ACL bundles (policy learned via a prompt-less metadata read), matching the existing file-backend exemption. Source: `apps/cli/src/lib/secrets/bundles.ts`.
package/README.md CHANGED
@@ -443,6 +443,8 @@ agents hosts check gpu-box # reachable? which agents-cli version?
443
443
  agents run claude --host gpu-box "profile this build" # headless: follows live by default
444
444
  agents run claude --host gpu-box # no prompt → interactive TTY over SSH (tmux-backed)
445
445
  agents run claude --host gpu-box --copy-creds "fix auth" # copy local runtime creds + Claude token, shred after
446
+ agents run claude --device auto "…" # affinity-pick host from 14d usage (harness stays claude)
447
+ agents run claude --host auto "…" # same — auto is a host value, not a harness name
446
448
  agents hosts ps # list dispatched runs + terminal status
447
449
  agents hosts stop <id> # terminate a hung/detached run (alias: kill)
448
450
  agents logs --host gpu-box # pick a dispatched run — concise summary by default
package/dist/bin/agents CHANGED
Binary file
@@ -22,6 +22,7 @@
22
22
  * apply pending sync.
23
23
  */
24
24
  import type { Command } from 'commander';
25
+ import { type FleetDivergenceReport } from '../lib/devices/fleet-divergence.js';
25
26
  export declare function wrapLine(prefix: string, text: string, width?: number): string[];
26
27
  /**
27
28
  * Windows-only advisory lines. When the effective PowerShell execution policy
@@ -33,4 +34,12 @@ export declare function wrapLine(prefix: string, text: string, width?: number):
33
34
  * PowerShell.
34
35
  */
35
36
  export declare function execPolicyWarningLines(platform: NodeJS.Platform, policy: string | null): string[];
37
+ /**
38
+ * Render the cross-device divergence section for `agents doctor --devices`
39
+ * (RUSH-2027): a clean all-clear when the fleet agrees, otherwise the specific
40
+ * gaps — a resource/version present here but missing on a box, or a diverged
41
+ * config repo — grouped by device, each a plain-language line. Devices that
42
+ * couldn't be compared (offline / older CLI) are named so the readout is honest.
43
+ */
44
+ export declare function renderFleetDivergence(report: FleetDivergenceReport): string[];
36
45
  export declare function registerDoctorCommand(program: Command): void;
@@ -4,6 +4,8 @@ import { addHostOption } from '../lib/hosts/option.js';
4
4
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
5
5
  import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
6
6
  import { fanOutDevices } from '../lib/devices/fleet.js';
7
+ import { compareFleetInventories } from '../lib/devices/fleet-divergence.js';
8
+ import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
7
9
  import { resolveHost } from '../lib/hosts/registry.js';
8
10
  import { sshExecAsync } from '../lib/ssh-exec.js';
9
11
  import { sshTargetFor } from '../lib/hosts/types.js';
@@ -24,6 +26,7 @@ import { heal, healChangedAnything } from '../lib/heal.js';
24
26
  import { blocksLocalScripts, getEffectiveExecutionPolicy } from '../lib/platform/winpath.js';
25
27
  import { scanUserRcFiles, rcSecretWarningLines } from '../lib/secrets/rc-hygiene.js';
26
28
  import { terminalWidth, truncateToWidth, stringWidth, padToWidth } from '../lib/session/width.js';
29
+ import { readRepoBehindMarkers } from '../lib/auto-pull.js';
27
30
  import * as fs from 'fs';
28
31
  const AGENT_NAMES = Object.fromEntries(ALL_AGENT_IDS.map((id) => [id, AGENTS[id].name]));
29
32
  // ─── overview mode (no target) ────────────────────────────────────────────────
@@ -61,7 +64,7 @@ function printWrappedLine(prefix, text) {
61
64
  for (const line of wrapLine(prefix, text))
62
65
  console.log(chalk.gray(line));
63
66
  }
64
- function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn) {
67
+ function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers) {
65
68
  console.log(chalk.bold('Agent CLIs'));
66
69
  // Show the fleet you actually run — agents that are ready in PATH, plus any
67
70
  // you MANAGE (have installed versions) whose binary isn't resolving (a real
@@ -182,6 +185,10 @@ function renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn) {
182
185
  // environment (readable via /proc/<pid>/environ) — the RUSH-1968 class. Flag
183
186
  // them here so the master passphrase never silently lives in `~/.zshenv` again.
184
187
  renderRcHygieneAdvisory();
188
+ // Repos that are behind upstream — surfaced here instead of on stderr during
189
+ // every command. Markers are written by the background fetch worker and persist
190
+ // until the user runs `agents repo pull <alias>`.
191
+ renderRepoBehindAdvisory(repoBehindMarkers);
185
192
  }
186
193
  // ─── windows execution-policy advisory ─────────────────────────────────────────
187
194
  /**
@@ -217,6 +224,25 @@ function renderRcHygieneAdvisory() {
217
224
  console.log(chalk.gray(` ${line}`));
218
225
  }
219
226
  }
227
+ // ─── repo-behind advisory ─────────────────────────────────────────────────────
228
+ /**
229
+ * Render repo-behind notices from background fetch markers as a "Repo updates"
230
+ * section in `agents doctor`. Reads without consuming the markers so repeated
231
+ * invocations keep showing the notice until the user runs `agents repo pull`.
232
+ */
233
+ function renderRepoBehindAdvisory(markers) {
234
+ const behind = markers.filter((m) => m.behind > 0);
235
+ if (behind.length === 0)
236
+ return;
237
+ console.log();
238
+ console.log(chalk.bold('Repo updates'));
239
+ for (const m of behind) {
240
+ const label = m.alias === 'user' ? '~/.agents/' : m.alias;
241
+ const commits = `${m.behind} commit${m.behind === 1 ? '' : 's'}`;
242
+ console.log(` ${chalk.yellow('warn ')} ${label} is ${commits} behind ${m.branch}`);
243
+ console.log(chalk.gray(` run \`agents repo pull ${m.alias}\` to update`));
244
+ }
245
+ }
220
246
  function renderExecPolicyAdvisory() {
221
247
  // Only probe the policy on Windows — getEffectiveExecutionPolicy() spawns
222
248
  // powershell, which is a wasted (doomed) process on POSIX where the advisory
@@ -314,29 +340,74 @@ async function probeFleetTarget(target) {
314
340
  };
315
341
  }
316
342
  }
343
+ /**
344
+ * Fetch a device's harness inventory by running its top-level `agents doctor
345
+ * --json` (which carries the `fleet` field, RUSH-2027). Separate from
346
+ * {@link probeFleetTarget} — that forwards `teams doctor --json` for per-agent
347
+ * readiness, a different payload. Returns the parsed {@link FleetInventory} or
348
+ * null (unreachable, non-zero exit, unparseable, or an older CLI with no
349
+ * `fleet` field); a null is skipped by the comparator, never a false gap.
350
+ */
351
+ async function probeFleetInventory(target) {
352
+ const isWin = /^win/i.test((target.os ?? '').trim());
353
+ const remoteCmd = buildRemoteAgentsInvocation(['doctor', '--json'], undefined, isWin ? 'windows' : undefined, isWin ? undefined : { PATH: '$HOME/.agents/.cache/shims:$HOME/.local/bin:$PATH' });
354
+ const res = await sshExecAsync(target.sshTarget, remoteCmd, { timeoutMs: 30000, multiplex: true });
355
+ if (res.code !== 0)
356
+ return null;
357
+ try {
358
+ const parsed = JSON.parse(res.stdout);
359
+ return parsed.fleet ?? null;
360
+ }
361
+ catch {
362
+ return null;
363
+ }
364
+ }
317
365
  async function runDevicesDoctor(opts) {
318
366
  const singleName = opts.host || opts.device;
319
367
  const targets = await resolveFleetTargets(opts);
320
368
  const localName = machineId();
321
369
  const results = [];
322
- // Local machine first, directly.
370
+ // Local machine first, directly. Its inventory is collected in-process — no
371
+ // SSH round-trip to this box.
323
372
  if (!singleName) {
324
- results.push({ name: localName, online: true, agents: await collectTeamsDoctorData() });
373
+ results.push({
374
+ name: localName,
375
+ online: true,
376
+ agents: await collectTeamsDoctorData(),
377
+ inventory: collectLocalFleetInventory(opts.cwd ?? process.cwd()),
378
+ });
325
379
  }
326
- // Remote targets in parallel, through the shared fleet fan-out helper.
327
- const remoteResults = await fanOutDevices(targets, probeFleetTarget);
380
+ // Remote targets in parallel: agent readiness (teams doctor) and the harness
381
+ // inventory (top-level doctor --json) in one fan-out per concern. Both run
382
+ // through the shared fleet helper so an offline box degrades to a skipped row.
383
+ const [remoteResults, inventoryResults] = await Promise.all([
384
+ fanOutDevices(targets, probeFleetTarget),
385
+ fanOutDevices(targets, probeFleetInventory),
386
+ ]);
387
+ const inventoryByName = new Map();
388
+ for (const r of inventoryResults)
389
+ inventoryByName.set(r.name, r.status === 'ok' ? (r.value ?? null) : null);
328
390
  results.push(...remoteResults.map((r) => {
391
+ const inventory = inventoryByName.get(r.name) ?? undefined;
329
392
  if (r.status === 'ok' && r.value)
330
- return r.value;
393
+ return { ...r.value, inventory: inventory ?? undefined };
331
394
  return {
332
395
  name: r.name,
333
396
  online: false,
334
397
  error: r.error ?? String(r.reason ?? 'skipped'),
335
398
  agents: {},
399
+ inventory: inventory ?? undefined,
336
400
  };
337
401
  }));
402
+ // Cross-device divergence: compare every device's inventory against the local
403
+ // baseline and flag resources / agent versions / repo state present on one box
404
+ // but missing on another (RUSH-2027). A single-device filter has no baseline
405
+ // to compare against, so the section is only meaningful for the full fan-out.
406
+ const divergence = singleName
407
+ ? null
408
+ : compareFleetInventories(results.map((r) => ({ name: r.name, inventory: r.inventory ?? null })), localName);
338
409
  if (opts.json) {
339
- console.log(JSON.stringify({ devices: results }, null, 2));
410
+ console.log(JSON.stringify({ devices: results, fleet: divergence }, null, 2));
340
411
  return;
341
412
  }
342
413
  if (results.length === 0) {
@@ -360,6 +431,49 @@ async function runDevicesDoctor(opts) {
360
431
  }
361
432
  console.log();
362
433
  console.log(chalk.gray(' rdy* = installed and signed in · rdy = installed · no = not installed · err = probe failed · - = offline'));
434
+ if (divergence) {
435
+ console.log();
436
+ for (const line of renderFleetDivergence(divergence))
437
+ console.log(line);
438
+ }
439
+ }
440
+ /**
441
+ * Render the cross-device divergence section for `agents doctor --devices`
442
+ * (RUSH-2027): a clean all-clear when the fleet agrees, otherwise the specific
443
+ * gaps — a resource/version present here but missing on a box, or a diverged
444
+ * config repo — grouped by device, each a plain-language line. Devices that
445
+ * couldn't be compared (offline / older CLI) are named so the readout is honest.
446
+ */
447
+ export function renderFleetDivergence(report) {
448
+ const lines = [chalk.bold('Cross-device divergence')];
449
+ const skippedNote = report.skippedDevices.length > 0
450
+ ? chalk.gray(` (not compared: ${report.skippedDevices.join(', ')} — offline or no inventory)`)
451
+ : null;
452
+ if (!report.hasDivergence) {
453
+ lines.push(chalk.green(` Fleet is consistent — every compared device matches ${report.baseline}.`));
454
+ if (skippedNote)
455
+ lines.push(skippedNote);
456
+ return lines;
457
+ }
458
+ // Group findings by the LAGGING device so a box with several gaps reads as one
459
+ // block AND the remediation points at the right box. For a `*-missing-local`
460
+ // finding the resource is absent on the local baseline (present on `d.device`),
461
+ // so the box that's behind is the baseline — not the remote that has it.
462
+ const byDevice = new Map();
463
+ for (const d of report.divergences) {
464
+ const lagging = d.kind.endsWith('-missing-local') ? report.baseline : d.device;
465
+ (byDevice.get(lagging) ?? byDevice.set(lagging, []).get(lagging)).push(d);
466
+ }
467
+ for (const device of Array.from(byDevice.keys()).sort()) {
468
+ lines.push(` ${chalk.yellow('⚠')} ${chalk.bold(device)}`);
469
+ for (const d of byDevice.get(device)) {
470
+ lines.push(chalk.gray(` ${d.message}`));
471
+ }
472
+ }
473
+ if (skippedNote)
474
+ lines.push(skippedNote);
475
+ lines.push(chalk.gray(' Read-only — run `agents apply` or `agents repo pull` on a lagging box to reconcile.'));
476
+ return lines;
363
477
  }
364
478
  function parseTargetArg(arg) {
365
479
  const at = arg.indexOf('@');
@@ -647,7 +761,7 @@ export function registerDoctorCommand(program) {
647
761
  .option('--cwd <path>', 'Resolution cwd for project layer detection (default: process.cwd())')
648
762
  .option('--adopt <agent>', "Take over the agent's native launcher that shadows the shim (symlink it to the version-managed shim; reversible with --release)")
649
763
  .option('--release <agent>', 'Undo --adopt: restore the native launcher agents-cli previously adopted')
650
- .option('--devices', 'Check agent readiness on every registered device (alias --hosts)')
764
+ .option('--devices', 'Check agent readiness AND cross-device harness divergence (missing resources/versions, repo drift) on every registered device (alias --hosts)')
651
765
  .option('--hosts', 'Alias of --devices');
652
766
  setHelpSections(doctorCmd, {
653
767
  examples: `
@@ -671,6 +785,10 @@ export function registerDoctorCommand(program) {
671
785
 
672
786
  # Heal just one agent (all its installed versions)
673
787
  agents doctor claude --fix
788
+
789
+ # Fleet: agent readiness + cross-device divergence (missing plugins/skills,
790
+ # agent-version gaps, .agents/.system repo drift) vs this machine
791
+ agents doctor --devices
674
792
  `,
675
793
  });
676
794
  doctorCmd.action(async (target, opts) => {
@@ -758,6 +876,7 @@ export function registerDoctorCommand(program) {
758
876
  const syncRows = checkSyncStatus(cwd);
759
877
  const orphanRows = countOrphans();
760
878
  const hostClis = listCliStatus(cwd);
879
+ const repoBehindMarkers = readRepoBehindMarkers();
761
880
  // Advisory login state per installed agent (file-based getAccountInfo,
762
881
  // no home → the account-global/active credential). Best-effort: a probe
763
882
  // failure just leaves that agent's badge as "logged out".
@@ -782,6 +901,12 @@ export function registerDoctorCommand(program) {
782
901
  auth: summarizeHostAuth(readAuthHealthCache(), machineId()),
783
902
  sync: syncRows,
784
903
  orphans: orphanRows,
904
+ // This host's harness inventory — installed resources per kind,
905
+ // installed version ids per agent, and `.agents`/`.system` repo
906
+ // state — so `agents doctor --devices` can compare presence across
907
+ // the fleet and flag a resource/version present here but missing
908
+ // elsewhere (RUSH-2027). Read-only; no network.
909
+ fleet: collectLocalFleetInventory(cwd),
785
910
  hostClis: {
786
911
  statuses: hostClis.statuses.map((s) => ({
787
912
  name: s.manifest.name,
@@ -791,10 +916,19 @@ export function registerDoctorCommand(program) {
791
916
  })),
792
917
  errors: hostClis.errors,
793
918
  },
919
+ // Repos behind upstream — emitted here so menubar and other consumers
920
+ // can surface the notices without reading stderr from normal commands.
921
+ repos: repoBehindMarkers.map((m) => ({
922
+ alias: m.alias,
923
+ dir: m.dir,
924
+ behind: m.behind,
925
+ branch: m.branch,
926
+ fetchedAt: m.fetchedAt,
927
+ })),
794
928
  }, null, 2));
795
929
  return;
796
930
  }
797
- renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn);
931
+ renderOverviewText(clis, syncRows, orphanRows, hostClis, signIn, repoBehindMarkers);
798
932
  // Point at the interactive reconcile when anything is out of sync — the
799
933
  // report shouldn't be a dead end. `agents status` runs the unified
800
934
  // home-reading engine and offers to sync (opt-in, never auto-fires here).