@phnx-labs/agents-cli 1.22.31 → 1.22.33

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 (69) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +8 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/daemon.js +52 -12
  5. package/dist/commands/doctor.d.ts +19 -0
  6. package/dist/commands/doctor.js +119 -17
  7. package/dist/commands/routines.js +164 -36
  8. package/dist/commands/sessions-browser.js +2 -2
  9. package/dist/commands/sessions.d.ts +1 -1
  10. package/dist/commands/sessions.js +66 -22
  11. package/dist/commands/update.d.ts +2 -0
  12. package/dist/commands/update.js +148 -0
  13. package/dist/index.js +3 -1
  14. package/dist/lib/catchup.js +4 -1
  15. package/dist/lib/daemon.d.ts +17 -0
  16. package/dist/lib/daemon.js +69 -3
  17. package/dist/lib/devices/doctor-findings.d.ts +7 -2
  18. package/dist/lib/devices/doctor-findings.js +53 -2
  19. package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
  20. package/dist/lib/devices/doctor-overview-cache.js +15 -0
  21. package/dist/lib/devices/fleet-divergence.d.ts +11 -0
  22. package/dist/lib/devices/fleet-divergence.js +6 -0
  23. package/dist/lib/devices/fleet-inventory.js +16 -2
  24. package/dist/lib/drift.d.ts +6 -1
  25. package/dist/lib/drift.js +9 -0
  26. package/dist/lib/hooks/cache.js +20 -1
  27. package/dist/lib/hooks.d.ts +91 -1
  28. package/dist/lib/hooks.js +289 -3
  29. package/dist/lib/hosts/passthrough.js +3 -0
  30. package/dist/lib/installations/index.d.ts +14 -0
  31. package/dist/lib/installations/index.js +14 -0
  32. package/dist/lib/installations/resolve.d.ts +43 -0
  33. package/dist/lib/installations/resolve.js +93 -0
  34. package/dist/lib/installations/store.d.ts +56 -0
  35. package/dist/lib/installations/store.js +196 -0
  36. package/dist/lib/installations/strategies.d.ts +73 -0
  37. package/dist/lib/installations/strategies.js +293 -0
  38. package/dist/lib/installations/types.d.ts +78 -0
  39. package/dist/lib/installations/types.js +8 -0
  40. package/dist/lib/installations/update.d.ts +40 -0
  41. package/dist/lib/installations/update.js +131 -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/migrate.d.ts +27 -0
  45. package/dist/lib/migrate.js +112 -2
  46. package/dist/lib/routine-context.d.ts +144 -0
  47. package/dist/lib/routine-context.js +268 -0
  48. package/dist/lib/routine-readiness.d.ts +47 -0
  49. package/dist/lib/routine-readiness.js +239 -0
  50. package/dist/lib/routines.d.ts +97 -1
  51. package/dist/lib/routines.js +107 -1
  52. package/dist/lib/runner.d.ts +18 -4
  53. package/dist/lib/runner.js +291 -98
  54. package/dist/lib/scheduler.d.ts +7 -1
  55. package/dist/lib/scheduler.js +5 -2
  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/self-heal/checks/hook-runtime.d.ts +2 -0
  59. package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
  60. package/dist/lib/self-heal/registry.js +5 -2
  61. package/dist/lib/self-heal/types.d.ts +1 -1
  62. package/dist/lib/session/state.js +4 -1
  63. package/dist/lib/session/team-filter.d.ts +11 -0
  64. package/dist/lib/session/team-filter.js +10 -0
  65. package/dist/lib/startup/command-registry.d.ts +1 -0
  66. package/dist/lib/startup/command-registry.js +2 -0
  67. package/dist/lib/versions.d.ts +24 -0
  68. package/dist/lib/versions.js +49 -16
  69. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,77 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.33
