@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
@@ -0,0 +1,148 @@
1
+ import chalk from 'chalk';
2
+ import { formatAgentError, resolveAgentName } from '../lib/agents.js';
3
+ import { setHelpSections } from '../lib/help.js';
4
+ import { describeInstallation, listInstallations, resolveInstallation, selectUpdateStrategy, supportsPinnedUpdate, updateInstallation, } from '../lib/installations/index.js';
5
+ /**
6
+ * Split `<agent>[@<selector>]`. The selector names an INSTALLATION — its frozen
7
+ * label, or the release it currently carries — never a release to install; that
8
+ * is `--to`. Keeping them separate is what lets `agents update claude@2.0.65
9
+ * --to 2.0.71` read unambiguously.
10
+ */
11
+ function parseTarget(raw) {
12
+ const at = raw.indexOf('@');
13
+ const name = at === -1 ? raw : raw.slice(0, at);
14
+ const selector = at === -1 ? undefined : raw.slice(at + 1).trim();
15
+ if (at !== -1 && !selector) {
16
+ throw new Error(`Missing installation in '${raw}'. Use <agent>@<installed-version>, or just <agent>.`);
17
+ }
18
+ const agent = resolveAgentName(name);
19
+ if (!agent)
20
+ throw new Error(formatAgentError(name));
21
+ return { agent, selector };
22
+ }
23
+ function serialize(installation) {
24
+ return {
25
+ id: installation.id,
26
+ agent: installation.agent,
27
+ label: installation.label,
28
+ releaseVersion: installation.releaseVersion,
29
+ createdAt: installation.createdAt,
30
+ updatedAt: installation.updatedAt,
31
+ };
32
+ }
33
+ function printOutcome(outcome, json) {
34
+ if (json) {
35
+ console.log(JSON.stringify({
36
+ installation: serialize(outcome.installation),
37
+ strategy: outcome.strategy,
38
+ fromRelease: outcome.fromRelease,
39
+ toRelease: outcome.toRelease,
40
+ unchanged: outcome.unchanged,
41
+ alsoUpdated: outcome.alsoUpdated.map(serialize),
42
+ }, null, 2));
43
+ return;
44
+ }
45
+ const name = `${outcome.installation.agent}@${outcome.installation.label}`;
46
+ if (outcome.unchanged) {
47
+ console.log(chalk.gray(`${name} is already on release ${outcome.toRelease}.`));
48
+ return;
49
+ }
50
+ console.log(chalk.green(`Updated ${name}: release ${outcome.fromRelease} -> ${outcome.toRelease}`));
51
+ console.log(chalk.gray(`Its name is unchanged, so every default, project pin, and routine that names ${name} still resolves to it.`));
52
+ for (const other of outcome.alsoUpdated) {
53
+ console.log(chalk.gray(` ${other.agent}@${other.label} shares the same binary and now also reports release ${other.releaseVersion}.`));
54
+ }
55
+ }
56
+ function printInstallations(agent, json) {
57
+ const installations = listInstallations(agent);
58
+ if (json) {
59
+ console.log(JSON.stringify(installations.map(serialize), null, 2));
60
+ return;
61
+ }
62
+ if (!installations.length) {
63
+ console.log(chalk.gray(`No managed ${agent} installations. Install one with: agents add ${agent}@latest`));
64
+ return;
65
+ }
66
+ console.log(chalk.bold(`${agent} installations\n`));
67
+ for (const installation of installations) {
68
+ console.log(` ${chalk.cyan(installation.label)} release ${installation.releaseVersion} ${chalk.gray(installation.id)}`);
69
+ }
70
+ }
71
+ /**
72
+ * Surface a failure as one red line, not a stack trace. Everything this command
73
+ * can fail on — an unknown agent, an ambiguous selector, an unpinnable harness,
74
+ * a release that would not launch — is a message the user acts on, and a
75
+ * commander async action rejection otherwise reaches the user as a raw Node dump.
76
+ */
77
+ function fail(err) {
78
+ console.error(chalk.red(err.message));
79
+ process.exitCode = 1;
80
+ }
81
+ export function registerUpdateCommand(program) {
82
+ const update = program
83
+ .command('update [target]')
84
+ .description('Move a frozen agent installation to a new release, keeping its name and every reference to it')
85
+ .option('--to <release>', 'Release to move to: latest (default), oldest, or an exact version')
86
+ .option('--account <label>', 'Disambiguate by signed-in account when several installations match')
87
+ .option('--json', 'Machine-readable result')
88
+ .action(async (target, options) => {
89
+ try {
90
+ if (!target) {
91
+ throw new Error('Which agent? Use: agents update <agent>[@<installed-version>]');
92
+ }
93
+ const { agent, selector } = parseTarget(target);
94
+ if (options.to && options.to !== 'latest' && !supportsPinnedUpdate(agent)) {
95
+ // Fail loud at the boundary rather than installing the current release
96
+ // and reporting it as the pin that was asked for.
97
+ throw new Error(`${agent} is a single self-updating binary with no pinnable releases — drop --to, or pass --to latest.`);
98
+ }
99
+ const installation = await resolveInstallation(agent, selector, { account: options.account });
100
+ const strategy = selectUpdateStrategy(agent);
101
+ if (!options.json && !strategy.transactional) {
102
+ console.log(chalk.yellow(`${agent} installs one vendor-managed binary, so this update cannot be staged or rolled back; `
103
+ + `a failure leaves whatever its installer wrote.`));
104
+ }
105
+ if (!options.json) {
106
+ console.log(chalk.gray(`Updating ${agent}@${describeInstallation(installation)} via the ${strategy.id} strategy...`));
107
+ }
108
+ const outcome = await updateInstallation(installation, {
109
+ to: options.to,
110
+ onProgress: options.json ? undefined : (message) => console.log(chalk.gray(` ${message}`)),
111
+ });
112
+ printOutcome(outcome, !!options.json);
113
+ }
114
+ catch (err) {
115
+ fail(err);
116
+ }
117
+ });
118
+ update
119
+ .command('list <agent>')
120
+ .description('Show every frozen installation of an agent and the release each carries')
121
+ .option('--json', 'Machine-readable listing')
122
+ // `--json` is declared on both `update` and `update list`, and commander
123
+ // binds a flag the parent also declares to the PARENT's option store — so
124
+ // reading this subcommand's own opts alone silently drops it. Merge them.
125
+ .action((rawAgent, _options, command) => {
126
+ try {
127
+ const agent = resolveAgentName(rawAgent);
128
+ if (!agent)
129
+ throw new Error(formatAgentError(rawAgent));
130
+ printInstallations(agent, !!command.optsWithGlobals().json);
131
+ }
132
+ catch (err) {
133
+ fail(err);
134
+ }
135
+ });
136
+ setHelpSections(update, {
137
+ examples: `agents update list claude
138
+ agents update claude@2.0.65
139
+ agents update claude@2.0.65 --to 2.1.220
140
+ agents update claude --account work
141
+ agents update claude@2.0.65 --json`,
142
+ notes: 'An installation keeps its name for life; only the release inside it moves. That is why a default, a project pin, '
143
+ + 'a routine version, or a profile that names claude@2.0.65 keeps working after you update it. '
144
+ + 'The selector matches either the installation name or the release it currently carries — when two installations '
145
+ + 'share a release, name one or pass --account <label> (see: agents accounts). '
146
+ + 'The new release is fetched and launched before it replaces the working one, so a bad release leaves your agent running.',
147
+ });
148
+ }
package/dist/index.js CHANGED
@@ -94,7 +94,7 @@ if (IS_DEV_BUILD) {
94
94
  // module on each invocation (which loaded the whole ~50-module tree before the
95
95
  // first byte of output), the registry maps a command name to a thunk that
96
96
  // imports only what that command needs. See src/lib/startup/command-registry.ts.
97
- import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadImport, loadExport, loadPackages, loadRoutines, loadDaemon, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadAccounts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadBench, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
97
+ import { COMMAND_LOADERS, LAZY_COMMAND_NAMES, loadView, loadInspect, loadFeedback, loadCommands, loadHooks, loadSkills, loadRules, loadMemory, loadPermissions, loadMcp, loadCli, loadSubagents, loadPlugins, loadWorkflows, loadWorktree, loadVersions, loadUpdate, loadImport, loadExport, loadPackages, loadRoutines, loadDaemon, loadMonitors, loadProjects, loadRun, loadFork, loadDefaults, loadSet, loadModels, loadModes, loadPrune, loadTrash, loadRestore, loadDoctor, loadApply, loadStatus, loadSnapshot, loadProfiles, loadHarness, loadSecrets, loadLogin, loadWallet, loadHelper, loadMenubar, loadBeta, loadSync, loadLock, loadRefreshRules, loadFactory, loadUsage, loadCost, loadInsights, loadPerf, loadTrends, loadOutput, loadBudget, loadAlias, loadMine, loadPty, loadTmux, loadWatchdog, loadBrowser, loadComputer, loadHosts, loadLogs, loadEvents, loadAudit, loadWebhook, loadFunnel, loadHumans, loadAccounts, loadSsh, loadPull, loadPush, loadRepo, loadSetup, loadUninstall, loadBench, loadShare, loadSend, loadFeed, loadMailboxes, } from './lib/startup/command-registry.js';
98
98
  import { applyGlobalHelpConventions } from './lib/help.js';
99
99
  import { renderWhatsNew } from './lib/whats-new.js';
100
100
  import { getCliLaunch } from './lib/cli-entry.js';
@@ -292,6 +292,7 @@ Quick start:
292
292
  Agent versions:
293
293
  add <agent>[@version] Install an agent CLI (e.g. agents add grok or agents add codex)
294
294
  import <agent> Adopt an existing global install (npm/homebrew) into agents-cli
295
+ update <agent>[@version] Move an installed agent to a new release, keeping its name (agents-cli itself is 'agents upgrade')
295
296
  prune <agent>[@version] Uninstall a version
296
297
  remove <agent>[@version] Alias for prune
297
298
  use <agent>@<version> Set the default version
@@ -942,6 +943,7 @@ async function registerAllEagerCommands() {
942
943
  await reg(loadWorkflows);
943
944
  await reg(loadWorktree);
944
945
  await reg(loadVersions);
946
+ await reg(loadUpdate);
945
947
  await reg(loadImport);
946
948
  await reg(loadExport);
947
949
  await reg(loadPackages);
@@ -139,7 +139,10 @@ export async function runCatchup(opts = {}) {
139
139
  continue;
140
140
  }
141
141
  try {
142
- const meta = await executeJobDetached(config);
142
+ // No `scheduledFor` here on purpose: the missed slot is already claimed by
143
+ // `claimMissedFire` above (its atomic mkdir IS the catch-up single-fire), so
144
+ // the late run gets a fresh id rather than colliding with the missed record.
145
+ const meta = await executeJobDetached(config, undefined, { kind: 'catchup' });
143
146
  outcomes.push({
144
147
  name: entry.name,
145
148
  expectedAt: entry.expectedAt,
@@ -273,6 +273,23 @@ export interface DaemonStopResult {
273
273
  surviving: string[];
274
274
  detachedChildren: number[];
275
275
  }
276
+ /**
277
+ * Live `__daemon-run` processes still registered in THIS state dir's instance
278
+ * registry, excluding `exclude`. State-dir-scoped by construction: the registry
279
+ * lives inside this daemon dir, so a daemon serving a DIFFERENT state dir (a test
280
+ * fixture with its own HOME, a separate install/home) registers elsewhere and is
281
+ * invisible here — it is never a stop/takeover target. POSIX-only (the registry
282
+ * and its `ps` liveness probe are); `[]` on Windows.
283
+ *
284
+ * Exported for `agents daemon status`/`doctor`/`services` (RUSH-2368): those
285
+ * commands previously flagged every `__daemon-run` on the box (a raw `ps` scan)
286
+ * as a "duplicate" of this daemon, which misreported test fixtures under their
287
+ * own HOME — and therefore their own state dir and registry — as strays to
288
+ * kill. This registry read is the same scope the reaper (`reapStrayDaemons`)
289
+ * and the stop postcondition (`stopDaemon`) already use, so the display and the
290
+ * reaper agree on what a duplicate is.
291
+ */
292
+ export declare function findSurvivingStateDirDaemons(exclude: Set<number>): number[];
276
293
  /**
277
294
  * Stop the daemon and ASSERT its postcondition (SING-12, RUSH-2355), unloading it
278
295
  * from launchd/systemd if applicable.
@@ -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