@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/hooks.js
CHANGED
|
@@ -264,7 +264,7 @@ function collapseVersionHookEntries(entries, activeVersionHome) {
|
|
|
264
264
|
return true;
|
|
265
265
|
});
|
|
266
266
|
}
|
|
267
|
-
import { getEffectiveHome, getVersionHomePath, listInstalledVersions, resolveVersion } from './versions.js';
|
|
267
|
+
import { getEffectiveHome, getGlobalDefault, getVersionHomePath, isVersionIsolated, listInstalledVersions, resolveVersion, } from './versions.js';
|
|
268
268
|
import { generateHookShim, getHookShimPath, isValidHookShimName, parseCacheConfig, removeHookShim } from './hooks/cache.js';
|
|
269
269
|
import { getHookShimsDir } from './state.js';
|
|
270
270
|
function hookContentHash(scriptPath) {
|
|
@@ -772,6 +772,285 @@ export function listHooksInVersionHome(agent, version) {
|
|
|
772
772
|
const SETTINGS_JSON_HOOK_FAMILY = ['claude', 'droid', 'muse'];
|
|
773
773
|
const HOOKS_JSON_HOOK_FAMILY = ['grok'];
|
|
774
774
|
const TOML_ARRAY_HOOK_FAMILY = ['kimi'];
|
|
775
|
+
/**
|
|
776
|
+
* Check the filesystem properties a shell hook needs without invoking it. A
|
|
777
|
+
* dangling symlink is deliberately distinguished from an absent file because
|
|
778
|
+
* the repair/remediation is the same but the diagnostic is materially clearer.
|
|
779
|
+
*/
|
|
780
|
+
function shellQuoteForHookShim(value) {
|
|
781
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
782
|
+
}
|
|
783
|
+
function hookRuntimeProblem(artifact, platform = process.platform) {
|
|
784
|
+
const { shimPath } = artifact;
|
|
785
|
+
let link;
|
|
786
|
+
try {
|
|
787
|
+
link = fs.lstatSync(shimPath);
|
|
788
|
+
}
|
|
789
|
+
catch (err) {
|
|
790
|
+
const code = err.code;
|
|
791
|
+
if (code === 'ENOENT')
|
|
792
|
+
return 'missing';
|
|
793
|
+
// Stable: errno code only — never absolute path text (fleet/menu aggregation).
|
|
794
|
+
return `cannot inspect (${code || 'error'})`;
|
|
795
|
+
}
|
|
796
|
+
let target;
|
|
797
|
+
try {
|
|
798
|
+
target = link.isSymbolicLink() ? fs.statSync(shimPath) : link;
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
const code = err.code;
|
|
802
|
+
if (code === 'ENOENT')
|
|
803
|
+
return 'broken symlink';
|
|
804
|
+
return `cannot inspect (${code || 'error'})`;
|
|
805
|
+
}
|
|
806
|
+
if (!target.isFile())
|
|
807
|
+
return 'not a regular file';
|
|
808
|
+
// Generation always writes non-empty content; an empty placeholder is broken.
|
|
809
|
+
if (target.size === 0)
|
|
810
|
+
return 'broken (empty)';
|
|
811
|
+
// Windows does not use a POSIX executable bit; requiring it there would
|
|
812
|
+
// continuously rewrite healthy .sh files on Windows hosts.
|
|
813
|
+
if (platform !== 'win32' && (target.mode & 0o111) === 0)
|
|
814
|
+
return 'not executable';
|
|
815
|
+
try {
|
|
816
|
+
const body = fs.readFileSync(shimPath, 'utf-8');
|
|
817
|
+
// The global shim must name the selected live version-home script. A
|
|
818
|
+
// wrapper can remain executable while its SOURCE points to an older or
|
|
819
|
+
// deleted version; detect that without running either script.
|
|
820
|
+
if (!body.includes(`SOURCE=${shellQuoteForHookShim(artifact.scriptPath)}`)) {
|
|
821
|
+
return 'source mismatch';
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
catch (err) {
|
|
825
|
+
const code = err.code;
|
|
826
|
+
return `cannot inspect (${code || 'error'})`;
|
|
827
|
+
}
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Gather managed generated-shim expectations for one installed version. The
|
|
832
|
+
* script must resolve inside that version home (or be an existing absolute
|
|
833
|
+
* subrule), matching the registrar's selection rules. This function never
|
|
834
|
+
* creates a shim or runs a hook.
|
|
835
|
+
*/
|
|
836
|
+
function managedHookRuntimeArtifactsForVersion(agent, version) {
|
|
837
|
+
if (!AGENTS[agent].supportsHooks)
|
|
838
|
+
return [];
|
|
839
|
+
const localHooksDir = getVersionHooksDir(agent, version);
|
|
840
|
+
const resolveScript = (script) => {
|
|
841
|
+
if (path.isAbsolute(script) && fs.existsSync(script))
|
|
842
|
+
return script;
|
|
843
|
+
return resolveContainedHookPath(localHooksDir, script);
|
|
844
|
+
};
|
|
845
|
+
const artifacts = [];
|
|
846
|
+
for (const [name, hookDef] of Object.entries(parseHookManifest({ warn: false }))) {
|
|
847
|
+
if (!hookDef.events || hookDef.events.length === 0 || !isValidHookShimName(name))
|
|
848
|
+
continue;
|
|
849
|
+
const scriptPath = resolveScript(hookDef.script);
|
|
850
|
+
if (!scriptPath)
|
|
851
|
+
continue;
|
|
852
|
+
const cache = parseCacheConfig(hookDef.cache);
|
|
853
|
+
const hasMatches = hookDef.matches != null && Object.keys(hookDef.matches).length > 0;
|
|
854
|
+
if (!cache && !hasMatches && !hookDef.matcher)
|
|
855
|
+
continue;
|
|
856
|
+
artifacts.push({
|
|
857
|
+
agent,
|
|
858
|
+
version,
|
|
859
|
+
name,
|
|
860
|
+
scriptPath,
|
|
861
|
+
shimPath: getHookShimPath(name),
|
|
862
|
+
cache,
|
|
863
|
+
matches: hookDef.matches,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
return artifacts;
|
|
867
|
+
}
|
|
868
|
+
/** Inspect managed generated hook wrappers without executing user hook code. */
|
|
869
|
+
export function inspectBrokenManagedHookRuntimeArtifacts(filter, platform = process.platform) {
|
|
870
|
+
// Generated destinations are shared across every harness. Always select the
|
|
871
|
+
// owner from the global population first; an agent-scoped doctor call may
|
|
872
|
+
// restrict which shim names are relevant, but must not change the expected
|
|
873
|
+
// SOURCE for a shared wrapper.
|
|
874
|
+
const versions = iterHooksCapableVersions();
|
|
875
|
+
if (filter?.agent &&
|
|
876
|
+
filter.version &&
|
|
877
|
+
!versions.some((v) => v.agent === filter.agent && v.version === filter.version)) {
|
|
878
|
+
versions.push({ agent: filter.agent, version: filter.version });
|
|
879
|
+
}
|
|
880
|
+
const artifacts = [];
|
|
881
|
+
for (const { agent, version } of versions) {
|
|
882
|
+
artifacts.push(...managedHookRuntimeArtifactsForVersion(agent, version));
|
|
883
|
+
}
|
|
884
|
+
const requestedArtifacts = filter?.agent
|
|
885
|
+
? artifacts.filter((artifact) => artifact.agent === filter.agent &&
|
|
886
|
+
(!filter.version || artifact.version === filter.version))
|
|
887
|
+
: undefined;
|
|
888
|
+
const relevantShimPaths = requestedArtifacts
|
|
889
|
+
? new Set(eligibleHookRuntimeArtifacts(requestedArtifacts).map((artifact) => artifact.shimPath))
|
|
890
|
+
: undefined;
|
|
891
|
+
const broken = [];
|
|
892
|
+
for (const artifact of selectCanonicalHookRuntimeArtifacts(artifacts)) {
|
|
893
|
+
if (relevantShimPaths && !relevantShimPaths.has(artifact.shimPath))
|
|
894
|
+
continue;
|
|
895
|
+
const reason = hookRuntimeProblem(artifact, platform);
|
|
896
|
+
if (reason)
|
|
897
|
+
broken.push({ ...artifact, reason });
|
|
898
|
+
}
|
|
899
|
+
return broken.sort((a, b) => a.shimPath.localeCompare(b.shimPath) ||
|
|
900
|
+
`${a.agent}@${a.version}/${a.name}`.localeCompare(`${b.agent}@${b.version}/${b.name}`));
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Prefer the agent's global default when it appears in the candidate set;
|
|
904
|
+
* otherwise the newest non-isolated version. Shared shim paths embed a single
|
|
905
|
+
* SOURCE script — picking the wrong version permanently points every harness
|
|
906
|
+
* at a stale or dead path.
|
|
907
|
+
*/
|
|
908
|
+
function pickCanonicalHookRuntimeArtifact(candidates) {
|
|
909
|
+
if (candidates.length === 1)
|
|
910
|
+
return candidates[0];
|
|
911
|
+
const agents = Array.from(new Set(candidates.map((c) => c.agent))).sort();
|
|
912
|
+
for (const agent of agents) {
|
|
913
|
+
const defaultVersion = getGlobalDefault(agent);
|
|
914
|
+
if (!defaultVersion || isVersionIsolated(agent, defaultVersion))
|
|
915
|
+
continue;
|
|
916
|
+
const active = candidates.find((c) => c.agent === agent && c.version === defaultVersion);
|
|
917
|
+
if (active)
|
|
918
|
+
return active;
|
|
919
|
+
}
|
|
920
|
+
// listInstalledVersions is the canonical semver ordering. Scan backward
|
|
921
|
+
// rather than inventing another version comparator at this call site.
|
|
922
|
+
for (const agent of agents) {
|
|
923
|
+
for (const version of [...listInstalledVersions(agent)].reverse()) {
|
|
924
|
+
const newest = candidates.find((c) => c.agent === agent && c.version === version);
|
|
925
|
+
if (newest)
|
|
926
|
+
return newest;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
return candidates[0];
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* One repair target per unique shim path. Generated shims are global, so both
|
|
933
|
+
* unattended and explicit repair leave isolated/private version homes out.
|
|
934
|
+
*/
|
|
935
|
+
function selectCanonicalHookRuntimeArtifacts(artifacts) {
|
|
936
|
+
// No hook runtime exists for a version that does not support hooks. Isolated
|
|
937
|
+
// versions never own a global generated shim, even when a caller names one:
|
|
938
|
+
// selecting it would repoint every harness at a private version home.
|
|
939
|
+
const pool = eligibleHookRuntimeArtifacts(artifacts);
|
|
940
|
+
const byPath = new Map();
|
|
941
|
+
for (const artifact of pool) {
|
|
942
|
+
const list = byPath.get(artifact.shimPath) ?? [];
|
|
943
|
+
list.push(artifact);
|
|
944
|
+
byPath.set(artifact.shimPath, list);
|
|
945
|
+
}
|
|
946
|
+
const selected = [];
|
|
947
|
+
for (const group of byPath.values()) {
|
|
948
|
+
selected.push(pickCanonicalHookRuntimeArtifact(group));
|
|
949
|
+
}
|
|
950
|
+
return selected;
|
|
951
|
+
}
|
|
952
|
+
/** Versions that may own — and therefore diagnose — a shared generated shim. */
|
|
953
|
+
function eligibleHookRuntimeArtifacts(artifacts) {
|
|
954
|
+
return artifacts.filter((artifact) => supports(artifact.agent, 'hooks', artifact.version).ok &&
|
|
955
|
+
!isVersionIsolated(artifact.agent, artifact.version));
|
|
956
|
+
}
|
|
957
|
+
/**
|
|
958
|
+
* Stable failure text: errno code + detector reason only.
|
|
959
|
+
* Never include absolute paths or randomized atomic-temp basenames — those
|
|
960
|
+
* break cross-device / menu-bar aggregation of identical failures. The hook
|
|
961
|
+
* name is attached by the caller (`hook shim <name>: …`).
|
|
962
|
+
*/
|
|
963
|
+
function stableHookRuntimeRepairFailure(before, err) {
|
|
964
|
+
const code = err?.code;
|
|
965
|
+
return `repair failed [${code || 'UNKNOWN'}]: ${before}`;
|
|
966
|
+
}
|
|
967
|
+
/**
|
|
968
|
+
* Regenerate one known-broken wrapper and prove the result is usable. Callers
|
|
969
|
+
* provide a snapshot from inspectBrokenManagedHookRuntimeArtifacts; this never
|
|
970
|
+
* loops or retries and returns a stable error for the current pass.
|
|
971
|
+
*
|
|
972
|
+
* Generation is delegated to generateHookShim (idempotent — preserves mtime when
|
|
973
|
+
* content already matches). This path never calls registerHooksToSettings,
|
|
974
|
+
* installHooks, or any sync routine.
|
|
975
|
+
*/
|
|
976
|
+
export function repairManagedHookRuntimeArtifact(artifact, platform = process.platform) {
|
|
977
|
+
const before = hookRuntimeProblem(artifact, platform);
|
|
978
|
+
// Already healthy (e.g. race with another pass) — no-op, no needsAttention.
|
|
979
|
+
if (!before)
|
|
980
|
+
return { repaired: false };
|
|
981
|
+
try {
|
|
982
|
+
generateHookShim({
|
|
983
|
+
name: artifact.name,
|
|
984
|
+
scriptPath: artifact.scriptPath,
|
|
985
|
+
cache: artifact.cache,
|
|
986
|
+
matches: artifact.matches,
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
catch (err) {
|
|
990
|
+
return { repaired: false, reason: stableHookRuntimeRepairFailure(before, err) };
|
|
991
|
+
}
|
|
992
|
+
// Post-repair reinspection — prove the artifact is usable without executing it.
|
|
993
|
+
const after = hookRuntimeProblem(artifact, platform);
|
|
994
|
+
return after
|
|
995
|
+
? { repaired: false, reason: `${before}; repair did not produce a usable shim (${after})` }
|
|
996
|
+
: { repaired: true };
|
|
997
|
+
}
|
|
998
|
+
/**
|
|
999
|
+
* Bounded repair of all broken agents-managed generated hook shims.
|
|
1000
|
+
*
|
|
1001
|
+
* - Inspect first (read-only, no hook execution).
|
|
1002
|
+
* - One generation attempt per unique shim path per call (no retry, no timer).
|
|
1003
|
+
* - Canonical owner per shared path: global default, else newest non-isolated.
|
|
1004
|
+
* - Post-repair reinspection; unresolved findings become stable needsAttention.
|
|
1005
|
+
* - Never recurses into resource sync / registerHooksToSettings.
|
|
1006
|
+
*
|
|
1007
|
+
* This is the shared routine used by the self-heal `hook-runtime` check and
|
|
1008
|
+
* exported for the doctor track.
|
|
1009
|
+
*/
|
|
1010
|
+
export function repairManagedHookRuntimeArtifacts(opts = {}) {
|
|
1011
|
+
const platform = opts.platform ?? process.platform;
|
|
1012
|
+
const dryRun = opts.dryRun ?? false;
|
|
1013
|
+
const brokenBefore = inspectBrokenManagedHookRuntimeArtifacts(opts.filter, platform);
|
|
1014
|
+
const targets = selectCanonicalHookRuntimeArtifacts(brokenBefore);
|
|
1015
|
+
const attemptedPaths = [];
|
|
1016
|
+
const attempts = [];
|
|
1017
|
+
const fixed = [];
|
|
1018
|
+
const needsAttention = [];
|
|
1019
|
+
for (const artifact of targets) {
|
|
1020
|
+
attemptedPaths.push(artifact.shimPath);
|
|
1021
|
+
if (dryRun) {
|
|
1022
|
+
// Would-fix: same shape as a real fix so doctor dry-run and daemon previews
|
|
1023
|
+
// stay consistent with other HealChecks.
|
|
1024
|
+
attempts.push({
|
|
1025
|
+
name: artifact.name,
|
|
1026
|
+
path: artifact.shimPath,
|
|
1027
|
+
reasonBefore: artifact.reason,
|
|
1028
|
+
attempted: false,
|
|
1029
|
+
repaired: false,
|
|
1030
|
+
});
|
|
1031
|
+
fixed.push(`hook shim ${artifact.name}`);
|
|
1032
|
+
continue;
|
|
1033
|
+
}
|
|
1034
|
+
const result = repairManagedHookRuntimeArtifact(artifact, platform);
|
|
1035
|
+
attempts.push({
|
|
1036
|
+
name: artifact.name,
|
|
1037
|
+
path: artifact.shimPath,
|
|
1038
|
+
reasonBefore: artifact.reason,
|
|
1039
|
+
attempted: true,
|
|
1040
|
+
repaired: result.repaired,
|
|
1041
|
+
reason: result.reason,
|
|
1042
|
+
});
|
|
1043
|
+
if (result.repaired) {
|
|
1044
|
+
fixed.push(`hook shim ${artifact.name}`);
|
|
1045
|
+
}
|
|
1046
|
+
else if (result.reason) {
|
|
1047
|
+
// Stable needs-attention wording for aggregation across devices / menubar.
|
|
1048
|
+
// No reason means already-healthy no-op (race) — do not emit attention noise.
|
|
1049
|
+
needsAttention.push(`hook shim ${artifact.name}: ${result.reason}`);
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
return { brokenBefore, attemptedPaths, attempts, fixed, needsAttention };
|
|
1053
|
+
}
|
|
775
1054
|
/**
|
|
776
1055
|
* Verify that every hook the manifest says should be wired is actually
|
|
777
1056
|
* referenced in that version's harness-native config, not merely present as a
|
|
@@ -787,11 +1066,16 @@ const TOML_ARRAY_HOOK_FAMILY = ['kimi'];
|
|
|
787
1066
|
* resolveHookCommand performs, so it never mutates the version home.
|
|
788
1067
|
*/
|
|
789
1068
|
export function checkVersionHookWiring(agent, version) {
|
|
1069
|
+
// Runtime verification does not rely on parsing a native settings format.
|
|
1070
|
+
// That lets doctor catch a deleted generated shim for every hooks-capable
|
|
1071
|
+
// harness, including families whose wiring schema is intentionally unsupported.
|
|
1072
|
+
const runtimeBroken = inspectBrokenManagedHookRuntimeArtifacts({ agent, version })
|
|
1073
|
+
.map(({ name, shimPath, reason }) => ({ name, path: shimPath, reason }));
|
|
790
1074
|
if (!AGENTS[agent].supportsHooks ||
|
|
791
1075
|
(!SETTINGS_JSON_HOOK_FAMILY.includes(agent) &&
|
|
792
1076
|
!HOOKS_JSON_HOOK_FAMILY.includes(agent) &&
|
|
793
1077
|
!TOML_ARRAY_HOOK_FAMILY.includes(agent))) {
|
|
794
|
-
return { supported: false, unwired: [], wired: [] };
|
|
1078
|
+
return { supported: false, unwired: [], wired: [], runtimeBroken };
|
|
795
1079
|
}
|
|
796
1080
|
const versionHome = getVersionHomePath(agent, version);
|
|
797
1081
|
const settingsPath = HOOKS_JSON_HOOK_FAMILY.includes(agent)
|
|
@@ -855,6 +1139,7 @@ export function checkVersionHookWiring(agent, version) {
|
|
|
855
1139
|
settingsMissing: expected.length > 0,
|
|
856
1140
|
unwired: [],
|
|
857
1141
|
wired: [],
|
|
1142
|
+
runtimeBroken,
|
|
858
1143
|
};
|
|
859
1144
|
}
|
|
860
1145
|
let config;
|
|
@@ -872,6 +1157,7 @@ export function checkVersionHookWiring(agent, version) {
|
|
|
872
1157
|
settingsUnparseable: true,
|
|
873
1158
|
unwired: [],
|
|
874
1159
|
wired: [],
|
|
1160
|
+
runtimeBroken,
|
|
875
1161
|
};
|
|
876
1162
|
}
|
|
877
1163
|
// Command strings actually referenced, keyed by (event, matcher) — a hook wired
|
|
@@ -921,7 +1207,7 @@ export function checkVersionHookWiring(agent, version) {
|
|
|
921
1207
|
const isWired = (entry) => wiredByGroup.get(groupKey(entry.event, entry.matcher))?.has(entry.command) ?? false;
|
|
922
1208
|
const unwired = expected.filter((entry) => !isWired(entry));
|
|
923
1209
|
const wired = expected.filter(isWired);
|
|
924
|
-
return { supported: true, settingsPath, expected: expected.length, unwired, wired };
|
|
1210
|
+
return { supported: true, settingsPath, expected: expected.length, unwired, wired, runtimeBroken };
|
|
925
1211
|
}
|
|
926
1212
|
/**
|
|
927
1213
|
* Check if a hook installed in a specific version matches central content.
|
|
@@ -79,6 +79,9 @@ export const REMOTE_PASSTHROUGH = {
|
|
|
79
79
|
profiles: {},
|
|
80
80
|
defaults: {},
|
|
81
81
|
alias: {},
|
|
82
|
+
// Installations are per-machine, so updating one on a peer means running it
|
|
83
|
+
// there — the same local/remote shape `add` would need.
|
|
84
|
+
update: {},
|
|
82
85
|
// lifecycle
|
|
83
86
|
teams: {},
|
|
84
87
|
message: {},
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen agent installations.
|
|
3
|
+
*
|
|
4
|
+
* An installation's identity (`id`, `label`) is stable for its whole life; the
|
|
5
|
+
* vendor release it carries moves only on an explicit `agents update`. This
|
|
6
|
+
* barrel is the supported surface — import from here, not from the files behind
|
|
7
|
+
* it, so the internal split can change without breaking callers (the Cursor
|
|
8
|
+
* per-installation isolation track consumes exactly this).
|
|
9
|
+
*/
|
|
10
|
+
export { INSTALLATION_RECORD_FILE, INSTALLATION_SCHEMA, type Installation, type InstallationRelease, type UpdateOutcome, type UpdateStrategyId, } from './types.js';
|
|
11
|
+
export { createInstallation, ensureInstallation, installationDir, installationRecordPath, listInstallationLabels, listInstallations, mintInstallationId, readInstallation, recordRelease, writeInstallation, } from './store.js';
|
|
12
|
+
export { InstallationAmbiguousError, InstallationNotFoundError, describeInstallation, resolveInstallation, type ResolveInstallationOptions, } from './resolve.js';
|
|
13
|
+
export { assertValidRelease, selectUpdateStrategy, supportsPinnedUpdate, type UpdateStrategy, } from './strategies.js';
|
|
14
|
+
export { updateInstallation, type UpdateInstallationOptions } from './update.js';
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frozen agent installations.
|
|
3
|
+
*
|
|
4
|
+
* An installation's identity (`id`, `label`) is stable for its whole life; the
|
|
5
|
+
* vendor release it carries moves only on an explicit `agents update`. This
|
|
6
|
+
* barrel is the supported surface — import from here, not from the files behind
|
|
7
|
+
* it, so the internal split can change without breaking callers (the Cursor
|
|
8
|
+
* per-installation isolation track consumes exactly this).
|
|
9
|
+
*/
|
|
10
|
+
export { INSTALLATION_RECORD_FILE, INSTALLATION_SCHEMA, } from './types.js';
|
|
11
|
+
export { createInstallation, ensureInstallation, installationDir, installationRecordPath, listInstallationLabels, listInstallations, mintInstallationId, readInstallation, recordRelease, writeInstallation, } from './store.js';
|
|
12
|
+
export { InstallationAmbiguousError, InstallationNotFoundError, describeInstallation, resolveInstallation, } from './resolve.js';
|
|
13
|
+
export { assertValidRelease, selectUpdateStrategy, supportsPinnedUpdate, } from './strategies.js';
|
|
14
|
+
export { updateInstallation } from './update.js';
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { AgentId } from '../types.js';
|
|
2
|
+
import type { Installation } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Addressing a frozen installation.
|
|
5
|
+
*
|
|
6
|
+
* A selector matches either the installation's stable {@link Installation.label}
|
|
7
|
+
* or the vendor release it currently carries — the label because that is what
|
|
8
|
+
* every persisted reference and every `agents add` invocation uses, the release
|
|
9
|
+
* because after an update the two differ and a user reading `agents view` may
|
|
10
|
+
* name either. Matching both is also precisely what makes duplicate same-release
|
|
11
|
+
* installations addressable at all: two installs can share a release, so the
|
|
12
|
+
* release alone is not an identifier and the ambiguity has to be reported rather
|
|
13
|
+
* than silently resolved to whichever sorted first.
|
|
14
|
+
*/
|
|
15
|
+
export declare class InstallationNotFoundError extends Error {
|
|
16
|
+
readonly agent: AgentId;
|
|
17
|
+
readonly selector: string | undefined;
|
|
18
|
+
readonly available: readonly Installation[];
|
|
19
|
+
constructor(agent: AgentId, selector: string | undefined, available: readonly Installation[]);
|
|
20
|
+
}
|
|
21
|
+
export declare class InstallationAmbiguousError extends Error {
|
|
22
|
+
readonly agent: AgentId;
|
|
23
|
+
readonly selector: string | undefined;
|
|
24
|
+
readonly candidates: readonly Installation[];
|
|
25
|
+
constructor(agent: AgentId, selector: string | undefined, candidates: readonly Installation[]);
|
|
26
|
+
}
|
|
27
|
+
/** `2.0.65` when frozen at its original release, `2.0.65 (release 2.0.71)` after an update. */
|
|
28
|
+
export declare function describeInstallation(installation: Installation): string;
|
|
29
|
+
export interface ResolveInstallationOptions {
|
|
30
|
+
/**
|
|
31
|
+
* An `agents accounts` label. Narrows to the installation currently signed
|
|
32
|
+
* into that account before the selector is applied.
|
|
33
|
+
*/
|
|
34
|
+
account?: string;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Resolve `<agent>[@<selector>]` to exactly one installation.
|
|
38
|
+
*
|
|
39
|
+
* With no selector: the agent's default installation when one is pinned, else
|
|
40
|
+
* the sole installation. Never a "newest wins" guess — picking for the user
|
|
41
|
+
* across several installs is how an update lands on the wrong one.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveInstallation(agent: AgentId, selector: string | undefined, options?: ResolveInstallationOptions): Promise<Installation>;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { resolveAccountLabel } from '../account-labels.js';
|
|
2
|
+
import { AGENTS } from '../agents.js';
|
|
3
|
+
import { getGlobalDefault } from '../versions.js';
|
|
4
|
+
import { listInstallations } from './store.js';
|
|
5
|
+
/**
|
|
6
|
+
* Addressing a frozen installation.
|
|
7
|
+
*
|
|
8
|
+
* A selector matches either the installation's stable {@link Installation.label}
|
|
9
|
+
* or the vendor release it currently carries — the label because that is what
|
|
10
|
+
* every persisted reference and every `agents add` invocation uses, the release
|
|
11
|
+
* because after an update the two differ and a user reading `agents view` may
|
|
12
|
+
* name either. Matching both is also precisely what makes duplicate same-release
|
|
13
|
+
* installations addressable at all: two installs can share a release, so the
|
|
14
|
+
* release alone is not an identifier and the ambiguity has to be reported rather
|
|
15
|
+
* than silently resolved to whichever sorted first.
|
|
16
|
+
*/
|
|
17
|
+
export class InstallationNotFoundError extends Error {
|
|
18
|
+
agent;
|
|
19
|
+
selector;
|
|
20
|
+
available;
|
|
21
|
+
constructor(agent, selector, available) {
|
|
22
|
+
// Nothing installed is a different problem from "your selector missed", and
|
|
23
|
+
// the remedy differs — say which one it is rather than printing an empty list.
|
|
24
|
+
super(available.length === 0
|
|
25
|
+
? `No ${AGENTS[agent].name} installations are managed by agents-cli. Install one with: agents add ${agent}@latest`
|
|
26
|
+
: `No ${AGENTS[agent].name} installation matches '${selector}'. Installed: ${available.map((i) => describeInstallation(i)).join(', ')}`);
|
|
27
|
+
this.agent = agent;
|
|
28
|
+
this.selector = selector;
|
|
29
|
+
this.available = available;
|
|
30
|
+
this.name = 'InstallationNotFoundError';
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export class InstallationAmbiguousError extends Error {
|
|
34
|
+
agent;
|
|
35
|
+
selector;
|
|
36
|
+
candidates;
|
|
37
|
+
constructor(agent, selector, candidates) {
|
|
38
|
+
super(`'${selector ?? agent}' matches ${candidates.length} ${AGENTS[agent].name} installations `
|
|
39
|
+
+ `(${candidates.map((i) => describeInstallation(i)).join(', ')}). `
|
|
40
|
+
+ `Name one by its installation label, or disambiguate with --account <label>.`);
|
|
41
|
+
this.agent = agent;
|
|
42
|
+
this.selector = selector;
|
|
43
|
+
this.candidates = candidates;
|
|
44
|
+
this.name = 'InstallationAmbiguousError';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** `2.0.65` when frozen at its original release, `2.0.65 (release 2.0.71)` after an update. */
|
|
48
|
+
export function describeInstallation(installation) {
|
|
49
|
+
return installation.releaseVersion === installation.label
|
|
50
|
+
? installation.label
|
|
51
|
+
: `${installation.label} (release ${installation.releaseVersion})`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Resolve `<agent>[@<selector>]` to exactly one installation.
|
|
55
|
+
*
|
|
56
|
+
* With no selector: the agent's default installation when one is pinned, else
|
|
57
|
+
* the sole installation. Never a "newest wins" guess — picking for the user
|
|
58
|
+
* across several installs is how an update lands on the wrong one.
|
|
59
|
+
*/
|
|
60
|
+
export async function resolveInstallation(agent, selector, options = {}) {
|
|
61
|
+
const all = listInstallations(agent);
|
|
62
|
+
if (all.length === 0)
|
|
63
|
+
throw new InstallationNotFoundError(agent, selector, all);
|
|
64
|
+
let candidates = all;
|
|
65
|
+
if (options.account) {
|
|
66
|
+
// resolveAccountLabel answers with the version-dir label of the install that
|
|
67
|
+
// is signed into that account — i.e. an installation label.
|
|
68
|
+
const label = await resolveAccountLabel(agent, options.account);
|
|
69
|
+
candidates = candidates.filter((i) => i.label === label);
|
|
70
|
+
if (candidates.length === 0)
|
|
71
|
+
throw new InstallationNotFoundError(agent, selector, all);
|
|
72
|
+
}
|
|
73
|
+
if (selector) {
|
|
74
|
+
const byLabel = candidates.filter((i) => i.label === selector);
|
|
75
|
+
// A label is unique by construction (it is a directory name), so a label hit
|
|
76
|
+
// is decisive and never competes with a release hit on another installation.
|
|
77
|
+
if (byLabel.length === 1)
|
|
78
|
+
return byLabel[0];
|
|
79
|
+
const byRelease = candidates.filter((i) => i.releaseVersion === selector);
|
|
80
|
+
if (byRelease.length === 1)
|
|
81
|
+
return byRelease[0];
|
|
82
|
+
if (byRelease.length > 1)
|
|
83
|
+
throw new InstallationAmbiguousError(agent, selector, byRelease);
|
|
84
|
+
throw new InstallationNotFoundError(agent, selector, all);
|
|
85
|
+
}
|
|
86
|
+
if (candidates.length === 1)
|
|
87
|
+
return candidates[0];
|
|
88
|
+
const defaultLabel = getGlobalDefault(agent);
|
|
89
|
+
const pinned = defaultLabel ? candidates.find((i) => i.label === defaultLabel) : undefined;
|
|
90
|
+
if (pinned)
|
|
91
|
+
return pinned;
|
|
92
|
+
throw new InstallationAmbiguousError(agent, selector, candidates);
|
|
93
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { AgentId } from '../types.js';
|
|
2
|
+
import { type Installation } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Persistence for {@link Installation} records.
|
|
5
|
+
*
|
|
6
|
+
* The record lives at `<versionDir>/installation.json` rather than in one
|
|
7
|
+
* central index: the version dir is what `agents trash`/`agents prune` move,
|
|
8
|
+
* copy and restore wholesale, so keeping identity inside it means identity
|
|
9
|
+
* travels with the install instead of dangling in a registry that forgets to
|
|
10
|
+
* follow. It is also why the file name is registered in versions.ts's
|
|
11
|
+
* `PRESERVED_ON_CLEAN_REINSTALL` — a repair reinstall must not mint a new id.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately depends on nothing but `state`/`fs-atomic`/`primitives` so
|
|
14
|
+
* versions.ts can import it without an import cycle.
|
|
15
|
+
*/
|
|
16
|
+
/** Directory holding one installation. Mirrors versions.ts `getVersionDir`. */
|
|
17
|
+
export declare function installationDir(agent: AgentId, label: string): string;
|
|
18
|
+
export declare function installationRecordPath(agent: AgentId, label: string): string;
|
|
19
|
+
/** Mint an opaque installation id. Random, never derived from the release. */
|
|
20
|
+
export declare function mintInstallationId(): string;
|
|
21
|
+
/**
|
|
22
|
+
* Read the record for one installation, or null when the version dir has none.
|
|
23
|
+
* Never mints — use {@link ensureInstallation} for the migrating read.
|
|
24
|
+
*/
|
|
25
|
+
export declare function readInstallation(agent: AgentId, label: string): Installation | null;
|
|
26
|
+
export declare function writeInstallation(installation: Installation): void;
|
|
27
|
+
/**
|
|
28
|
+
* Read the record for an existing version dir, minting and persisting one on
|
|
29
|
+
* first sight. This is the migration path for every installation created before
|
|
30
|
+
* frozen identity existed: their directory name IS their release, so the
|
|
31
|
+
* migrated record seeds `label === releaseVersion` and dates the install from
|
|
32
|
+
* the directory's own mtime rather than pretending it was created now.
|
|
33
|
+
*
|
|
34
|
+
* Throws when the version dir does not exist — an installation record must never
|
|
35
|
+
* describe an install that isn't there.
|
|
36
|
+
*/
|
|
37
|
+
export declare function ensureInstallation(agent: AgentId, label: string): Installation;
|
|
38
|
+
/**
|
|
39
|
+
* Create the record for a freshly-installed version dir. Idempotent: a repeat
|
|
40
|
+
* `agents add` of the same label keeps the original id (identity is frozen) and
|
|
41
|
+
* only records the release if it actually moved.
|
|
42
|
+
*/
|
|
43
|
+
export declare function createInstallation(agent: AgentId, label: string, releaseVersion: string): Installation;
|
|
44
|
+
/**
|
|
45
|
+
* Move an installation's recorded release forward, preserving identity. Returns
|
|
46
|
+
* the persisted record. Call only AFTER the new release is live on disk — the
|
|
47
|
+
* record is the claim that it is.
|
|
48
|
+
*/
|
|
49
|
+
export declare function recordRelease(installation: Installation, releaseVersion: string): Installation;
|
|
50
|
+
/** Version-dir basenames present for an agent, oldest-first by directory name. */
|
|
51
|
+
export declare function listInstallationLabels(agent: AgentId): string[];
|
|
52
|
+
/**
|
|
53
|
+
* Every installation of an agent, migrating records as needed. A version dir
|
|
54
|
+
* that disappears mid-scan is skipped rather than failing the whole listing.
|
|
55
|
+
*/
|
|
56
|
+
export declare function listInstallations(agent: AgentId): Installation[];
|