@phnx-labs/agents-cli 1.22.31 → 1.22.32
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 +66 -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.d.ts +1 -1
- package/dist/commands/sessions.js +44 -10
- 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/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/dist/lib/daemon.js
CHANGED
|
@@ -30,6 +30,7 @@ import { isSchedulerEnabled, assertSchedulerEnabled, isDaemonEnabled } from './d
|
|
|
30
30
|
import { reapTerminalRoutineProcesses } from './routine-process-cleanup.js';
|
|
31
31
|
import { recordSubsystemOk, recordSubsystemError, SUBSYSTEM_SECRETS_BROKER, SUBSYSTEM_BROWSER_IPC } from './daemon-health.js';
|
|
32
32
|
const PID_FILE = 'daemon.pid';
|
|
33
|
+
const LIFETIME_FILE = 'daemon.lifetime';
|
|
33
34
|
const LOCK_FILE = 'daemon.lock';
|
|
34
35
|
const LOG_FILE = 'logs.jsonl';
|
|
35
36
|
const HEARTBEAT_FILE = 'heartbeat.json';
|
|
@@ -543,6 +544,12 @@ export async function runDaemon() {
|
|
|
543
544
|
// rather than a failure to restart-flap on.
|
|
544
545
|
process.exit(0);
|
|
545
546
|
}
|
|
547
|
+
// Unlike the pid and heartbeat files, this marker is written exactly once
|
|
548
|
+
// for this daemon lifetime. Status probes deliberately repair those other
|
|
549
|
+
// files, so they cannot prove that the original state tree still exists.
|
|
550
|
+
const lifetimePath = path.join(getDaemonDirRoot(), LIFETIME_FILE);
|
|
551
|
+
const lifetimeToken = `${process.pid}:${Date.now()}`;
|
|
552
|
+
fs.writeFileSync(lifetimePath, lifetimeToken, 'utf-8');
|
|
546
553
|
log('INFO', `Daemon started (PID: ${process.pid})`);
|
|
547
554
|
anchorDaemonCwd();
|
|
548
555
|
warnEphemeralDaemonRoot();
|
|
@@ -610,7 +617,7 @@ export async function runDaemon() {
|
|
|
610
617
|
log('WARN', err.message);
|
|
611
618
|
}
|
|
612
619
|
}
|
|
613
|
-
const triggerJob = async (config) => {
|
|
620
|
+
const triggerJob = async (config, ctx) => {
|
|
614
621
|
const jobLabel = config.command
|
|
615
622
|
? 'command'
|
|
616
623
|
: config.workflow
|
|
@@ -644,7 +651,7 @@ export async function runDaemon() {
|
|
|
644
651
|
})
|
|
645
652
|
.catch(() => { });
|
|
646
653
|
},
|
|
647
|
-
});
|
|
654
|
+
}, { kind: 'schedule', scheduledFor: ctx?.scheduledFor });
|
|
648
655
|
log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
|
|
649
656
|
}
|
|
650
657
|
catch (err) {
|
|
@@ -847,9 +854,16 @@ export async function runDaemon() {
|
|
|
847
854
|
const runHealCheck = async () => {
|
|
848
855
|
if (healing)
|
|
849
856
|
return;
|
|
857
|
+
// The daemon's state directory is its liveness boundary. Once that tree is
|
|
858
|
+
// removed, background maintenance must not recreate it while the
|
|
859
|
+
// self-terminate guard is shutting the process down.
|
|
860
|
+
if (!fs.existsSync(getDaemonDirRoot()))
|
|
861
|
+
return;
|
|
850
862
|
healing = true;
|
|
851
863
|
try {
|
|
852
864
|
const { runSelfHeal, selfHealChangedAnything, selfHealNeedsAttention, summarizeSelfHeal } = await import('./self-heal/registry.js');
|
|
865
|
+
if (!fs.existsSync(getDaemonDirRoot()))
|
|
866
|
+
return;
|
|
853
867
|
// Background heal is conservative (mode: 'safe'): fixes low-risk drift (shims,
|
|
854
868
|
// symlink adoption, PATH, missing resources) and only reports risky ones. The
|
|
855
869
|
// 30s kickoff means shims/PATH settle shortly after the daemon starts. No
|
|
@@ -925,6 +939,42 @@ export async function runDaemon() {
|
|
|
925
939
|
}
|
|
926
940
|
};
|
|
927
941
|
const keychainReapInterval = setInterval(() => { void runKeychainReap(); }, 5 * 60_000);
|
|
942
|
+
// RUSH-2367: self-terminate if this daemon's own state dir has been removed
|
|
943
|
+
// out from under it — the shape of a leaked test-fixture daemon whose /tmp
|
|
944
|
+
// HOME was deleted by its test's own cleanup while the process itself
|
|
945
|
+
// somehow survived (lost the SIGTERM/SIGKILL race, or outlived a killed
|
|
946
|
+
// test runner before its `finally` ever ran). Nothing else can reach a
|
|
947
|
+
// daemon in that state: no `agents daemon` command targets it, since a
|
|
948
|
+
// different HOME resolves a different getDaemonDir() and therefore a
|
|
949
|
+
// different instance registry — without this it runs forever. Reads
|
|
950
|
+
// Reads the lifetime marker directly, never the local getDaemonDir() wrapper,
|
|
951
|
+
// which recreates the directory as a side effect and would defeat the check.
|
|
952
|
+
// Heartbeat/status paths may recreate the directory and pid file after a
|
|
953
|
+
// deletion; they never recreate this per-lifetime token.
|
|
954
|
+
let checkingStateDir = false;
|
|
955
|
+
const runStateDirSelfCheck = () => {
|
|
956
|
+
if (checkingStateDir)
|
|
957
|
+
return;
|
|
958
|
+
checkingStateDir = true;
|
|
959
|
+
try {
|
|
960
|
+
let markerMatches = false;
|
|
961
|
+
try {
|
|
962
|
+
markerMatches = fs.readFileSync(lifetimePath, 'utf-8') === lifetimeToken;
|
|
963
|
+
}
|
|
964
|
+
catch {
|
|
965
|
+
// A missing state tree or marker is the condition this guard detects.
|
|
966
|
+
}
|
|
967
|
+
if (!markerMatches) {
|
|
968
|
+
log('WARN', `Daemon state dir ${getDaemonDirRoot()} no longer exists; exiting (self-terminate guard)`);
|
|
969
|
+
void handleShutdown();
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
finally {
|
|
973
|
+
checkingStateDir = false;
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
const stateDirCheckMs = Number(process.env.AGENTS_DAEMON_STATE_DIR_CHECK_MS) || 60_000;
|
|
977
|
+
const stateDirCheckInterval = setInterval(runStateDirSelfCheck, stateDirCheckMs);
|
|
928
978
|
const handleReload = () => {
|
|
929
979
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
930
980
|
// Refresh user-layer copies of opted-in project routines BEFORE the
|
|
@@ -975,6 +1025,14 @@ export async function runDaemon() {
|
|
|
975
1025
|
clearTimeout(healKickoff);
|
|
976
1026
|
clearInterval(brokerSelfHealInterval);
|
|
977
1027
|
clearInterval(keychainReapInterval);
|
|
1028
|
+
clearInterval(stateDirCheckInterval);
|
|
1029
|
+
try {
|
|
1030
|
+
if (fs.readFileSync(lifetimePath, 'utf-8') === lifetimeToken)
|
|
1031
|
+
fs.unlinkSync(lifetimePath);
|
|
1032
|
+
}
|
|
1033
|
+
catch {
|
|
1034
|
+
// Already removed with the state tree, or replaced by a newer owner.
|
|
1035
|
+
}
|
|
978
1036
|
hostedBroker?.close();
|
|
979
1037
|
removeDaemonPid();
|
|
980
1038
|
removeHeartbeat();
|
|
@@ -1356,8 +1414,16 @@ function waitForPid(timeoutMs) {
|
|
|
1356
1414
|
* fixture with its own HOME, a separate install/home) registers elsewhere and is
|
|
1357
1415
|
* invisible here — it is never a stop/takeover target. POSIX-only (the registry
|
|
1358
1416
|
* and its `ps` liveness probe are); `[]` on Windows.
|
|
1417
|
+
*
|
|
1418
|
+
* Exported for `agents daemon status`/`doctor`/`services` (RUSH-2368): those
|
|
1419
|
+
* commands previously flagged every `__daemon-run` on the box (a raw `ps` scan)
|
|
1420
|
+
* as a "duplicate" of this daemon, which misreported test fixtures under their
|
|
1421
|
+
* own HOME — and therefore their own state dir and registry — as strays to
|
|
1422
|
+
* kill. This registry read is the same scope the reaper (`reapStrayDaemons`)
|
|
1423
|
+
* and the stop postcondition (`stopDaemon`) already use, so the display and the
|
|
1424
|
+
* reaper agree on what a duplicate is.
|
|
1359
1425
|
*/
|
|
1360
|
-
function findSurvivingStateDirDaemons(exclude) {
|
|
1426
|
+
export function findSurvivingStateDirDaemons(exclude) {
|
|
1361
1427
|
if (process.platform === 'win32')
|
|
1362
1428
|
return [];
|
|
1363
1429
|
const dir = getDaemonInstancesDir();
|
|
@@ -6,13 +6,13 @@ import { type WindowsSshEnrollmentAudit } from './windows-ssh-enrollment.js';
|
|
|
6
6
|
import type { SyncStatusRow, OrphanRow } from '../drift.js';
|
|
7
7
|
import type { FetchStatusMarker } from '../auto-pull.js';
|
|
8
8
|
import type { VersionResourceReport } from '../doctor-diff.js';
|
|
9
|
-
import type { FleetDivergence, FleetVersionSignIn } from './fleet-divergence.js';
|
|
9
|
+
import type { FleetDivergence, FleetHookRuntimeState, FleetVersionSignIn } from './fleet-divergence.js';
|
|
10
10
|
export type FindingSeverity = 'critical' | 'warning';
|
|
11
11
|
/** A machine-stable class for a finding — drives {@link remediationFor} and lets
|
|
12
12
|
* the JSON consumer group by kind. */
|
|
13
13
|
/** Every finding class. Severity is NOT annotated here — {@link FINDING_SEVERITY}
|
|
14
14
|
* below owns it, and a second copy in these comments is a fourth place to drift. */
|
|
15
|
-
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "ssh-key-enrollment", "stale-cli", "owner-sink-unreachable"];
|
|
15
|
+
export declare const ALL_FINDING_KINDS: readonly ["logged-out", "logout-unprovable", "missing-hook", "missing-plugin", "unwired-hook", "hook-runtime-broken", "hook-runtime-visibility-unavailable", "cli-missing", "missing-resource", "content-drift", "never-synced", "stale", "repo-behind", "repo-drift", "fleet-resource-gap", "host-cli-missing", "host-cli-invalid", "version-skew", "orphan", "duplicate-hook", "duplicate-hook-drift", "rc-secret-export", "env-secret-export", "exec-policy", "ssh-key-enrollment", "stale-cli", "owner-sink-unreachable"];
|
|
16
16
|
/**
|
|
17
17
|
* The severity each kind is emitted with - the SINGLE source of truth, read by
|
|
18
18
|
* the builders below and asserted against both prose rubrics by
|
|
@@ -161,6 +161,11 @@ export declare function collapseAcrossVersions(findings: DoctorFinding[], isolat
|
|
|
161
161
|
* logout provable. Pure.
|
|
162
162
|
*/
|
|
163
163
|
export declare function signInToFindings(device: string, signIn: Record<string, FleetVersionSignIn[]>): DoctorFinding[];
|
|
164
|
+
/**
|
|
165
|
+
* Rebuild remote generated-wrapper findings from the closed enum inventory
|
|
166
|
+
* state. Remote paths and detector messages never cross the fleet boundary.
|
|
167
|
+
*/
|
|
168
|
+
export declare function hookRuntimeToFindings(device: string, hookRuntime: Record<string, Record<string, FleetHookRuntimeState>> | undefined): DoctorFinding[];
|
|
164
169
|
/**
|
|
165
170
|
* Map cross-device divergence (from {@link compareFleetInventories}) into
|
|
166
171
|
* warnings: an agent version present elsewhere but absent on a device is a
|
|
@@ -18,11 +18,13 @@
|
|
|
18
18
|
* with. Keep this list exhaustive; a kind missing from it is a doc that lies.
|
|
19
19
|
* CRITICAL — logged-out (provable) · missing-hook · missing-plugin ·
|
|
20
20
|
* unwired-hook (a hook on disk that settings.json never fires) ·
|
|
21
|
-
*
|
|
21
|
+
* hook-runtime-broken (a wired hook's generated shim wrapper is
|
|
22
|
+
* missing or unusable) · cli-missing · ssh-key-enrollment ·
|
|
23
|
+
* owner-sink-unreachable (the feed/notify owner lane
|
|
22
24
|
* cannot reach the owner from this box).
|
|
23
25
|
* WARNING — logout-unprovable (hedged) · missing-resource · content-drift ·
|
|
24
26
|
* never-synced · stale · repo-behind · repo-drift · version-skew ·
|
|
25
|
-
* fleet-resource-gap · orphan · duplicate-hook ·
|
|
27
|
+
* fleet-resource-gap · hook-runtime-visibility-unavailable · orphan · duplicate-hook ·
|
|
26
28
|
* duplicate-hook-drift · host-cli-missing · host-cli-invalid ·
|
|
27
29
|
* rc-secret-export · env-secret-export · exec-policy · stale-cli.
|
|
28
30
|
* (RUSH-2162 moved never-synced and duplicate-hook-drift to WARNING: both are
|
|
@@ -81,6 +83,8 @@ export const ALL_FINDING_KINDS = [
|
|
|
81
83
|
'missing-hook', // a declared hook absent from a version home
|
|
82
84
|
'missing-plugin', // a declared plugin absent from a version home
|
|
83
85
|
'unwired-hook', // hook present on disk but not wired into settings.json
|
|
86
|
+
'hook-runtime-broken', // a wired hook's generated shim wrapper is missing/unusable
|
|
87
|
+
'hook-runtime-visibility-unavailable', // remote CLI cannot report generated wrapper health
|
|
84
88
|
'cli-missing', // a managed agent whose binary won't resolve
|
|
85
89
|
'missing-resource', // a missing command/skill/rule/mcp/permission/subagent
|
|
86
90
|
'content-drift', // a resource diverged from source
|
|
@@ -119,6 +123,7 @@ export const FINDING_SEVERITY = {
|
|
|
119
123
|
'missing-hook': 'critical',
|
|
120
124
|
'missing-plugin': 'critical',
|
|
121
125
|
'unwired-hook': 'critical',
|
|
126
|
+
'hook-runtime-broken': 'critical',
|
|
122
127
|
'cli-missing': 'critical',
|
|
123
128
|
// A factory that cannot escalate a blocked agent to the owner is not healthy,
|
|
124
129
|
// and the failure is otherwise silent until a block is filed (RUSH-2262/2258).
|
|
@@ -127,6 +132,7 @@ export const FINDING_SEVERITY = {
|
|
|
127
132
|
// the harness right now. RUSH-2162 moved never-synced and duplicate-hook-drift
|
|
128
133
|
// here: both are stale-sync states that one `agents sync` resolves.
|
|
129
134
|
'logout-unprovable': 'warning',
|
|
135
|
+
'hook-runtime-visibility-unavailable': 'warning',
|
|
130
136
|
'missing-resource': 'warning',
|
|
131
137
|
'content-drift': 'warning',
|
|
132
138
|
'never-synced': 'warning',
|
|
@@ -200,10 +206,13 @@ export function remediationFor(finding) {
|
|
|
200
206
|
case 'missing-hook':
|
|
201
207
|
case 'missing-plugin':
|
|
202
208
|
case 'unwired-hook':
|
|
209
|
+
case 'hook-runtime-broken':
|
|
203
210
|
case 'missing-resource':
|
|
204
211
|
case 'content-drift':
|
|
205
212
|
case 'stale':
|
|
206
213
|
return idLabel ? `agents doctor ${idLabel} --fix` : 'agents doctor --fix';
|
|
214
|
+
case 'hook-runtime-visibility-unavailable':
|
|
215
|
+
return 'upgrade agents-cli on this device';
|
|
207
216
|
case 'never-synced':
|
|
208
217
|
// A bare `agents sync <agent>` targets only the default/sole installed
|
|
209
218
|
// version (`commands/sync.ts:8`), so a row collapsed across versions must
|
|
@@ -354,6 +363,15 @@ export function buildLocalFindings(input) {
|
|
|
354
363
|
}
|
|
355
364
|
}
|
|
356
365
|
}
|
|
366
|
+
// Generated shim wrapper missing/unusable for a wired hook — independent of
|
|
367
|
+
// whether the native settings format itself is understood, so this fires
|
|
368
|
+
// even for harnesses `w.supported` is false for (RUSH-2382).
|
|
369
|
+
for (const issue of w?.runtimeBroken ?? []) {
|
|
370
|
+
out.push(finding({
|
|
371
|
+
severity: FINDING_SEVERITY['hook-runtime-broken'], kind: 'hook-runtime-broken', device, agent, version,
|
|
372
|
+
message: `hook '${issue.name}' wired but its generated shim is ${issue.reason}`,
|
|
373
|
+
}));
|
|
374
|
+
}
|
|
357
375
|
// A never-synced version has EVERY declared resource "missing" — that's one
|
|
358
376
|
// root cause (never synced), not one emergency per hook. Collapse it to a
|
|
359
377
|
// single critical rather than flooding the top section with 100+ lines. The
|
|
@@ -766,6 +784,39 @@ export function signInToFindings(device, signIn) {
|
|
|
766
784
|
}
|
|
767
785
|
return out;
|
|
768
786
|
}
|
|
787
|
+
/**
|
|
788
|
+
* Rebuild remote generated-wrapper findings from the closed enum inventory
|
|
789
|
+
* state. Remote paths and detector messages never cross the fleet boundary.
|
|
790
|
+
*/
|
|
791
|
+
export function hookRuntimeToFindings(device, hookRuntime) {
|
|
792
|
+
if (!hookRuntime) {
|
|
793
|
+
return [finding({
|
|
794
|
+
severity: FINDING_SEVERITY['hook-runtime-visibility-unavailable'],
|
|
795
|
+
kind: 'hook-runtime-visibility-unavailable',
|
|
796
|
+
device,
|
|
797
|
+
message: "older agents-cli — can't report generated hook-wrapper health",
|
|
798
|
+
})];
|
|
799
|
+
}
|
|
800
|
+
const out = [];
|
|
801
|
+
for (const agent of ALL_AGENT_IDS) {
|
|
802
|
+
const versions = hookRuntime[agent];
|
|
803
|
+
if (!versions)
|
|
804
|
+
continue;
|
|
805
|
+
for (const [version, state] of Object.entries(versions)) {
|
|
806
|
+
if (state !== 'broken')
|
|
807
|
+
continue;
|
|
808
|
+
out.push(finding({
|
|
809
|
+
severity: FINDING_SEVERITY['hook-runtime-broken'],
|
|
810
|
+
kind: 'hook-runtime-broken',
|
|
811
|
+
device,
|
|
812
|
+
agent,
|
|
813
|
+
version,
|
|
814
|
+
message: 'generated hook wrapper is unusable',
|
|
815
|
+
}));
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
return out;
|
|
819
|
+
}
|
|
769
820
|
/**
|
|
770
821
|
* Map cross-device divergence (from {@link compareFleetInventories}) into
|
|
771
822
|
* warnings: an agent version present elsewhere but absent on a device is a
|
|
@@ -14,6 +14,13 @@ export declare function readDoctorOverviewCache(deps?: DoctorOverviewCacheDeps):
|
|
|
14
14
|
} | null;
|
|
15
15
|
/** Persist a fresh overview payload (best-effort; tmp+rename so reads are atomic). */
|
|
16
16
|
export declare function writeDoctorOverviewCache(payload: unknown, deps?: DoctorOverviewCacheDeps): void;
|
|
17
|
+
/**
|
|
18
|
+
* Drop the cached overview after a doctor repair attempt changes (or fails to
|
|
19
|
+
* change) live health. Best-effort and deliberately narrow: it never touches
|
|
20
|
+
* the singleflight lock, so an in-progress overview compute remains owned by
|
|
21
|
+
* its holder and no repair can create a retry loop.
|
|
22
|
+
*/
|
|
23
|
+
export declare function invalidateDoctorOverviewCache(deps?: DoctorOverviewCacheDeps): void;
|
|
17
24
|
/**
|
|
18
25
|
* Result of {@link enterDoctorOverviewGate}.
|
|
19
26
|
* - `cached` non-null → the caller MUST print this string and return; no compute.
|
|
@@ -82,6 +82,21 @@ export function writeDoctorOverviewCache(payload, deps = {}) {
|
|
|
82
82
|
// best-effort; a failed write just means the next read falls back to live
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Drop the cached overview after a doctor repair attempt changes (or fails to
|
|
87
|
+
* change) live health. Best-effort and deliberately narrow: it never touches
|
|
88
|
+
* the singleflight lock, so an in-progress overview compute remains owned by
|
|
89
|
+
* its holder and no repair can create a retry loop.
|
|
90
|
+
*/
|
|
91
|
+
export function invalidateDoctorOverviewCache(deps = {}) {
|
|
92
|
+
const dir = deps.dir ?? getCacheDir();
|
|
93
|
+
try {
|
|
94
|
+
fs.unlinkSync(cachePath(dir));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Missing/unlinkable cache is already equivalent to invalidated.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
85
100
|
/**
|
|
86
101
|
* Enter the doctor-overview singleflight gate. Returns a cached string to print,
|
|
87
102
|
* or a lock token telling the caller to compute (and then write + release).
|
|
@@ -57,6 +57,13 @@ export interface FleetVersionSignIn {
|
|
|
57
57
|
* AND globally) — the caller gates a critical on this. */
|
|
58
58
|
provable: boolean;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* A deliberately closed summary of one version's generated hook-wrapper
|
|
62
|
+
* runtime. Fleet payloads carry only this state — never the remote wrapper
|
|
63
|
+
* path, embedded source path, or detector text.
|
|
64
|
+
*/
|
|
65
|
+
export declare const FLEET_HOOK_RUNTIME_STATES: readonly ["healthy", "broken", "not-applicable"];
|
|
66
|
+
export type FleetHookRuntimeState = typeof FLEET_HOOK_RUNTIME_STATES[number];
|
|
60
67
|
/**
|
|
61
68
|
* The self-reported harness inventory a single device emits in `doctor --json`.
|
|
62
69
|
* Comparable device-to-device with no further probing.
|
|
@@ -77,6 +84,10 @@ export interface FleetInventory {
|
|
|
77
84
|
* predates this field omits it, and the caller degrades to a warning
|
|
78
85
|
* ("older agents-cli — can't report per-version sign-in"). */
|
|
79
86
|
signIn?: Record<string, FleetVersionSignIn[]>;
|
|
87
|
+
/** Generated hook-wrapper health per installed agent/version. Optional for
|
|
88
|
+
* wire compatibility with older remotes; a present value is fully validated
|
|
89
|
+
* before it is used by fleet doctor. */
|
|
90
|
+
hookRuntime?: Record<string, Record<string, FleetHookRuntimeState>>;
|
|
80
91
|
}
|
|
81
92
|
/** A device's inventory paired with its name (and reachability). A device that
|
|
82
93
|
* was unreachable / failed to report carries `inventory: null` and is skipped
|
|
@@ -37,6 +37,12 @@ export const FLEET_RESOURCE_KINDS = [
|
|
|
37
37
|
'promptcuts',
|
|
38
38
|
'workflows',
|
|
39
39
|
];
|
|
40
|
+
/**
|
|
41
|
+
* A deliberately closed summary of one version's generated hook-wrapper
|
|
42
|
+
* runtime. Fleet payloads carry only this state — never the remote wrapper
|
|
43
|
+
* path, embedded source path, or detector text.
|
|
44
|
+
*/
|
|
45
|
+
export const FLEET_HOOK_RUNTIME_STATES = ['healthy', 'broken', 'not-applicable'];
|
|
40
46
|
function sortedUnique(list) {
|
|
41
47
|
return Array.from(new Set(list)).sort();
|
|
42
48
|
}
|
|
@@ -8,7 +8,9 @@
|
|
|
8
8
|
* FleetInventory} that both the local baseline and every remote box serialize
|
|
9
9
|
* into their doctor payload; the comparator then diffs those payloads.
|
|
10
10
|
*/
|
|
11
|
-
import { getAvailableResources, getVersionHomePath, listInstalledVersions } from '../versions.js';
|
|
11
|
+
import { getAvailableResources, getVersionHomePath, isVersionIsolated, listInstalledVersions } from '../versions.js';
|
|
12
|
+
import { supports } from '../capabilities.js';
|
|
13
|
+
import { checkVersionHookWiring } from '../hooks.js';
|
|
12
14
|
import { getUserAgentsDir, getSystemAgentsDir } from '../state.js';
|
|
13
15
|
import { readRepoState } from '../git.js';
|
|
14
16
|
import { ALL_AGENT_IDS, accountDisplayLabel, credentialPresence, getAccountInfo, supportsAccountInspection, } from '../agents.js';
|
|
@@ -88,10 +90,21 @@ export async function collectLocalFleetInventory(cwd = process.cwd()) {
|
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
const agentVersions = {};
|
|
93
|
+
const hookRuntime = {};
|
|
91
94
|
for (const agent of ALL_AGENT_IDS) {
|
|
92
95
|
const versions = listInstalledVersions(agent);
|
|
93
|
-
if (versions.length > 0)
|
|
96
|
+
if (versions.length > 0) {
|
|
94
97
|
agentVersions[agent] = [...versions].sort();
|
|
98
|
+
hookRuntime[agent] = Object.fromEntries(versions.map((version) => {
|
|
99
|
+
const eligible = supports(agent, 'hooks', version).ok && !isVersionIsolated(agent, version);
|
|
100
|
+
if (!eligible)
|
|
101
|
+
return [version, 'not-applicable'];
|
|
102
|
+
const state = checkVersionHookWiring(agent, version).runtimeBroken.length > 0
|
|
103
|
+
? 'broken'
|
|
104
|
+
: 'healthy';
|
|
105
|
+
return [version, state];
|
|
106
|
+
}));
|
|
107
|
+
}
|
|
95
108
|
}
|
|
96
109
|
return {
|
|
97
110
|
resources,
|
|
@@ -101,5 +114,6 @@ export async function collectLocalFleetInventory(cwd = process.cwd()) {
|
|
|
101
114
|
system: toRepoState(readRepoState(getSystemAgentsDir())),
|
|
102
115
|
},
|
|
103
116
|
signIn: await collectLocalFleetSignIn(),
|
|
117
|
+
hookRuntime,
|
|
104
118
|
};
|
|
105
119
|
}
|
package/dist/lib/drift.d.ts
CHANGED
|
@@ -22,6 +22,8 @@ export interface SyncStatusRow {
|
|
|
22
22
|
* (claude/droid). A non-zero value makes the version out-of-sync even when the
|
|
23
23
|
* manifest reads fresh — the yosemite-s1 blind spot the CI gate must catch. */
|
|
24
24
|
unwiredHooks?: number;
|
|
25
|
+
/** Generated hooks whose shared runtime wrapper is missing or unusable. */
|
|
26
|
+
brokenHookRuntime?: number;
|
|
25
27
|
}
|
|
26
28
|
export interface OrphanRow {
|
|
27
29
|
agent: AgentId;
|
|
@@ -52,11 +54,14 @@ export interface DriftSummary {
|
|
|
52
54
|
orphanVersionCount: number;
|
|
53
55
|
/** Versions with hooks present on disk but not wired into settings.json. */
|
|
54
56
|
unwiredHookVersions: number;
|
|
57
|
+
/** Versions with at least one broken generated hook-runtime wrapper. */
|
|
58
|
+
brokenHookRuntimeVersions: number;
|
|
55
59
|
/** Source layers behind their upstream (reconciled against stale truth). */
|
|
56
60
|
sourceBehind: SourceLayerBehind[];
|
|
57
61
|
/**
|
|
58
62
|
* True when the install is out of sync: any installed version is stale,
|
|
59
|
-
* never-synced,
|
|
63
|
+
* never-synced, carries unwired hooks, or has a broken generated hook runtime,
|
|
64
|
+
* OR a source layer is behind origin.
|
|
60
65
|
* `agents doctor` surfaces it as "run `agents status`"; `agents doctor --check`
|
|
61
66
|
* maps it to a non-zero exit. Orphans are a `prune` concern, not sync drift, so they do
|
|
62
67
|
* NOT set this flag (mirrors the sync-status engine: an orphan alone never
|
package/dist/lib/drift.js
CHANGED
|
@@ -60,6 +60,12 @@ export function checkSyncStatus(cwd) {
|
|
|
60
60
|
// never fires. Surface that for every version, fresh or stale, so overview
|
|
61
61
|
// AND `agents doctor --check` flag it (claude/droid; other agents report unsupported).
|
|
62
62
|
const wiring = checkVersionHookWiring(agent, version);
|
|
63
|
+
if (wiring.runtimeBroken.length > 0) {
|
|
64
|
+
row.brokenHookRuntime = wiring.runtimeBroken.length;
|
|
65
|
+
const names = wiring.runtimeBroken.map((issue) => issue.name);
|
|
66
|
+
const shown = names.slice(0, 3).join(', ');
|
|
67
|
+
divergence.push(`hooks ${names.length} generated wrapper${names.length === 1 ? '' : 's'} broken (${shown}${names.length > 3 ? ', …' : ''})`);
|
|
68
|
+
}
|
|
63
69
|
if (wiring.supported) {
|
|
64
70
|
const expected = wiring.expected ?? 0;
|
|
65
71
|
if (wiring.settingsMissing && expected > 0) {
|
|
@@ -147,6 +153,7 @@ export function computeDrift(cwd) {
|
|
|
147
153
|
const staleCount = syncRows.filter((r) => r.status === 'stale').length;
|
|
148
154
|
const neverSyncedCount = syncRows.filter((r) => r.status === 'never-synced').length;
|
|
149
155
|
const unwiredHookVersions = syncRows.filter((r) => (r.unwiredHooks ?? 0) > 0).length;
|
|
156
|
+
const brokenHookRuntimeVersions = syncRows.filter((r) => (r.brokenHookRuntime ?? 0) > 0).length;
|
|
150
157
|
const sourceBehind = computeSourceBehind();
|
|
151
158
|
return {
|
|
152
159
|
syncRows,
|
|
@@ -155,9 +162,11 @@ export function computeDrift(cwd) {
|
|
|
155
162
|
neverSyncedCount,
|
|
156
163
|
orphanVersionCount: orphanRows.length,
|
|
157
164
|
unwiredHookVersions,
|
|
165
|
+
brokenHookRuntimeVersions,
|
|
158
166
|
sourceBehind,
|
|
159
167
|
hasDrift: syncRows.some((r) => r.status !== 'fresh') ||
|
|
160
168
|
unwiredHookVersions > 0 ||
|
|
169
|
+
brokenHookRuntimeVersions > 0 ||
|
|
161
170
|
sourceBehind.length > 0,
|
|
162
171
|
};
|
|
163
172
|
}
|
package/dist/lib/hooks/cache.js
CHANGED
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import * as fs from 'fs';
|
|
20
20
|
import * as path from 'path';
|
|
21
|
+
import * as crypto from 'crypto';
|
|
21
22
|
import { getHookCacheDir, getHookShimsDir, getLogsDir, getPerfDir } from '../state.js';
|
|
22
23
|
/**
|
|
23
24
|
* Parse a `cache:` value from hooks.yaml into the canonical config form.
|
|
@@ -128,7 +129,25 @@ export function generateHookShim(args) {
|
|
|
128
129
|
catch { /* rewrite */ }
|
|
129
130
|
}
|
|
130
131
|
if (existing !== content) {
|
|
131
|
-
|
|
132
|
+
// A hook may fire while a background self-heal repairs another stale shim.
|
|
133
|
+
// Write in the destination directory and rename only after its mode and
|
|
134
|
+
// complete contents are ready, so observers see either the old complete
|
|
135
|
+
// wrapper or the new complete wrapper — never a truncated shell script.
|
|
136
|
+
const tempPath = path.join(shimsDir, `.${path.basename(shimPath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
137
|
+
try {
|
|
138
|
+
fs.writeFileSync(tempPath, content, { mode: 0o755 });
|
|
139
|
+
fs.chmodSync(tempPath, 0o755);
|
|
140
|
+
fs.renameSync(tempPath, shimPath);
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
// Rename removes the temp path on success. On a failed write/rename this
|
|
144
|
+
// best-effort cleanup prevents a bounded repair failure from leaving
|
|
145
|
+
// growing debris behind for every periodic pass.
|
|
146
|
+
try {
|
|
147
|
+
fs.unlinkSync(tempPath);
|
|
148
|
+
}
|
|
149
|
+
catch { /* already renamed or unavailable */ }
|
|
150
|
+
}
|
|
132
151
|
}
|
|
133
152
|
else {
|
|
134
153
|
// Ensure exec bit even when content unchanged (file mode can drift).
|
package/dist/lib/hooks.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ export declare function toPortableCommand(absPath: string, home?: string, sep?:
|
|
|
28
28
|
* outside version homes are user-owned and remain untouched.
|
|
29
29
|
*/
|
|
30
30
|
export declare function deduplicateVersionHookCommands(commands: string[], activeVersionHome: string): string[];
|
|
31
|
-
import type { AgentId, InstalledHook, ManifestHook } from './types.js';
|
|
31
|
+
import type { AgentId, HookCacheConfig, HookMatches, InstalledHook, ManifestHook } from './types.js';
|
|
32
32
|
export type HookEntry = {
|
|
33
33
|
name: string;
|
|
34
34
|
scriptPath: string;
|
|
@@ -110,6 +110,27 @@ export interface HookWiringIssue {
|
|
|
110
110
|
/** The command the harness-native config should reference under `event`. */
|
|
111
111
|
command: string;
|
|
112
112
|
}
|
|
113
|
+
/** A generated, agents-managed hook wrapper and the inputs needed to recreate it. */
|
|
114
|
+
export interface ManagedHookRuntimeArtifact {
|
|
115
|
+
agent: AgentId;
|
|
116
|
+
version: string;
|
|
117
|
+
name: string;
|
|
118
|
+
scriptPath: string;
|
|
119
|
+
shimPath: string;
|
|
120
|
+
cache: HookCacheConfig | null;
|
|
121
|
+
matches?: HookMatches;
|
|
122
|
+
}
|
|
123
|
+
/** A generated hook wrapper that is referenced by managed configuration but cannot run. */
|
|
124
|
+
export interface BrokenManagedHookRuntimeArtifact extends ManagedHookRuntimeArtifact {
|
|
125
|
+
/** Stable, human-readable filesystem failure; the hook is never executed to find it. */
|
|
126
|
+
reason: string;
|
|
127
|
+
}
|
|
128
|
+
/** Public, doctor-safe shape for a broken generated hook wrapper. */
|
|
129
|
+
export interface HookRuntimeIssue {
|
|
130
|
+
name: string;
|
|
131
|
+
path: string;
|
|
132
|
+
reason: string;
|
|
133
|
+
}
|
|
113
134
|
export interface HookWiringReport {
|
|
114
135
|
/** Whether this agent's hook config format is understood by the inspector. */
|
|
115
136
|
supported: boolean;
|
|
@@ -128,7 +149,76 @@ export interface HookWiringReport {
|
|
|
128
149
|
* Empty whenever wiring cannot be verified (unsupported family, missing or
|
|
129
150
|
* unparseable settings). */
|
|
130
151
|
wired: HookWiringIssue[];
|
|
152
|
+
/** Generated agents-managed wrappers that are absent or unusable. This is
|
|
153
|
+
* independent of whether the native config format itself is understood. */
|
|
154
|
+
runtimeBroken: HookRuntimeIssue[];
|
|
155
|
+
}
|
|
156
|
+
/** Inspect managed generated hook wrappers without executing user hook code. */
|
|
157
|
+
export declare function inspectBrokenManagedHookRuntimeArtifacts(filter?: {
|
|
158
|
+
agent?: AgentId;
|
|
159
|
+
version?: string;
|
|
160
|
+
}, platform?: NodeJS.Platform): BrokenManagedHookRuntimeArtifact[];
|
|
161
|
+
/** Outcome of one generation attempt for a unique shim path. */
|
|
162
|
+
export interface HookRuntimeRepairAttempt {
|
|
163
|
+
name: string;
|
|
164
|
+
path: string;
|
|
165
|
+
reasonBefore: string;
|
|
166
|
+
/** True when generateHookShim ran for this path in this pass. */
|
|
167
|
+
attempted: boolean;
|
|
168
|
+
repaired: boolean;
|
|
169
|
+
/** Stable failure text when not repaired after an attempt (or dry-run skip). */
|
|
170
|
+
reason?: string;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Result of one bounded repair pass over agents-managed generated hook shims.
|
|
174
|
+
* Shared by self-heal and doctor --fix; additive types for the doctor track.
|
|
175
|
+
*/
|
|
176
|
+
export interface HookRuntimeRepairReport {
|
|
177
|
+
/** Broken artifacts found before any write (inspect-only snapshot). */
|
|
178
|
+
brokenBefore: BrokenManagedHookRuntimeArtifact[];
|
|
179
|
+
/** Unique shim paths considered for generation in this pass. */
|
|
180
|
+
attemptedPaths: string[];
|
|
181
|
+
attempts: HookRuntimeRepairAttempt[];
|
|
182
|
+
/** Human-readable lines for CheckResult.fixed (includes dry-run would-fix). */
|
|
183
|
+
fixed: string[];
|
|
184
|
+
/** Human-readable lines for CheckResult.needsAttention — stable wording. */
|
|
185
|
+
needsAttention: string[];
|
|
131
186
|
}
|
|
187
|
+
export interface RepairManagedHookRuntimeOptions {
|
|
188
|
+
/** Detect only — never write. Default false. */
|
|
189
|
+
dryRun?: boolean;
|
|
190
|
+
filter?: {
|
|
191
|
+
agent?: AgentId;
|
|
192
|
+
version?: string;
|
|
193
|
+
};
|
|
194
|
+
platform?: NodeJS.Platform;
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Regenerate one known-broken wrapper and prove the result is usable. Callers
|
|
198
|
+
* provide a snapshot from inspectBrokenManagedHookRuntimeArtifacts; this never
|
|
199
|
+
* loops or retries and returns a stable error for the current pass.
|
|
200
|
+
*
|
|
201
|
+
* Generation is delegated to generateHookShim (idempotent — preserves mtime when
|
|
202
|
+
* content already matches). This path never calls registerHooksToSettings,
|
|
203
|
+
* installHooks, or any sync routine.
|
|
204
|
+
*/
|
|
205
|
+
export declare function repairManagedHookRuntimeArtifact(artifact: ManagedHookRuntimeArtifact, platform?: NodeJS.Platform): {
|
|
206
|
+
repaired: boolean;
|
|
207
|
+
reason?: string;
|
|
208
|
+
};
|
|
209
|
+
/**
|
|
210
|
+
* Bounded repair of all broken agents-managed generated hook shims.
|
|
211
|
+
*
|
|
212
|
+
* - Inspect first (read-only, no hook execution).
|
|
213
|
+
* - One generation attempt per unique shim path per call (no retry, no timer).
|
|
214
|
+
* - Canonical owner per shared path: global default, else newest non-isolated.
|
|
215
|
+
* - Post-repair reinspection; unresolved findings become stable needsAttention.
|
|
216
|
+
* - Never recurses into resource sync / registerHooksToSettings.
|
|
217
|
+
*
|
|
218
|
+
* This is the shared routine used by the self-heal `hook-runtime` check and
|
|
219
|
+
* exported for the doctor track.
|
|
220
|
+
*/
|
|
221
|
+
export declare function repairManagedHookRuntimeArtifacts(opts?: RepairManagedHookRuntimeOptions): HookRuntimeRepairReport;
|
|
132
222
|
/**
|
|
133
223
|
* Verify that every hook the manifest says should be wired is actually
|
|
134
224
|
* referenced in that version's harness-native config, not merely present as a
|