@phnx-labs/agents-cli 1.20.77 → 1.20.82

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 (212) hide show
  1. package/CHANGELOG.md +589 -0
  2. package/README.md +27 -25
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/activity.d.ts +9 -0
  5. package/dist/commands/activity.js +153 -24
  6. package/dist/commands/apply.js +11 -4
  7. package/dist/commands/cli.js +1 -1
  8. package/dist/commands/commands.d.ts +1 -1
  9. package/dist/commands/commands.js +7 -6
  10. package/dist/commands/defaults.js +3 -11
  11. package/dist/commands/doctor.d.ts +74 -0
  12. package/dist/commands/doctor.js +718 -40
  13. package/dist/commands/events.js +1 -1
  14. package/dist/commands/exec.js +53 -8
  15. package/dist/commands/feed.d.ts +4 -0
  16. package/dist/commands/feed.js +87 -11
  17. package/dist/commands/harness.js +2 -2
  18. package/dist/commands/hooks.js +1 -1
  19. package/dist/commands/import.js +8 -4
  20. package/dist/commands/logs.js +6 -0
  21. package/dist/commands/models.js +1 -1
  22. package/dist/commands/packages.js +6 -6
  23. package/dist/commands/permissions.js +1 -1
  24. package/dist/commands/profiles.js +1 -1
  25. package/dist/commands/pull.d.ts +1 -1
  26. package/dist/commands/pull.js +4 -4
  27. package/dist/commands/repo.d.ts +78 -0
  28. package/dist/commands/repo.js +195 -11
  29. package/dist/commands/routines.js +16 -4
  30. package/dist/commands/rules.js +13 -12
  31. package/dist/commands/secrets.d.ts +3 -1
  32. package/dist/commands/secrets.js +108 -34
  33. package/dist/commands/sessions-browser.d.ts +45 -3
  34. package/dist/commands/sessions-browser.js +118 -12
  35. package/dist/commands/sessions-export.d.ts +3 -0
  36. package/dist/commands/sessions-export.js +13 -10
  37. package/dist/commands/sessions-inject.d.ts +7 -4
  38. package/dist/commands/sessions-inject.js +18 -18
  39. package/dist/commands/sessions-picker.d.ts +15 -1
  40. package/dist/commands/sessions-picker.js +90 -7
  41. package/dist/commands/sessions-resume.d.ts +16 -0
  42. package/dist/commands/sessions-resume.js +14 -13
  43. package/dist/commands/sessions.d.ts +126 -1
  44. package/dist/commands/sessions.js +365 -42
  45. package/dist/commands/set.d.ts +15 -0
  46. package/dist/commands/set.js +79 -0
  47. package/dist/commands/setup-fleet.d.ts +21 -0
  48. package/dist/commands/setup-fleet.js +201 -0
  49. package/dist/commands/setup-secrets.d.ts +20 -0
  50. package/dist/commands/setup-secrets.js +223 -0
  51. package/dist/commands/setup.js +17 -3
  52. package/dist/commands/skills.js +1 -1
  53. package/dist/commands/ssh.js +9 -1
  54. package/dist/commands/sync.js +7 -2
  55. package/dist/commands/teams-picker.js +0 -1
  56. package/dist/commands/teams.d.ts +33 -1
  57. package/dist/commands/teams.js +208 -44
  58. package/dist/commands/versions.js +10 -5
  59. package/dist/commands/view.d.ts +1 -0
  60. package/dist/commands/view.js +11 -0
  61. package/dist/index.js +8 -13
  62. package/dist/lib/acp/harnesses.js +0 -7
  63. package/dist/lib/activity.d.ts +156 -0
  64. package/dist/lib/activity.js +282 -0
  65. package/dist/lib/agents.d.ts +9 -23
  66. package/dist/lib/agents.js +72 -100
  67. package/dist/lib/auto-pull.d.ts +16 -8
  68. package/dist/lib/auto-pull.js +23 -28
  69. package/dist/lib/browser/service.js +3 -0
  70. package/dist/lib/browser/types.d.ts +7 -0
  71. package/dist/lib/capabilities.js +6 -2
  72. package/dist/lib/crabbox/lease.js +2 -2
  73. package/dist/lib/crabbox/setup-copy.d.ts +4 -4
  74. package/dist/lib/crabbox/setup-copy.js +4 -4
  75. package/dist/lib/daemon.d.ts +26 -40
  76. package/dist/lib/daemon.js +63 -123
  77. package/dist/lib/devices/fleet-divergence.d.ts +101 -0
  78. package/dist/lib/devices/fleet-divergence.js +188 -0
  79. package/dist/lib/devices/fleet-inventory.d.ts +19 -0
  80. package/dist/lib/devices/fleet-inventory.js +57 -0
  81. package/dist/lib/devices/fleet.d.ts +1 -1
  82. package/dist/lib/devices/fleet.js +1 -1
  83. package/dist/lib/devices/health-report.d.ts +10 -2
  84. package/dist/lib/devices/health-report.js +32 -1
  85. package/dist/lib/doctor-diff.d.ts +20 -0
  86. package/dist/lib/doctor-diff.js +6 -1
  87. package/dist/lib/drift.d.ts +31 -11
  88. package/dist/lib/drift.js +58 -7
  89. package/dist/lib/events.d.ts +8 -1
  90. package/dist/lib/events.js +10 -1
  91. package/dist/lib/exec.js +35 -8
  92. package/dist/lib/feed-post.d.ts +28 -1
  93. package/dist/lib/feed-post.js +110 -2
  94. package/dist/lib/fleet/apply.js +8 -1
  95. package/dist/lib/fleet/auth-sync.d.ts +18 -1
  96. package/dist/lib/fleet/auth-sync.js +25 -11
  97. package/dist/lib/fleet/remote-login.js +5 -0
  98. package/dist/lib/git.d.ts +27 -14
  99. package/dist/lib/git.js +138 -43
  100. package/dist/lib/hooks.d.ts +42 -0
  101. package/dist/lib/hooks.js +137 -93
  102. package/dist/lib/hosts/dispatch.d.ts +28 -1
  103. package/dist/lib/hosts/dispatch.js +33 -5
  104. package/dist/lib/hosts/option.js +2 -2
  105. package/dist/lib/hosts/passthrough.d.ts +22 -1
  106. package/dist/lib/hosts/passthrough.js +238 -13
  107. package/dist/lib/hosts/remote-cmd.js +2 -0
  108. package/dist/lib/hosts/run-target.d.ts +2 -0
  109. package/dist/lib/hosts/run-target.js +1 -0
  110. package/dist/lib/hq/floor.d.ts +1 -1
  111. package/dist/lib/hq/floor.js +6 -1
  112. package/dist/lib/mcp.js +3 -71
  113. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +2 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  116. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
  117. package/dist/lib/menubar/notify-desktop.d.ts +15 -0
  118. package/dist/lib/menubar/notify-desktop.js +41 -5
  119. package/dist/lib/merged-resources.d.ts +11 -0
  120. package/dist/{commands/resources.js → lib/merged-resources.js} +11 -19
  121. package/dist/lib/permissions.d.ts +0 -32
  122. package/dist/lib/permissions.js +4 -157
  123. package/dist/lib/picker.js +1 -1
  124. package/dist/lib/platform/process.d.ts +16 -5
  125. package/dist/lib/platform/process.js +54 -0
  126. package/dist/lib/plugins.d.ts +0 -8
  127. package/dist/lib/plugins.js +4 -110
  128. package/dist/lib/project-resources.js +4 -1
  129. package/dist/lib/refresh.d.ts +5 -3
  130. package/dist/lib/refresh.js +7 -5
  131. package/dist/lib/resources/commands.js +4 -1
  132. package/dist/lib/resources/mcp.js +0 -4
  133. package/dist/lib/resources/permissions.d.ts +1 -1
  134. package/dist/lib/resources/permissions.js +1 -5
  135. package/dist/lib/resources/rules.js +4 -1
  136. package/dist/lib/resources/skills.js +4 -1
  137. package/dist/lib/resources/subagents.js +4 -0
  138. package/dist/lib/resources/types.d.ts +1 -1
  139. package/dist/lib/routines.d.ts +20 -0
  140. package/dist/lib/routines.js +6 -0
  141. package/dist/lib/run-defaults.d.ts +1 -0
  142. package/dist/lib/run-defaults.js +9 -0
  143. package/dist/lib/runner.d.ts +0 -12
  144. package/dist/lib/runner.js +71 -29
  145. package/dist/lib/sandbox.js +0 -16
  146. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  147. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  149. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  151. package/dist/lib/secrets/agent.d.ts +18 -0
  152. package/dist/lib/secrets/agent.js +131 -17
  153. package/dist/lib/secrets/audit.d.ts +46 -0
  154. package/dist/lib/secrets/audit.js +56 -0
  155. package/dist/lib/secrets/bundles.d.ts +12 -9
  156. package/dist/lib/secrets/bundles.js +47 -31
  157. package/dist/lib/secrets/index.d.ts +2 -1
  158. package/dist/lib/secrets/index.js +8 -2
  159. package/dist/lib/secrets/remote.d.ts +75 -2
  160. package/dist/lib/secrets/remote.js +139 -5
  161. package/dist/lib/secrets/scope.d.ts +26 -0
  162. package/dist/lib/secrets/scope.js +29 -0
  163. package/dist/lib/secrets/session-store.d.ts +10 -0
  164. package/dist/lib/secrets/session-store.js +64 -11
  165. package/dist/lib/secrets/sync.js +3 -3
  166. package/dist/lib/session/active.d.ts +117 -26
  167. package/dist/lib/session/active.js +199 -53
  168. package/dist/lib/session/actor-sidecar.d.ts +23 -0
  169. package/dist/lib/session/actor-sidecar.js +101 -0
  170. package/dist/lib/session/db.d.ts +41 -1
  171. package/dist/lib/session/db.js +173 -8
  172. package/dist/lib/session/digest.js +126 -21
  173. package/dist/lib/session/discover.d.ts +19 -2
  174. package/dist/lib/session/discover.js +32 -11
  175. package/dist/lib/session/hook-sessions.d.ts +9 -0
  176. package/dist/lib/session/hook-sessions.js +57 -8
  177. package/dist/lib/session/origin-machine.d.ts +18 -0
  178. package/dist/lib/session/origin-machine.js +34 -0
  179. package/dist/lib/session/pid-registry.d.ts +13 -0
  180. package/dist/lib/session/render.d.ts +2 -0
  181. package/dist/lib/session/render.js +1 -1
  182. package/dist/lib/session/state.d.ts +1 -0
  183. package/dist/lib/session/state.js +1 -1
  184. package/dist/lib/session/sync/config.js +2 -2
  185. package/dist/lib/session/types.d.ts +16 -0
  186. package/dist/lib/smart-launch.d.ts +86 -0
  187. package/dist/lib/smart-launch.js +172 -0
  188. package/dist/lib/staleness/detectors/commands.js +2 -2
  189. package/dist/lib/staleness/detectors/permissions.js +1 -45
  190. package/dist/lib/staleness/writers/commands.js +4 -5
  191. package/dist/lib/staleness/writers/hooks.js +1 -1
  192. package/dist/lib/startup/command-registry.d.ts +1 -2
  193. package/dist/lib/startup/command-registry.js +2 -4
  194. package/dist/lib/subagents-registry.js +5 -12
  195. package/dist/lib/subagents.d.ts +0 -10
  196. package/dist/lib/subagents.js +0 -12
  197. package/dist/lib/teams/agents.d.ts +0 -2
  198. package/dist/lib/teams/agents.js +10 -27
  199. package/dist/lib/teams/index.d.ts +1 -1
  200. package/dist/lib/teams/index.js +1 -1
  201. package/dist/lib/types.d.ts +17 -9
  202. package/dist/lib/usage.d.ts +28 -5
  203. package/dist/lib/usage.js +271 -8
  204. package/dist/lib/versions.js +7 -1
  205. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  206. package/dist/lib/watchdog/watchdog.js +25 -13
  207. package/package.json +1 -2
  208. package/dist/commands/check.d.ts +0 -15
  209. package/dist/commands/check.js +0 -180
  210. package/dist/commands/resources.d.ts +0 -5
  211. package/dist/lib/secrets/account-token.d.ts +0 -20
  212. package/dist/lib/secrets/account-token.js +0 -64