4
+
5
+ - **Release tags are annotated with the folded changelog notes.** `scripts/release.sh` now creates `v<version>` as an annotated tag whose message is `Release <version>` plus the body of `.changelog/<version>.md` (the same notes that already become the release PR body). Agents keep writing one fragment under `.changelog/next/`; there is no separate tag-description channel. The already-published missing-tag recovery path uses the same helper with `--force`. Source: `apps/cli/scripts/release.sh`.
6
+
7
+ `agents sessions --routines` once again opens the routine picker across every working directory instead of an empty current-repository session browser.
8
+
9
+ ## 1.22.32
10
+
11
+ - **Routines validate execution context before activation and fire once per schedule slot (RUSH-2290).** A routine selects one execution `project` plus a portable `cwd`; add/edit save proven blockers paused, durable slot and active-run claims prevent duplicate or overlapping launches, and every blocked/skipped/pre-spawn attempt remains visible without requiring a session transcript. Source: `apps/cli/src/lib/routine-context.ts`, `apps/cli/src/lib/routine-readiness.ts`, `apps/cli/src/lib/runner.ts`.
12
+
13
+ - **Daemon: self-terminates if its own state dir disappears; the routines test suite reaps
14
+ leaked daemons instead of letting them run for days (RUSH-2367).** Three real daemon
15
+ processes were found alive on a fleet box for up to 3.5 days, each spawned by a
16
+ vitest fixture under its own `/tmp` `HOME` and invisible to every `agents daemon`
17
+ guard, since a different `HOME` resolves a different state dir and instance registry.
18
+ The daemon now polls (`AGENTS_DAEMON_STATE_DIR_CHECK_MS`, default 60s) for a
19
+ per-lifetime marker and exits gracefully if it disappears — unlike the pid and
20
+ heartbeat files, status repair cannot recreate that marker after deleting the state
21
+ tree. This is the only defense
22
+ that survives the whole test runner being killed externally before any in-test cleanup
23
+ can run. The routines test suite also gained a leak detector that fails the run and
24
+ force-kills anything it spawned (or, on CI, any daemon it finds under its own fixture
25
+ prefix from a previous interrupted run) that survived past its own test. Source:
26
+ `apps/cli/src/lib/daemon.ts`, `apps/cli/src/commands/routines.test.ts`.
27
+
28
+ - **Daemon: `status`/`doctor`/`services` no longer misreport test fixtures as duplicate
29
+ daemons to kill, and no longer render `healthy` for an unreachable service (RUSH-2368).**
30
+ Duplicate detection used to scan every `__daemon-run` process on the box, so a fixture
31
+ daemon under its own `HOME` (and therefore its own `getDaemonDir()`/instance registry) —
32
+ a separate install, or a leaked test process — showed up as a stray for the reader to
33
+ `kill`, contradicting the reaper (`reapStrayDaemons`) and `stop`'s postcondition, which
34
+ already scope by the instance registry. Duplicates are now read from that same registry
35
+ (`findSurvivingStateDirDaemons`), never a raw `ps` match. Separately, the secrets-broker
36
+ and browser-IPC health lines derived their `healthy`/`down` verdict from the daemon's
37
+ persisted last-ok record, which is only updated at the daemon's own startup — a broker
38
+ that went unreachable hours into a still-running daemon rendered `healthy (unreachable)`
39
+ on one line. The verdict now comes from the live probe; the persisted record supplies
40
+ only supporting last-ok/last-error context. Source: `apps/cli/src/commands/daemon.ts`,
41
+ `apps/cli/src/lib/daemon.ts`.
42
+
43
+ - **Agent installations are frozen, and `agents update <agent>@<installed-version>` moves the release inside one (RUSH-2372).** An installation used to be identified only by its version-dir name, so the vendor release *was* the identity: moving to a new release meant a new directory, which dangled every default, project pin, routine `version:`, and profile that named the old one — and two installations of the same release could not coexist at all. Each install now carries an `installation.json` with a stable opaque id plus the release currently on disk; the name is frozen for life and only the release moves, so every reference keeps resolving. Pre-existing version dirs migrate on first read. `agents update` takes `--to <release>` (`latest` by default), `--account <label>` to disambiguate when several installations match, `--json`, `agents update list <agent>`, and routes through `--host` to update a peer's installation. The target release is fetched into a sibling directory and launched there *before* it replaces the working one, so a release that cannot start is discarded rather than installed; a post-swap failure restores the previous one. Update strategies are chosen from the agent registry's declared capabilities, so every harness `agents add` manages is covered (npm package, shared self-updating binary, install script) — and a harness whose binary lives outside the directory being swapped is refused with the reason instead of being recorded as updated. Not to be confused with `agents upgrade`, which updates agents-cli itself. Source: `apps/cli/src/lib/installations/*`, `apps/cli/src/commands/update.ts`, `apps/cli/src/lib/versions.ts`.
44
+
45
+ - **`agents doctor` detects and self-heals a hook whose generated shim wrapper is
46
+ missing or broken, and the menu bar shows it (RUSH-2382).** A native hook command
47
+ could read as wired when its generated `~/.agents/.cache/shims/hooks/<name>.sh`
48
+ target was absent, a dangling symlink, empty, or non-executable — the harness
49
+ silently never ran the hook. `agents doctor` now emits a `hook-runtime-broken`
50
+ critical finding for every hooks-capable harness (not just the settings formats
51
+ the wiring inspector understands), and the daemon's safe self-heal regenerates
52
+ one broken shim per unique path per pass with post-repair verification, never
53
+ retrying or recursing into resource sync within the same pass. The macOS menu
54
+ bar System row now reads `N critical · M warnings` (or `all set`) from
55
+ `agents doctor --json`'s `findings` on the existing 15-minute poll, and the
56
+ submenu lists up to 5 actionable findings with remediation (any kind, not just
57
+ this one), with a `+N more — run agents doctor` row past the cap. Source:
58
+ `apps/cli/src/lib/hooks.ts`, `apps/cli/src/lib/self-heal/checks/hook-runtime.ts`,
59
+ `apps/cli/src/lib/devices/doctor-findings.ts`,
60
+ `apps/cli/menubar/Sources/MenubarHelper/{Models,StatusItemController,AgentsCLI}.swift`.
61
+
62
+ - **Menu bar: the ROUTINES section is now a collapsible project-group accordion.**
63
+ Routines render the same way ACTIVE sessions do — one collapsible header per
64
+ project group (a project name, or the `Operations` / `All projects` /
65
+ `Cross-project` specials the CLI derives from each routine's `projects:` field),
66
+ collapsed by default, click `▶` to fold every routine in that group inline. Each
67
+ header carries per-state glyph counts (`◔` upcoming, `✕` failing, `⃠` missed,
68
+ `⏸` not-ready) and a paused tail, so a collapsed group still shows what is inside
69
+ it; the header row also names the group count (`ROUTINES · … · N groups`).
70
+ Expanding a group orders it attention-first, then by next run, then paused last.
71
+ This replaces the flat group labels plus the single "All routines…" flyout for a
72
+ CLI that emits `projectGroup`; an older CLI falls back to the previous view.
73
+ Source: `apps/cli/menubar/Sources/MenubarHelper/StatusItemController.swift`.
74
+
3
75
  ## 1.22.31
4
76
 
5
77
  - **`agents secrets unlock --keys` folds in the scoped-hold surface; the `secrets lease`/`leases`/`revoke` commands are deleted (RUSH-2350).** `unlock` now takes `--keys K1,K2` to hold ONLY that subset of a bundle behind its own expiry instead of the whole bundle — the one capability `lease` had that `unlock` did not, now on the command agents already reach for. Without `--keys`, `unlock` behaves exactly as before (whole bundle). An unknown or empty key subset fails closed (`Unknown secret lease key(s): …` / `requires at least one key`), `--keys` scopes exactly one local bundle (rejected with `--all`/`--host`), and `secrets status` now names the held keys of a scoped hold; `secrets lock <name>` releases it. The duplicate `secrets lease`/`secrets leases`/`secrets revoke` trio is gone — it had zero consumers, and its jobs (list holdings, release one) are already `secrets status` and `secrets lock`. No alias or deprecation shim (per the repo's no-unasked-shims rule). The underlying lease model (`src/lib/secrets/lease.ts`) is unchanged — `unlock --keys` reuses it. Source: `apps/cli/src/commands/secrets.ts` (`scopeHeldEnv`, the `unlock`/`status` actions), `apps/cli/src/lib/secrets/{agent,session-store}.ts`, `apps/cli/src/commands/secrets.scope.test.ts`, `apps/cli/src/commands/secrets.flags.test.ts`.
package/README.md CHANGED
@@ -54,7 +54,7 @@ agents run claude "explain this repo" # run any agent on your existing subscrip
54
54
 
55
55
  Full path -- installing harnesses, logging in, smoke-testing `agents teams`, and setting up your own fleet: [`apps/cli/docs/QUICKSTART.md`](apps/cli/docs/QUICKSTART.md).
56
56
 
57
- Already installed? `agents upgrade` updates agents-cli itself to the latest version (`agents upgrade 1.2.3` for a specific version or dist-tag, `-y` to skip the confirm prompt). The command is `upgrade` on every platform -- there is no `agents update` (on macOS, `agents helper update` is a different command that reinstalls the keychain helper, not agents-cli).
57
+ Already installed? `agents upgrade` updates agents-cli itself to the latest version (`agents upgrade 1.2.3` for a specific version or dist-tag, `-y` to skip the confirm prompt). The command is `upgrade` on every platform -- do not reach for `agents update`, which updates an installed **agent harness**, not agents-cli (and on macOS, `agents helper update` is a third thing: it reinstalls the keychain helper).
58
58
 
59
59
  Source: [github.com/phnx-labs/agents-cli](https://github.com/phnx-labs/agents-cli)
60
60
 
@@ -1014,23 +1014,29 @@ agents secrets list # EXPIRING column flags secrets due in the next 30 days
1014
1014
  agents routines add daily-digest \
1015
1015
  --schedule "0 9 * * 1-5" \
1016
1016
  --agent claude \
1017
+ --project-anchor agents-cli \
1018
+ --cwd apps/cli \
1017
1019
  --prompt "Review yesterday's PRs and summarize key changes"
1018
1020
 
1019
1021
  agents routines list # All jobs + next run times
1020
1022
  agents routines run daily-digest # Test it now, ignore the schedule
1021
1023
  agents routines logs daily-digest # Last execution — status + report (add --full for raw stdout)
1024
+ agents routines runs daily-digest # Every attempt, including blocked/skipped pre-session runs
1025
+ agents routines doctor daily-digest # Project/CWD/trust/write/auth readiness
1022
1026
  agents routines stats # Run count, failed, missed, avg/p50/p95 duration — per job or all
1023
1027
 
1024
1028
  # Definitions sync to every device; activation is stored per hostname
1025
1029
  agents routines add nightly-drain --schedule "0 3 * * *" --agent claude \
1030
+ --cwd '~' \
1026
1031
  --prompt "Drain the local work queue"
1027
1032
 
1028
- agents routines devices nightly-drain --set yosemite-s0,mac-mini # enable on both hosts
1033
+ agents routines devices nightly-drain --set yosemite-s0 # one schedule owner
1029
1034
  agents routines list --host yosemite-s0 # query another device
1030
1035
 
1031
1036
  # Signed webhook trigger: Linear issue labeled "agent" fires a routine
1032
1037
  agents routines add agent-labeled-issue --on linear:Issue --action update \
1033
1038
  --team-key RUSH --label agent --agent claude \
1039
+ --cwd '~' \
1034
1040
  --prompt "Work the Linear issue that was just labeled agent"
1035
1041
  agents webhook serve --secrets-bundle webhooks --port 8787 # /hooks/linear, /hooks/github
1036
1042
  agents funnel up yosemite-s0 --local-port 8787 --port 443 # public HTTPS ingress
package/dist/bin/agents CHANGED
Binary file
@@ -19,7 +19,7 @@ import { execFileSync } from 'child_process';
19
19
  import * as fs from 'fs';
20
20
  import * as path from 'path';
21
21
  import { setHelpSections } from '../lib/help.js';
22
- import { getDaemonStatus, isDaemonRunning, isDaemonWedged, readDaemonLog, startDaemon, stopDaemon, signalDaemonReload, } from '../lib/daemon.js';
22
+ import { getDaemonStatus, isDaemonRunning, isDaemonWedged, readDaemonLog, startDaemon, stopDaemon, signalDaemonReload, findSurvivingStateDirDaemons, } from '../lib/daemon.js';
23
23
  import { getConfigValue, setConfigValue, isDaemonEnabled } from '../lib/device-config.js';
24
24
  import { readSubsystemHealth, SUBSYSTEM_SECRETS_BROKER, SUBSYSTEM_BROWSER_IPC, } from '../lib/daemon-health.js';
25
25
  import { listJobs, getLatestRun } from '../lib/routines.js';
@@ -94,8 +94,8 @@ function entryFromTokens(tokens) {
94
94
  }
95
95
  /**
96
96
  * Every live `__daemon-run` process on this box, regardless of which install
97
- * launched it. POSIX-only (uses `ps`), mirroring `reapStrayDaemons`'s scope —
98
- * a no-op on Windows.
97
+ * launched it or which state dir it serves. POSIX-only (uses `ps`); a no-op on
98
+ * Windows.
99
99
  *
100
100
  * `getDaemonLaunch` always spawns `<node> <entry> __daemon-run` with nothing
101
101
  * after it — the ONLY argv `__daemon-run` ever appears in for a real daemon.
@@ -104,6 +104,14 @@ function entryFromTokens(tokens) {
104
104
  * literal text `__daemon-run` (this ticket's own brief does) matches that test
105
105
  * too, and was observed producing false "duplicate daemon" rows. Requiring it
106
106
  * to be the LAST whitespace-delimited token is the actual invariant.
107
+ *
108
+ * This raw box-wide scan is deliberately NOT the duplicate-detection scope
109
+ * (RUSH-2368): a `__daemon-run` under a different HOME serves a different
110
+ * `getDaemonDir()` and is not a duplicate of THIS device's daemon, however
111
+ * `ps` sees it — a leaked vitest fixture under its own `/tmp` HOME matched
112
+ * this scan and was reported as a stray to `kill`. It is used only to attach
113
+ * display metadata (entry/version) to pids the registry-scoped
114
+ * `findSurvivingStateDirDaemons` has already confirmed as real duplicates.
107
115
  */
108
116
  function scanDaemonProcesses() {
109
117
  if (process.platform === 'win32')
@@ -133,6 +141,22 @@ function scanDaemonProcesses() {
133
141
  }
134
142
  return found;
135
143
  }
144
+ /**
145
+ * Duplicates of THIS device's daemon, scoped to the same instance registry the
146
+ * reaper and the stop postcondition use (RUSH-2368) — never a raw `ps` match.
147
+ * `processes` (from `scanDaemonProcesses`) supplies display metadata
148
+ * (entry/version); `findSurvivingStateDirDaemons` supplies the actual scope, so
149
+ * a `__daemon-run` under a different HOME (a different `getDaemonDir()`) —
150
+ * whether a leaked test fixture or a genuinely separate install — never shows
151
+ * up here even though it is visible to the box-wide `ps` scan.
152
+ */
153
+ function registryScopedDuplicates(processes, ownerPid) {
154
+ const exclude = new Set();
155
+ if (ownerPid)
156
+ exclude.add(ownerPid);
157
+ const registered = new Set(findSurvivingStateDirDaemons(exclude));
158
+ return processes.filter((p) => registered.has(p.pid));
159
+ }
136
160
  /** Elapsed wall-clock seconds since `pid` started, or null if unavailable (best-effort, POSIX only). */
137
161
  function uptimeSeconds(pid) {
138
162
  if (process.platform === 'win32')
@@ -198,12 +222,28 @@ function schedulerSummary() {
198
222
  return { routineCount: jobs.length, enabledCount: enabled.length, nextFire, failingCount };
199
223
  }
200
224
  // ─── Rendering ────────────────────────────────────────────────────────────
201
- function healthLine(label, record) {
202
- if (!record || record.consecutiveFailures === 0) {
225
+ /**
226
+ * Render one service's health line. `live` is the verdict — a probe run RIGHT
227
+ * NOW against the actual socket/binding — and is the only thing allowed to say
228
+ * `healthy` (RUSH-2368). `record` is the daemon's persisted last-ok/last-error
229
+ * history: supporting context, never the verdict. Before this fix the verdict
230
+ * came from `record.consecutiveFailures`, which the daemon only updates at its
231
+ * own startup (`recordSubsystemOk`/`recordSubsystemError` in daemon.ts) — a
232
+ * broker that went unreachable hours into a still-running daemon rendered
233
+ * `healthy (unreachable)` on one line, a contradiction that is exactly the
234
+ * silent-success pattern this command exists to remove.
235
+ */
236
+ function healthLine(label, live, record) {
237
+ if (live) {
203
238
  const ok = record?.lastOkAt ? chalk.gray(`(last ok ${record.lastOkAt})`) : '';
204
239
  return ` ${chalk.green('healthy')} ${label} ${ok}`;
205
240
  }
206
- return ` ${chalk.red(`${record.consecutiveFailures} consecutive failure(s)`)} ${label} ${chalk.gray(`— ${record.lastError}`)}`;
241
+ const detail = record && record.consecutiveFailures > 0
242
+ ? chalk.gray(`— ${record.lastError}`)
243
+ : record?.lastOkAt
244
+ ? chalk.gray(`(last ok ${record.lastOkAt})`)
245
+ : '';
246
+ return ` ${chalk.red('down')} ${label} ${detail}`;
207
247
  }
208
248
  async function runStatus(opts) {
209
249
  const status = getDaemonStatus();
@@ -214,7 +254,7 @@ async function runStatus(opts) {
214
254
  const heartbeatAgeMs = status.heartbeat ? Date.now() - Date.parse(status.heartbeat.lastTick) : null;
215
255
  const processes = scanDaemonProcesses();
216
256
  const owner = pid ? processes.find((p) => p.pid === pid) : undefined;
217
- const duplicates = processes.filter((p) => p.pid !== pid);
257
+ const duplicates = registryScopedDuplicates(processes, pid ?? null);
218
258
  const [secrets, browserIpc] = await Promise.all([probeSecretsBroker(), probeBrowserIPC()]);
219
259
  const scheduler = schedulerSummary();
220
260
  if (opts.json) {
@@ -277,8 +317,8 @@ async function runStatus(opts) {
277
317
  console.log(chalk.gray('\n Only one install should own the daemon. Stop the stray(s): kill <pid>'));
278
318
  }
279
319
  console.log(chalk.bold('\nHealth\n'));
280
- console.log(healthLine(`secrets broker ${secrets.reachable ? `(${secrets.socketPath}, ${secrets.heldBundles} bundle(s) held)` : '(unreachable)'}`, secrets.record));
281
- console.log(healthLine(`browser IPC ${browserIpc.bound ? `(${browserIpc.socketPath}, ${browserIpc.sessionCount} session(s))` : '(unbound)'}`, browserIpc.record));
320
+ console.log(healthLine(`secrets broker ${secrets.reachable ? `(${secrets.socketPath}, ${secrets.heldBundles} bundle(s) held)` : '(unreachable)'}`, secrets.reachable, secrets.record));
321
+ console.log(healthLine(`browser IPC ${browserIpc.bound ? `(${browserIpc.socketPath}, ${browserIpc.sessionCount} session(s))` : '(unbound)'}`, browserIpc.bound, browserIpc.record));
282
322
  const schedulerEnabled = getConfigValue('scheduler.enabled').value !== false;
283
323
  console.log(` ${schedulerEnabled ? chalk.green('enabled') : chalk.yellow('disabled')} scheduler — ${scheduler.enabledCount}/${scheduler.routineCount} routine(s) enabled` +
284
324
  (scheduler.nextFire ? `, next ${scheduler.nextFire.toLocaleString()}` : ''));
@@ -299,8 +339,8 @@ async function runServices(opts) {
299
339
  return;
300
340
  }
301
341
  console.log(chalk.bold('Hosted services\n'));
302
- console.log(healthLine(`secrets broker ${secrets.reachable ? `(${secrets.socketPath}, ${secrets.heldBundles} bundle(s) held)` : '(unreachable)'}`, secrets.record));
303
- console.log(healthLine(`browser IPC ${browserIpc.bound ? `(${browserIpc.socketPath}, ${browserIpc.sessionCount} session(s))` : '(unbound)'}`, browserIpc.record));
342
+ console.log(healthLine(`secrets broker ${secrets.reachable ? `(${secrets.socketPath}, ${secrets.heldBundles} bundle(s) held)` : '(unreachable)'}`, secrets.reachable, secrets.record));
343
+ console.log(healthLine(`browser IPC ${browserIpc.bound ? `(${browserIpc.socketPath}, ${browserIpc.sessionCount} session(s))` : '(unbound)'}`, browserIpc.bound, browserIpc.record));
304
344
  console.log(chalk.gray('\nScheduled routines run through `agents routines` — see: agents routines stats'));
305
345
  }
306
346
  function parseLogLines(raw) {
@@ -379,7 +419,7 @@ async function runDoctor(opts) {
379
419
  problems.push('Daemon is not running. Start it: agents daemon start');
380
420
  if (status.running && isDaemonWedged())
381
421
  problems.push('Daemon is wedged (heartbeat stale). Restart: agents daemon restart');
382
- const duplicates = scanDaemonProcesses().filter((p) => p.pid !== status.pid);
422
+ const duplicates = registryScopedDuplicates(scanDaemonProcesses(), status.pid);
383
423
  if (duplicates.length > 0) {
384
424
  problems.push(`${duplicates.length} duplicate daemon process(es) running: ${duplicates.map((d) => d.pid).join(', ')}. Stop the stray(s).`);
385
425
  }
@@ -22,6 +22,7 @@
22
22
  * apply pending sync.
23
23
  */
24
24
  import type { Command } from 'commander';
25
+ import { type FleetInventory } from '../lib/devices/fleet-divergence.js';
25
26
  import { type DoctorFinding } from '../lib/devices/doctor-findings.js';
26
27
  import { type VersionResourceReport } from '../lib/doctor-diff.js';
27
28
  import { type DuplicateVersionHook } from '../lib/hooks.js';
@@ -51,6 +52,24 @@ export declare function asRemoteSecretFindings(raw: unknown, device: string): Do
51
52
  * whole-fleet wall time is bounded near the slowest box, not 180s x N.
52
53
  */
53
54
  export declare const FLEET_INVENTORY_TIMEOUT_MS = 180000;
55
+ /**
56
+ * Narrow a remote `doctor --json` `.fleet` payload to a usable inventory, or null.
57
+ *
58
+ * The remote runs ITS OWN agents-cli, whose version we do not control, so the
59
+ * payload is untrusted input rather than a typed value — a cast alone let a
60
+ * partial object (`{"fleet":{}}` from a skewed or truncated remote) reach
61
+ * `compareFleetInventories`, which indexes `inv.resources[kind]` and
62
+ * `Object.keys(inv.agentVersions)` unconditionally and throws. Returning null
63
+ * routes that device into the existing "older agents-cli / no inventory" path,
64
+ * which is honest and already rendered, instead of aborting the whole fan-out.
65
+ *
66
+ * This validates EVERY field of {@link FleetInventory} that anything downstream
67
+ * reads — `resources`, `agentVersions`, `repos` and each optional `signIn` row.
68
+ * It was tightened four times during review, each round finding the next
69
+ * unvalidated layer, so treat partial validation here as a bug: a field added to
70
+ * the inventory must be checked here in the same change.
71
+ */
72
+ export declare function asFleetInventory(value: unknown): FleetInventory | null;
54
73
  export type IssueSeverity = 'critical' | 'warning' | 'info';
55
74
  /**
56
75
  * One triaged health finding. `severity`/`category`/`subject`/`impact`/`fix` are
@@ -4,11 +4,11 @@ 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, planFleetTargets, remoteFleetTargets } from '../lib/devices/fleet.js';
7
- import { enterDoctorOverviewGate, writeDoctorOverviewCache } from '../lib/devices/doctor-overview-cache.js';
7
+ import { enterDoctorOverviewGate, invalidateDoctorOverviewCache, writeDoctorOverviewCache } from '../lib/devices/doctor-overview-cache.js';
8
8
  import { fleetDialTarget } from '../lib/devices/connect.js';
9
- import { compareFleetInventories } from '../lib/devices/fleet-divergence.js';
9
+ import { compareFleetInventories, FLEET_HOOK_RUNTIME_STATES } from '../lib/devices/fleet-divergence.js';
10
10
  import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
11
- import { buildLocalFindings, fleetDivergenceToFindings, signInToFindings, renderFindings, remediationFor, FINDING_SEVERITY, } from '../lib/devices/doctor-findings.js';
11
+ import { buildLocalFindings, fleetDivergenceToFindings, hookRuntimeToFindings, signInToFindings, renderFindings, remediationFor, FINDING_SEVERITY, } from '../lib/devices/doctor-findings.js';
12
12
  import { getCliVersion } from '../lib/version.js';
13
13
  import { resolveHost } from '../lib/hosts/registry.js';
14
14
  import { sshExecAsync } from '../lib/ssh-exec.js';
@@ -23,7 +23,7 @@ import { getVersionHomePath, listInstalledVersions, } from '../lib/versions.js';
23
23
  import { resolveAgentTargets, AgentSpecError } from '../lib/agent-spec/index.js';
24
24
  import { loadManifest, isStale } from '../lib/staleness/index.js';
25
25
  import { diffVersionResources, DOCTOR_ALL_KINDS, } from '../lib/doctor-diff.js';
26
- import { checkVersionHookWiring, inspectDuplicateVersionHooks, registerHooksToSettings } from '../lib/hooks.js';
26
+ import { checkVersionHookWiring, inspectDuplicateVersionHooks, registerHooksToSettings, repairManagedHookRuntimeArtifacts } from '../lib/hooks.js';
27
27
  import { isVersionIsolated } from '../lib/versions.js';
28
28
  import { computeDrift, checkSyncStatus, countOrphans, computeSourceBehind } from '../lib/drift.js';
29
29
  import { readAuthHealthCache, summarizeHostAuth } from '../lib/auth-health.js';
@@ -281,7 +281,7 @@ async function probeFleetInventory(target) {
281
281
  * unvalidated layer, so treat partial validation here as a bug: a field added to
282
282
  * the inventory must be checked here in the same change.
283
283
  */
284
- function asFleetInventory(value) {
284
+ export function asFleetInventory(value) {
285
285
  // `typeof [] === 'object'`, so a shallow object check is not enough: an array
286
286
  // passes it, every `inv.resources[kind] ?? []` then yields empty, and the
287
287
  // comparison reports EVERY baseline resource as missing on that device — a
@@ -323,6 +323,33 @@ function asFleetInventory(value) {
323
323
  if (!rowsOk)
324
324
  return null;
325
325
  }
326
+ // Hook-runtime state is optional for wire compatibility with older remotes,
327
+ // but a present field is a closed enum map keyed exactly by the installed
328
+ // agent/version pairs. The fleet never accepts a remote path or diagnostic
329
+ // string, so an untrusted box cannot inject shell paths/text into this doctor.
330
+ if (v.hookRuntime !== undefined) {
331
+ if (!isMap(v.hookRuntime))
332
+ return null;
333
+ const validStates = new Set(FLEET_HOOK_RUNTIME_STATES);
334
+ const agentVersions = v.agentVersions;
335
+ const hookRuntime = v.hookRuntime;
336
+ const expectedAgents = Object.keys(agentVersions);
337
+ if (Object.keys(hookRuntime).length !== expectedAgents.length)
338
+ return null;
339
+ for (const agent of expectedAgents) {
340
+ if (!ALL_AGENT_IDS.includes(agent))
341
+ return null;
342
+ const states = hookRuntime[agent];
343
+ const versions = agentVersions[agent];
344
+ if (!isMap(states) || Object.keys(states).length !== versions.length)
345
+ return null;
346
+ const expectedVersions = new Set(versions);
347
+ for (const [version, state] of Object.entries(states)) {
348
+ if (!expectedVersions.has(version) || typeof state !== 'string' || !validStates.has(state))
349
+ return null;
350
+ }
351
+ }
352
+ }
326
353
  return v;
327
354
  }
328
355
  async function runDevicesDoctor(opts) {
@@ -443,6 +470,10 @@ async function runDevicesDoctor(opts) {
443
470
  // reports a leaking box as clean (RUSH-1968).
444
471
  if (r.secretFindings?.length)
445
472
  findings.push(...r.secretFindings);
473
+ // Unlike sign-in, the remote's hook-runtime payload is deliberately just a
474
+ // closed enum state. Rebuild the finding locally so no remote file path or
475
+ // detector text reaches this host's output.
476
+ findings.push(...hookRuntimeToFindings(r.name, r.inventory?.hookRuntime));
446
477
  if (r.inventory?.signIn) {
447
478
  findings.push(...signInToFindings(r.name, r.inventory.signIn));
448
479
  accounts[r.name] = r.inventory.signIn;
@@ -627,7 +658,7 @@ const HOOK_WIRING_FIX_AGENTS = ['claude', 'droid'];
627
658
  /** Categories `--fix` reconciles (vs. `agents repo pull` for a behind source, or
628
659
  * `agents prune cleanup` for an orphan). Drives the heal footer. */
629
660
  const AUTO_FIXABLE_CATEGORIES = new Set([
630
- 'unwired-hook', 'settings-missing', 'settings-unparseable', 'missing', 'divergent', 'stale', 'never-synced',
661
+ 'hook-runtime-broken', 'unwired-hook', 'settings-missing', 'settings-unparseable', 'missing', 'divergent', 'stale', 'never-synced',
631
662
  ]);
632
663
  export function verdictIsAutoFixable(v) {
633
664
  return v.issues.some((i) => AUTO_FIXABLE_CATEGORIES.has(i.category));
@@ -646,6 +677,14 @@ export function computeVerdict(report) {
646
677
  const syncCmd = `agents sync ${idLabel} --yes`;
647
678
  // ── critical: settings.json / unwired hooks (silent breakage) ──
648
679
  const w = report.hookWiring;
680
+ for (const issue of w?.runtimeBroken ?? []) {
681
+ issues.push({
682
+ severity: 'critical', category: 'hook-runtime-broken', subject: issue.name,
683
+ impact: `generated hook wrapper is ${issue.reason}; the hook cannot run`,
684
+ fix: fixCmd,
685
+ text: `${issue.name} hook runtime broken`, color: 'red',
686
+ });
687
+ }
649
688
  if (w?.settingsMissing) {
650
689
  const n = w.expected ?? 0;
651
690
  issues.push({
@@ -828,8 +867,18 @@ export function computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, d
828
867
  color: drift ? 'red' : 'yellow',
829
868
  });
830
869
  }
831
- // critical: unwired hooks / broken settings.json per version
870
+ // critical: generated hook runtime / unwired hooks / broken settings.json per version
832
871
  for (const row of syncRows) {
872
+ const brokenRuntime = row.brokenHookRuntime ?? 0;
873
+ if (brokenRuntime > 0) {
874
+ const label = pretty(row.agent, row.version);
875
+ issues.push({
876
+ severity: 'critical', category: 'hook-runtime-broken', subject: label,
877
+ impact: `${brokenRuntime} generated hook wrapper${brokenRuntime === 1 ? '' : 's'} unusable; affected hooks cannot run`,
878
+ fix: `agents doctor ${row.agent}@${row.version} --fix`,
879
+ text: `${label} ${brokenRuntime} hook runtime broken`, color: 'red',
880
+ });
881
+ }
833
882
  const n = row.unwiredHooks ?? 0;
834
883
  if (n <= 0)
835
884
  continue;
@@ -890,7 +939,7 @@ export function computeOverviewHealth(syncRows, orphanRows, repoBehindMarkers, d
890
939
  text: `${label} orphan`, color: 'magenta',
891
940
  });
892
941
  }
893
- const reconciled = syncRows.filter((r) => r.status === 'fresh' && (r.unwiredHooks ?? 0) === 0).length;
942
+ const reconciled = syncRows.filter((r) => r.status === 'fresh' && (r.unwiredHooks ?? 0) === 0 && (r.brokenHookRuntime ?? 0) === 0).length;
894
943
  return { healthy: issues.length === 0, issues, reconciled };
895
944
  }
896
945
  /**
@@ -1059,10 +1108,22 @@ function rewireUnwiredHooks(parsed) {
1059
1108
  const need = before.unwired.length + (before.settingsMissing ? (before.expected ?? 0) : 0);
1060
1109
  if (need === 0)
1061
1110
  continue;
1062
- registerHooksToSettings(agent, getVersionHomePath(agent, version));
1063
- const after = checkVersionHookWiring(agent, version);
1064
- const remaining = after.unwired.length + (after.settingsMissing ? (after.expected ?? 0) : 0);
1065
- out.push({ agent, version, rewired: Math.max(0, need - remaining), remaining });
1111
+ try {
1112
+ const registration = registerHooksToSettings(agent, getVersionHomePath(agent, version));
1113
+ if (registration.errors.length > 0) {
1114
+ out.push({ agent, version, rewired: 0, remaining: need, failure: 'register-failed' });
1115
+ continue;
1116
+ }
1117
+ const after = checkVersionHookWiring(agent, version);
1118
+ const remaining = after.unwired.length + (after.settingsMissing ? (after.expected ?? 0) : 0);
1119
+ out.push({ agent, version, rewired: Math.max(0, need - remaining), remaining });
1120
+ }
1121
+ catch {
1122
+ // A shim write can fail before the native config writer gets to return
1123
+ // its own errors. Record the same stable class and keep doctor --fix
1124
+ // moving to the one bounded runtime repair pass below.
1125
+ out.push({ agent, version, rewired: 0, remaining: need, failure: 'register-failed' });
1126
+ }
1066
1127
  }
1067
1128
  }
1068
1129
  return out;
@@ -1070,7 +1131,10 @@ function rewireUnwiredHooks(parsed) {
1070
1131
  function renderHookRewireText(rewired) {
1071
1132
  for (const r of rewired) {
1072
1133
  const label = `${AGENT_NAMES[r.agent] || r.agent}@${r.version}`;
1073
- if (r.remaining === 0) {
1134
+ if (r.failure) {
1135
+ console.log(` ${chalk.red('hold ')} ${label} ${chalk.gray('native hook wiring could not be updated')}`);
1136
+ }
1137
+ else if (r.remaining === 0) {
1074
1138
  console.log(` ${chalk.green('rewired')} ${label} ${chalk.gray(`${r.rewired} hook${r.rewired === 1 ? '' : 's'} wired into settings.json`)}`);
1075
1139
  }
1076
1140
  else {
@@ -1078,6 +1142,24 @@ function renderHookRewireText(rewired) {
1078
1142
  }
1079
1143
  }
1080
1144
  }
1145
+ function runtimeRepairFilter(parsed) {
1146
+ if (!parsed)
1147
+ return undefined;
1148
+ // A concrete target can be narrowed to that version. Broad selectors (@all,
1149
+ // @latest, agent-only) still make one bounded repair pass for that harness.
1150
+ return {
1151
+ agent: parsed.agent,
1152
+ ...(parsed.versionExplicit && parsed.versions.length === 1 ? { version: parsed.versions[0] } : {}),
1153
+ };
1154
+ }
1155
+ function renderHookRuntimeRepairText(repair) {
1156
+ for (const fixed of repair.fixed) {
1157
+ console.log(` ${chalk.green('fixed ')} ${chalk.gray(fixed)}`);
1158
+ }
1159
+ for (const unresolved of repair.needsAttention) {
1160
+ console.log(` ${chalk.red('hold ')} ${chalk.gray(unresolved)}`);
1161
+ }
1162
+ }
1081
1163
  async function runFix(parsed, opts) {
1082
1164
  // Heal targets the global install — project layer is irrelevant, so cwd is
1083
1165
  // left to heal's neutral default rather than process.cwd().
@@ -1094,12 +1176,27 @@ async function runFix(parsed, opts) {
1094
1176
  });
1095
1177
  // Re-wire hooks the diff-driven heal leaves behind (present file, not wired).
1096
1178
  const rewired = rewireUnwiredHooks(parsed);
1179
+ // One inspect→generate→verify pass, after normal resource and native-wiring
1180
+ // repair have settled. This routine never calls sync/register and never
1181
+ // retries; unresolved wrappers remain an explicit non-zero doctor outcome.
1182
+ const hookRuntimeRepair = repairManagedHookRuntimeArtifacts({ filter: runtimeRepairFilter(parsed) });
1183
+ const rewireFailed = rewired.some((entry) => entry.failure !== undefined);
1184
+ if (healChangedAnything(result) ||
1185
+ rewired.some((entry) => entry.rewired > 0 || entry.remaining > 0 || entry.failure !== undefined) ||
1186
+ hookRuntimeRepair.attempts.length > 0) {
1187
+ invalidateDoctorOverviewCache();
1188
+ }
1097
1189
  if (opts.json) {
1098
- console.log(JSON.stringify({ ...result, hookRewire: rewired }, null, 2));
1190
+ console.log(JSON.stringify({ ...result, hookRewire: rewired, hookRuntimeRepair }, null, 2));
1191
+ if (rewireFailed || hookRuntimeRepair.needsAttention.length > 0)
1192
+ process.exitCode = 1;
1099
1193
  return;
1100
1194
  }
1101
1195
  renderHealText(result);
1102
1196
  renderHookRewireText(rewired);
1197
+ renderHookRuntimeRepairText(hookRuntimeRepair);
1198
+ if (rewireFailed || hookRuntimeRepair.needsAttention.length > 0)
1199
+ process.exitCode = 1;
1103
1200
  }
1104
1201
  function checkLabel(row) {
1105
1202
  return `${AGENT_NAMES[row.agent] || row.agent}@${row.version}`;
@@ -1112,6 +1209,7 @@ function runCheckGate(opts, cwd) {
1112
1209
  stale: drift.staleCount,
1113
1210
  neverSynced: drift.neverSyncedCount,
1114
1211
  unwiredHookVersions: drift.unwiredHookVersions,
1212
+ brokenHookRuntimeVersions: drift.brokenHookRuntimeVersions,
1115
1213
  orphanVersions: drift.orphanVersionCount,
1116
1214
  sourceBehind: drift.sourceBehind,
1117
1215
  versions: drift.syncRows.map((r) => ({
@@ -1120,6 +1218,7 @@ function runCheckGate(opts, cwd) {
1120
1218
  status: r.status,
1121
1219
  isDefault: r.isDefault,
1122
1220
  unwiredHooks: r.unwiredHooks ?? 0,
1221
+ brokenHookRuntime: r.brokenHookRuntime ?? 0,
1123
1222
  divergence: r.divergence ?? [],
1124
1223
  })),
1125
1224
  }, null, 2));
@@ -1149,6 +1248,8 @@ function runCheckGate(opts, cwd) {
1149
1248
  parts.push(`${drift.neverSyncedCount} never-synced`);
1150
1249
  if (drift.unwiredHookVersions > 0)
1151
1250
  parts.push(`${drift.unwiredHookVersions} with unwired hooks`);
1251
+ if (drift.brokenHookRuntimeVersions > 0)
1252
+ parts.push(`${drift.brokenHookRuntimeVersions} with broken hook runtime`);
1152
1253
  if (drift.sourceBehind.length > 0)
1153
1254
  parts.push(`${drift.sourceBehind.length} source layer(s) behind origin`);
1154
1255
  console.error(`${chalk.gray('check:')} ${chalk.red('drift')} — ${parts.join(', ')} across ${drift.syncRows.length} version(s)`);
@@ -1160,11 +1261,12 @@ function runCheckGate(opts, cwd) {
1160
1261
  const STATUS_BADGE_WIDTH = 'never-synced'.length + 2;
1161
1262
  for (const row of drift.syncRows) {
1162
1263
  const unwired = (row.unwiredHooks ?? 0) > 0;
1163
- if (row.status === 'fresh' && !unwired)
1264
+ const brokenRuntime = (row.brokenHookRuntime ?? 0) > 0;
1265
+ if (row.status === 'fresh' && !unwired && !brokenRuntime)
1164
1266
  continue;
1165
1267
  const tag = row.status === 'stale' ? chalk.yellow('stale'.padEnd(STATUS_BADGE_WIDTH))
1166
1268
  : row.status === 'never-synced' ? chalk.gray('never-synced'.padEnd(STATUS_BADGE_WIDTH))
1167
- : chalk.red('unwired'.padEnd(STATUS_BADGE_WIDTH)); // fresh but hooks not wired into settings.json
1269
+ : chalk.red((brokenRuntime ? 'hook-runtime' : 'unwired').padEnd(STATUS_BADGE_WIDTH));
1168
1270
  console.error(` ${tag}${checkLabel(row)}`);
1169
1271
  for (const line of row.divergence ?? []) {
1170
1272
  console.error(chalk.gray(` ${line}`));
@@ -1174,7 +1276,7 @@ function runCheckGate(opts, cwd) {
1174
1276
  console.error(` ${chalk.red('behind')} source ${b.label} ${chalk.gray(`${b.behind} commit${b.behind === 1 ? '' : 's'} behind ${b.branch}`)}`);
1175
1277
  }
1176
1278
  const hints = [];
1177
- if (drift.staleCount > 0 || drift.neverSyncedCount > 0 || drift.unwiredHookVersions > 0) {
1279
+ if (drift.staleCount > 0 || drift.neverSyncedCount > 0 || drift.unwiredHookVersions > 0 || drift.brokenHookRuntimeVersions > 0) {
1178
1280
  hints.push('`agents doctor --fix` (or `agents doctor <agent>@<version> --fix`)');
1179
1281
  }
1180
1282
  if (drift.sourceBehind.length > 0)