@phnx-labs/agents-cli 1.20.74 → 1.20.77

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 (231) hide show
  1. package/CHANGELOG.md +1055 -0
  2. package/README.md +36 -10
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/apply.js +21 -3
  5. package/dist/commands/attach.d.ts +10 -0
  6. package/dist/commands/attach.js +41 -0
  7. package/dist/commands/computer.js +3 -3
  8. package/dist/commands/detach-core.d.ts +46 -0
  9. package/dist/commands/detach-core.js +61 -0
  10. package/dist/commands/detach.d.ts +5 -0
  11. package/dist/commands/detach.js +171 -0
  12. package/dist/commands/doctor.js +18 -0
  13. package/dist/commands/exec.js +83 -13
  14. package/dist/commands/feed.d.ts +7 -1
  15. package/dist/commands/feed.js +47 -4
  16. package/dist/commands/go.d.ts +9 -2
  17. package/dist/commands/go.js +16 -6
  18. package/dist/commands/harness.d.ts +14 -0
  19. package/dist/commands/harness.js +145 -0
  20. package/dist/commands/import.js +38 -8
  21. package/dist/commands/inspect.js +5 -2
  22. package/dist/commands/profiles.d.ts +20 -0
  23. package/dist/commands/profiles.js +68 -21
  24. package/dist/commands/repo.js +14 -0
  25. package/dist/commands/routines.js +286 -15
  26. package/dist/commands/secrets.js +102 -37
  27. package/dist/commands/sessions-browser.js +12 -2
  28. package/dist/commands/sessions-export.js +19 -5
  29. package/dist/commands/sessions-migrate.d.ts +29 -0
  30. package/dist/commands/sessions-migrate.js +596 -0
  31. package/dist/commands/sessions-picker.d.ts +14 -1
  32. package/dist/commands/sessions-picker.js +184 -16
  33. package/dist/commands/sessions.d.ts +56 -14
  34. package/dist/commands/sessions.js +322 -62
  35. package/dist/commands/setup-computer.js +2 -2
  36. package/dist/commands/ssh.d.ts +13 -0
  37. package/dist/commands/ssh.js +110 -37
  38. package/dist/commands/status.js +10 -2
  39. package/dist/commands/versions.js +15 -6
  40. package/dist/commands/view.d.ts +5 -0
  41. package/dist/commands/view.js +24 -4
  42. package/dist/commands/watchdog.d.ts +7 -2
  43. package/dist/commands/watchdog.js +73 -57
  44. package/dist/index.js +59 -7
  45. package/dist/lib/activity.d.ts +17 -6
  46. package/dist/lib/activity.js +466 -40
  47. package/dist/lib/actor.d.ts +50 -0
  48. package/dist/lib/actor.js +166 -0
  49. package/dist/lib/agent-spec/provider.js +2 -1
  50. package/dist/lib/agent-spec/resolve.js +19 -5
  51. package/dist/lib/agent-spec/types.d.ts +9 -1
  52. package/dist/lib/agents.d.ts +21 -0
  53. package/dist/lib/agents.js +70 -6
  54. package/dist/lib/cloud/codex.d.ts +2 -0
  55. package/dist/lib/cloud/codex.js +14 -3
  56. package/dist/lib/cloud/session-index.d.ts +32 -0
  57. package/dist/lib/cloud/session-index.js +58 -0
  58. package/dist/lib/cloud/store.d.ts +7 -0
  59. package/dist/lib/cloud/store.js +25 -0
  60. package/dist/lib/config-transfer.js +4 -0
  61. package/dist/lib/daemon.d.ts +10 -1
  62. package/dist/lib/daemon.js +114 -11
  63. package/dist/lib/devices/connect.d.ts +15 -1
  64. package/dist/lib/devices/connect.js +15 -1
  65. package/dist/lib/devices/fleet.d.ts +37 -1
  66. package/dist/lib/devices/fleet.js +36 -2
  67. package/dist/lib/devices/health-report.d.ts +38 -0
  68. package/dist/lib/devices/health-report.js +214 -0
  69. package/dist/lib/devices/health.js +4 -1
  70. package/dist/lib/devices/reachability.d.ts +31 -0
  71. package/dist/lib/devices/reachability.js +40 -0
  72. package/dist/lib/devices/registry.d.ts +33 -0
  73. package/dist/lib/devices/registry.js +37 -0
  74. package/dist/lib/devices/resolve-target.d.ts +15 -27
  75. package/dist/lib/devices/resolve-target.js +63 -102
  76. package/dist/lib/devices/sync.d.ts +18 -0
  77. package/dist/lib/devices/sync.js +23 -1
  78. package/dist/lib/devices/tailscale.d.ts +3 -0
  79. package/dist/lib/devices/tailscale.js +1 -0
  80. package/dist/lib/events.d.ts +1 -1
  81. package/dist/lib/exec.d.ts +82 -0
  82. package/dist/lib/exec.js +218 -6
  83. package/dist/lib/feed-post.d.ts +63 -0
  84. package/dist/lib/feed-post.js +204 -0
  85. package/dist/lib/feed.d.ts +7 -1
  86. package/dist/lib/feed.js +96 -6
  87. package/dist/lib/fleet/apply.d.ts +32 -2
  88. package/dist/lib/fleet/apply.js +97 -10
  89. package/dist/lib/fleet/types.d.ts +11 -0
  90. package/dist/lib/fs-walk.d.ts +13 -0
  91. package/dist/lib/fs-walk.js +16 -7
  92. package/dist/lib/heal.d.ts +7 -4
  93. package/dist/lib/heal.js +10 -22
  94. package/dist/lib/hooks.d.ts +1 -0
  95. package/dist/lib/hooks.js +156 -0
  96. package/dist/lib/hosts/dispatch.d.ts +16 -0
  97. package/dist/lib/hosts/dispatch.js +26 -4
  98. package/dist/lib/hosts/passthrough.js +9 -2
  99. package/dist/lib/hosts/reconnect.d.ts +78 -0
  100. package/dist/lib/hosts/reconnect.js +127 -0
  101. package/dist/lib/hosts/registry.d.ts +75 -14
  102. package/dist/lib/hosts/registry.js +205 -30
  103. package/dist/lib/hosts/remote-cmd.d.ts +11 -0
  104. package/dist/lib/hosts/remote-cmd.js +32 -8
  105. package/dist/lib/hosts/remote-session-id.d.ts +45 -0
  106. package/dist/lib/hosts/remote-session-id.js +84 -0
  107. package/dist/lib/hosts/run-target.d.ts +17 -5
  108. package/dist/lib/hosts/run-target.js +30 -10
  109. package/dist/lib/hosts/session-index.d.ts +18 -1
  110. package/dist/lib/hosts/session-index.js +40 -5
  111. package/dist/lib/hosts/session-marker.d.ts +33 -0
  112. package/dist/lib/hosts/session-marker.js +51 -0
  113. package/dist/lib/hosts/tasks.d.ts +8 -4
  114. package/dist/lib/import.d.ts +2 -0
  115. package/dist/lib/import.js +35 -2
  116. package/dist/lib/menubar/install-menubar.d.ts +29 -0
  117. package/dist/lib/menubar/install-menubar.js +59 -1
  118. package/dist/lib/menubar/notify-desktop.d.ts +44 -0
  119. package/dist/lib/menubar/notify-desktop.js +78 -0
  120. package/dist/lib/migrate.d.ts +16 -0
  121. package/dist/lib/migrate.js +36 -0
  122. package/dist/lib/models.d.ts +27 -0
  123. package/dist/lib/models.js +54 -1
  124. package/dist/lib/overdue.d.ts +6 -2
  125. package/dist/lib/overdue.js +10 -36
  126. package/dist/lib/picker.js +4 -1
  127. package/dist/lib/platform/process.d.ts +17 -0
  128. package/dist/lib/platform/process.js +70 -0
  129. package/dist/lib/profiles-presets.js +9 -7
  130. package/dist/lib/profiles.d.ts +31 -0
  131. package/dist/lib/profiles.js +70 -0
  132. package/dist/lib/pty-server.d.ts +2 -10
  133. package/dist/lib/pty-server.js +4 -38
  134. package/dist/lib/registry.d.ts +1 -1
  135. package/dist/lib/registry.js +48 -8
  136. package/dist/lib/rotate.d.ts +18 -0
  137. package/dist/lib/rotate.js +28 -0
  138. package/dist/lib/routine-notify.d.ts +76 -0
  139. package/dist/lib/routine-notify.js +190 -0
  140. package/dist/lib/routines-placement.d.ts +38 -0
  141. package/dist/lib/routines-placement.js +79 -0
  142. package/dist/lib/routines-project.d.ts +97 -0
  143. package/dist/lib/routines-project.js +349 -0
  144. package/dist/lib/routines.d.ts +94 -0
  145. package/dist/lib/routines.js +93 -9
  146. package/dist/lib/runner.d.ts +12 -2
  147. package/dist/lib/runner.js +242 -20
  148. package/dist/lib/sandbox.js +10 -0
  149. package/dist/lib/secrets/account-token.d.ts +20 -0
  150. package/dist/lib/secrets/account-token.js +64 -0
  151. package/dist/lib/secrets/agent.d.ts +74 -4
  152. package/dist/lib/secrets/agent.js +181 -107
  153. package/dist/lib/secrets/bundles.d.ts +54 -6
  154. package/dist/lib/secrets/bundles.js +230 -34
  155. package/dist/lib/secrets/index.d.ts +26 -3
  156. package/dist/lib/secrets/index.js +42 -12
  157. package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
  158. package/dist/lib/secrets/rc-hygiene.js +154 -0
  159. package/dist/lib/secrets/remote.d.ts +7 -4
  160. package/dist/lib/secrets/remote.js +7 -4
  161. package/dist/lib/secrets/session-store.d.ts +6 -2
  162. package/dist/lib/secrets/session-store.js +65 -15
  163. package/dist/lib/secrets/sync-commands.d.ts +21 -0
  164. package/dist/lib/secrets/sync-commands.js +21 -0
  165. package/dist/lib/secrets/unlock-hints.d.ts +27 -0
  166. package/dist/lib/secrets/unlock-hints.js +36 -0
  167. package/dist/lib/self-update.d.ts +23 -2
  168. package/dist/lib/self-update.js +86 -5
  169. package/dist/lib/session/active.d.ts +124 -7
  170. package/dist/lib/session/active.js +390 -56
  171. package/dist/lib/session/cloud.js +2 -1
  172. package/dist/lib/session/db.d.ts +54 -0
  173. package/dist/lib/session/db.js +320 -19
  174. package/dist/lib/session/detached.d.ts +30 -0
  175. package/dist/lib/session/detached.js +92 -0
  176. package/dist/lib/session/discover.d.ts +478 -3
  177. package/dist/lib/session/discover.js +1740 -472
  178. package/dist/lib/session/fork.js +2 -1
  179. package/dist/lib/session/hook-sessions.d.ts +43 -0
  180. package/dist/lib/session/hook-sessions.js +135 -0
  181. package/dist/lib/session/linear.d.ts +6 -0
  182. package/dist/lib/session/linear.js +55 -0
  183. package/dist/lib/session/migrate-targets.d.ts +65 -0
  184. package/dist/lib/session/migrate-targets.js +94 -0
  185. package/dist/lib/session/migrations.d.ts +37 -0
  186. package/dist/lib/session/migrations.js +60 -0
  187. package/dist/lib/session/parse.d.ts +18 -0
  188. package/dist/lib/session/parse.js +141 -32
  189. package/dist/lib/session/pid-registry.d.ts +34 -2
  190. package/dist/lib/session/pid-registry.js +49 -2
  191. package/dist/lib/session/prompt.d.ts +7 -0
  192. package/dist/lib/session/prompt.js +37 -0
  193. package/dist/lib/session/provenance.d.ts +7 -0
  194. package/dist/lib/session/render.d.ts +7 -2
  195. package/dist/lib/session/render.js +40 -26
  196. package/dist/lib/session/short-id.d.ts +17 -0
  197. package/dist/lib/session/short-id.js +20 -0
  198. package/dist/lib/session/state.d.ts +4 -0
  199. package/dist/lib/session/state.js +99 -13
  200. package/dist/lib/session/types.d.ts +25 -1
  201. package/dist/lib/session/types.js +8 -0
  202. package/dist/lib/shims.d.ts +1 -1
  203. package/dist/lib/shims.js +20 -6
  204. package/dist/lib/sqlite.d.ts +3 -2
  205. package/dist/lib/sqlite.js +27 -4
  206. package/dist/lib/staleness/detectors/commands.js +1 -1
  207. package/dist/lib/staleness/detectors/workflows.js +13 -2
  208. package/dist/lib/staleness/writers/commands.js +7 -7
  209. package/dist/lib/staleness/writers/workflows.d.ts +4 -2
  210. package/dist/lib/startup/command-registry.d.ts +1 -0
  211. package/dist/lib/startup/command-registry.js +3 -0
  212. package/dist/lib/state.d.ts +8 -4
  213. package/dist/lib/state.js +21 -37
  214. package/dist/lib/teams/agents.d.ts +19 -10
  215. package/dist/lib/teams/agents.js +40 -39
  216. package/dist/lib/types.d.ts +47 -2
  217. package/dist/lib/usage.d.ts +33 -1
  218. package/dist/lib/usage.js +172 -12
  219. package/dist/lib/versions.d.ts +9 -2
  220. package/dist/lib/versions.js +37 -8
  221. package/dist/lib/watchdog/log.d.ts +43 -0
  222. package/dist/lib/watchdog/log.js +69 -0
  223. package/dist/lib/watchdog/routine.d.ts +44 -0
  224. package/dist/lib/watchdog/routine.js +69 -0
  225. package/dist/lib/watchdog/runner.d.ts +51 -7
  226. package/dist/lib/watchdog/runner.js +239 -64
  227. package/dist/lib/watchdog/watchdog.d.ts +1 -1
  228. package/dist/lib/watchdog/watchdog.js +31 -16
  229. package/dist/lib/workflows.d.ts +16 -0
  230. package/dist/lib/workflows.js +110 -1
  231. package/package.json +1 -1