@@ -68,28 +68,25 @@ export declare function reapStrayDaemons(keepPid?: number): {
68
68
  /** Append a JSONL log entry to the daemon log file (owner-only permissions). */
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
- 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
71
  /**
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.).
72
+ * Anchor the daemon's working directory to a stable, always-present path.
73
+ *
74
+ * The daemon is long-lived and inherits whatever cwd it was launched from — often
75
+ * a git worktree (e.g. a `.agents/worktrees/<slug>/` a session happened to be in).
76
+ * When that directory is later removed (`git worktree remove`, `rm -rf`), the
77
+ * daemon keeps the deleted inode as its cwd — a process cannot chdir out of a
78
+ * deleted directory on its own — and every job it spawns inherits the dead cwd
79
+ * (`spawnJobAttempt` and command runs pass no explicit `cwd`, so the child uses
80
+ * the parent's). Bun then fails `getcwd()` during startup and every routine crashes
81
+ * at 0 seconds with `ENOENT: Bun could not find a file` before the agent even runs.
82
+ *
83
+ * Re-anchoring to the home directory once, at daemon startup, makes the daemon
84
+ * immune regardless of how it was launched (systemd unit, launchd, or a manual
85
+ * `agents __daemon-run` from any directory). Returns the resolved cwd, or null if
86
+ * anchoring failed (logged, non-fatal).
89
87
  */
90
- export declare function readDaemonClaudeBundleEnv(opts?: {
91
- allowPrompt?: boolean;
92
- }): Record<string, string>;
88
+ export declare function anchorDaemonCwd(): string | null;
89
+ export declare function runDaemon(): Promise<void>;
93
90
  /**
94
91
  * Write a launchd plist or systemd unit with owner-only permissions atomically.
95
92
  *
@@ -103,21 +100,19 @@ export declare function writeOwnerOnlyServiceManifest(filePath: string, content:
103
100
  /**
104
101
  * Generate a macOS launchd plist for auto-starting the daemon.
105
102
  *
106
- * The plist never embeds the Claude OAuth token (RUSH-1759): a persisted service
107
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
108
- * obtains the token at startup from the `claude` secrets bundle
109
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the
110
- * Keychain-backed secure store and never touches the unit file.
103
+ * The plist never embeds a Claude OAuth token: the daemon holds no Claude
104
+ * credential at all. Routine runs authenticate through the per-account
105
+ * CLAUDE_CONFIG_DIR login on this device, exactly like an interactive
106
+ * `agents run`, so no credential ever touches the service manifest.
111
107
  */
112
108
  export declare function generateLaunchdPlist(agentsBin?: string): string;
113
109
  /**
114
110
  * Generate a Linux systemd user unit for auto-starting the daemon.
115
111
  *
116
- * The unit never embeds the Claude OAuth token (RUSH-1759): a persisted service
117
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
118
- * obtains the token at startup from the `claude` secrets bundle
119
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the secure
120
- * store and never touches the unit file.
112
+ * The unit never embeds a Claude OAuth token: the daemon holds no Claude
113
+ * credential at all. Routine runs authenticate through the per-account
114
+ * CLAUDE_CONFIG_DIR login on this device, exactly like an interactive
115
+ * `agents run`, so no credential ever touches the unit file.
121
116
  */
122
117
  export declare function generateSystemdUnit(agentsBin?: string): string;
123
118
  export { getAgentsBinPath };
@@ -140,15 +135,6 @@ export declare function ensureDaemonStarted(): {
140
135
  pid: number | null;
141
136
  method: string;
142
137
  } | 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
138
  /**
153
139
  * Resolve how to launch the daemon: `node <entry> __daemon-run`, matching the
154
140
  * exact form that works under a direct `__daemon-run`.
@@ -197,7 +183,7 @@ interface StartDetachedOptions {
197
183
  agentsBin?: string;
198
184
  /** Log file the daemon's stdio is redirected to (defaults to the daemon log). */
199
185
  logPath?: string;
200
- /** Environment for the child (defaults to the OAuth-augmented detached env). */
186
+ /** Environment for the child (defaults to the daemon's current process env). */
201
187
  env?: NodeJS.ProcessEnv;
202
188
  }
