@phnx-labs/agents-cli 1.20.76 → 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 +264 -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 +87 -19
- package/dist/commands/repo.js +8 -0
- package/dist/commands/routines.js +275 -15
- package/dist/commands/secrets.js +65 -17
- package/dist/commands/sessions-browser.d.ts +45 -3
- package/dist/commands/sessions-browser.js +128 -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 +28 -1
- package/dist/commands/sessions-picker.js +222 -15
- package/dist/commands/sessions.d.ts +117 -4
- package/dist/commands/sessions.js +350 -62
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +48 -3
- package/dist/index.js +5 -6
- package/dist/lib/activity.d.ts +5 -4
- package/dist/lib/activity.js +450 -36
- 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/cloud/session-index.js +2 -2
- package/dist/lib/daemon.d.ts +9 -32
- package/dist/lib/daemon.js +70 -84
- 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/fleet.d.ts +16 -1
- package/dist/lib/devices/fleet.js +10 -2
- package/dist/lib/devices/health-report.d.ts +10 -2
- package/dist/lib/devices/health-report.js +32 -1
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +16 -0
- package/dist/lib/exec.js +27 -4
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/git.d.ts +13 -0
- package/dist/lib/git.js +102 -4
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hosts/dispatch.d.ts +9 -0
- package/dist/lib/hosts/dispatch.js +24 -4
- package/dist/lib/hosts/passthrough.js +7 -2
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +33 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +4 -0
- package/dist/lib/hosts/run-target.js +5 -1
- package/dist/lib/hosts/session-index.js +3 -3
- 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/menubar/install-menubar.d.ts +9 -0
- package/dist/lib/menubar/install-menubar.js +14 -0
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/overdue.d.ts +4 -5
- package/dist/lib/overdue.js +8 -41
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +68 -0
- package/dist/lib/routines.js +74 -7
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +156 -28
- package/dist/lib/sandbox.js +0 -6
- 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 +11 -4
- package/dist/lib/secrets/agent.js +46 -24
- package/dist/lib/secrets/bundles.d.ts +21 -7
- package/dist/lib/secrets/bundles.js +82 -38
- package/dist/lib/secrets/index.d.ts +11 -2
- package/dist/lib/secrets/index.js +21 -9
- 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 +16 -2
- package/dist/lib/secrets/session-store.js +118 -15
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/session/active.d.ts +17 -0
- package/dist/lib/session/active.js +105 -7
- package/dist/lib/session/db.d.ts +43 -0
- package/dist/lib/session/db.js +191 -6
- package/dist/lib/session/digest.js +126 -21
- package/dist/lib/session/discover.d.ts +28 -3
- package/dist/lib/session/discover.js +110 -14
- package/dist/lib/session/origin-machine.d.ts +18 -0
- package/dist/lib/session/origin-machine.js +34 -0
- package/dist/lib/session/parse.js +11 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.js +18 -16
- package/dist/lib/session/state.d.ts +5 -0
- package/dist/lib/session/state.js +94 -12
- package/dist/lib/session/sync/config.js +2 -2
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/smart-launch.d.ts +86 -0
- package/dist/lib/smart-launch.js +172 -0
- package/dist/lib/state.d.ts +7 -3
- package/dist/lib/state.js +20 -5
- package/dist/lib/types.d.ts +10 -0
- package/package.json +1 -1
package/dist/lib/auto-pull.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
* Background sync for tracked git repos:
|
|
3
3
|
* - System repo (~/.agents/.system/) is read-only locally — fast-forward auto-pull is safe.
|
|
4
4
|
* - User repo (~/.agents/) and enabled extras may have local commits, so we only
|
|
5
|
-
* `git fetch` and write a status marker.
|
|
6
|
-
*
|
|
5
|
+
* `git fetch` and write a status marker. `agents doctor` surfaces these markers
|
|
6
|
+
* as a "Repo updates" section instead of printing to stderr on every command.
|
|
7
|
+
* Pulling is left to the user via `agents repo pull`.
|
|
7
8
|
*
|
|
8
9
|
* Public API:
|
|
9
|
-
* spawnDetachedSync()
|
|
10
|
-
*
|
|
10
|
+
* spawnDetachedSync() — fire-and-forget; never blocks the foreground command.
|
|
11
|
+
* readRepoBehindMarkers() — synchronous; reads markers without consuming them.
|
|
11
12
|
*/
|
|
12
13
|
import * as fs from 'fs';
|
|
13
14
|
import * as path from 'path';
|
|
@@ -57,21 +58,29 @@ export function spawnDetachedSync() {
|
|
|
57
58
|
}
|
|
58
59
|
}
|
|
59
60
|
/**
|
|
60
|
-
* Read
|
|
61
|
-
*
|
|
62
|
-
*
|
|
61
|
+
* Read all current status markers and return those where the local repo is
|
|
62
|
+
* behind upstream. Markers are NOT deleted — they persist until the next
|
|
63
|
+
* background fetch overwrites them with fresh data.
|
|
64
|
+
*
|
|
65
|
+
* Synchronous, cheap (small JSON files). Used by `agents doctor` to surface
|
|
66
|
+
* repo-behind warnings in one place instead of printing to stderr on every
|
|
67
|
+
* command.
|
|
68
|
+
*
|
|
69
|
+
* @param fetchDir - Override the fetch state dir (for tests). Defaults to the
|
|
70
|
+
* production getFetchCacheDir() path.
|
|
63
71
|
*/
|
|
64
|
-
export function
|
|
65
|
-
const dir = fetchStateDir();
|
|
72
|
+
export function readRepoBehindMarkers(fetchDir) {
|
|
73
|
+
const dir = fetchDir ?? fetchStateDir();
|
|
66
74
|
if (!fs.existsSync(dir))
|
|
67
|
-
return;
|
|
75
|
+
return [];
|
|
68
76
|
let entries = [];
|
|
69
77
|
try {
|
|
70
78
|
entries = fs.readdirSync(dir);
|
|
71
79
|
}
|
|
72
80
|
catch {
|
|
73
|
-
return;
|
|
81
|
+
return [];
|
|
74
82
|
}
|
|
83
|
+
const result = [];
|
|
75
84
|
for (const name of entries) {
|
|
76
85
|
if (!name.endsWith('.status.json'))
|
|
77
86
|
continue;
|
|
@@ -81,25 +90,11 @@ export function printPendingUpdateNotices() {
|
|
|
81
90
|
marker = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
82
91
|
}
|
|
83
92
|
catch {
|
|
84
|
-
try {
|
|
85
|
-
fs.unlinkSync(file);
|
|
86
|
-
}
|
|
87
|
-
catch { /* ignore */ }
|
|
88
93
|
continue;
|
|
89
94
|
}
|
|
90
|
-
if (!marker || marker.behind <= 0)
|
|
91
|
-
try {
|
|
92
|
-
fs.unlinkSync(file);
|
|
93
|
-
}
|
|
94
|
-
catch { /* ignore */ }
|
|
95
|
+
if (!marker || marker.behind <= 0)
|
|
95
96
|
continue;
|
|
96
|
-
|
|
97
|
-
const repoLabel = marker.alias === 'user' ? '~/.agents/' : marker.alias;
|
|
98
|
-
process.stderr.write(`agents-cli: ${repoLabel} is ${marker.behind} commit${marker.behind === 1 ? '' : 's'} ` +
|
|
99
|
-
`behind ${marker.branch} — run 'agents repo pull ${marker.alias}' to update.\n`);
|
|
100
|
-
try {
|
|
101
|
-
fs.unlinkSync(file);
|
|
102
|
-
}
|
|
103
|
-
catch { /* ignore */ }
|
|
97
|
+
result.push(marker);
|
|
104
98
|
}
|
|
99
|
+
return result;
|
|
105
100
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* nothing to resolve or resume by id.
|
|
18
18
|
*/
|
|
19
19
|
import { upsertSession } from '../session/db.js';
|
|
20
|
-
import {
|
|
20
|
+
import { isSessionTrackedAgent } from '../session/types.js';
|
|
21
21
|
import { deriveShortId } from '../session/short-id.js';
|
|
22
22
|
/** The execution-id charset the session index will accept as a row id. */
|
|
23
23
|
const EXECUTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
|
|
@@ -29,7 +29,7 @@ const EXECUTION_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,255}$/;
|
|
|
29
29
|
* failed index write must never break the dispatch/poll it rides on.
|
|
30
30
|
*/
|
|
31
31
|
export function registerCloudSession(task, ctx = {}) {
|
|
32
|
-
if (!
|
|
32
|
+
if (!task.agent || !isSessionTrackedAgent(task.agent))
|
|
33
33
|
return;
|
|
34
34
|
if (!task.id || !EXECUTION_ID_RE.test(task.id))
|
|
35
35
|
return;
|
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -69,18 +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
72
|
/**
|
|
85
73
|
* Write a launchd plist or systemd unit with owner-only permissions atomically.
|
|
86
74
|
*
|
|
@@ -94,21 +82,19 @@ export declare function writeOwnerOnlyServiceManifest(filePath: string, content:
|
|
|
94
82
|
/**
|
|
95
83
|
* Generate a macOS launchd plist for auto-starting the daemon.
|
|
96
84
|
*
|
|
97
|
-
* The plist never embeds
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
101
|
-
* 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.
|
|
102
89
|
*/
|
|
103
90
|
export declare function generateLaunchdPlist(agentsBin?: string): string;
|
|
104
91
|
/**
|
|
105
92
|
* Generate a Linux systemd user unit for auto-starting the daemon.
|
|
106
93
|
*
|
|
107
|
-
* The unit never embeds
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
* 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.
|
|
112
98
|
*/
|
|
113
99
|
export declare function generateSystemdUnit(agentsBin?: string): string;
|
|
114
100
|
export { getAgentsBinPath };
|
|
@@ -131,15 +117,6 @@ export declare function ensureDaemonStarted(): {
|
|
|
131
117
|
pid: number | null;
|
|
132
118
|
method: string;
|
|
133
119
|
} | null;
|
|
134
|
-
/**
|
|
135
|
-
* Environment for the detached daemon fallback. The launchd/systemd paths
|
|
136
|
-
* deliver the long-lived OAuth token via the service manifest's environment;
|
|
137
|
-
* the detached path has no manifest, so inject it here. Read happens during an
|
|
138
|
-
* interactive `routines start`, so a Keychain Touch ID prompt can be satisfied;
|
|
139
|
-
* the daemon then passes it to every routine run it spawns. An already-set
|
|
140
|
-
* value (e.g. inherited from launchd) is left untouched.
|
|
141
|
-
*/
|
|
142
|
-
export declare function buildDetachedDaemonEnv(baseEnv?: NodeJS.ProcessEnv, oauthToken?: string | null): NodeJS.ProcessEnv;
|
|
143
120
|
/**
|
|
144
121
|
* Resolve how to launch the daemon: `node <entry> __daemon-run`, matching the
|
|
145
122
|
* exact form that works under a direct `__daemon-run`.
|
|
@@ -188,7 +165,7 @@ interface StartDetachedOptions {
|
|
|
188
165
|
agentsBin?: string;
|
|
189
166
|
/** Log file the daemon's stdio is redirected to (defaults to the daemon log). */
|
|
190
167
|
logPath?: string;
|
|
191
|
-
/** Environment for the child (defaults to the
|
|
168
|
+
/** Environment for the child (defaults to the daemon's current process env). */
|
|
192
169
|
env?: NodeJS.ProcessEnv;
|
|
193
170
|
}
|
|
194
171
|
export declare function startDetached(opts?: StartDetachedOptions): {
|
package/dist/lib/daemon.js
CHANGED
|
@@ -13,13 +13,14 @@ import * as os from 'os';
|
|
|
13
13
|
import { getDaemonDir as getDaemonDirRoot } from './state.js';
|
|
14
14
|
import { isAlive, killTree, backgroundSpawnOptions } from './platform/index.js';
|
|
15
15
|
import { listJobs as listAllJobs } from './routines.js';
|
|
16
|
+
import { syncAllProjectRoutines } from './routines-project.js';
|
|
16
17
|
import { JobScheduler } from './scheduler.js';
|
|
17
18
|
import { MonitorEngine } from './monitors/engine.js';
|
|
18
19
|
import { executeJobDetached, monitorRunningJobs } from './runner.js';
|
|
19
|
-
import { detectOverdueJobs, notifyOverdue
|
|
20
|
+
import { detectOverdueJobs, notifyOverdue } from './overdue.js';
|
|
21
|
+
import { notifyRoutineStart, notifyRoutineFinish, notifyRoutineStartFailed } from './routine-notify.js';
|
|
20
22
|
import { BrowserService } from './browser/service.js';
|
|
21
23
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
22
|
-
import { readAndResolveBundleEnv } from './secrets/bundles.js';
|
|
23
24
|
import { redactSecrets } from './redact.js';
|
|
24
25
|
import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
|
|
25
26
|
const PID_FILE = 'daemon.pid';
|
|
@@ -32,12 +33,6 @@ const PLIST_NAME = 'com.phnx-labs.agents-daemon';
|
|
|
32
33
|
const SYSTEMD_UNIT = 'agents-daemon.service';
|
|
33
34
|
const MONITOR_TICK_MS = 60_000;
|
|
34
35
|
const WEDGE_THRESHOLD_TICKS = 3;
|
|
35
|
-
// A long-lived `claude setup-token` value stored in this secrets bundle/key is
|
|
36
|
-
// baked into the daemon's service-manager environment so headless routine runs
|
|
37
|
-
// authenticate without depending on the short-lived interactive Keychain OAuth
|
|
38
|
-
// session (which expires between runs and produces intermittent 401s).
|
|
39
|
-
const DAEMON_OAUTH_BUNDLE = 'claude';
|
|
40
|
-
const DAEMON_OAUTH_KEY = 'CLAUDE_CODE_OAUTH_TOKEN';
|
|
41
36
|
/**
|
|
42
37
|
* RUSH-1817: decide whether the daemon should (re)take over hosting the secrets
|
|
43
38
|
* broker. The startup host decision is one-shot; this drives the periodic
|
|
@@ -306,30 +301,13 @@ export async function runDaemon() {
|
|
|
306
301
|
process.exit(0);
|
|
307
302
|
}
|
|
308
303
|
log('INFO', `Daemon started (PID: ${process.pid})`);
|
|
309
|
-
//
|
|
310
|
-
//
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
// interactive OAuth session), matching the detached-start path. Never blocks.
|
|
317
|
-
if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
318
|
-
const oauthToken = readDaemonClaudeOAuthToken();
|
|
319
|
-
if (oauthToken) {
|
|
320
|
-
process.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
|
|
321
|
-
log('INFO', 'Loaded Claude OAuth token from secrets bundle for routine runs');
|
|
322
|
-
}
|
|
323
|
-
else {
|
|
324
|
-
// No token available (e.g. a headless macOS daemon whose keychain was
|
|
325
|
-
// locked at start resolves broker-only and gets nothing). Historically
|
|
326
|
-
// this was silent, so every Claude routine the daemon spawned failed auth
|
|
327
|
-
// with no signal. Make it loud: WARN in the log and fire a desktop alert.
|
|
328
|
-
log('WARN', 'No Claude OAuth token available — Claude routine runs will fail auth on this host. ' +
|
|
329
|
-
'Restart the daemon with the keychain unlocked, or unlock the `claude` secrets bundle.');
|
|
330
|
-
notifyDesktop('agents daemon: no Claude credential', 'Claude routines will fail auth on this host. Restart the daemon with the keychain unlocked.');
|
|
331
|
-
}
|
|
332
|
-
}
|
|
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.
|
|
333
311
|
// Reap any stray duplicate daemon of this install that slipped past the start
|
|
334
312
|
// lock or was orphaned by a hard-crash — before it can double-fire jobs.
|
|
335
313
|
try {
|
|
@@ -372,14 +350,50 @@ export async function runDaemon() {
|
|
|
372
350
|
? `workflow: ${config.workflow}`
|
|
373
351
|
: `agent: ${config.agent}`;
|
|
374
352
|
log('INFO', `Triggering job '${config.name}' (${jobLabel})`);
|
|
353
|
+
// RUSH-2030: branded desktop notification on start (agent/workflow routines;
|
|
354
|
+
// suppressed for command housekeeping). Finish/output is fired from the
|
|
355
|
+
// onFinish hook below — executeJobDetached finalizes the run in-process, so
|
|
356
|
+
// the monitor tick never sees the live transition. Never let a notification
|
|
357
|
+
// failure break the trigger.
|
|
375
358
|
try {
|
|
376
|
-
|
|
359
|
+
notifyRoutineStart(config);
|
|
360
|
+
}
|
|
361
|
+
catch { /* best-effort */ }
|
|
362
|
+
try {
|
|
363
|
+
const meta = await executeJobDetached(config, {
|
|
364
|
+
onFinish: (final) => {
|
|
365
|
+
try {
|
|
366
|
+
notifyRoutineFinish(final);
|
|
367
|
+
}
|
|
368
|
+
catch { /* best-effort */ }
|
|
369
|
+
},
|
|
370
|
+
});
|
|
377
371
|
log('INFO', `Job '${config.name}' spawned (run: ${meta.runId}, PID: ${meta.pid})`);
|
|
378
372
|
}
|
|
379
373
|
catch (err) {
|
|
380
|
-
|
|
374
|
+
const message = err.message;
|
|
375
|
+
log('ERROR', `Job '${config.name}' failed to spawn: ${message}`);
|
|
376
|
+
// RUSH-2030: the START ping already fired unconditionally above. A pre-spawn
|
|
377
|
+
// failure produces no run record and thus no onFinish, so send a synthetic
|
|
378
|
+
// "failed to start" finish here — otherwise the user is left with an orphaned
|
|
379
|
+
// "Routine started" and never told it failed.
|
|
380
|
+
try {
|
|
381
|
+
notifyRoutineStartFailed(config, message);
|
|
382
|
+
}
|
|
383
|
+
catch { /* best-effort */ }
|
|
381
384
|
}
|
|
382
385
|
});
|
|
386
|
+
// Materialise opted-in project routines into the user layer on every start
|
|
387
|
+
// so a fresh daemon picks up project YAML without a separate sync step.
|
|
388
|
+
try {
|
|
389
|
+
const result = syncAllProjectRoutines();
|
|
390
|
+
const n = result.projects.reduce((acc, p) => acc + p.synced.length, 0);
|
|
391
|
+
if (n > 0)
|
|
392
|
+
log('INFO', `Project routines sync: ${n} job(s) from ${result.projects.length} project(s)`);
|
|
393
|
+
}
|
|
394
|
+
catch (err) {
|
|
395
|
+
log('WARN', `Project routines sync failed: ${err.message}`);
|
|
396
|
+
}
|
|
383
397
|
scheduler.loadAll();
|
|
384
398
|
const scheduled = scheduler.listScheduled();
|
|
385
399
|
log('INFO', `Loaded ${scheduled.length} jobs`);
|
|
@@ -721,6 +735,19 @@ export async function runDaemon() {
|
|
|
721
735
|
const brokerSelfHealInterval = setInterval(() => { void runBrokerSelfHeal(); }, 60_000);
|
|
722
736
|
const handleReload = () => {
|
|
723
737
|
log('INFO', 'Reloading jobs (SIGHUP)');
|
|
738
|
+
// Refresh user-layer copies of opted-in project routines BEFORE the
|
|
739
|
+
// scheduler reloads, so YAML edits under `<project>/.agents/routines/`
|
|
740
|
+
// take effect on the next fire without a manual `routines sync`.
|
|
741
|
+
try {
|
|
742
|
+
const result = syncAllProjectRoutines();
|
|
743
|
+
const n = result.projects.reduce((acc, p) => acc + p.synced.length, 0);
|
|
744
|
+
if (n > 0 || result.missing.length > 0) {
|
|
745
|
+
log('INFO', `Project routines sync: ${n} updated, ${result.missing.length} missing roots`);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
catch (err) {
|
|
749
|
+
log('WARN', `Project routines sync failed: ${err.message}`);
|
|
750
|
+
}
|
|
724
751
|
scheduler.reloadAll();
|
|
725
752
|
const reloaded = scheduler.listScheduled();
|
|
726
753
|
log('INFO', `Reloaded ${reloaded.length} jobs`);
|
|
@@ -764,29 +791,6 @@ export async function runDaemon() {
|
|
|
764
791
|
process.on('SIGINT', () => handleShutdown());
|
|
765
792
|
await new Promise(() => { });
|
|
766
793
|
}
|
|
767
|
-
/**
|
|
768
|
-
* Read the long-lived Claude OAuth token (from `claude setup-token`) that the
|
|
769
|
-
* user stored under the `claude` secrets bundle. Resolves the bundle the same
|
|
770
|
-
* way `agents run --secrets` does. Interactive starts may prompt Keychain;
|
|
771
|
-
* headless auto-starts are broker-only and return null unless the user already
|
|
772
|
-
* unlocked the bundle in the secrets agent. That keeps a background browser
|
|
773
|
-
* command from hanging on an unseen biometric prompt. Never throws: an absent
|
|
774
|
-
* token leaves the daemon on its existing interactive OAuth session.
|
|
775
|
-
*/
|
|
776
|
-
export function readDaemonClaudeOAuthToken(opts = {}) {
|
|
777
|
-
try {
|
|
778
|
-
const allowPrompt = opts.allowPrompt ?? Boolean(process.stdin.isTTY);
|
|
779
|
-
const { env } = readAndResolveBundleEnv(DAEMON_OAUTH_BUNDLE, {
|
|
780
|
-
caller: 'daemon',
|
|
781
|
-
agentOnly: !allowPrompt,
|
|
782
|
-
});
|
|
783
|
-
const token = (env[DAEMON_OAUTH_KEY] ?? '').trim();
|
|
784
|
-
return token.length > 0 ? token : null;
|
|
785
|
-
}
|
|
786
|
-
catch {
|
|
787
|
-
return null;
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
794
|
/** Escape a string for safe inclusion in an XML <string> node. */
|
|
791
795
|
function xmlEscape(s) {
|
|
792
796
|
return s
|
|
@@ -814,11 +818,10 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
|
|
|
814
818
|
/**
|
|
815
819
|
* Generate a macOS launchd plist for auto-starting the daemon.
|
|
816
820
|
*
|
|
817
|
-
* The plist never embeds
|
|
818
|
-
*
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
* 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.
|
|
822
825
|
*/
|
|
823
826
|
export function generateLaunchdPlist(agentsBin = getAgentsBinPath()) {
|
|
824
827
|
const launch = getDaemonLaunch(agentsBin);
|
|
@@ -856,11 +859,10 @@ function systemdExecArg(value) {
|
|
|
856
859
|
/**
|
|
857
860
|
* Generate a Linux systemd user unit for auto-starting the daemon.
|
|
858
861
|
*
|
|
859
|
-
* The unit never embeds
|
|
860
|
-
*
|
|
861
|
-
*
|
|
862
|
-
*
|
|
863
|
-
* 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.
|
|
864
866
|
*/
|
|
865
867
|
export function generateSystemdUnit(agentsBin = getAgentsBinPath()) {
|
|
866
868
|
const launch = getDaemonLaunch(agentsBin);
|
|
@@ -1004,22 +1006,6 @@ function startDaemonLocked(agentsBin) {
|
|
|
1004
1006
|
}
|
|
1005
1007
|
return startDetached({ agentsBin });
|
|
1006
1008
|
}
|
|
1007
|
-
/**
|
|
1008
|
-
* Environment for the detached daemon fallback. The launchd/systemd paths
|
|
1009
|
-
* deliver the long-lived OAuth token via the service manifest's environment;
|
|
1010
|
-
* the detached path has no manifest, so inject it here. Read happens during an
|
|
1011
|
-
* interactive `routines start`, so a Keychain Touch ID prompt can be satisfied;
|
|
1012
|
-
* the daemon then passes it to every routine run it spawns. An already-set
|
|
1013
|
-
* value (e.g. inherited from launchd) is left untouched.
|
|
1014
|
-
*/
|
|
1015
|
-
export function buildDetachedDaemonEnv(baseEnv = process.env, oauthToken = readDaemonClaudeOAuthToken()) {
|
|
1016
|
-
const env = { ...baseEnv };
|
|
1017
|
-
if (!env.CLAUDE_CODE_OAUTH_TOKEN) {
|
|
1018
|
-
if (oauthToken)
|
|
1019
|
-
env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
|
|
1020
|
-
}
|
|
1021
|
-
return env;
|
|
1022
|
-
}
|
|
1023
1009
|
/**
|
|
1024
1010
|
* Resolve how to launch the daemon: `node <entry> __daemon-run`, matching the
|
|
1025
1011
|
* exact form that works under a direct `__daemon-run`.
|
|
@@ -1099,7 +1085,7 @@ export function startDetached(opts = {}) {
|
|
|
1099
1085
|
const child = spawn(command, args, {
|
|
1100
1086
|
stdio: ['ignore', logFd, logFd],
|
|
1101
1087
|
...backgroundSpawnOptions({ fdStdio: true }),
|
|
1102
|
-
env: opts.env ??
|
|
1088
|
+
env: opts.env ?? process.env,
|
|
1103
1089
|
});
|
|
1104
1090
|
// A failed spawn (ENOENT/EACCES) emits 'error' asynchronously; without a
|
|
1105
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;
|