@@ -15,6 +15,7 @@ import { safeJoin, isSafeSegmentName } from './paths.js';
15
15
  import { atomicWriteFileSync } from './fs-atomic.js';
16
16
  import { ALL_AGENT_IDS } from './agents.js';
17
17
  import { machineId, normalizeHost } from './machine-id.js';
18
+ export const HOST_STRATEGIES = ['local', 'host', 'fleet', 'cloud'];
18
19
  /** Canonical set of accepted GitHub trigger events — single source for validation. */
19
20
  export const GITHUB_TRIGGER_EVENTS = [
20
21
  'pull_request',
@@ -84,6 +85,39 @@ export function jobRunsOnThisDevice(config) {
84
85
  const self = machineId();
85
86
  return config.devices.some((d) => normalizeHost(d) === self);
86
87
  }
88
+ /**
89
+ * Resolve the effective host strategy for a job.
90
+ * Bare `host:` without an explicit strategy implies `host` (back-compat with
91
+ * pre-hostStrategy YAML). Otherwise default to `local`.
92
+ */
93
+ export function resolveHostStrategy(config) {
94
+ if (config.hostStrategy)
95
+ return config.hostStrategy;
96
+ if (config.host)
97
+ return 'host';
98
+ return 'local';
99
+ }
100
+ /**
101
+ * Parse a CLI `--placement` value into a HostStrategy, or null when empty.
102
+ * Throws a human-readable Error for unknown values.
103
+ */
104
+ export function parseHostStrategy(raw) {
105
+ if (raw === undefined || raw === null || raw === '')
106
+ return null;
107
+ const v = raw.trim().toLowerCase();
108
+ if (HOST_STRATEGIES.includes(v))
109
+ return v;
110
+ throw new Error(`Invalid placement '${raw}'. Use one of: ${HOST_STRATEGIES.join(', ')}`);
111
+ }
112
+ /**
113
+ * Strategies that dispatch the job body off the firing machine. Without a
114
+ * `devices` pin every daemon in the fleet would fire and each would dispatch
115
+ * once — N× duplicate runs. Callers pin to this machine when the user did not
116
+ * set an explicit allowlist.
117
+ */
118
+ export function placementRequiresFiringPin(strategy) {
119
+ return strategy === 'host' || strategy === 'fleet' || strategy === 'cloud';
120
+ }
87
121
  /**
88
122
  * Return null when the job may run here; otherwise return a structured,
89
123
  * human-friendly eligibility failure. Centralizes the message/suggestion
@@ -419,24 +453,48 @@ export function validateJob(config) {
419
453
  if (config.device !== undefined) {
420
454
  errors.push('singular "device" key is no longer supported — replace with devices: [<name>] (an array)');
421
455
  }
456
+ if (config.hostStrategy !== undefined) {
457
+ if (!HOST_STRATEGIES.includes(config.hostStrategy)) {
458
+ errors.push(`hostStrategy must be one of: ${HOST_STRATEGIES.join(', ')}`);
459
+ }
460
+ }
461
+ const strategy = resolveHostStrategy(config);
422
462
  if (config.host !== undefined) {
423
463
  if (typeof config.host !== 'string' || config.host.trim() === '') {
424
464
  errors.push('host must be a non-empty machine name (a registered host, device, capability tag, or user@host)');
425
465
  }
426
- // v1: the workflow bundle and the loop driver (with its signal files) live
427
- // on the firing machine neither can cross SSH to the target yet.
466
+ }
467
+ if (strategy === 'host' && (!config.host || config.host.trim() === '')) {
468
+ errors.push("hostStrategy: host requires host: (set via --run-on or host: in YAML)");
469
+ }
470
+ // Remote placement (host/fleet/cloud) can't carry workflow/loop/command yet —
471
+ // those live on the firing machine.
472
+ if (strategy === 'host' || strategy === 'fleet' || strategy === 'cloud') {
428
473
  if (config.workflow) {
429
- errors.push("host: can't be combined with workflow: yet (the bundle lives on the firing machine) — run the workflow locally or convert it to a plain prompt");
474
+ errors.push(`${strategy} placement can't be combined with workflow: yet — run the workflow locally or convert it to a plain prompt`);
430
475
  }
431
476
  if (config.loop) {
432
- errors.push("host: can't be combined with loop: yet (the loop driver and its signal files live on the firing machine)");
477
+ errors.push(`${strategy} placement can't be combined with loop: yet (the loop driver and its signal files live on the firing machine)`);
433
478
  }
434
479
  if (config.command) {
435
- errors.push("host: can't be combined with command: yet (a plain shell command has no agent to place remotely) — run it locally, or convert it to a prompt");
480
+ errors.push(`${strategy} placement can't be combined with command: yet (a plain shell command has no agent to place remotely)`);
436
481
  }
437
482
  }
438
- if (config.remoteCwd !== undefined && config.host === undefined) {
439
- errors.push('remoteCwd only applies to host:-placed routines — set host: too, or drop it');
483
+ if (config.remoteCwd !== undefined && strategy !== 'host' && strategy !== 'fleet') {
484
+ errors.push('remoteCwd only applies to host/fleet-placed routines — set hostStrategy: host|fleet, or drop it');
485
+ }
486
+ if (config.source !== undefined) {
487
+ if (!config.source || typeof config.source !== 'object') {
488
+ errors.push('source must be an object');
489
+ }
490
+ else {
491
+ if (config.source.kind !== 'project') {
492
+ errors.push("source.kind must be 'project'");
493
+ }
494
+ if (typeof config.source.projectPath !== 'string' || config.source.projectPath.trim() === '') {
495
+ errors.push('source.projectPath must be a non-empty absolute path');
496
+ }
497
+ }
440
498
  }
441
499
  if (config.devices !== undefined) {
442
500
  if (!Array.isArray(config.devices)) {
@@ -451,6 +509,15 @@ export function validateJob(config) {
451
509
  }
452
510
  }
453
511
  }
512
+ // Off-box placement without a devices pin fires on every fleet daemon and
513
+ // each dispatches once (RUSH-1980). Enforce the pin at validation so hand
514
+ // edits and devices --clear cannot re-open the hole.
515
+ if (placementRequiresFiringPin(strategy)) {
516
+ if (!config.devices || config.devices.length === 0) {
517
+ errors.push(`hostStrategy: ${strategy} requires devices: [<name>] to pin which daemon may fire ` +
518
+ '(otherwise every fleet daemon dispatches once). Pin to one machine, e.g. devices: [this-host]');
519
+ }
520
+ }
454
521
  return errors;
455
522
  }
456
523
  /** Validate a job trigger block, returning a list of human-readable errors. */
@@ -536,8 +603,11 @@ export function resolveJobPrompt(config) {
536
603
  prompt = prompt.replace(new RegExp(`\\{${key}\\}`, 'g'), value);
537
604
  }
538
605
  }
