@phnx-labs/agents-cli 1.20.77 → 1.20.78

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 (70) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/README.md +2 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +9 -0
  5. package/dist/commands/doctor.js +143 -9
  6. package/dist/commands/exec.js +29 -3
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +4 -2
  9. package/dist/commands/secrets.js +8 -2
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +118 -12
  12. package/dist/commands/sessions-export.d.ts +3 -0
  13. package/dist/commands/sessions-export.js +13 -10
  14. package/dist/commands/sessions-picker.d.ts +15 -1
  15. package/dist/commands/sessions-picker.js +58 -4
  16. package/dist/commands/sessions.d.ts +95 -1
  17. package/dist/commands/sessions.js +224 -26
  18. package/dist/commands/ssh.js +9 -1
  19. package/dist/index.js +3 -5
  20. package/dist/lib/agents.d.ts +0 -21
  21. package/dist/lib/agents.js +0 -37
  22. package/dist/lib/auto-pull.d.ts +16 -8
  23. package/dist/lib/auto-pull.js +23 -28
  24. package/dist/lib/daemon.d.ts +9 -41
  25. package/dist/lib/daemon.js +16 -117
  26. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  27. package/dist/lib/devices/fleet-divergence.js +188 -0
  28. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  29. package/dist/lib/devices/fleet-inventory.js +57 -0
  30. package/dist/lib/devices/health-report.d.ts +10 -2
  31. package/dist/lib/devices/health-report.js +32 -1
  32. package/dist/lib/git.d.ts +13 -0
  33. package/dist/lib/git.js +102 -4
  34. package/dist/lib/hosts/remote-cmd.js +2 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  36. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  37. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  38. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  39. package/dist/lib/runner.js +29 -26
  40. package/dist/lib/sandbox.js +0 -16
  41. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  42. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  43. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  44. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  45. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  46. package/dist/lib/secrets/agent.d.ts +2 -0
  47. package/dist/lib/secrets/agent.js +26 -14
  48. package/dist/lib/secrets/bundles.d.ts +1 -1
  49. package/dist/lib/secrets/bundles.js +16 -8
  50. package/dist/lib/secrets/scope.d.ts +26 -0
  51. package/dist/lib/secrets/scope.js +29 -0
  52. package/dist/lib/secrets/session-store.d.ts +10 -0
  53. package/dist/lib/secrets/session-store.js +64 -11
  54. package/dist/lib/session/active.d.ts +7 -0
  55. package/dist/lib/session/active.js +38 -3
  56. package/dist/lib/session/db.d.ts +37 -0
  57. package/dist/lib/session/db.js +112 -4
  58. package/dist/lib/session/digest.js +126 -21
  59. package/dist/lib/session/discover.d.ts +15 -2
  60. package/dist/lib/session/discover.js +19 -11
  61. package/dist/lib/session/origin-machine.d.ts +18 -0
  62. package/dist/lib/session/origin-machine.js +34 -0
  63. package/dist/lib/session/state.d.ts +1 -0
  64. package/dist/lib/session/state.js +1 -1
  65. package/dist/lib/session/sync/config.js +2 -2
  66. package/dist/lib/smart-launch.d.ts +86 -0
  67. package/dist/lib/smart-launch.js +172 -0
  68. package/package.json +1 -1
  69. package/dist/lib/secrets/account-token.d.ts +0 -20
  70. package/dist/lib/secrets/account-token.js +0 -64
