@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.
Files changed (66) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +8 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/daemon.js +52 -12
  5. package/dist/commands/doctor.d.ts +19 -0
  6. package/dist/commands/doctor.js +119 -17
  7. package/dist/commands/routines.js +164 -36
  8. package/dist/commands/sessions.d.ts +1 -1
  9. package/dist/commands/sessions.js +44 -10
  10. package/dist/commands/update.d.ts +2 -0
  11. package/dist/commands/update.js +148 -0
  12. package/dist/index.js +3 -1
  13. package/dist/lib/catchup.js +4 -1
  14. package/dist/lib/daemon.d.ts +17 -0
  15. package/dist/lib/daemon.js +69 -3
  16. package/dist/lib/devices/doctor-findings.d.ts +7 -2
  17. package/dist/lib/devices/doctor-findings.js +53 -2
  18. package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
  19. package/dist/lib/devices/doctor-overview-cache.js +15 -0
  20. package/dist/lib/devices/fleet-divergence.d.ts +11 -0
  21. package/dist/lib/devices/fleet-divergence.js +6 -0
  22. package/dist/lib/devices/fleet-inventory.js +16 -2
  23. package/dist/lib/drift.d.ts +6 -1
  24. package/dist/lib/drift.js +9 -0
  25. package/dist/lib/hooks/cache.js +20 -1
  26. package/dist/lib/hooks.d.ts +91 -1
  27. package/dist/lib/hooks.js +289 -3
  28. package/dist/lib/hosts/passthrough.js +3 -0
  29. package/dist/lib/installations/index.d.ts +14 -0
  30. package/dist/lib/installations/index.js +14 -0
  31. package/dist/lib/installations/resolve.d.ts +43 -0
  32. package/dist/lib/installations/resolve.js +93 -0
  33. package/dist/lib/installations/store.d.ts +56 -0
  34. package/dist/lib/installations/store.js +196 -0
  35. package/dist/lib/installations/strategies.d.ts +73 -0
  36. package/dist/lib/installations/strategies.js +293 -0
  37. package/dist/lib/installations/types.d.ts +78 -0
  38. package/dist/lib/installations/types.js +8 -0
  39. package/dist/lib/installations/update.d.ts +40 -0
  40. package/dist/lib/installations/update.js +131 -0
  41. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  43. package/dist/lib/migrate.d.ts +27 -0
  44. package/dist/lib/migrate.js +112 -2
  45. package/dist/lib/routine-context.d.ts +144 -0
  46. package/dist/lib/routine-context.js +268 -0
  47. package/dist/lib/routine-readiness.d.ts +47 -0
  48. package/dist/lib/routine-readiness.js +239 -0
  49. package/dist/lib/routines.d.ts +97 -1
  50. package/dist/lib/routines.js +107 -1
  51. package/dist/lib/runner.d.ts +18 -4
  52. package/dist/lib/runner.js +291 -98
  53. package/dist/lib/scheduler.d.ts +7 -1
  54. package/dist/lib/scheduler.js +5 -2
  55. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  56. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  57. package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
  58. package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
  59. package/dist/lib/self-heal/registry.js +5 -2
  60. package/dist/lib/self-heal/types.d.ts +1 -1
  61. package/dist/lib/session/state.js +4 -1
  62. package/dist/lib/startup/command-registry.d.ts +1 -0
  63. package/dist/lib/startup/command-registry.js +2 -0
  64. package/dist/lib/versions.d.ts +24 -0
  65. package/dist/lib/versions.js +49 -16
  66. 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[];