539
- // Last report (special handling)
540
- const latestRun = getLatestRun(config.name);
606
+ // Last report (special handling). Only a COMPLETED run's report is injected —
607
+ // a failed run's report.md is the agent's error text (e.g. a login prompt on
608
+ // an auth failure), and feeding that into the next prompt poisons every
609
+ // subsequent run until a human intervenes.
610
+ const latestRun = getLatestCompletedRun(config.name);
541
611
  if (latestRun) {
542
612
  const reportPath = path.join(getJobRunsDir(config.name), latestRun.runId, 'report.md');
543
613
  if (fs.existsSync(reportPath)) {
@@ -595,6 +665,20 @@ export function getLatestRun(jobName) {
595
665
  const runs = listRuns(jobName);
596
666
  return runs.length > 0 ? runs[runs.length - 1] : null;
597
667
  }
668
+ /**
669
+ * Get the most recent COMPLETED run for a job, or null if none has completed.
670
+ * Used to resolve `{last_report}` so a failed run's error text (e.g. an auth
671
+ * login prompt written into report.md) can never be injected into the next
672
+ * run's prompt.
673
+ */
674
+ export function getLatestCompletedRun(jobName) {
675
+ const runs = listRuns(jobName);
676
+ for (let i = runs.length - 1; i >= 0; i--) {
677
+ if (runs[i].status === 'completed')
678
+ return runs[i];
679
+ }
680
+ return null;
681
+ }
598
682
  /** Persist run metadata to its run directory as meta.json. */
599
683
  export function writeRunMeta(meta) {
600
684
  ensureAgentsDir();
@@ -77,9 +77,19 @@ export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, ag
77
77
  * failover across healthy same-agent accounts (RUSH-1016).
78
78
  */
79
79
  export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
80
+ /**
81
+ * Optional lifecycle callbacks for a detached routine run. The daemon passes an
82
+ * `onFinish` that fires the branded finish/output notification (RUSH-2030) — it
83
+ * runs from the in-process settle() when the child exits, the only seam that
84
+ * observes the live running→terminal transition (the monitor tick would already
85
+ * see a finalized record and skip it). Never let a hook throw into finalization.
86
+ */
87
+ export interface RoutineHooks {
88
+ /** Called once with the finalized meta when the run reaches a terminal state. */
89
+ onFinish?: (meta: RunMeta) => void;
90
+ }
80
91
  /** Spawn a job as a detached process and return immediately with run metadata. */
81
- /** Spawn a job as a detached process and return immediately with run metadata. */
82
- export declare function executeJobDetached(config: JobConfig): Promise<RunMeta>;
92
+ export declare function executeJobDetached(config: JobConfig, hooks?: RoutineHooks): Promise<RunMeta>;
83
93
  /** Extract the final assistant message from a stream-JSON log file as a markdown report. */
84
94
  export declare function extractReport(stdoutPath: string, agentType: AgentId): string | null;
85
95
  /** Scan all runs marked "running" and finalize any whose process has exited. */
@@ -22,13 +22,17 @@ import { getRunsDir } from './state.js';
22
22
  import { prepareJobHome, buildSpawnEnv, getJobHomePath } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
24
24
  import { createTimer, maybeRotate, redactPrompt } from './events.js';
25
- import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, } from './exec.js';
25
+ import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, detectAuthFailure, isAuthFailureFromLog, authFailureReason, } from './exec.js';
26
26
  import { loadTask as loadHostTask } from './hosts/tasks.js';
27
27
  import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
28
28
  import { backgroundSpawnOptions } from './platform/process.js';
29
29
  import { walkForFiles } from './fs-walk.js';
30
- import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
31
- import { getConfiguredRunStrategy, resolveRunVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
30
+ import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
31
+ import { claudeHomeHasOwnCredential } from './agents.js';
32
+ import { resolveAccountSetupToken } from './secrets/account-token.js';
33
+ import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
34
+ import { readAuthHealth, isDeadVerdict } from './auth-health.js';
35
+ import { machineId } from './machine-id.js';
32
36
  /** CLI command templates per agent, with {prompt} as a placeholder. */
33
37
  const AGENT_COMMANDS = {
34
38
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -287,6 +291,19 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd()) {
287
291
  pinned: true,
288
292
  };
289
293
  }
294
+ // Account pin: resolve the login identity to the version slot that holds it and
295
+ // run pinned — no rotation, no failover onto other accounts — so concurrent
296
+ // unattended routines never share (and mutually revoke) one single-use OAuth
297
+ // credential (RUSH-1957). Falls through to the strategy only when the account
298
+ // is not signed in on this box, with a loud warning rather than a silent stall.
299
+ if (config.account) {
300
+ const version = await resolveAccountVersion(agent, config.account);
301
+ if (version) {
302
+ return { chain: [{ agent, version }], rotation: null, pinned: true };
303
+ }
304
+ process.stderr.write(`[agents] routine ${config.name}: account '${config.account}' is not signed in for ${agent}; ` +
305
+ `falling back to strategy — pin a signed-in account to stop OAuth-rotation revocation\n`);
306
+ }
290
307
  const strategy = getConfiguredRunStrategy(agent, cwd);
291
308
  let version;
292
309
  let rotation = null;
@@ -383,6 +400,29 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
383
400
  if (v !== undefined)
384
401
  out[k] = v;
385
402
  }