@@ -69,27 +69,6 @@ export declare function reapStrayDaemons(keepPid?: number): {
69
69
  export declare function log(level: string, message: string): void;
70
70
  /** Main daemon loop: load jobs, schedule crons, monitor runs, and handle signals. */
71
71
  export declare function runDaemon(): Promise<void>;
72
- /**
73
- * Read the long-lived Claude OAuth token (from `claude setup-token`) that the
74
- * user stored under the `claude` secrets bundle. Resolves the bundle the same
75
- * way `agents run --secrets` does. Interactive starts may prompt Keychain;
76
- * headless auto-starts are broker-only and return null unless the user already
77
- * unlocked the bundle in the secrets agent. That keeps a background browser
78
- * command from hanging on an unseen biometric prompt. Never throws: an absent
79
- * token leaves the daemon on its existing interactive OAuth session.
80
- */
81
- export declare function readDaemonClaudeOAuthToken(opts?: {
82
- allowPrompt?: boolean;
83
- }): string | null;
84
- /**
85
- * Read the FULL `claude` bundle env — the main `CLAUDE_CODE_OAUTH_TOKEN` plus any
86
- * per-account `CLAUDE_CODE_OAUTH_TOKEN_<slug>` setup-tokens. Same resolution and
87
- * never-throws contract as {@link readDaemonClaudeOAuthToken}; returns `{}` when
88
- * the bundle can't be read (broker-only headless miss, absent bundle, etc.).
89
- */
90
- export declare function readDaemonClaudeBundleEnv(opts?: {
91
- allowPrompt?: boolean;
92
- }): Record<string, string>;
93
72
  /**
94
73
  * Write a launchd plist or systemd unit with owner-only permissions atomically.
95
74
  *
@@ -103,21 +82,19 @@ export declare function writeOwnerOnlyServiceManifest(filePath: string, content:
103
82
  /**
104
83
  * Generate a macOS launchd plist for auto-starting the daemon.
105
84
  *
106
- * The plist never embeds the Claude OAuth token (RUSH-1759): a persisted service
107
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
108
- * obtains the token at startup from the `claude` secrets bundle
109
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the
110
- * Keychain-backed secure store and never touches the unit file.
85
+ * The plist never embeds a Claude OAuth token: the daemon holds no Claude
86
+ * credential at all. Routine runs authenticate through the per-account
87
+ * CLAUDE_CONFIG_DIR login on this device, exactly like an interactive
88
+ * `agents run`, so no credential ever touches the service manifest.
111
89
  */
112
90
  export declare function generateLaunchdPlist(agentsBin?: string): string;
113
91
  /**
114
92
  * Generate a Linux systemd user unit for auto-starting the daemon.
115
93
  *
116
- * The unit never embeds the Claude OAuth token (RUSH-1759): a persisted service
117
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
118
- * obtains the token at startup from the `claude` secrets bundle
119
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the secure
120
- * store and never touches the unit file.
94
+ * The unit never embeds a Claude OAuth token: the daemon holds no Claude
95
+ * credential at all. Routine runs authenticate through the per-account
96
+ * CLAUDE_CONFIG_DIR login on this device, exactly like an interactive
97
+ * `agents run`, so no credential ever touches the unit file.
121
98
  */
122
99
  export declare function generateSystemdUnit(agentsBin?: string): string;
123
100
  export { getAgentsBinPath };
@@ -140,15 +117,6 @@ export declare function ensureDaemonStarted(): {
140
117
  pid: number | null;
141
118
  method: string;
142
119
  } | null;
143
- /**
144
- * Environment for the detached daemon fallback. The launchd/systemd paths
145
- * deliver the long-lived OAuth token via the service manifest's environment;
146
- * the detached path has no manifest, so inject it here. Read happens during an
147
- * interactive `routines start`, so a Keychain Touch ID prompt can be satisfied;
148
- * the daemon then passes it to every routine run it spawns. An already-set
149
- * value (e.g. inherited from launchd) is left untouched.
150
- */
151
- export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv, bundleEnv?: Record<string, string>): NodeJS.ProcessEnv;
152
120
  /**
153
121
  * Resolve how to launch the daemon: `node <entry> __daemon-run`, matching the
154
122
  * exact form that works under a direct `__daemon-run`.
@@ -197,7 +165,7 @@ interface StartDetachedOptions {
197
165
  agentsBin?: string;
198
166
  /** Log file the daemon's stdio is redirected to (defaults to the daemon log). */
199
167
  logPath?: string;
200
- /** Environment for the child (defaults to the OAuth-augmented detached env). */
168
+ /** Environment for the child (defaults to the daemon's current process env). */
201
169
  env?: NodeJS.ProcessEnv;
202
170
  }
