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