403
+ // A headless routine should authenticate the rotation-pinned account via its
404
+ // long-lived, NON-rotating setup-token, not the interactive OAuth session.
405
+ // Claude Code's interactive session uses single-use rotating refresh tokens: one
406
+ // fleet machine refreshing invalidates that account on every other machine, so
407
+ // they 401 and log out (Claude Code #25609 / #56339) — the fleet-wide daily
408
+ // logout. If the daemon injected this account's per-account
409
+ // CLAUDE_CODE_OAUTH_TOKEN_<slug> (from a headless-readable no-ACL bundle), use it;
410
+ // that also works on macOS, where the drop path below is inert
411
+ // (claudeHomeHasOwnCredential is false on darwin, agents.ts).
412
+ //
413
+ // Fallback (RUSH-1979): with no per-account token, keep the prior behavior — drop
414
+ // the single ambient CLAUDE_CODE_OAUTH_TOKEN when the pinned account has its own
415
+ // on-disk credential (Linux) so it isn't shadowed; keep it otherwise.
416
+ if (agent === 'claude' && version && out.CLAUDE_CONFIG_DIR) {
417
+ const home = getVersionHomePath('claude', version);
418
+ const accountToken = resolveAccountSetupToken(baseEnv, home);
419
+ if (accountToken) {
420
+ out.CLAUDE_CODE_OAUTH_TOKEN = accountToken;
421
+ }
422
+ else if (claudeHomeHasOwnCredential(home)) {
423
+ delete out.CLAUDE_CODE_OAUTH_TOKEN;
424
+ }
425
+ }
386
426
  if (timezone)