203
189
  export declare function startDetached(opts?: StartDetachedOptions): {
@@ -11,18 +11,16 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as os from 'os';
13
13
  import { getDaemonDir as getDaemonDirRoot } from './state.js';
14
- import { isAlive, killTree, backgroundSpawnOptions } from './platform/index.js';
14
+ import { isAlive, killTree, backgroundSpawnOptions, waitForExit } from './platform/index.js';
15
15
  import { listJobs as listAllJobs } from './routines.js';
16
16
  import { syncAllProjectRoutines } from './routines-project.js';
17
17
  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
@@ -179,6 +171,10 @@ export function isDaemonWedged() {
179
171
  const elapsed = Date.now() - Date.parse(hb.lastTick);
180
172
  return elapsed > WEDGE_THRESHOLD_TICKS * MONITOR_TICK_MS;
181
173
  }
174
+ /** How long stopDaemon waits for a SIGTERMed daemon to exit before escalating. */
175
+ const STOP_GRACE_MS = 5000;
176
+ /** How long it waits after the hard tree-kill before giving up. */
177
+ const STOP_KILL_GRACE_MS = 2000;
182
178
  /** Check if the daemon process is alive by sending signal 0 to the stored PID. */
183
179
  export function isDaemonRunning() {
184
180
  const pid = readDaemonPid();
@@ -297,6 +293,34 @@ export function log(level, message) {
297
293
  catch { /* best effort */ }
298
294
  }
299
295
  /** Main daemon loop: load jobs, schedule crons, monitor runs, and handle signals. */
296
+ /**
297
+ * Anchor the daemon's working directory to a stable, always-present path.
298
+ *
299
+ * The daemon is long-lived and inherits whatever cwd it was launched from — often
300
+ * a git worktree (e.g. a `.agents/worktrees/<slug>/` a session happened to be in).
301
+ * When that directory is later removed (`git worktree remove`, `rm -rf`), the
302
+ * daemon keeps the deleted inode as its cwd — a process cannot chdir out of a
303
+ * deleted directory on its own — and every job it spawns inherits the dead cwd
304
+ * (`spawnJobAttempt` and command runs pass no explicit `cwd`, so the child uses
305
+ * the parent's). Bun then fails `getcwd()` during startup and every routine crashes
306
+ * at 0 seconds with `ENOENT: Bun could not find a file` before the agent even runs.
307
+ *
308
+ * Re-anchoring to the home directory once, at daemon startup, makes the daemon
309
+ * immune regardless of how it was launched (systemd unit, launchd, or a manual
310
+ * `agents __daemon-run` from any directory). Returns the resolved cwd, or null if
311
+ * anchoring failed (logged, non-fatal).
312
+ */
313
+ export function anchorDaemonCwd() {
314
+ const home = os.homedir();
315
+ try {
316
+ process.chdir(home);
317
+ return home;
318
+ }
319
+ catch (err) {
320
+ log('WARN', `Could not anchor daemon cwd to ${home}: ${err.message}`);
321
+ return null;
322
+ }
323
+ }
300
324
  export async function runDaemon() {
301
325
  // Single-instance guard: a direct `agents __daemon-run` (manual, or a
302
326
  // service-manager restart racing a live predecessor) must not clobber a
@@ -309,47 +333,14 @@ export async function runDaemon() {
309
333
  process.exit(0);
310
334
  }
311
335
  log('INFO', `Daemon started (PID: ${process.pid})`);
312
- // RUSH-1759: the launchd plist / systemd unit no longer bake the Claude OAuth
313
- // token onto disk. Obtain it here from the secure `claude` secrets bundle and
314
- // inject into this process's env so every routine run this daemon spawns still
315
- // receives it (via the sandbox allowlist), without the token ever being
316
- // persisted in the service manifest. A read from a file-backed store (Linux)
317
- // needs no prompt; on macOS it resolves broker-only from an unlocked
318
- // secrets-agent and is otherwise absent (leaving the daemon on its existing
319
- // interactive OAuth session), matching the detached-start path. Never blocks.
320
- if (!process.env.CLAUDE_CODE_OAUTH_TOKEN) {
321
- const bundleEnv = readDaemonClaudeBundleEnv();
322
- const oauthToken = (bundleEnv[DAEMON_OAUTH_KEY] ?? '').trim();
323
- if (oauthToken) {
324
- process.env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
325
- // Also inject each per-account CLAUDE_CODE_OAUTH_TOKEN_<slug> present in the
326
- // bundle, so a routine authenticates its rotation-pinned account via that
327
- // account's own long-lived, non-rotating setup-token (runner.ts
328
- // buildRoutineSpawnEnv) instead of the interactive session that rotates and
329
- // logs the fleet out (Claude Code #25609 / #56339).
330
- let perAccount = 0;
331
- for (const [k, v] of Object.entries(bundleEnv)) {
332
- if (k.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && (v ?? '').trim() && !process.env[k]) {
333
- process.env[k] = v.trim();
334
- perAccount++;
335
- }
336
- }
337
- log('INFO', `Loaded Claude OAuth token from secrets bundle for routine runs${perAccount ? ` (+${perAccount} per-account)` : ''}`);
338
- }
339
- else {
340
- // No token available (e.g. a headless macOS daemon whose keychain was
341
- // locked at start resolves broker-only and gets nothing). Historically
342
- // this was silent, so every Claude routine the daemon spawned failed auth
343
- // with no signal. Make it loud: WARN in the log and fire a desktop alert.
344
- log('WARN', 'No Claude OAuth token available — Claude routine runs will fail auth on this host. ' +
345
- 'Restart the daemon with the keychain unlocked, or unlock the `claude` secrets bundle.');
346
- notifyDesktop({
347
- title: 'agents daemon: no Claude credential',
348
- body: 'Claude routines will fail auth on this host. Restart the daemon with the keychain unlocked.',
349
- action: 'routines:list',
350
- });
351
- }
352
- }
336
+ anchorDaemonCwd();
337
+ // The daemon holds NO Claude credential of its own. Routine runs authenticate
338
+ // exactly like an interactive `agents run`: through the per-account
339
+ // CLAUDE_CONFIG_DIR login on this device (its own auto-refreshing
340
+ // .credentials.json). Claude Code's interactive access token is short-lived but
341
+ // refreshes itself per-device; a routine whose account login has gone dead is
342
+ // skipped up front by the auth-health preflight (runner.ts) with a re-login
343
+ // hint, rather than papered over by an injected fallback token.
353
344
  // Reap any stray duplicate daemon of this install that slipped past the start
354
345
  // lock or was orphaned by a hard-crash — before it can double-fire jobs.
355
346
  try {
@@ -833,38 +824,6 @@ export async function runDaemon() {
833
824
  process.on('SIGINT', () => handleShutdown());
834
825
  await new Promise(() => { });
835
826
  }
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
827
  /** Escape a string for safe inclusion in an XML <string> node. */
869
828
  function xmlEscape(s) {
870
829
  return s
@@ -892,11 +851,10 @@ export function writeOwnerOnlyServiceManifest(filePath, content) {
892
851
  /**
893
852
  * Generate a macOS launchd plist for auto-starting the daemon.
894
853
  *
895
- * The plist never embeds the Claude OAuth token (RUSH-1759): a persisted service
896
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
897
- * obtains the token at startup from the `claude` secrets bundle
898
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the
899
- * Keychain-backed secure store and never touches the unit file.
854
+ * The plist never embeds a Claude OAuth token: the daemon holds no Claude
855
+ * credential at all. Routine runs authenticate through the per-account
856
+ * CLAUDE_CONFIG_DIR login on this device, exactly like an interactive
857
+ * `agents run`, so no credential ever touches the service manifest.
900
858
  */
901
859
  export function generateLaunchdPlist(agentsBin = getAgentsBinPath()) {
902
860
  const launch = getDaemonLaunch(agentsBin);
@@ -934,11 +892,10 @@ function systemdExecArg(value) {
934
892
  /**
935
893
  * Generate a Linux systemd user unit for auto-starting the daemon.
936
894
  *
937
- * The unit never embeds the Claude OAuth token (RUSH-1759): a persisted service
938
- * manifest is a plaintext credential on disk even at 0600. The daemon instead
939
- * obtains the token at startup from the `claude` secrets bundle
940
- * (readDaemonClaudeOAuthToken, injected in runDaemon), so it stays in the secure
941
- * store and never touches the unit file.
895
+ * The unit never embeds a Claude OAuth token: the daemon holds no Claude
896
+ * credential at all. Routine runs authenticate through the per-account
897
+ * CLAUDE_CONFIG_DIR login on this device, exactly like an interactive
898
+ * `agents run`, so no credential ever touches the unit file.
942
899
  */
943
900
  export function generateSystemdUnit(agentsBin = getAgentsBinPath()) {
944
901
  const launch = getDaemonLaunch(agentsBin);
@@ -1082,31 +1039,6 @@ function startDaemonLocked(agentsBin) {
1082
1039
  }
1083
1040
  return startDetached({ agentsBin });
1084
1041
  }
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
1042
  /**
1111
1043
  * Resolve how to launch the daemon: `node <entry> __daemon-run`, matching the
1112
1044
  * exact form that works under a direct `__daemon-run`.
@@ -1186,7 +1118,7 @@ export function startDetached(opts = {}) {
1186
1118
  const child = spawn(command, args, {
1187
1119
  stdio: ['ignore', logFd, logFd],
1188
1120
  ...backgroundSpawnOptions({ fdStdio: true }),
1189
- env: opts.env ?? buildDetachedDaemonEnv(),
1121
+ env: opts.env ?? process.env,
1190
1122
  });
1191
1123
  // A failed spawn (ENOENT/EACCES) emits 'error' asynchronously; without a
1192
1124
  // listener that would crash the parent as an unhandled EventEmitter error.
@@ -1262,11 +1194,19 @@ export function stopDaemon() {
1262
1194
  process.kill(pid, 'SIGTERM');
1263
1195
  }
1264
1196
  catch { /* process already exited */ }
1265
- // Escalate to a hard tree-kill if it ignored SIGTERM after the grace period.
1266
- setTimeout(() => {
1267
- if (isAlive(pid))
1268
- killTree(pid);
1269
- }, 5000);
1197
+ // Wait for it to actually go. This used to be a setTimeout escalation plus
1198
+ // an immediate removeDaemonPid(), which had two failure modes: in a
1199
+ // short-lived process (the npm postinstall) the timer never fired at all,
1200
+ // and clearing the pid file while the old daemon still ran made
1201
+ // isDaemonRunning() report false, so startDaemon() launched a SECOND
1202
+ // daemon. Its hosted broker then unlinked the live socket and rebound,
1203
+ // orphaning the first broker with every unlocked bundle still in its RAM
1204
+ // and unreachable — two brokers on one socket path, seen on a real machine
1205
+ // after an install into a second prefix.
1206
+ if (!waitForExit(pid, STOP_GRACE_MS)) {
1207
+ killTree(pid);
1208
+ waitForExit(pid, STOP_KILL_GRACE_MS);
1209
+ }
1270
1210
  }
1271
1211
  }
1272
1212
  removeDaemonPid();
@@ -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;