@phnx-labs/agents-cli 1.22.31 → 1.22.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +8 -2
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/daemon.js +52 -12
  5. package/dist/commands/doctor.d.ts +19 -0
  6. package/dist/commands/doctor.js +119 -17
  7. package/dist/commands/routines.js +164 -36
  8. package/dist/commands/sessions-browser.js +2 -2
  9. package/dist/commands/sessions.d.ts +1 -1
  10. package/dist/commands/sessions.js +66 -22
  11. package/dist/commands/update.d.ts +2 -0
  12. package/dist/commands/update.js +148 -0
  13. package/dist/index.js +3 -1
  14. package/dist/lib/catchup.js +4 -1
  15. package/dist/lib/daemon.d.ts +17 -0
  16. package/dist/lib/daemon.js +69 -3
  17. package/dist/lib/devices/doctor-findings.d.ts +7 -2
  18. package/dist/lib/devices/doctor-findings.js +53 -2
  19. package/dist/lib/devices/doctor-overview-cache.d.ts +7 -0
  20. package/dist/lib/devices/doctor-overview-cache.js +15 -0
  21. package/dist/lib/devices/fleet-divergence.d.ts +11 -0
  22. package/dist/lib/devices/fleet-divergence.js +6 -0
  23. package/dist/lib/devices/fleet-inventory.js +16 -2
  24. package/dist/lib/drift.d.ts +6 -1
  25. package/dist/lib/drift.js +9 -0
  26. package/dist/lib/hooks/cache.js +20 -1
  27. package/dist/lib/hooks.d.ts +91 -1
  28. package/dist/lib/hooks.js +289 -3
  29. package/dist/lib/hosts/passthrough.js +3 -0
  30. package/dist/lib/installations/index.d.ts +14 -0
  31. package/dist/lib/installations/index.js +14 -0
  32. package/dist/lib/installations/resolve.d.ts +43 -0
  33. package/dist/lib/installations/resolve.js +93 -0
  34. package/dist/lib/installations/store.d.ts +56 -0
  35. package/dist/lib/installations/store.js +196 -0
  36. package/dist/lib/installations/strategies.d.ts +73 -0
  37. package/dist/lib/installations/strategies.js +293 -0
  38. package/dist/lib/installations/types.d.ts +78 -0
  39. package/dist/lib/installations/types.js +8 -0
  40. package/dist/lib/installations/update.d.ts +40 -0
  41. package/dist/lib/installations/update.js +131 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/migrate.d.ts +27 -0
  45. package/dist/lib/migrate.js +112 -2
  46. package/dist/lib/routine-context.d.ts +144 -0
  47. package/dist/lib/routine-context.js +268 -0
  48. package/dist/lib/routine-readiness.d.ts +47 -0
  49. package/dist/lib/routine-readiness.js +239 -0
  50. package/dist/lib/routines.d.ts +97 -1
  51. package/dist/lib/routines.js +107 -1
  52. package/dist/lib/runner.d.ts +18 -4
  53. package/dist/lib/runner.js +291 -98
  54. package/dist/lib/scheduler.d.ts +7 -1
  55. package/dist/lib/scheduler.js +5 -2
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/self-heal/checks/hook-runtime.d.ts +2 -0
  59. package/dist/lib/self-heal/checks/hook-runtime.js +16 -0
  60. package/dist/lib/self-heal/registry.js +5 -2
  61. package/dist/lib/self-heal/types.d.ts +1 -1
  62. package/dist/lib/session/state.js +4 -1
  63. package/dist/lib/session/team-filter.d.ts +11 -0
  64. package/dist/lib/session/team-filter.js +10 -0
  65. package/dist/lib/startup/command-registry.d.ts +1 -0
  66. package/dist/lib/startup/command-registry.js +2 -0
  67. package/dist/lib/versions.d.ts +24 -0
  68. package/dist/lib/versions.js +49 -16
  69. package/package.json +2 -2
package/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
  }
@@ -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
- fs.writeFileSync(shimPath, content, { mode: 0o755 });
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).
@@ -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
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>;