387
427
  out.TZ = timezone;
388
428
  return out;
@@ -483,11 +523,19 @@ export async function executeJob(config, deps) {
483
523
  if (eligibility) {
484
524
  throw new Error(eligibility.message);
485
525
  }
486
- // `host:` placement the job body runs on another machine over SSH; local
487
- // version selection / sandbox / spawn do not apply. Sync callers (manual
488
- // `routines run`, catchup) follow the remote run to completion.
489
- if (config.host) {
490
- return executeJobOnHost(config, { detached: false });
526
+ // Placement (hostStrategy / bare host:) body may run on another machine
527
+ // over SSH or in the cloud; local version selection / sandbox / spawn then
528
+ // do not apply. Sync callers (manual `routines run`, catchup) follow the
529
+ // remote run to completion when possible.
530
+ {
531
+ const { resolvePlacementTarget } = await import('./routines-placement.js');
532
+ const target = resolvePlacementTarget(config);
533
+ if (target.mode === 'host') {
534
+ return executeJobOnHost({ ...config, host: target.host }, { detached: false });
535
+ }
536
+ if (target.mode === 'cloud') {
537
+ return executeJobOnCloud(config, { detached: false });
538
+ }
491
539
  }
492
540
  // Command-mode: run a plain shell command directly (no agent, no rotation,
493
541
  // no pinning, no sandbox overlay). Reuses the run-record machinery so
@@ -536,6 +584,25 @@ export async function executeJob(config, deps) {
536
584
  exitCode: null,
537
585
  };
538
586
  writeRunMeta(meta);
587
+ // Auth preflight: if the last live probe rejected this (agent, version)'s
588
+ // token (verdict `revoked`), the run is guaranteed to fail auth — fail fast
589
+ // before spawning instead of producing a doomed run + poisoned report. Cache-
590
+ // only (the daemon refreshes it periodically); fail OPEN on any non-revoked or
591
+ // missing verdict so a stale/absent probe or a network blip never blocks a
592
+ // run, and agents with no live probe (codex/gemini/grok) are never blocked.
593
+ const preflightVersion = launch.chain[0]?.version;
594
+ if (preflightVersion) {
595
+ const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
596
+ if (health && isDeadVerdict(health.verdict)) {
597
+ const reason = `auth_preflight: ${health.verdict}`;
598
+ process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
599
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
600
+ writeRunMeta(meta);
601
+ timer.end({ status: 'failed', exitCode: 1, runId, error: reason });
602
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
603
+ return { meta, reportPath: null };
604
+ }
605
+ }
539
606
  const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
540
607
  // Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
541
608
  if (config.loop) {
@@ -616,6 +683,21 @@ export async function executeJob(config, deps) {
616
683
  return { meta, reportPath };
617
684
  }
618
685
  if (attempt.status === 'completed') {
686
+ // Exit code alone is unreliable for auth: a logged-out Claude can exit 0
687
+ // with a `result` event carrying is_error:true (terminal_reason
688
+ // "completed"). Consult the SAME structural signal the detached path uses
689
+ // so both paths agree — raw text is deliberately NOT used here, so a
690
+ // genuinely-completed run that merely mentions an auth phrase stays a
691
+ // success (processFailed:false).
692
+ if (isAuthFailureFromLog(attempt.logText, effectiveAgent, { processFailed: false })) {
693
+ const reason = authFailureReason(attempt.logText) ?? 'authentication_failed';
694
+ finalizeRunMeta(meta, 'failed', attempt.exitCode ?? 1, { errorMessage: `auth_failed: ${reason}` });
695
+ writeRunMeta(meta);
696
+ timer.end({ status: 'failed', exitCode: meta.exitCode ?? undefined, runId, error: `auth_failed: ${reason}` });
697
+ // Never persist the login-error text as the report.
698
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
699
+ return { meta, reportPath: null };
700
+ }
619
701
  finalizeRunMeta(meta, 'completed', 0);
620
702
  writeRunMeta(meta);
621
703
  timer.end({ status: 'completed', exitCode: 0, runId });
@@ -633,18 +715,36 @@ export async function executeJob(config, deps) {
633
715
  fs.appendFileSync(stdoutPath, `\n[agents] ${label} hit rate/usage limit — failover → ${nextLabel}\n`);
634
716
  continue;
635
717
  }
718
+ // Auth failure — the agent is logged out / token revoked. Unlike a rate
719
+ // limit it is not self-healing by failover (every chain entry on the same
720
+ // account fails identically), so rate-limit is classified first (above) and
721
+ // auth only when NOT rate-limited. Classified so the failure is visible and,
722
+ // critically, so the login-error text is never persisted as the report.
723
+ const authFailed = !rateLimited && (isAuthFailureFromLog(attempt.logText, effectiveAgent, { processFailed: true }) ||
724
+ (attempt.error ? detectAuthFailure(attempt.error) : false));
636
725
  if (attempt.error) {
637
726
  process.stderr.write(`[agents] routine ${config.name}: spawn failed for ${label}: ${attempt.error}\n`);
638
727
  }
639
- finalizeRunMeta(meta, 'failed', attempt.exitCode ?? 1, attempt.error ? { errorMessage: attempt.error } : undefined);
728
+ const authReason = authFailed
729
+ ? (authFailureReason(attempt.logText)
730
+ ?? (attempt.error ? authFailureReason(attempt.error) : null)
731
+ ?? 'authentication_failed')
732
+ : null;
733
+ const failureErrorMessage = authReason
734
+ ? `auth_failed: ${authReason}`
735
+ : (attempt.error ?? undefined);
736
+ finalizeRunMeta(meta, 'failed', attempt.exitCode ?? 1, failureErrorMessage ? { errorMessage: failureErrorMessage } : undefined);
640
737
  writeRunMeta(meta);
641
738
  timer.end({
642
739
  status: 'failed',
643
740
  exitCode: meta.exitCode ?? undefined,
644
741
  runId,
645
- ...(attempt.error ? { error: attempt.error } : {}),
742
+ ...(failureErrorMessage ? { error: failureErrorMessage } : {}),
646
743
  });
647
- const reportPath = extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
744
+ // On auth failure the last assistant text IS the login error — never persist
745
+ // it as report.md; it would otherwise be injected into the next run's prompt
746
+ // via {last_report}.
747
+ const reportPath = authFailed ? null : extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
648
748
  archiveRoutineTranscripts(meta, runDir, overlayHome);
649
749
  return { meta, reportPath };
650
750
  }
@@ -654,6 +754,86 @@ export async function executeJob(config, deps) {
654
754
  timer.end({ status: 'failed', exitCode: 1, runId });
655
755
  return { meta, reportPath: null };
656
756
  }
757
+ /**
758
+ * Dispatch a routine to the agent's native cloud provider (or the configured
759
+ * default). Writes a local run record with `cloudTaskId` so list/runs still
760
+ * work; does not wait for cloud completion on the detached path.
761
+ */
762
+ async function executeJobOnCloud(config, opts) {
763
+ if (config.workflow) {
764
+ throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'workflow:'.`);
765
+ }
766
+ if (config.loop) {
767
+ throw new Error(`Routine '${config.name}' uses 'loop:', which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'loop:'.`);
768
+ }
769
+ if (config.command) {
770
+ throw new Error(`Routine '${config.name}' uses 'command:', which can't execute in the cloud yet — remove 'hostStrategy: cloud' or 'command:'.`);
771
+ }
772
+ if (!config.agent) {
773
+ throw new Error(`Routine '${config.name}' hostStrategy: cloud requires an agent`);
774
+ }
775
+ const { resolveProvider } = await import('./cloud/registry.js');
776
+ const { insertTask } = await import('./cloud/store.js');
777
+ const provider = resolveProvider(undefined, config.agent);
778
+ const timer = createTimer('agent.run', {
779
+ agent: config.agent,
780
+ jobName: config.name,
781
+ mode: config.mode,
782
+ placement: 'cloud',
783
+ ...redactPrompt(config.prompt),
784
+ schedule: config.schedule,
785
+ });
786
+ const runId = generateRunId();
787
+ const runDir = getRunDir(config.name, runId);
788
+ fs.mkdirSync(runDir, { recursive: true });
789
+ const meta = {
790
+ jobName: config.name,
791
+ runId,
792
+ agent: config.agent,
793
+ pid: null,
794
+ spawnedAt: Date.now(),
795
+ status: 'running',
796
+ startedAt: new Date().toISOString(),
797
+ completedAt: null,
798
+ exitCode: null,
799
+ };
800
+ writeRunMeta(meta);
801
+ try {
802
+ const task = await provider.dispatch({
803
+ prompt: resolveJobPrompt(config),
804
+ agent: config.agent,
805
+ repo: config.repo,
806
+ timeout: config.timeout,
807
+ model: config.config?.model,
808
+ });
809
+ try {
810
+ insertTask(task);
811
+ }
812
+ catch { /* store is best-effort */ }
813
+ meta.cloudTaskId = task.id;
814
+ meta.cloudProvider = task.provider;
815
+ // Terminal cloud responses (e.g. antigravity) finalize immediately.
816
+ // Async providers leave the run `running` with the cloud task id for
817
+ // the user to follow via `agents cloud status`.
818
+ if (task.status === 'completed') {
819
+ finalizeRunMeta(meta, 'completed', 0);
820
+ }
821
+ else if (task.status === 'failed' || task.status === 'cancelled') {
822
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: task.summary ?? `cloud ${task.status}` });
823
+ }
824
+ // Non-terminal statuses stay `running` for both detached and sync paths;
825
+ // the user follows via `agents cloud status <id>`.
826
+ writeRunMeta(meta);
827
+ timer.end({ status: meta.status, exitCode: meta.exitCode ?? undefined, runId });
828
+ return { meta, reportPath: null };
829
+ }
830
+ catch (err) {
831
+ finalizeRunMeta(meta, 'failed', 1, { errorMessage: err.message });
832
+ writeRunMeta(meta);
833
+ timer.end({ status: 'failed', exitCode: 1, runId, error: err.message });
834
+ throw err;
835
+ }
836
+ }
657
837
  async function executeJobOnHost(config, opts) {
658
838
  if (config.workflow) {
659
839
  throw new Error(`Routine '${config.name}' runs a workflow bundle, which can't execute on a host yet — remove 'host:' or 'workflow:'.`);
@@ -796,24 +976,44 @@ async function executeCommandJobForeground(config) {
796
976
  });
797
977
  return { meta, reportPath: null };
798
978
  }
979
+ /** Invoke a lifecycle hook without letting a caller error break run finalization. */
980
+ function safeHook(fn) {
981
+ if (!fn)
982
+ return;
983
+ try {
984
+ fn();
985
+ }
986
+ catch { /* hooks are best-effort */ }
987
+ }
799
988
  /** Spawn a job as a detached process and return immediately with run metadata. */
800
- /** Spawn a job as a detached process and return immediately with run metadata. */
801
- export async function executeJobDetached(config) {
989
+ export async function executeJobDetached(config, hooks) {
802
990
  const eligibility = checkJobDeviceEligibility(config);
803
991
  if (eligibility) {
804
992
  process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
805
993
  throw new Error(eligibility.message);
806
994
  }
807
- // `host:` placement — dispatch over SSH and return; the monitor finalizes.
808
- if (config.host) {
809
- const { meta } = await executeJobOnHost(config, { detached: true });
810
- return meta;
995
+ // Placement (hostStrategy / bare host:) — dispatch off-box and return; the
996
+ // monitor finalizes host: runs, cloud runs stay terminal when dispatch ends.
997
+ // Either way the in-process onFinish hook does not fire for off-box routines
998
+ // (the monitor tick observes an already-finalized record), so notify-desktop
999
+ // sends the finish notification only for local detached runs below (RUSH-2030).
1000
+ {
1001
+ const { resolvePlacementTarget } = await import('./routines-placement.js');
1002
+ const target = resolvePlacementTarget(config);
1003
+ if (target.mode === 'host') {
1004
+ const { meta } = await executeJobOnHost({ ...config, host: target.host }, { detached: true });
1005
+ return meta;
1006
+ }
1007
+ if (target.mode === 'cloud') {
1008
+ const { meta } = await executeJobOnCloud(config, { detached: true });
1009
+ return meta;
1010
+ }
811
1011
  }
812
1012
  // Command-mode: fire a plain shell command detached (no agent, no rotation,
813
1013
  // no pinning, no sandbox overlay). Still writes a run record so the daemon,
814
1014
  // list/runs, and overdue tracking keep working.
815
1015
  if (config.command) {
816
- return executeCommandJobDetached(config);
1016
+ return executeCommandJobDetached(config, hooks);
817
1017
  }
818
1018
  // Pre-flight: pick a healthy version/account so the daemon does not launch
819
1019
  // into a credit-exhausted install. Detached cannot mid-run failover (no exit
@@ -876,10 +1076,29 @@ export async function executeJobDetached(config) {
876
1076
  finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
877
1077
  writeRunMeta(meta);
878
1078
  archiveRoutineTranscripts(meta, runDir, overlayHome);
879
- if (status !== 'timeout')
1079
+ // Never persist the report on an auth failure — the last assistant text is
1080
+ // the login error, which would poison the next run's {last_report} prompt.
1081
+ const isAuthFailure = !!errorMessage && errorMessage.startsWith('auth_failed:');
1082
+ if (status !== 'timeout' && !isAuthFailure)
880
1083
  extractAndSaveReport(stdoutPath, effectiveAgent, runDir);
1084
+ // Fire the finish/output notification AFTER the report is written so the hook
1085
+ // can read report.md (RUSH-2030). Best-effort; never breaks finalization.
1086
+ safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
881
1087
  };
882
1088
  child.on('exit', (code) => {
1089
+ let logText = '';
1090
+ try {
1091
+ logText = fs.readFileSync(stdoutPath, 'utf-8');
1092
+ }
1093
+ catch { /* log unreadable */ }
1094
+ // processFailed gates the raw-text fallback so a COMPLETED run (exit 0) that
1095
+ // merely mentions an auth phrase is never misclassified; the structural
1096
+ // marker still catches an exit-0 auth failure on its own.
1097
+ if (isAuthFailureFromLog(logText, effectiveAgent, { processFailed: (code ?? 1) !== 0 })) {
1098
+ const reason = authFailureReason(logText) ?? 'authentication_failed';
1099
+ settle('failed', code ?? 1, `auth_failed: ${reason}`);
1100
+ return;
1101
+ }
883
1102
  const inferred = inferFinalStatusFromLog(stdoutPath, effectiveAgent);
884
1103
  if (inferred) {
885
1104
  settle(inferred.status, inferred.exitCode);
@@ -911,7 +1130,7 @@ export async function executeJobDetached(config) {
911
1130
  * un-sandboxed, unref, then record the pid. The daemon does not wait for exit;
912
1131
  * `monitorRunningJobs` reaps the record on the next tick.
913
1132
  */
914
- function executeCommandJobDetached(config) {
1133
+ function executeCommandJobDetached(config, hooks) {
915
1134
  const runId = generateRunId();
916
1135
  const runDir = getRunDir(config.name, runId);
917
1136
  fs.mkdirSync(runDir, { recursive: true });
@@ -956,6 +1175,9 @@ function executeCommandJobDetached(config) {
956
1175
  settled = true;
957
1176
  finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
958
1177
  writeRunMeta(meta);
1178
+ // Finish notification (RUSH-2030). For command routines the threshold only
1179
+ // surfaces failures, decided in routine-notify.ts. Best-effort.
1180
+ safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
959
1181
  };
960
1182
  child.on('exit', (code) => settle(code === 0 ? 'completed' : 'failed', code ?? 1));
961
1183
  child.on('error', (err) => {
@@ -75,6 +75,16 @@ export function buildSpawnEnv(overlayHome, extraEnv) {
75
75
  env[key] = process.env[key];
76
76
  }
77
77
  }
78
+ // Per-account Claude setup-tokens: the daemon injects a CLAUDE_CODE_OAUTH_TOKEN_<slug>
79
+ // for each account so a routine authenticates its rotation-pinned account via a
80
+ // long-lived, non-rotating token (see runner.ts buildRoutineSpawnEnv). Forward every
81
+ // such key by prefix — same trust tier as CLAUDE_CODE_OAUTH_TOKEN above; still no API
82
+ // keys or other provider secrets.
83
+ for (const key of Object.keys(process.env)) {
84
+ if (key.startsWith('CLAUDE_CODE_OAUTH_TOKEN_') && process.env[key]) {
85
+ env[key] = process.env[key];
86
+ }
87
+ }
78
88
  if (extraEnv) {
79
89
  Object.assign(env, extraEnv);
80
90
  }
@@ -0,0 +1,20 @@
1
+ /** The `CLAUDE_CODE_OAUTH_TOKEN_<slug>` env key for a given account email/id. */
2
+ export declare function accountTokenKey(account: string): string;
3
+ /**
4
+ * Read the signed-in account email for a Claude version home from
5
+ * `oauthAccount.emailAddress`. Sync, no keychain, no network. Tries both stores
6
+ * the canonical resolver uses (agents.ts getAccountInfo): the shim-set
7
+ * `CLAUDE_CONFIG_DIR` location `<home>/.claude/.claude.json` first, then the
8
+ * home-level `<home>/.claude.json` (an account signed in via the IDE / direct
9
+ * binary without the shim writes there). Returns null when neither has a usable
10
+ * email.
11
+ */
12
+ export declare function readClaudeAccountEmail(home: string): string | null;
13
+ /**
14
+ * Resolve the per-account setup-token for the account pinned to `home`, looking
15
+ * it up in the provided env (the daemon injects the `claude` bundle's
16
+ * `CLAUDE_CODE_OAUTH_TOKEN_*` keys). Returns null when the home has no known
17
+ * account, or no matching per-account token is present — callers then leave the
18
+ * existing ambient/interactive credential untouched (a safe no-op).
19
+ */
20
+ export declare function resolveAccountSetupToken(env: Record<string, string | undefined>, home: string): string | null;
@@ -0,0 +1,64 @@
1
+ // Per-account Claude setup-token resolution.
2
+ //
3
+ // A headless/unattended Claude run should authenticate with the account's
4
+ // long-lived `claude setup-token` (exported as a per-account
5
+ // `CLAUDE_CODE_OAUTH_TOKEN_<slug>` env var), NOT the interactive OAuth session.
6
+ // Claude Code's interactive session uses single-use ROTATING refresh tokens: when
7
+ // one machine refreshes, the server invalidates the old refresh token globally, so
8
+ // every other machine on that account 401s and gets logged out (Claude Code
9
+ // #25609 / #56339). The 1-year setup-token sits earlier in the auth precedence
10
+ // (`CLAUDE_CODE_OAUTH_TOKEN`, before subscription login) and does not participate
11
+ // in that rotation — so per-account setup-tokens keep the fleet signed in.
12
+ //
13
+ // The per-account env key encodes the account email. The convention (verified
14
+ // against live bundles, e.g. `muqsit@getrush.ai` →
15
+ // `CLAUDE_CODE_OAUTH_TOKEN_MUQSIT_AT_GETRUSH_DOT_AI`): upper-case, `@`→`_AT_`,
16
+ // `.`→`_DOT_`, any other non-alphanumeric → `_`.
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ /** The `CLAUDE_CODE_OAUTH_TOKEN_<slug>` env key for a given account email/id. */
20
+ export function accountTokenKey(account) {
21
+ const slug = account
22
+ .trim()
23
+ .toUpperCase()
24
+ .replace(/@/g, '_AT_')
25
+ .replace(/\./g, '_DOT_')
26
+ .replace(/[^A-Z0-9_]/g, '_');
27
+ return `CLAUDE_CODE_OAUTH_TOKEN_${slug}`;
28
+ }
29
+ /**
30
+ * Read the signed-in account email for a Claude version home from
31
+ * `oauthAccount.emailAddress`. Sync, no keychain, no network. Tries both stores
32
+ * the canonical resolver uses (agents.ts getAccountInfo): the shim-set
33
+ * `CLAUDE_CONFIG_DIR` location `<home>/.claude/.claude.json` first, then the
34
+ * home-level `<home>/.claude.json` (an account signed in via the IDE / direct
35
+ * binary without the shim writes there). Returns null when neither has a usable
36
+ * email.
37
+ */
38
+ export function readClaudeAccountEmail(home) {
39
+ for (const p of [path.join(home, '.claude', '.claude.json'), path.join(home, '.claude.json')]) {
40
+ try {
41
+ const email = JSON.parse(fs.readFileSync(p, 'utf-8')).oauthAccount?.emailAddress;
42
+ if (typeof email === 'string' && email.trim().length > 0)
43
+ return email.trim();
44
+ }
45
+ catch {
46
+ // Missing/unreadable/malformed at this location — try the next.
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+ /**
52
+ * Resolve the per-account setup-token for the account pinned to `home`, looking
53
+ * it up in the provided env (the daemon injects the `claude` bundle's
54
+ * `CLAUDE_CODE_OAUTH_TOKEN_*` keys). Returns null when the home has no known
55
+ * account, or no matching per-account token is present — callers then leave the
56
+ * existing ambient/interactive credential untouched (a safe no-op).
57
+ */
58
+ export function resolveAccountSetupToken(env, home) {
59
+ const email = readClaudeAccountEmail(home);
60
+ if (!email)
61
+ return null;
62
+ const token = env[accountTokenKey(email)];
63
+ return typeof token === 'string' && token.trim().length > 0 ? token.trim() : null;
64
+ }