@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.
Files changed (119) hide show
  1. package/CHANGELOG.md +264 -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 +87 -19
  7. package/dist/commands/repo.js +8 -0
  8. package/dist/commands/routines.js +275 -15
  9. package/dist/commands/secrets.js +65 -17
  10. package/dist/commands/sessions-browser.d.ts +45 -3
  11. package/dist/commands/sessions-browser.js +128 -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 +28 -1
  15. package/dist/commands/sessions-picker.js +222 -15
  16. package/dist/commands/sessions.d.ts +117 -4
  17. package/dist/commands/sessions.js +350 -62
  18. package/dist/commands/ssh.d.ts +13 -0
  19. package/dist/commands/ssh.js +48 -3
  20. package/dist/index.js +5 -6
  21. package/dist/lib/activity.d.ts +5 -4
  22. package/dist/lib/activity.js +450 -36
  23. package/dist/lib/agents.d.ts +0 -21
  24. package/dist/lib/agents.js +0 -37
  25. package/dist/lib/auto-pull.d.ts +16 -8
  26. package/dist/lib/auto-pull.js +23 -28
  27. package/dist/lib/cloud/session-index.js +2 -2
  28. package/dist/lib/daemon.d.ts +9 -32
  29. package/dist/lib/daemon.js +70 -84
  30. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  31. package/dist/lib/devices/fleet-divergence.js +188 -0
  32. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  33. package/dist/lib/devices/fleet-inventory.js +57 -0
  34. package/dist/lib/devices/fleet.d.ts +16 -1
  35. package/dist/lib/devices/fleet.js +10 -2
  36. package/dist/lib/devices/health-report.d.ts +10 -2
  37. package/dist/lib/devices/health-report.js +32 -1
  38. package/dist/lib/events.d.ts +1 -1
  39. package/dist/lib/exec.d.ts +16 -0
  40. package/dist/lib/exec.js +27 -4
  41. package/dist/lib/feed.d.ts +7 -1
  42. package/dist/lib/feed.js +96 -6
  43. package/dist/lib/git.d.ts +13 -0
  44. package/dist/lib/git.js +102 -4
  45. package/dist/lib/heal.d.ts +7 -4
  46. package/dist/lib/heal.js +10 -22
  47. package/dist/lib/hosts/dispatch.d.ts +9 -0
  48. package/dist/lib/hosts/dispatch.js +24 -4
  49. package/dist/lib/hosts/passthrough.js +7 -2
  50. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  51. package/dist/lib/hosts/remote-cmd.js +33 -8
  52. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  53. package/dist/lib/hosts/remote-session-id.js +84 -0
  54. package/dist/lib/hosts/run-target.d.ts +4 -0
  55. package/dist/lib/hosts/run-target.js +5 -1
  56. package/dist/lib/hosts/session-index.js +3 -3
  57. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  58. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  59. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  60. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  61. package/dist/lib/menubar/install-menubar.d.ts +9 -0
  62. package/dist/lib/menubar/install-menubar.js +14 -0
  63. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  64. package/dist/lib/menubar/notify-desktop.js +78 -0
  65. package/dist/lib/overdue.d.ts +4 -5
  66. package/dist/lib/overdue.js +8 -41
  67. package/dist/lib/routine-notify.d.ts +76 -0
  68. package/dist/lib/routine-notify.js +190 -0
  69. package/dist/lib/routines-placement.d.ts +38 -0
  70. package/dist/lib/routines-placement.js +79 -0
  71. package/dist/lib/routines-project.d.ts +97 -0
  72. package/dist/lib/routines-project.js +349 -0
  73. package/dist/lib/routines.d.ts +68 -0
  74. package/dist/lib/routines.js +74 -7
  75. package/dist/lib/runner.d.ts +12 -2
  76. package/dist/lib/runner.js +156 -28
  77. package/dist/lib/sandbox.js +0 -6
  78. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  79. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  80. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  81. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  82. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  83. package/dist/lib/secrets/agent.d.ts +11 -4
  84. package/dist/lib/secrets/agent.js +46 -24
  85. package/dist/lib/secrets/bundles.d.ts +21 -7
  86. package/dist/lib/secrets/bundles.js +82 -38
  87. package/dist/lib/secrets/index.d.ts +11 -2
  88. package/dist/lib/secrets/index.js +21 -9
  89. package/dist/lib/secrets/scope.d.ts +26 -0
  90. package/dist/lib/secrets/scope.js +29 -0
  91. package/dist/lib/secrets/session-store.d.ts +16 -2
  92. package/dist/lib/secrets/session-store.js +118 -15
  93. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  94. package/dist/lib/secrets/unlock-hints.js +36 -0
  95. package/dist/lib/session/active.d.ts +17 -0
  96. package/dist/lib/session/active.js +105 -7
  97. package/dist/lib/session/db.d.ts +43 -0
  98. package/dist/lib/session/db.js +191 -6
  99. package/dist/lib/session/digest.js +126 -21
  100. package/dist/lib/session/discover.d.ts +28 -3
  101. package/dist/lib/session/discover.js +110 -14
  102. package/dist/lib/session/origin-machine.d.ts +18 -0
  103. package/dist/lib/session/origin-machine.js +34 -0
  104. package/dist/lib/session/parse.js +11 -2
  105. package/dist/lib/session/prompt.d.ts +7 -0
  106. package/dist/lib/session/prompt.js +37 -0
  107. package/dist/lib/session/provenance.d.ts +7 -0
  108. package/dist/lib/session/render.js +18 -16
  109. package/dist/lib/session/state.d.ts +5 -0
  110. package/dist/lib/session/state.js +94 -12
  111. package/dist/lib/session/sync/config.js +2 -2
  112. package/dist/lib/session/types.d.ts +25 -1
  113. package/dist/lib/session/types.js +8 -0
  114. package/dist/lib/smart-launch.d.ts +86 -0
  115. package/dist/lib/smart-launch.js +172 -0
  116. package/dist/lib/state.d.ts +7 -3
  117. package/dist/lib/state.js +20 -5
  118. package/dist/lib/types.d.ts +10 -0
  119. package/package.json +1 -1