203
171
  export declare function startDetached(opts?: StartDetachedOptions): {
@@ -18,11 +18,9 @@ import { JobScheduler } from './scheduler.js';
18
18
  import { MonitorEngine } from './monitors/engine.js';
19
19
  import { executeJobDetached, monitorRunningJobs } from './runner.js';
20
20
  import { detectOverdueJobs, notifyOverdue } from './overdue.js';
21
- import { notifyDesktop } from './menubar/notify-desktop.js';
22
21
  import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
23
22
  import { BrowserService } from './browser/service.js';
24
23
  import { BrowserIPCServer } from './browser/ipc.js';
25
- import { readAndResolveBundleEnv } from './secrets/bundles.js';
26
24
  import { redactSecrets } from './redact.js';
27
25
  import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
28
26
  const PID_FILE = 'daemon.pid';
@@ -35,12 +33,6 @@ const PLIST_NAME = 'com.phnx-labs.agents-daemon';
35
33
  const SYSTEMD_UNIT = 'agents-daemon.service';
36
34
  const MONITOR_TICK_MS = 60_000;
37
35
  const WEDGE_THRESHOLD_TICKS = 3;
38
- // A long-lived `claude setup-token` value stored in this secrets bundle/key is
39
- // baked into the daemon's service-manager environment so headless routine runs
40
- // authenticate without depending on the short-lived interactive Keychain OAuth
41
- // session (which expires between runs and produces intermittent 401s).
42
- const DAEMON_OAUTH_BUNDLE = 'claude';
43
- const DAEMON_OAUTH_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
44
36
  /**
45
37
  * RUSH-1817: decide whether the daemon should (re)take over hosting the secrets
46
38
  * broker. The startup host decision is one-shot; this drives the periodic
@@ -309,47 +301,13 @@ export async function runDaemon() {
309
301
  process.exit(0);
310
302
  }
311
303
  log('INFO', `Daemon started (PID: ${process.pid})`);
312
- // RUSH-1759: the launchd plist / systemd unit no longer bake the Claude OAuth
313
- // token onto disk. Obtain it here from the secure `claude` secrets bundle and
314
- // inject into this process's env so every routine run this daemon spawns still
315
- // receives it (via the sandbox allowlist), without the token ever being
316
- // persisted in the service manifest. A read from a file-backed store (Linux)
317
- // needs no prompt; on macOS it resolves broker-only from an unlocked
318
- // secrets-agent and is otherwise absent (leaving the daemon on its existing
319
- // interactive OAuth session), matching the detached-start path. Never blocks.
320
- if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
321
- const bundleEnv = readDaemonClaudeBundleEnv();
322
- const oauthToken = (bundleEnv[DAEMON_OAUTH_KEY] ?? '').trim();
323
- if (oauthToken) {
324
- process.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
325
- // Also inject each per-account CLAUDE_CODE_OAUTH_TOKEN_<slug> present in the
326
- // bundle, so a routine authenticates its rotation-pinned account via that
327
- // account's own long-lived, non-rotating setup-token (runner.ts
328
- // buildRoutineSpawnEnv) instead of the interactive session that rotates and
329
- // logs the fleet out (Claude Code #25609 / #56339).
330
- let perAccount = 0;
331
- for (const [k, v] of Object.entries(bundleEnv)) {
332
- if (k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && (v ?? '').trim() && !process.env[k]) {
333
- process.env[k] = v.trim();
334
- perAccount++;
335
- }
336
- }
337
- log('INFO', `Loaded Claude OAuth token from secrets bundle for routine runs${perAccount ? ` (+${perAccount} per-account)` : ''}`);
338
- }
339
- else {
340
- // No token available (e.g. a headless macOS daemon whose keychain was
341
- // locked at start resolves broker-only and gets nothing). Historically
342
- // this was silent, so every Claude routine the daemon spawned failed auth
343
- // with no signal. Make it loud: WARN in the log and fire a desktop alert.
344
- log('WARN', 'No Claude OAuth token available — Claude routine runs will fail auth on this host. ' +
345
- 'Restart the daemon with the keychain unlocked, or unlock the `claude` secrets bundle.');
346
- notifyDesktop({
347
- title: 'agents daemon: no Claude credential',
348
- body: 'Claude routines will fail auth on this host. Restart the daemon with the keychain unlocked.',
349
- action: 'routines:list',
350
- });
351
- }
352
- }
304
+ // The daemon holds NO Claude credential of its own. Routine runs authenticate
305
+ // exactly like an interactive `agents run`: through the per-account
306
+ // CLAUDE_CONFIG_DIR login on this device (its own auto-refreshing
307
+ // .credentials.json). Claude Code's interactive access token is short-lived but
308
+ // refreshes itself per-device; a routine whose account login has gone dead is
309
+ // skipped up front by the auth-health preflight (runner.ts) with a re-login
310
+ // hint, rather than papered over by an injected fallback token.
353
311
  // Reap any stray duplicate daemon of this install that slipped past the start
354
312
  // lock or was orphaned by a hard-crash — before it can double-fire jobs.
355
313
  try {
@@ -833,38 +791,6 @@ export async function runDaemon() {
833
791
  process.on('SIGINT', () => handleShutdown());
834
792
  await new Promise(() => { });
835
793
  }
836
- /**
837
- * Read the long-lived Claude OAuth token (from `claude setup-token`) that the
838
- * user stored under the `claude` secrets bundle. Resolves the bundle the same
839
- * way `agents run --secrets` does. Interactive starts may prompt Keychain;
840
- * headless auto-starts are broker-only and return null unless the user already
841
- * unlocked the bundle in the secrets agent. That keeps a background browser
842
- * command from hanging on an unseen biometric prompt. Never throws: an absent
843
- * token leaves the daemon on its existing interactive OAuth session.
844
- */
845
- export function readDaemonClaudeOAuthToken(opts = {}) {
846
- const token = (readDaemonClaudeBundleEnv(opts)[DAEMON_OAUTH_KEY] ?? '').trim();
847
- return token.length > 0 ? token : null;
848
- }
849
- /**
850
- * Read the FULL `claude` bundle env — the main `CLAUDE_CODE_OAUTH_TOKEN` plus any
851
- * per-account `CLAUDE_CODE_OAUTH_TOKEN_<slug>` setup-tokens. Same resolution and
852
- * never-throws contract as {@link readDaemonClaudeOAuthToken}; returns `{}` when
853
- * the bundle can't be read (broker-only headless miss, absent bundle, etc.).
854
- */
855
- export function readDaemonClaudeBundleEnv(opts = {}) {
856
- try {
857
- const allowPrompt = opts.allowPrompt ?? Boolean(process.stdin.isTTY);
858
- const { env } = readAndResolveBundleEnv(DAEMON_OAUTH_BUNDLE, {
859
- caller: 'daemon',
860
- agentOnly: !allowPrompt,
861
- });
862
- return env;
863
- }
864
- catch {
865
- return {};
866
- }
867
- }
868
794
  /** Escape a string for safe inclusion in an XML <string> node. */
869
795
  function xmlEscape(s) {
870
796
  return s
@@ -892,11 +818,10 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
892
818
  /**
893
819
  * Generate a macOS launchd plist for auto-starting the daemon.
894
820
  *
895
- * The plist never embeds the Claude OAuth token (RUSH-1759): a persisted service
896
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
897
- * obtains the token at startup from the `claude` secrets bundle
898
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the
899
- * Keychain-backed secure store and never touches the unit file.
821
+ * The plist never embeds a Claude OAuth token: the daemon holds no Claude
822
+ * credential at all. Routine runs authenticate through the per-account
823
+ * CLAUDE_CONFIG_DIR login on this device, exactly like an interactive
824
+ * `agents run`, so no credential ever touches the service manifest.
900
825
  */
901
826
  export function generateLaunchdPlist(agentsBin = getAgentsBinPath()) {
902
827
  const launch = getDaemonLaunch(agentsBin);
@@ -934,11 +859,10 @@ function systemdExecArg(value) {
934
859
  /**
935
860
  * Generate a Linux systemd user unit for auto-starting the daemon.
936
861
  *
937
- * The unit never embeds the Claude OAuth token (RUSH-1759): a persisted service
938
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
939
- * obtains the token at startup from the `claude` secrets bundle
940
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the secure
941
- * store and never touches the unit file.
862
+ * The unit never embeds a Claude OAuth token: the daemon holds no Claude
863
+ * credential at all. Routine runs authenticate through the per-account
864
+ * CLAUDE_CONFIG_DIR login on this device, exactly like an interactive
865
+ * `agents run`, so no credential ever touches the unit file.
942
866
  */
943
867
  export function generateSystemdUnit(agentsBin = getAgentsBinPath()) {
944
868
  const launch = getDaemonLaunch(agentsBin);
@@ -1082,31 +1006,6 @@ function startDaemonLocked(agentsBin) {
1082
1006
  }
1083
1007
  return startDetached({ agentsBin });
1084
1008
  }
1085
- /**
1086
- * Environment for the detached daemon fallback. The launchd/systemd paths
1087
- * deliver the long-lived OAuth token via the service manifest's environment;
1088
- * the detached path has no manifest, so inject it here. Read happens during an
1089
- * interactive `routines start`, so a Keychain Touch ID prompt can be satisfied;
1090
- * the daemon then passes it to every routine run it spawns. An already-set
1091
- * value (e.g. inherited from launchd) is left untouched.
1092
- */
1093
- export function buildDetachedDaemonEnv(baseEnv = process.env, bundleEnv = readDaemonClaudeBundleEnv()) {
1094
- const env = { ...baseEnv };
1095
- if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
1096
- const main = (bundleEnv[DAEMON_OAUTH_KEY] ?? '').trim();
1097
- if (main)
1098
- env.CLAUDE_CODE_OAUTH_TOKEN = main;
1099
- }
1100
- // Per-account setup-tokens — same rationale as the runDaemon startup injection:
1101
- // a routine authenticates its rotation-pinned account via that account's own
1102
- // long-lived, non-rotating token. An already-set value wins.
1103
- for (const [k, v] of Object.entries(bundleEnv)) {
1104
- if (k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && (v ?? '').trim() && !env[k]) {
1105
- env[k] = v.trim();
1106
- }
1107
- }
1108
- return env;
1109
- }
1110
1009
  /**
1111
1010
  * Resolve how to launch the daemon: `node <entry> __daemon-run`, matching the
1112
1011
  * exact form that works under a direct `__daemon-run`.
@@ -1186,7 +1085,7 @@ export function startDetached(opts = {}) {
1186
1085
  const child = spawn(command, args, {
1187
1086
  stdio: ['ignore', logFd, logFd],
1188
1087
  ...backgroundSpawnOptions({ fdStdio: true }),
1189
- env: opts.env ?? buildDetachedDaemonEnv(),
1088
+ env: opts.env ?? process.env,
1190
1089
  });
1191
1090
  // A failed spawn (ENOENT/EACCES) emits 'error' asynchronously; without a
1192
1091
  // listener that would crash the parent as an unhandled EventEmitter error.
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Cross-device harness divergence for `agents doctor` (RUSH-2027).
3
+ *
4
+ * `agents fleet status` already fans out `agents doctor --json` per device and
5
+ * builds a {@link FleetHealthReport} of coarse device health (reachable, CLIs
6
+ * installed, agents-cli version skew, local sync drift). It deliberately does
7
+ * NOT compare fine-grained *resource presence* across devices — so a plugin like
8
+ * `swarm` installed on one box but missing on another is silent until a user
9
+ * types `/swarm:run` on the wrong machine and gets `Unknown command`.
10
+ *
11
+ * This module is the missing comparison. Each device self-reports its installed
12
+ * inventory (resource kinds, per-agent version sets, `.agents`/`.system` repo
13
+ * state) in its `doctor --json` payload; {@link compareFleetInventories} takes
14
+ * the collected per-device inventories and, treating the local machine as the
15
+ * baseline, flags:
16
+ *
17
+ * 1. Resource presence gaps — a resource present on one device, missing on
18
+ * another (either direction relative to the local baseline).
19
+ * 2. Agent version gaps — an agent version installed on one device but
20
+ * not another (e.g. `yosemite-s0 missing claude@2.1.220`).
21
+ * 3. `.agents`/`.system` repo drift — a device whose config-repo HEAD, branch,
22
+ * or dirty state diverges from the local baseline.
23
+ *
24
+ * Pure and SSH-free: the SSH fan-out lives in the doctor command; this module
25
+ * only consumes the already-collected payloads, so the divergence logic is
26
+ * unit-tested against fixture inventories with no live fleet.
27
+ */
28
+ /** Resource kinds compared across devices. Mirrors {@link DoctorKind} plus the
29
+ * top-level `workflows`/`memory` inventory that `getAvailableResources` emits;
30
+ * `promptcuts` is a single present/absent bit surfaced as one named resource. */
31
+ export type FleetResourceKind = 'commands' | 'skills' | 'hooks' | 'rules' | 'mcp' | 'permissions' | 'subagents' | 'plugins' | 'promptcuts' | 'workflows';
32
+ export declare const FLEET_RESOURCE_KINDS: FleetResourceKind[];
33
+ /** Per-repo state for the `.agents` (user) or `.system` config repo, as a device
34
+ * self-reports it. `null` fields mean "not a git repo / unreadable" on that box. */
35
+ export interface RepoState {
36
+ /** Current branch, or null when detached / unreadable. */
37
+ branch: string | null;
38
+ /** Short HEAD commit (8 chars), or null when unreadable. */
39
+ head: string | null;
40
+ /** True when the working tree has uncommitted changes. */
41
+ dirty: boolean;
42
+ }
43
+ /**
44
+ * The self-reported harness inventory a single device emits in `doctor --json`.
45
+ * Comparable device-to-device with no further probing.
46
+ */
47
+ export interface FleetInventory {
48
+ /** Installed resource names per kind. `rules` here are the top-level rules
49
+ * files (memory presets), not per-agent compiled AGENTS.md. */
50
+ resources: Record<FleetResourceKind, string[]>;
51
+ /** Installed version ids per agent id (e.g. `{ claude: ['2.1.170','2.1.220'] }`). */
52
+ agentVersions: Record<string, string[]>;
53
+ /** State of the user (`~/.agents`) and system (`~/.agents/.system`) config repos. */
54
+ repos: {
55
+ agents: RepoState | null;
56
+ system: RepoState | null;
57
+ };
58
+ }
59
+ /** A device's inventory paired with its name (and reachability). A device that
60
+ * was unreachable / failed to report carries `inventory: null` and is skipped
61
+ * by the comparison (its absence is a `fleet status` concern, not divergence). */
62
+ export interface DeviceInventory {
63
+ name: string;
64
+ inventory: FleetInventory | null;
65
+ }
66
+ export type FleetDivergenceKind = 'resource-missing-remote' | 'resource-missing-local' | 'agent-version-missing-remote' | 'agent-version-missing-local' | 'repo-drift';
67
+ /** One cross-device divergence finding, baseline = the local machine. */
68
+ export interface FleetDivergence {
69
+ kind: FleetDivergenceKind;
70
+ /** The device the finding is about (the remote box, or the local box for a
71
+ * `*-missing-local` finding where a remote has something local lacks). */
72
+ device: string;
73
+ /** Resource kind for a resource finding; agent id for a version finding;
74
+ * `agents`/`system` for a repo finding. */
75
+ category: string;
76
+ /** The specific resource name / version id / repo label that diverges. */
77
+ name: string;
78
+ /** Human-readable one-liner, ready for the warnings list. */
79
+ message: string;
80
+ }
81
+ export interface FleetDivergenceReport {
82
+ /** Name of the device used as the comparison baseline (the local machine). */
83
+ baseline: string;
84
+ divergences: FleetDivergence[];
85
+ /** Devices that reported an inventory and were compared. */
86
+ comparedDevices: string[];
87
+ /** Devices that could not be compared (offline / no inventory in payload). */
88
+ skippedDevices: string[];
89
+ hasDivergence: boolean;
90
+ }
91
+ /**
92
+ * Compare a set of per-device inventories against the local baseline and emit
93
+ * every cross-device divergence. The baseline is the device whose name equals
94
+ * {@link baselineName} (the local machine); if that device has no inventory the
95
+ * report is empty (nothing to compare against). Devices with no inventory are
96
+ * recorded under `skippedDevices` and never produce false "missing" findings.
97
+ *
98
+ * Ordering is deterministic (device, then category, then name) so the human and
99
+ * JSON output — and the tests — are stable.
100
+ */
101
+ export declare function compareFleetInventories(devices: DeviceInventory[], baselineName: string): FleetDivergenceReport;
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Cross-device harness divergence for `agents doctor` (RUSH-2027).
3
+ *
4
+ * `agents fleet status` already fans out `agents doctor --json` per device and
5
+ * builds a {@link FleetHealthReport} of coarse device health (reachable, CLIs
6
+ * installed, agents-cli version skew, local sync drift). It deliberately does
7
+ * NOT compare fine-grained *resource presence* across devices — so a plugin like
8
+ * `swarm` installed on one box but missing on another is silent until a user
9
+ * types `/swarm:run` on the wrong machine and gets `Unknown command`.
10
+ *
11
+ * This module is the missing comparison. Each device self-reports its installed
12
+ * inventory (resource kinds, per-agent version sets, `.agents`/`.system` repo
13
+ * state) in its `doctor --json` payload; {@link compareFleetInventories} takes
14
+ * the collected per-device inventories and, treating the local machine as the
15
+ * baseline, flags:
16
+ *
17
+ * 1. Resource presence gaps — a resource present on one device, missing on
18
+ * another (either direction relative to the local baseline).
19
+ * 2. Agent version gaps — an agent version installed on one device but
20
+ * not another (e.g. `yosemite-s0 missing claude@2.1.220`).
21
+ * 3. `.agents`/`.system` repo drift — a device whose config-repo HEAD, branch,
22
+ * or dirty state diverges from the local baseline.
23
+ *
24
+ * Pure and SSH-free: the SSH fan-out lives in the doctor command; this module
25
+ * only consumes the already-collected payloads, so the divergence logic is
26
+ * unit-tested against fixture inventories with no live fleet.
27
+ */
28
+ export const FLEET_RESOURCE_KINDS = [
29
+ 'commands',
30
+ 'skills',
31
+ 'hooks',
32
+ 'rules',
33
+ 'mcp',
34
+ 'permissions',
35
+ 'subagents',
36
+ 'plugins',
37
+ 'promptcuts',
38
+ 'workflows',
39
+ ];
40
+ function sortedUnique(list) {
41
+ return Array.from(new Set(list)).sort();
42
+ }
43
+ function repoLabel(repo) {
44
+ return repo === 'agents' ? '.agents' : '.system';
45
+ }
46
+ /** Describe how a remote repo state diverges from the local baseline, or null
47
+ * when they match. Compares HEAD first (the load-bearing difference), then
48
+ * branch, then a dirty tree on either side (naming which side is dirty). */
49
+ function describeRepoDrift(local, remote) {
50
+ if (local.head && remote.head && local.head !== remote.head) {
51
+ return `HEAD ${remote.head} != local ${local.head}`;
52
+ }
53
+ if (local.branch !== remote.branch) {
54
+ return `branch ${remote.branch ?? 'detached'} != local ${local.branch ?? 'detached'}`;
55
+ }
56
+ // Flag a dirty tree on EITHER side (symmetric with HEAD/branch above), and name
57
+ // the side that has the uncommitted changes — the remote, or the local baseline.
58
+ if (remote.dirty !== local.dirty) {
59
+ return remote.dirty
60
+ ? 'remote tree has uncommitted changes'
61
+ : 'local tree has uncommitted changes';
62
+ }
63
+ return null;
64
+ }
65
+ /**
66
+ * Compare a set of per-device inventories against the local baseline and emit
67
+ * every cross-device divergence. The baseline is the device whose name equals
68
+ * {@link baselineName} (the local machine); if that device has no inventory the
69
+ * report is empty (nothing to compare against). Devices with no inventory are
70
+ * recorded under `skippedDevices` and never produce false "missing" findings.
71
+ *
72
+ * Ordering is deterministic (device, then category, then name) so the human and
73
+ * JSON output — and the tests — are stable.
74
+ */
75
+ export function compareFleetInventories(devices, baselineName) {
76
+ const baseline = devices.find((d) => d.name === baselineName)?.inventory ?? null;
77
+ const remotes = devices.filter((d) => d.name !== baselineName);
78
+ const comparedDevices = [];
79
+ const skippedDevices = [];
80
+ const divergences = [];
81
+ if (!baseline) {
82
+ // No local baseline to compare against — record every remote as skipped so
83
+ // the caller can say so, but emit no divergences (we can't know the truth).
84
+ for (const d of remotes)
85
+ skippedDevices.push(d.name);
86
+ return {
87
+ baseline: baselineName,
88
+ divergences: [],
89
+ comparedDevices: [],
90
+ skippedDevices: skippedDevices.sort(),
91
+ hasDivergence: false,
92
+ };
93
+ }
94
+ for (const remote of remotes) {
95
+ if (!remote.inventory) {
96
+ skippedDevices.push(remote.name);
97
+ continue;
98
+ }
99
+ comparedDevices.push(remote.name);
100
+ const inv = remote.inventory;
101
+ // 1) Resource presence, both directions.
102
+ for (const kind of FLEET_RESOURCE_KINDS) {
103
+ const localSet = new Set(baseline.resources[kind] ?? []);
104
+ const remoteSet = new Set(inv.resources[kind] ?? []);
105
+ for (const name of sortedUnique(baseline.resources[kind] ?? [])) {
106
+ if (!remoteSet.has(name)) {
107
+ divergences.push({
108
+ kind: 'resource-missing-remote',
109
+ device: remote.name,
110
+ category: kind,
111
+ name,
112
+ message: `${remote.name} is missing ${kind.replace(/s$/, '')} '${name}' (present on ${baselineName})`,
113
+ });
114
+ }
115
+ }
116
+ for (const name of sortedUnique(inv.resources[kind] ?? [])) {
117
+ if (!localSet.has(name)) {
118
+ divergences.push({
119
+ kind: 'resource-missing-local',
120
+ device: remote.name,
121
+ category: kind,
122
+ name,
123
+ message: `${baselineName} is missing ${kind.replace(/s$/, '')} '${name}' (present on ${remote.name})`,
124
+ });
125
+ }
126
+ }
127
+ }
128
+ // 2) Agent version parity, both directions, per agent id.
129
+ const agentIds = sortedUnique([
130
+ ...Object.keys(baseline.agentVersions),
131
+ ...Object.keys(inv.agentVersions),
132
+ ]);
133
+ for (const agent of agentIds) {
134
+ const localVers = new Set(baseline.agentVersions[agent] ?? []);
135
+ const remoteVers = new Set(inv.agentVersions[agent] ?? []);
136
+ for (const v of sortedUnique(baseline.agentVersions[agent] ?? [])) {
137
+ if (!remoteVers.has(v)) {
138
+ divergences.push({
139
+ kind: 'agent-version-missing-remote',
140
+ device: remote.name,
141
+ category: agent,
142
+ name: v,
143
+ message: `${remote.name} is missing ${agent}@${v} (installed on ${baselineName})`,
144
+ });
145
+ }
146
+ }
147
+ for (const v of sortedUnique(inv.agentVersions[agent] ?? [])) {
148
+ if (!localVers.has(v)) {
149
+ divergences.push({
150
+ kind: 'agent-version-missing-local',
151
+ device: remote.name,
152
+ category: agent,
153
+ name: v,
154
+ message: `${baselineName} is missing ${agent}@${v} (installed on ${remote.name})`,
155
+ });
156
+ }
157
+ }
158
+ }
159
+ // 3) `.agents` / `.system` repo drift vs the local baseline.
160
+ for (const repo of ['agents', 'system']) {
161
+ const localRepo = baseline.repos[repo];
162
+ const remoteRepo = inv.repos[repo];
163
+ if (!localRepo || !remoteRepo)
164
+ continue; // one side isn't a readable repo
165
+ const drift = describeRepoDrift(localRepo, remoteRepo);
166
+ if (drift) {
167
+ divergences.push({
168
+ kind: 'repo-drift',
169
+ device: remote.name,
170
+ category: repo,
171
+ name: repoLabel(repo),
172
+ message: `${remote.name} ${repoLabel(repo)} repo diverged: ${drift}`,
173
+ });
174
+ }
175
+ }
176
+ }
177
+ divergences.sort((a, b) => a.device.localeCompare(b.device) ||
178
+ a.kind.localeCompare(b.kind) ||
179
+ a.category.localeCompare(b.category) ||
180
+ a.name.localeCompare(b.name));
181
+ return {
182
+ baseline: baselineName,
183
+ divergences,
184
+ comparedDevices: comparedDevices.sort(),
185
+ skippedDevices: skippedDevices.sort(),
186
+ hasDivergence: divergences.length > 0,
187
+ };
188
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Build the self-reported harness inventory a device emits in `doctor --json`
3
+ * for cross-device divergence detection (RUSH-2027).
4
+ *
5
+ * Kept separate from {@link ../devices/fleet-divergence.js} — which stays a pure,
6
+ * SSH-free comparator — because this reads the live install (resource dirs,
7
+ * installed version homes, config repos). One call produces the {@link
8
+ * FleetInventory} that both the local baseline and every remote box serialize
9
+ * into their doctor payload; the comparator then diffs those payloads.
10
+ */
11
+ import { type FleetInventory } from './fleet-divergence.js';
12
+ /**
13
+ * Collect this machine's harness inventory: installed resources per kind,
14
+ * installed version ids per agent, and `.agents`/`.system` repo state. Pure
15
+ * reads — never mutates the install. `promptcuts` (a single present/absent bit
16
+ * in {@link getAvailableResources}) is surfaced as a one-element list so it
17
+ * compares like any other named resource.
18
+ */
19
+ export declare function collectLocalFleetInventory(cwd?: string): FleetInventory;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Build the self-reported harness inventory a device emits in `doctor --json`
3
+ * for cross-device divergence detection (RUSH-2027).
4
+ *
5
+ * Kept separate from {@link ../devices/fleet-divergence.js} — which stays a pure,
6
+ * SSH-free comparator — because this reads the live install (resource dirs,
7
+ * installed version homes, config repos). One call produces the {@link
8
+ * FleetInventory} that both the local baseline and every remote box serialize
9
+ * into their doctor payload; the comparator then diffs those payloads.
10
+ */
11
+ import { getAvailableResources, listInstalledVersions } from '../versions.js';
12
+ import { getUserAgentsDir, getSystemAgentsDir } from '../state.js';
13
+ import { readRepoState } from '../git.js';
14
+ import { ALL_AGENT_IDS } from '../agents.js';
15
+ import { FLEET_RESOURCE_KINDS } from './fleet-divergence.js';
16
+ function toRepoState(snap) {
17
+ if (!snap)
18
+ return null;
19
+ return { branch: snap.branch, head: snap.head, dirty: snap.dirty };
20
+ }
21
+ /**
22
+ * Collect this machine's harness inventory: installed resources per kind,
23
+ * installed version ids per agent, and `.agents`/`.system` repo state. Pure
24
+ * reads — never mutates the install. `promptcuts` (a single present/absent bit
25
+ * in {@link getAvailableResources}) is surfaced as a one-element list so it
26
+ * compares like any other named resource.
27
+ */
28
+ export function collectLocalFleetInventory(cwd = process.cwd()) {
29
+ const available = getAvailableResources(cwd);
30
+ const resources = {};
31
+ for (const kind of FLEET_RESOURCE_KINDS) {
32
+ if (kind === 'promptcuts') {
33
+ resources[kind] = available.promptcuts ? ['promptcuts.yaml'] : [];
34
+ }
35
+ else if (kind === 'rules') {
36
+ // getAvailableResources exposes top-level rules under `memory`.
37
+ resources[kind] = [...available.memory].sort();
38
+ }
39
+ else {
40
+ resources[kind] = [...(available[kind] ?? [])].sort();
41
+ }
42
+ }
43
+ const agentVersions = {};
44
+ for (const agent of ALL_AGENT_IDS) {
45
+ const versions = listInstalledVersions(agent);
46
+ if (versions.length > 0)
47
+ agentVersions[agent] = [...versions].sort();
48
+ }
49
+ return {
50
+ resources,
51
+ agentVersions,
52
+ repos: {
53
+ agents: toRepoState(readRepoState(getUserAgentsDir())),
54
+ system: toRepoState(readRepoState(getSystemAgentsDir())),
55
+ },
56
+ };
57
+ }