@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.
- package/CHANGELOG.md +72 -0
- package/README.md +8 -2
- package/dist/bin/agents +0 -0
- package/dist/commands/daemon.js +52 -12
- package/dist/commands/doctor.d.ts +19 -0
- package/dist/commands/doctor.js +119 -17
- package/dist/commands/routines.js +164 -36
- package/dist/commands/sessions-browser.js +2 -2
- package/dist/commands/sessions.d.ts +1 -1
- package/dist/commands/sessions.js +66 -22
- package/dist/commands/update.d.ts +2 -0
- package/dist/commands/update.js +148 -0
- package/dist/index.js +3 -1
- package/dist/lib/catchup.js +4 -1
- package/dist/lib/daemon.d.ts +17 -0
- package/dist/lib/daemon.js +69 -3
- package/dist/lib/devices/doctor-findings.d.ts +7 -2
- package/dist/lib/devices/doctor-findings.js +53 -2
- package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
- package/dist/lib/devices/doctor-overview-cache.js +15 -0
- package/dist/lib/devices/fleet-divergence.d.ts +11 -0
- package/dist/lib/devices/fleet-divergence.js +6 -0
- package/dist/lib/devices/fleet-inventory.js +16 -2
- package/dist/lib/drift.d.ts +6 -1
- package/dist/lib/drift.js +9 -0
- package/dist/lib/hooks/cache.js +20 -1
- package/dist/lib/hooks.d.ts +91 -1
- package/dist/lib/hooks.js +289 -3
- package/dist/lib/hosts/passthrough.js +3 -0
- package/dist/lib/installations/index.d.ts +14 -0
- package/dist/lib/installations/index.js +14 -0
- package/dist/lib/installations/resolve.d.ts +43 -0
- package/dist/lib/installations/resolve.js +93 -0
- package/dist/lib/installations/store.d.ts +56 -0
- package/dist/lib/installations/store.js +196 -0
- package/dist/lib/installations/strategies.d.ts +73 -0
- package/dist/lib/installations/strategies.js +293 -0
- package/dist/lib/installations/types.d.ts +78 -0
- package/dist/lib/installations/types.js +8 -0
- package/dist/lib/installations/update.d.ts +40 -0
- package/dist/lib/installations/update.js +131 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/migrate.d.ts +27 -0
- package/dist/lib/migrate.js +112 -2
- package/dist/lib/routine-context.d.ts +144 -0
- package/dist/lib/routine-context.js +268 -0
- package/dist/lib/routine-readiness.d.ts +47 -0
- package/dist/lib/routine-readiness.js +239 -0
- package/dist/lib/routines.d.ts +97 -1
- package/dist/lib/routines.js +107 -1
- package/dist/lib/runner.d.ts +18 -4
- package/dist/lib/runner.js +291 -98
- package/dist/lib/scheduler.d.ts +7 -1
- package/dist/lib/scheduler.js +5 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
- package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
- package/dist/lib/self-heal/registry.js +5 -2
- package/dist/lib/self-heal/types.d.ts +1 -1
- package/dist/lib/session/state.js +4 -1
- package/dist/lib/session/team-filter.d.ts +11 -0
- package/dist/lib/session/team-filter.js +10 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/versions.d.ts +24 -0
- package/dist/lib/versions.js +49 -16
- 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 --
|
|
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
|
|
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
|
package/dist/commands/daemon.js
CHANGED
|
@@ -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`)
|
|
98
|
-
*
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
|
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()
|
|
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
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
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.
|
|
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
|
-
|
|
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));
|
|
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)
|