@@ -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. Next CLI invocation surfaces a one-line
6
- * notice if upstream is ahead. Pulling is left to the user via `agents repo pull`.
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() — fire-and-forget; never blocks the foreground command.
10
- * printPendingUpdateNotices() — synchronous; reads markers and prints + consumes them.
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 any pending status markers and print one-line notices for repos that
61
- * are behind upstream. Markers are deleted after printing so notices don't
62
- * repeat on every invocation. Synchronous, cheap (small JSON files).
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 printPendingUpdateNotices() {
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 { SESSION_AGENTS } from '../session/types.js';
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 (!SESSION_AGENTS.includes(task.agent))
32
+ if (!task.agent || !isSessionTrackedAgent(task.agent))
33
33
  return;
34
34
  if (!task.id || !EXECUTION_ID_RE.test(task.id))
35
35
  return;
@@ -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 the Claude OAuth token (RUSH-1759): a persisted service
98
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
99
- * obtains the token at startup from the `claude` secrets bundle
100
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the
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 the Claude OAuth token (RUSH-1759): a persisted service
108
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
109
- * obtains the token at startup from the `claude` secrets bundle
110
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the secure
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 OAuth-augmented detached env). */
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): {
@@ -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, notifyDesktop } from './overdue.js';
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
- // RUSH-1759: the launchd plist / systemd unit no longer bake the Claude OAuth
310
- // token onto disk. Obtain it here from the secure `claude` secrets bundle and
311
- // inject into this process's env so every routine run this daemon spawns still
312
- // receives it (via the sandbox allowlist), without the token ever being
313
- // persisted in the service manifest. A read from a file-backed store (Linux)
314
- // needs no prompt; on macOS it resolves broker-only from an unlocked
315
- // secrets-agent and is otherwise absent (leaving the daemon on its existing
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
- const meta = await executeJobDetached(config);
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
- log('ERROR', `Job '${config.name}' failed to spawn: ${err.message}`);
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 the Claude OAuth token (RUSH-1759): a persisted service
818
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
819
- * obtains the token at startup from the `claude` secrets bundle
820
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the
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 the Claude OAuth token (RUSH-1759): a persisted service
860
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
861
- * obtains the token at startup from the `claude` secrets bundle
862
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the secure
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 ?? buildDetachedDaemonEnv(),
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;