@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.
- package/CHANGELOG.md +102 -0
- package/README.md +2 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.d.ts +9 -0
- package/dist/commands/doctor.js +143 -9
- package/dist/commands/exec.js +29 -3
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +4 -2
- package/dist/commands/secrets.js +8 -2
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +118 -12
- package/dist/commands/sessions-export.d.ts +3 -0
- package/dist/commands/sessions-export.js +13 -10
- package/dist/commands/sessions-picker.d.ts +15 -1
- package/dist/commands/sessions-picker.js +58 -4
- package/dist/commands/sessions.d.ts +95 -1
- package/dist/commands/sessions.js +224 -26
- package/dist/commands/ssh.js +9 -1
- package/dist/index.js +3 -5
- package/dist/lib/agents.d.ts +0 -21
- package/dist/lib/agents.js +0 -37
- package/dist/lib/auto-pull.d.ts +16 -8
- package/dist/lib/auto-pull.js +23 -28
- package/dist/lib/daemon.d.ts +9 -41
- package/dist/lib/daemon.js +16 -117
- package/dist/lib/devices/fleet-divergence.d.ts +101 -0
- package/dist/lib/devices/fleet-divergence.js +188 -0
- package/dist/lib/devices/fleet-inventory.d.ts +19 -0
- package/dist/lib/devices/fleet-inventory.js +57 -0
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/hosts/remote-cmd.js +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/runner.js +29 -26
- package/dist/lib/sandbox.js +0 -16
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
- package/dist/lib/secrets/agent.d.ts +2 -0
- package/dist/lib/secrets/agent.js +26 -14
- package/dist/lib/secrets/bundles.d.ts +1 -1
- package/dist/lib/secrets/bundles.js +16 -8
- package/dist/lib/secrets/scope.d.ts +26 -0
- package/dist/lib/secrets/scope.js +29 -0
- package/dist/lib/secrets/session-store.d.ts +10 -0
- package/dist/lib/secrets/session-store.js +64 -11
- package/dist/lib/session/active.d.ts +7 -0
- package/dist/lib/session/active.js +38 -3
- package/dist/lib/session/db.d.ts +37 -0
- package/dist/lib/session/db.js +112 -4
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +15 -2
- package/dist/lib/session/discover.js +19 -11
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/state.d.ts +1 -0
- package/dist/lib/session/state.js +1 -1
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/package.json +1 -1
- package/dist/lib/secrets/account-token.d.ts +0 -20
- package/dist/lib/secrets/account-token.js +0 -64
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -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
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
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
|
|
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): {
|
package/dist/lib/daemon.js
CHANGED
|
@@ -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
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
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
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
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
|
|
938
|
-
*
|
|
939
|
-
*
|
|
940
|
-
*
|
|
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 ??
|
|
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
|
+
}
|