@phnx-labs/agents-cli 1.22.42 → 1.22.44

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 (256) hide show
  1. package/CHANGELOG.md +149 -0
  2. package/README.md +15 -13
  3. package/dist/bootstrap.js +33 -13
  4. package/dist/cli/command-registry.d.ts +0 -8
  5. package/dist/cli/command-registry.js +1 -25
  6. package/dist/commands/alias.d.ts +7 -4
  7. package/dist/commands/alias.js +33 -8
  8. package/dist/commands/apply.d.ts +7 -10
  9. package/dist/commands/apply.js +15 -35
  10. package/dist/commands/artifacts.js +4 -5
  11. package/dist/commands/audit.d.ts +6 -6
  12. package/dist/commands/audit.js +12 -12
  13. package/dist/commands/auth.js +14 -6
  14. package/dist/commands/beta.d.ts +7 -1
  15. package/dist/commands/beta.js +16 -9
  16. package/dist/commands/commands.js +1 -1
  17. package/dist/commands/computer.js +20 -6
  18. package/dist/commands/daemon.js +1 -1
  19. package/dist/commands/doctor.d.ts +4 -2
  20. package/dist/commands/doctor.js +21 -9
  21. package/dist/commands/events.d.ts +2 -2
  22. package/dist/commands/events.js +6 -5
  23. package/dist/commands/exec.d.ts +1 -1
  24. package/dist/commands/exec.js +11 -6
  25. package/dist/commands/factory.d.ts +1 -1
  26. package/dist/commands/feed.js +1 -30
  27. package/dist/commands/fleet-capture.d.ts +1 -1
  28. package/dist/commands/fleet-capture.js +4 -5
  29. package/dist/commands/harness.js +4 -52
  30. package/dist/commands/hooks.js +1 -1
  31. package/dist/commands/insights.d.ts +1 -1
  32. package/dist/commands/insights.js +3 -2
  33. package/dist/commands/menubar.js +58 -1
  34. package/dist/commands/monitors.js +1 -1
  35. package/dist/commands/org.d.ts +5 -0
  36. package/dist/commands/org.js +83 -29
  37. package/dist/commands/output.js +2 -2
  38. package/dist/commands/profiles.js +2 -2
  39. package/dist/commands/prune.js +5 -4
  40. package/dist/commands/routines.js +43 -25
  41. package/dist/commands/routines.test-fixture.d.ts +86 -0
  42. package/dist/commands/routines.test-fixture.js +345 -0
  43. package/dist/commands/sessions-picker.js +124 -122
  44. package/dist/commands/sessions.test-fixture.d.ts +24 -0
  45. package/dist/commands/sessions.test-fixture.js +224 -0
  46. package/dist/commands/setup.js +9 -1
  47. package/dist/commands/share.d.ts +9 -10
  48. package/dist/commands/share.js +68 -50
  49. package/dist/commands/skills.js +1 -1
  50. package/dist/commands/snapshot.d.ts +1 -1
  51. package/dist/commands/snapshot.js +4 -4
  52. package/dist/commands/ssh.js +15 -62
  53. package/dist/commands/status.d.ts +4 -2
  54. package/dist/commands/status.js +10 -7
  55. package/dist/commands/subagents.js +1 -1
  56. package/dist/commands/sync.d.ts +1 -0
  57. package/dist/commands/sync.js +29 -2
  58. package/dist/commands/view.js +11 -2
  59. package/dist/commands/webhook.js +1 -1
  60. package/dist/lib/accounting/rotate.d.ts +5 -3
  61. package/dist/lib/accounting/rotate.js +36 -7
  62. package/dist/lib/accounting/usage.d.ts +18 -4
  63. package/dist/lib/accounting/usage.js +138 -27
  64. package/dist/lib/agent-spec/agents.d.ts +7 -7
  65. package/dist/lib/agent-spec/agents.js +15 -15
  66. package/dist/lib/analytics/mix-commands.d.ts +6 -28
  67. package/dist/lib/analytics/mix-commands.js +14 -40
  68. package/dist/lib/audit/log.d.ts +2 -2
  69. package/dist/lib/audit/log.js +2 -2
  70. package/dist/lib/beta.js +1 -1
  71. package/dist/lib/browser/domain-skills.d.ts +26 -6
  72. package/dist/lib/browser/domain-skills.js +81 -43
  73. package/dist/lib/cloud/rush.d.ts +0 -15
  74. package/dist/lib/cloud/rush.js +0 -35
  75. package/dist/lib/crabbox/runtimes.d.ts +6 -2
  76. package/dist/lib/crabbox/runtimes.js +21 -5
  77. package/dist/lib/daemon/daemon.js +97 -58
  78. package/dist/lib/daemon/daemon.test-fixture.d.ts +24 -0
  79. package/dist/lib/daemon/daemon.test-fixture.js +71 -0
  80. package/dist/lib/daemon/runner.d.ts +18 -1
  81. package/dist/lib/daemon/runner.js +105 -11
  82. package/dist/lib/daemon-ticks.d.ts +38 -1
  83. package/dist/lib/daemon-ticks.js +52 -5
  84. package/dist/lib/device-config.js +1 -2
  85. package/dist/lib/devices/discovery-policy.d.ts +3 -3
  86. package/dist/lib/devices/discovery-policy.js +3 -3
  87. package/dist/lib/devices/doctor-findings.js +1 -1
  88. package/dist/lib/devices/fleet.d.ts +4 -8
  89. package/dist/lib/devices/fleet.js +3 -15
  90. package/dist/lib/devices/health-report.js +4 -4
  91. package/dist/lib/devices/pool.d.ts +0 -6
  92. package/dist/lib/devices/pool.js +0 -6
  93. package/dist/lib/devices/registry.d.ts +0 -15
  94. package/dist/lib/devices/registry.js +0 -9
  95. package/dist/lib/devices/stats-cache.d.ts +12 -0
  96. package/dist/lib/devices/stats-cache.js +24 -5
  97. package/dist/lib/drift-sync.d.ts +1 -1
  98. package/dist/lib/drift-sync.js +2 -2
  99. package/dist/lib/drift.d.ts +1 -1
  100. package/dist/lib/exec.js +13 -3
  101. package/dist/lib/fleet/capture.d.ts +1 -1
  102. package/dist/lib/fleet/manifest.js +1 -1
  103. package/dist/lib/fleet/remote-login.d.ts +2 -2
  104. package/dist/lib/fleet/remote-login.js +3 -4
  105. package/dist/lib/format.d.ts +9 -0
  106. package/dist/lib/format.js +16 -0
  107. package/dist/lib/github/pr-mergeable.d.ts +49 -0
  108. package/dist/lib/github/pr-mergeable.js +157 -0
  109. package/dist/lib/github/pr-verdict.d.ts +67 -0
  110. package/dist/lib/github/pr-verdict.js +72 -0
  111. package/dist/lib/harness/adapter.d.ts +6 -5
  112. package/dist/lib/hosts/dispatch.d.ts +2 -0
  113. package/dist/lib/hosts/dispatch.js +8 -1
  114. package/dist/lib/hosts/passthrough.d.ts +10 -0
  115. package/dist/lib/hosts/passthrough.js +28 -7
  116. package/dist/lib/hosts/providers/devices.js +1 -13
  117. package/dist/lib/hosts/ready.d.ts +12 -3
  118. package/dist/lib/hosts/ready.js +27 -12
  119. package/dist/lib/hosts/registry.d.ts +4 -5
  120. package/dist/lib/hosts/registry.js +3 -7
  121. package/dist/lib/hosts/session-index.js +1 -1
  122. package/dist/lib/installations/shims.js +1 -1
  123. package/dist/lib/installations/versions.d.ts +40 -0
  124. package/dist/lib/installations/versions.js +78 -4
  125. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  126. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  127. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  128. package/dist/lib/menubar/install-menubar.d.ts +109 -0
  129. package/dist/lib/menubar/install-menubar.js +218 -12
  130. package/dist/lib/monitors/config.d.ts +2 -2
  131. package/dist/lib/monitors/config.js +3 -2
  132. package/dist/lib/origin-machine.d.ts +18 -0
  133. package/dist/lib/origin-machine.js +34 -0
  134. package/dist/lib/perf/db.js +1 -1
  135. package/dist/lib/perf/spool.js +1 -1
  136. package/dist/lib/prix-account.d.ts +2 -1
  137. package/dist/lib/prix-account.js +2 -1
  138. package/dist/lib/profiles.d.ts +7 -0
  139. package/dist/lib/profiles.js +26 -1
  140. package/dist/lib/remote-agents-json.js +1 -7
  141. package/dist/lib/routines-placement.d.ts +1 -1
  142. package/dist/lib/routines-placement.js +1 -1
  143. package/dist/lib/sandbox.d.ts +24 -0
  144. package/dist/lib/sandbox.js +101 -0
  145. package/dist/lib/scheduling/routines.d.ts +18 -9
  146. package/dist/lib/scheduling/routines.js +16 -8
  147. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  148. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  149. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  150. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  151. package/dist/lib/secrets/agent.js +9 -1
  152. package/dist/lib/secrets/index.js +1 -1
  153. package/dist/lib/secrets/list-filter.js +1 -1
  154. package/dist/lib/service-manifest.d.ts +20 -0
  155. package/dist/lib/service-manifest.js +34 -0
  156. package/dist/lib/session/active.d.ts +4 -16
  157. package/dist/lib/session/active.js +2 -6
  158. package/dist/lib/session/cloud.js +1 -1
  159. package/dist/lib/session/db.d.ts +13 -98
  160. package/dist/lib/session/db.js +5 -12
  161. package/dist/lib/session/discover.d.ts +5 -81
  162. package/dist/lib/session/discover.js +5 -5
  163. package/dist/lib/session/fork.js +1 -1
  164. package/dist/lib/session/live-metadata.js +1 -1
  165. package/dist/lib/session/origin-machine.d.ts +1 -18
  166. package/dist/lib/session/origin-machine.js +4 -34
  167. package/dist/lib/session/relative-time.d.ts +1 -44
  168. package/dist/lib/session/relative-time.js +4 -120
  169. package/dist/lib/session/remote/remote-bundle.d.ts +12 -0
  170. package/dist/lib/session/remote/remote-bundle.js +61 -0
  171. package/dist/lib/session/remote/remote-list.d.ts +148 -0
  172. package/dist/lib/session/remote/remote-list.js +607 -0
  173. package/dist/lib/session/remote/remote.d.ts +132 -0
  174. package/dist/lib/session/remote/remote.js +314 -0
  175. package/dist/lib/session/remote/watch.d.ts +101 -0
  176. package/dist/lib/session/remote/watch.js +241 -0
  177. package/dist/lib/session/remote-bundle.d.ts +1 -12
  178. package/dist/lib/session/remote-bundle.js +3 -61
  179. package/dist/lib/session/remote-list.d.ts +1 -148
  180. package/dist/lib/session/remote-list.js +3 -607
  181. package/dist/lib/session/remote.d.ts +1 -132
  182. package/dist/lib/session/remote.js +3 -314
  183. package/dist/lib/session/short-id.d.ts +1 -17
  184. package/dist/lib/session/short-id.js +4 -20
  185. package/dist/lib/session/watch.d.ts +1 -101
  186. package/dist/lib/session/watch.js +3 -242
  187. package/dist/lib/session/width.d.ts +1 -29
  188. package/dist/lib/session/width.js +4 -101
  189. package/dist/lib/share/config.d.ts +12 -2
  190. package/dist/lib/share/config.js +47 -11
  191. package/dist/lib/share/delete.js +1 -1
  192. package/dist/lib/share/publish.js +1 -1
  193. package/dist/lib/share/worker-template.js +2 -2
  194. package/dist/lib/smart-launch.d.ts +4 -4
  195. package/dist/lib/smart-launch.js +8 -18
  196. package/dist/lib/snapshot.d.ts +2 -2
  197. package/dist/lib/snapshot.js +1 -1
  198. package/dist/lib/staleness/index.d.ts +3 -1
  199. package/dist/lib/staleness/index.js +21 -1
  200. package/dist/lib/staleness/types.d.ts +11 -0
  201. package/dist/lib/staleness/writers/commands.js +13 -4
  202. package/dist/lib/staleness/writers/hooks.js +3 -1
  203. package/dist/lib/staleness/writers/mcp.d.ts +0 -7
  204. package/dist/lib/staleness/writers/mcp.js +16 -1
  205. package/dist/lib/staleness/writers/rules.js +1 -1
  206. package/dist/lib/staleness/writers/skills.js +3 -1
  207. package/dist/lib/staleness/writers/subagents.d.ts +0 -9
  208. package/dist/lib/staleness/writers/subagents.js +18 -1
  209. package/dist/lib/staleness/writers/types.d.ts +10 -0
  210. package/dist/lib/startup/command-registry.d.ts +10 -1
  211. package/dist/lib/startup/command-registry.js +26 -7
  212. package/dist/lib/startup/root-command.d.ts +18 -1
  213. package/dist/lib/startup/root-command.js +18 -1
  214. package/dist/lib/state.js +7 -0
  215. package/dist/lib/teams/placement-probe.js +28 -13
  216. package/dist/lib/teams/scheduler.d.ts +3 -1
  217. package/dist/lib/teams/scheduler.js +1 -1
  218. package/dist/lib/text/relative-time.d.ts +44 -0
  219. package/dist/lib/text/relative-time.js +120 -0
  220. package/dist/lib/text/short-id.d.ts +17 -0
  221. package/dist/lib/text/short-id.js +20 -0
  222. package/dist/lib/text/width.d.ts +29 -0
  223. package/dist/lib/text/width.js +101 -0
  224. package/dist/lib/triggers/handlers.d.ts +7 -0
  225. package/dist/lib/triggers/handlers.js +8 -0
  226. package/dist/lib/triggers/webhook.d.ts +1 -0
  227. package/dist/lib/triggers/webhook.js +2 -1
  228. package/dist/lib/types.d.ts +1 -1
  229. package/dist/lib/view-types.d.ts +7 -0
  230. package/package.json +2 -3
  231. package/dist/bin/agents +0 -0
  232. package/dist/commands/serve.d.ts +0 -10
  233. package/dist/commands/serve.js +0 -68
  234. package/dist/commands/tickets.d.ts +0 -2
  235. package/dist/commands/tickets.js +0 -43
  236. package/dist/commands/trends.d.ts +0 -10
  237. package/dist/commands/trends.js +0 -12
  238. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  239. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  240. package/dist/lib/observe-aliases.d.ts +0 -30
  241. package/dist/lib/observe-aliases.js +0 -38
  242. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  243. package/dist/lib/serve/control.d.ts +0 -95
  244. package/dist/lib/serve/control.js +0 -260
  245. package/dist/lib/serve/data.d.ts +0 -81
  246. package/dist/lib/serve/data.js +0 -91
  247. package/dist/lib/serve/page.d.ts +0 -7
  248. package/dist/lib/serve/page.js +0 -140
  249. package/dist/lib/serve/server.d.ts +0 -80
  250. package/dist/lib/serve/server.js +0 -145
  251. package/dist/lib/serve/stream.d.ts +0 -43
  252. package/dist/lib/serve/stream.js +0 -116
  253. package/dist/lib/serve/token.d.ts +0 -35
  254. package/dist/lib/serve/token.js +0 -85
  255. package/dist/lib/tickets/list.d.ts +0 -53
  256. package/dist/lib/tickets/list.js +0 -153
@@ -3,6 +3,7 @@
3
3
  *
4
4
  * Forms:
5
5
  * agents sync # umbrella: fetch config repos -> reconcile all (secrets opt-in)
6
+ * agents sync status # report fleet drift (former top-level `agents status`)
6
7
  * agents sync --repos|--secrets # umbrella: fetch only those, then reconcile
7
8
  * agents sync --cloud # umbrella: fetch all, skip reconcile
8
9
  * agents sync --local # umbrella: reconcile all, no fetch
@@ -34,7 +35,7 @@ import { Option } from 'commander';
34
35
  import chalk from 'chalk';
35
36
  import { resolveSyncPassphraseFromEnv } from '../lib/secrets/sync-passphrase.js';
36
37
  import { agentLabel, resolveAgentName, MANAGED_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
37
- import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, buildSelection, mergeRepoScopedSelections, listRepoNames, getVersionHomePath, } from '../lib/installations/versions.js';
38
+ import { isVersionInstalled, syncResourcesToVersion, parseAgentSpec, resolveVersion, resolveVersionAlias, listInstalledVersions, healDanglingVersionPointers, getAvailableResources, getActuallySyncedResources, getProjectOnlyResources, getNewResources, hasNewResources, promptResourceSelection, promptNewResourceSelection, buildSelection, mergeRepoScopedSelections, listRepoNames, getVersionHomePath, } from '../lib/installations/versions.js';
38
39
  import { capableAgents } from '../lib/capabilities.js';
39
40
  import { parseHookManifest, registerHooksToSettings } from '../lib/hooks/install.js';
40
41
  import { compileRulesForProject } from '../lib/rules/compile.js';
@@ -45,6 +46,7 @@ import { runUmbrellaSync } from '../lib/sync-umbrella.js';
45
46
  import { addHostOption } from '../lib/hosts/option.js';
46
47
  import { syncRepoGit } from '../lib/git.js';
47
48
  import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos } from '../lib/state.js';
49
+ import { registerStatusCommand } from './status.js';
48
50
  /** Emit one JSON object to stdout for `--json` callers / fleet fan-out. */
49
51
  function emitJson(payload) {
50
52
  console.log(JSON.stringify(payload));
@@ -134,7 +136,7 @@ export function addSelectorOptions(cmd) {
134
136
  export function registerSyncCommand(program) {
135
137
  const cmd = addHostOption(program.command('sync [agentSpec] [repo]'))
136
138
  .summary('Make this machine current, or sync resources into one agent')
137
- .description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142", a selector: @latest / @oldest / @pinned (= @default), or @all for every installed version.\n\nAppend a [repo] (or pass --repo) to scope the sync to a single DotAgent repo — system / user / project / <alias>. e.g. "agents sync claude@all system" reconciles only the system repo\'s resources into every installed Claude.\n\nGive a DotAgent repo name ALONE — "agents sync system" / "agents sync user" / "agents sync <alias>" — to git-sync that one repo: git pull --rebase against origin when the tree is clean; when it is dirty, fast-forward anyway if no incoming path is uncommitted, else refuse and name what collided. The user repo and extra aliases also push local commits up; the system repo is a pull-only mirror.\n\nWith NO agent, runs the umbrella verb: fetch the config repos then reconcile them into every installed agent. Secrets are opt-in — add --secrets to pull secret bundles. Session transcripts are queryable live via "agents sessions --device <machine>", or moved with "agents sessions export/import". Also: --cloud (fetch only), --local (reconcile only).')
139
+ .description('With an [agentSpec], syncs resources (commands, skills, hooks, rules, MCPs, plugins, etc.) into that installed agent version — previews changes and lets you pick. e.g. "claude", "claude@2.1.142", a selector: @latest / @oldest / @pinned (= @default), or @all for every installed version.\n\nAppend a [repo] (or pass --repo) to scope the sync to a single DotAgent repo — system / user / project / <alias>. e.g. "agents sync claude@all system" reconciles only the system repo\'s resources into every installed Claude.\n\nGive a DotAgent repo name ALONE — "agents sync system" / "agents sync user" / "agents sync <alias>" — to git-sync that one repo: git pull --rebase against origin when the tree is clean; when it is dirty, fast-forward anyway if no incoming path is uncommitted, else refuse and name what collided. The user repo and extra aliases also push local commits up; the system repo is a pull-only mirror.\n\nWith NO agent, runs the umbrella verb: fetch the config repos then reconcile them into every installed agent. Secrets are opt-in — add --secrets to pull secret bundles. Session transcripts are queryable live via "agents sessions --device <machine>", or moved with "agents sessions export/import". Also: --cloud (fetch only), --local (reconcile only).\n\n`agents sync status` reports fleet drift (what is drifted, missing, or behind) and can reconcile it — the former top-level `agents status`.')
138
140
  .option('--agent <agent>', 'Agent identifier (legacy form; prefer the positional spec)')
139
141
  .option('--agent-version <version>', 'Version to sync into (legacy form; prefer "agent@version" or --version)')
140
142
  .option('--repo <name>', 'Scope the sync to a single DotAgent repo: system / user / project / <alias> (also accepted as a positional)')
@@ -158,6 +160,8 @@ export function registerSyncCommand(program) {
158
160
  // Per-kind resource selectors + --version. Registered after the main flags so
159
161
  // help output groups the positional/repo/agent flags first.
160
162
  addSelectorOptions(cmd);
163
+ // `status` is a reserved subcommand (not an agentSpec/repo positional).
164
+ registerStatusCommand(cmd);
161
165
  }
162
166
  /**
163
167
  * Resolve a DotAgent repo name to its git working directory + whether local
@@ -507,6 +511,27 @@ async function runSync(agentSpec, repoArg, opts) {
507
511
  const projectDir = opts.projectDir;
508
512
  const cwd = opts.cwd || process.cwd();
509
513
  const force = !!opts.force;
514
+ // RUSH-2471: self-heal any version pointer (global/isolated default, ~/.<agent>
515
+ // symlink) left aimed at a version that is no longer installed BEFORE resolving
516
+ // the version to sync. Skipped on --dry-run since it mutates on-disk pointers.
517
+ let healed = {};
518
+ if (!opts.dryRun) {
519
+ healed = await healDanglingVersionPointers(agentId, cwd);
520
+ if (!quiet && !json) {
521
+ if (healed.globalDefault) {
522
+ const to = healed.globalDefault.to ? `@${healed.globalDefault.to}` : 'none';
523
+ outLog(chalk.yellow(`Reassigned ${agentLabel(agentId)} default from @${healed.globalDefault.from} (not installed) to ${to}.`));
524
+ }
525
+ if (healed.isolatedDefault) {
526
+ const to = healed.isolatedDefault.to ? `@${healed.isolatedDefault.to}` : 'none';
527
+ outLog(chalk.yellow(`Reassigned ${agentLabel(agentId)} isolated default from @${healed.isolatedDefault.from} (not installed) to ${to}.`));
528
+ }
529
+ if (healed.configSymlink) {
530
+ outLog(chalk.yellow(`Repointed ${agentLabel(agentId)} config symlink from @${healed.configSymlink.from} (not installed) to @${healed.configSymlink.to}.`));
531
+ }
532
+ }
533
+ }
534
+ const healedPointers = Object.keys(healed).length > 0 ? { healedPointers: healed } : {};
510
535
  // Promote to @all when no version can be resolved and multiple are installed.
511
536
  // Replaces the old "no default version pinned" error: bare `agents sync claude`
512
537
  // with multiple installed versions and no pinned default now syncs them all.
@@ -582,6 +607,7 @@ async function runSync(agentSpec, repoArg, opts) {
582
607
  mode: 'agent-all',
583
608
  agent: agentId,
584
609
  repo: repoScope,
610
+ ...healedPointers,
585
611
  versions: versions.map(({ version: v, result }) => ({
586
612
  version: v,
587
613
  commands: !!result.commands,
@@ -786,6 +812,7 @@ async function runSync(agentSpec, repoArg, opts) {
786
812
  if (json) {
787
813
  emitJson({
788
814
  ...agentSyncJson(agentId, version, result),
815
+ ...healedPointers,
789
816
  projectCompile: projectCompile
790
817
  ? {
791
818
  compiled: !!projectCompile.compiled,
@@ -1241,6 +1241,8 @@ export function parseResourceSections(options, jsonMode) {
1241
1241
  */
1242
1242
  export async function collectAgentsJson(filterAgentId, resourceSections) {
1243
1243
  const agentsToShow = filterAgentId ? [filterAgentId] : ALL_AGENT_IDS;
1244
+ const authCache = readAuthHealthCache();
1245
+ const host = machineId();
1244
1246
  const wantResources = !!resourceSections && resourceSections.size > 0;
1245
1247
  // Only pay for the git-status + resource scans when resources were requested.
1246
1248
  const resourceSync = wantResources ? await loadResourceSyncData() : null;
@@ -1293,6 +1295,7 @@ export async function collectAgentsJson(filterAgentId, resourceSections) {
1293
1295
  isolated: isVersionIsolated(agentId, version),
1294
1296
  isIsolatedDefault: getIsolatedDefault(agentId) === version,
1295
1297
  signedIn: info.signedIn,
1298
+ authVerdict: authCache[authCacheKey(host, agentId, version)]?.verdict ?? null,
1296
1299
  email: info.email,
1297
1300
  accountId: info.accountId,
1298
1301
  organizationType: info.organizationType ?? null,
@@ -1308,6 +1311,12 @@ export async function collectAgentsJson(filterAgentId, resourceSections) {
1308
1311
  resetsAt: w.resetsAt ? w.resetsAt.toISOString() : null,
1309
1312
  }))
1310
1313
  : [],
1314
+ unavailable: snapshot?.unavailable
1315
+ ? {
1316
+ reason: snapshot.unavailable.reason,
1317
+ resetsAt: snapshot.unavailable.resetsAt.toISOString(),
1318
+ }
1319
+ : undefined,
1311
1320
  lastActive: info.lastActive ? info.lastActive.toISOString() : null,
1312
1321
  path: getVersionDir(agentId, version),
1313
1322
  configuredModel: resolveConfiguredModel(agentId, version),
@@ -1734,8 +1743,8 @@ Output:
1734
1743
  - With agent name: versions for that agent, showing which is the default
1735
1744
  - With a custom harness name: that harness's host, model, provider, and auth
1736
1745
  - With agent@version: detailed breakdown of resources synced to that version
1737
- - With --json: structured JSON with version, isDefault, signedIn, email, plan,
1738
- usageStatus, per-window usedPercent, lastActive, and path
1746
+ - With --json: structured JSON with version, isDefault, signedIn, authVerdict,
1747
+ email, plan, usageStatus, per-window usedPercent, lastActive, and path
1739
1748
  - With --prune: plan of which older versions will be removed, then confirm
1740
1749
  - With --prune --dry-run: preview only, no deletions
1741
1750
  `)
@@ -85,7 +85,7 @@ export function registerWebhooksCommand(program) {
85
85
  const address = server.address();
86
86
  const bound = typeof address === 'object' && address ? address.port : port;
87
87
  console.log(`${chalk.green('agents webhooks')} ${chalk.dim('→')} ${chalk.cyan(`http://${opts.bind ?? DEFAULT_HOST}:${bound}`)}`);
88
- console.log(chalk.dim('signed · localhost by default · endpoints: /hooks/github, /hooks/linear · acks 202 then dispatches · Ctrl-C to stop'));
88
+ console.log(chalk.dim('signed · localhost by default · endpoints: /hooks/github, /hooks/linear, /hooks/slack · acks 202 then dispatches · Ctrl-C to stop'));
89
89
  console.log(chalk.dim('for a supervised receiver that survives reboot: agents daemon webhooks add --secrets-bundle <name>'));
90
90
  const shutdown = () => {
91
91
  server.close(() => process.exit(0));
@@ -58,9 +58,11 @@ export interface RotateResult {
58
58
  /** Candidates excluded (not signed in, or out of credits). */
59
59
  excluded: RotateCandidate[];
60
60
  /**
61
- * True when NO candidate on this machine had usage data fresh enough to decide
62
- * on, so the pick was made from unverified snapshots. Callers surface it —
63
- * routing blind is a fact the operator needs, not an internal detail.
61
+ * True when the PICKED candidate's usage could not be verified fresh, so the
62
+ * route was decided on an unverified snapshot. Callers surface it — routing
63
+ * blind is a fact the operator needs, not an internal detail. (Previously
64
+ * this only reported the all-stale case; a stale pick out of a mixed pool
65
+ * was silently reported as verified.)
64
66
  */
65
67
  usageUnverified?: boolean;
66
68
  }
@@ -122,7 +122,7 @@ export function isUsageVerified(candidate, nowMs = Date.now()) {
122
122
  }
123
123
  function hasUsageAvailable(candidate) {
124
124
  const snapshot = candidate.usageSnapshot;
125
- if (snapshot && snapshot.windows.length > 0) {
125
+ if (snapshot) {
126
126
  // Eligibility mirrors the `agents view` throttle badge exactly
127
127
  // (deriveUsageStatusFromSnapshot): an account maxed on ANY blocking window —
128
128
  // including the 5-hour session window — cannot serve the next request, so it
@@ -131,7 +131,9 @@ function hasUsageAvailable(candidate) {
131
131
  // stayed "eligible" and the router kept launching into it while `ag view`
132
132
  // showed it rate-limited. Capacity *weighting* still ranks eligible accounts
133
133
  // by weekly headroom; this gate only decides can-it-run-right-now.
134
- return deriveUsageStatusFromSnapshot(snapshot) !== 'rate_limited';
134
+ const status = deriveUsageStatusFromSnapshot(snapshot);
135
+ if (status !== null)
136
+ return status !== 'rate_limited';
135
137
  }
136
138
  // No live snapshot: fall back to the coarse cached status.
137
139
  if (candidate.usageStatus === 'out_of_credits' || candidate.usageStatus === 'rate_limited') {
@@ -300,11 +302,12 @@ export function pickBalancedCandidate(candidates, nowMs = Date.now()) {
300
302
  if (!deduped.has(c))
301
303
  excluded.push(c);
302
304
  }
303
- const { picked, usageUnverified } = preferVerified(sorted, nowMs, weightedRandomByCapacity);
305
+ const { picked, usageUnverified } = preferVerified(sorted, nowMs, weightedRandomByCapacity, 'representative');
304
306
  return { picked, healthy: sorted, excluded, usageUnverified };
305
307
  }
306
308
  /**
307
- * Choose from the VERIFIED candidates when any exist, else from the whole pool.
309
+ * Choose from the VERIFIED candidates when they are a representative share of
310
+ * the pool (at least half), else from the whole pool.
308
311
  *
309
312
  * An eligible account whose usage we could not confirm is a guess, not a green
310
313
  * light: the snapshot reads "48% used" with equal confidence whether it was
@@ -312,6 +315,29 @@ export function pickBalancedCandidate(candidates, nowMs = Date.now()) {
312
315
  * wrong indefinitely. Confirmed headroom therefore beats apparent headroom, even
313
316
  * when the unconfirmed number looks better.
314
317
  *
318
+ * But narrowing to a verified MINORITY inverts the safety. When the usage
319
+ * endpoint 429-throttles a machine, each refresh cycle confirms roughly one
320
+ * account before backing off — so "verified" is a singleton, `choose` runs over
321
+ * a one-element list, and every launch lands on the same account. Launching
322
+ * into it refreshes that account's snapshot again, so it stays the only
323
+ * verified candidate while the rest are never picked and never probed: the
324
+ * rotation degrades to a fixed pin that burns one account to its weekly cap
325
+ * while its siblings idle (observed 2026-08-20 across yosemite-s0/s1 — the
326
+ * "same version every time under --strategy balanced" incident).
327
+ *
328
+ * The two failure modes belong to different CHOOSERS, so `narrowing` is picked
329
+ * per caller: `'representative'` (the weighted-random balanced path) narrows to
330
+ * verified only when they cover at least half the pool — below that the whole
331
+ * pool competes, fresh candidates keep their confirmed weights, stale/unknown
332
+ * ones get the full default weight `capacityWeight` assigns to "no signal", and
333
+ * the random roll spreads the load. `'any-verified'` (deterministic `from[0]`
334
+ * choosers: `--strategy available`, run-auto harness classification) keeps the
335
+ * original rule — narrow whenever ANY verified candidate exists — because a
336
+ * deterministic pick over the whole pool would hand the front slot back to an
337
+ * unconfirmed "48% used" over an accurate "90% used", the exact yosemite-s1
338
+ * inversion above, and a deterministic chooser cannot spread load anyway, so
339
+ * the singleton-collapse concern does not apply to it.
340
+ *
315
341
  * `healthy` deliberately keeps every eligible candidate rather than just the
316
342
  * verified ones. Declining to *pick* an account on stale data and declining to
317
343
  * *fail over to* it after the primary has already hit a 429 are different risks:
@@ -319,11 +345,14 @@ export function pickBalancedCandidate(candidates, nowMs = Date.now()) {
319
345
  * (rotationFailoverChain, which reads `healthy`) keeps its full safety net —
320
346
  * exactly on the machines this guard is protecting.
321
347
  */
322
- function preferVerified(pool, nowMs, choose) {
348
+ function preferVerified(pool, nowMs, choose, narrowing = 'any-verified') {
323
349
  const verified = pool.filter((c) => isUsageVerified(c, nowMs));
350
+ const narrow = verified.length > 0 &&
351
+ (narrowing === 'any-verified' || verified.length >= Math.ceil(pool.length / 2));
352
+ const picked = choose(narrow ? verified : pool);
324
353
  return {
325
- picked: choose(verified.length > 0 ? verified : pool),
326
- usageUnverified: verified.length === 0,
354
+ picked,
355
+ usageUnverified: !isUsageVerified(picked, nowMs),
327
356
  };
328
357
  }
329
358
  /**
@@ -91,6 +91,11 @@ export interface UsageSnapshot {
91
91
  capturedAt: Date | null;
92
92
  windows: UsageWindow[];
93
93
  plan?: string | null;
94
+ /** A refusal observed from a real harness run, independent of API windows. */
95
+ unavailable?: {
96
+ reason: 'session_limit';
97
+ resetsAt: Date;
98
+ };
94
99
  }
95
100
  /** Usage data plus any error encountered while fetching. */
96
101
  export interface UsageInfo {
@@ -464,7 +469,6 @@ export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse):
464
469
  * the Claude CLI stores its OAuth token in this plaintext file instead. The
465
470
  * keychain read above finds nothing on that platform, so we fall back to the
466
471
  * file. Same wrapped `{ claudeAiOauth }` shape, so one parser handles both.
467
- * Mirrors `readClaudeCredentialsBlob` (cloud/rush.ts), the proven pattern.
468
472
  *
469
473
  * Without step 2 the live usage fetch got no token on Linux, so `agents view`
470
474
  * (run remotely over SSH by `--device`) rendered no usage bars even though the
@@ -476,9 +480,10 @@ export declare function normalizeDroidWindows(data: DroidBillingLimitsResponse):
476
480
  * reads Claude Code's interactive login (transmitting that ACL-bound OAuth token
477
481
  * to Anthropic's API is what gets it revoked; see the branch body and
478
482
  * docs/credential-management.md). It is OFF by default so full-credential
479
- * callers that refresh (`isClaudeAuthValid` -> `getClaudeAccessToken`) or export
480
- * the full blob (`readClaudeCredentialsBlob` for Rush Cloud dispatch) still read
481
- * the interactive login.
483
+ * callers that refresh (`isClaudeAuthValid` -> `getClaudeAccessToken`) still
484
+ * read the interactive login. Rush Cloud dispatch does not call this helper
485
+ * at all (SING-1b: the account manifest is email-only; RUSH-2359 removed the
486
+ * leftover blob reader that used to send the interactive login).
482
487
  *
483
488
  * `opts.fileOnly` skips the ACL keychain read entirely — setup-token and
484
489
  * `.credentials.json` only. Used by the daemon usage refresher so a background
@@ -506,8 +511,17 @@ export declare function getClaudeKeychainService(home?: string): string;
506
511
  export declare function isClaudeUsageOrgMatch(requestedOrgId: string | null | undefined, liveOrgId: string | null | undefined): boolean;
507
512
  /** Read a cached usage snapshot for a given usage key. Returns null if absent or stale. */
508
513
  export declare function readClaudeUsageCache(usageKey: string, cachePath?: string, now?: Date): UsageSnapshot | null;
514
+ /** Delete an expired cache row only if it is still expired under the write lock. */
515
+ export declare function pruneExpiredClaudeUsageCacheEntry(usageKey: string, cachePath?: string, now?: Date): void;
509
516
  /** Write a usage snapshot to the on-disk cache. */
510
517
  export declare function writeClaudeUsageCache(usageKey: string, snapshot: UsageSnapshot, cachePath?: string): void;
518
+ /**
519
+ * Persist a Claude session-limit refusal from a real run until its stated reset.
520
+ * This quota is not part of Anthropic's five-hour/weekly usage response.
521
+ */
522
+ export declare function noteClaudeSessionLimit(usageKey: string, resetsAt: Date, cachePath?: string): void;
523
+ /** Parse Claude's `hit your session limit · resets …` refusal. */
524
+ export declare function parseClaudeSessionLimitReset(text: string, nowMs?: number): Date | null;
511
525
  /**
512
526
  * Check whether the Claude OAuth credentials for a given home are usable.
513
527
  * Attempts a token refresh if the access token is expired.
@@ -398,6 +398,9 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
398
398
  parts.push(chalk.gray(plan.padEnd(planWidth)));
399
399
  }
400
400
  if (snapshot) {
401
+ if (snapshot.unavailable?.reason === 'session_limit') {
402
+ parts.push(chalk.yellow(`session-limited (${formatResetHint(snapshot.unavailable.resetsAt)})`));
403
+ }
401
404
  // Compact rows show BLOCKING windows — the same set
402
405
  // deriveUsageStatusFromSnapshot uses for the rate-limited badge — so an
403
406
  // account throttled by its month window (Droid meters on 5h/week/month)
@@ -467,7 +470,12 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
467
470
  * one model, not the account, so it shouldn't flip the whole row to throttled.
468
471
  */
469
472
  export function deriveUsageStatusFromSnapshot(snapshot) {
470
- if (!snapshot || snapshot.windows.length === 0)
473
+ if (!snapshot)
474
+ return null;
475
+ if (snapshot.unavailable && snapshot.unavailable.resetsAt.getTime() > Date.now()) {
476
+ return 'rate_limited';
477
+ }
478
+ if (snapshot.windows.length === 0)
471
479
  return null;
472
480
  const blocking = snapshot.windows.filter((window) => window.key !== 'sonnet_week');
473
481
  const windows = blocking.length > 0 ? blocking : snapshot.windows;
@@ -1286,7 +1294,6 @@ function deleteCachedClaudeOauth(service) {
1286
1294
  * the Claude CLI stores its OAuth token in this plaintext file instead. The
1287
1295
  * keychain read above finds nothing on that platform, so we fall back to the
1288
1296
  * file. Same wrapped `{ claudeAiOauth }` shape, so one parser handles both.
1289
- * Mirrors `readClaudeCredentialsBlob` (cloud/rush.ts), the proven pattern.
1290
1297
  *
1291
1298
  * Without step 2 the live usage fetch got no token on Linux, so `agents view`
1292
1299
  * (run remotely over SSH by `--device`) rendered no usage bars even though the
@@ -1298,9 +1305,10 @@ function deleteCachedClaudeOauth(service) {
1298
1305
  * reads Claude Code's interactive login (transmitting that ACL-bound OAuth token
1299
1306
  * to Anthropic's API is what gets it revoked; see the branch body and
1300
1307
  * docs/credential-management.md). It is OFF by default so full-credential
1301
- * callers that refresh (`isClaudeAuthValid` -> `getClaudeAccessToken`) or export
1302
- * the full blob (`readClaudeCredentialsBlob` for Rush Cloud dispatch) still read
1303
- * the interactive login.
1308
+ * callers that refresh (`isClaudeAuthValid` -> `getClaudeAccessToken`) still
1309
+ * read the interactive login. Rush Cloud dispatch does not call this helper
1310
+ * at all (SING-1b: the account manifest is email-only; RUSH-2359 removed the
1311
+ * leftover blob reader that used to send the interactive login).
1304
1312
  *
1305
1313
  * `opts.fileOnly` skips the ACL keychain read entirely — setup-token and
1306
1314
  * `.credentials.json` only. Used by the daemon usage refresher so a background
@@ -1334,9 +1342,10 @@ export async function loadClaudeOauth(home, opts) {
1334
1342
  // setup-token via the mint-auth path to restore usage/probe for the account.
1335
1343
  return null;
1336
1344
  }
1337
- // Full-credential callers (isClaudeAuthValid -> getClaudeAccessToken; Rush
1338
- // Cloud dispatch) legitimately read the interactive login to run/refresh
1339
- // Claude. The OS keychain/keyring step is macOS/Linux-only; Windows and any
1345
+ // Full-credential callers (isClaudeAuthValid -> getClaudeAccessToken)
1346
+ // legitimately read the interactive login to run/refresh Claude. Rush Cloud
1347
+ // dispatch does not (SING-1b / RUSH-2359). The OS keychain/keyring step is
1348
+ // macOS/Linux-only; Windows and any
1340
1349
  // fileOnly caller skip to the .credentials.json read below (the Claude CLI
1341
1350
  // stores its OAuth token in that file too). An injected test backend makes the
1342
1351
  // keychain path exercisable anywhere, so the platform check yields to it.
@@ -1446,11 +1455,29 @@ export function readClaudeUsageCache(usageKey, cachePath = getClaudeUsageCachePa
1446
1455
  }
1447
1456
  const snapshot = deserializeClaudeUsageSnapshot(cached, now);
1448
1457
  if (!snapshot) {
1449
- delete cache[usageKey];
1450
- writeClaudeUsageCacheFile(cache, cachePath);
1458
+ pruneExpiredClaudeUsageCacheEntry(usageKey, cachePath, now);
1451
1459
  }
1452
1460
  return snapshot;
1453
1461
  }
1462
+ /** Delete an expired cache row only if it is still expired under the write lock. */
1463
+ export function pruneExpiredClaudeUsageCacheEntry(usageKey, cachePath = getClaudeUsageCachePath(), now = new Date()) {
1464
+ try {
1465
+ ensureLockTarget(cachePath, '{}');
1466
+ withFileLock(cachePath, () => {
1467
+ // The row may have been refreshed after readClaudeUsageCache observed it.
1468
+ // Re-read under the lock so stale cleanup cannot erase that newer write.
1469
+ const latest = readClaudeUsageCacheFile(cachePath);
1470
+ const current = latest[usageKey];
1471
+ if (!current || deserializeClaudeUsageSnapshot(current, now))
1472
+ return;
1473
+ delete latest[usageKey];
1474
+ atomicWriteFileSync(cachePath, JSON.stringify(latest, null, 2), 'utf-8');
1475
+ });
1476
+ }
1477
+ catch {
1478
+ /* best-effort cache cleanup — lock busy or disk full */
1479
+ }
1480
+ }
1454
1481
  /** Write a usage snapshot to the on-disk cache. */
1455
1482
  export function writeClaudeUsageCache(usageKey, snapshot, cachePath = getClaudeUsageCachePath()) {
1456
1483
  try {
@@ -1459,7 +1486,14 @@ export function writeClaudeUsageCache(usageKey, snapshot, cachePath = getClaudeU
1459
1486
  // Re-read under the lock so a concurrent daemon tick / agents view
1460
1487
  // refresh cannot drop another account's row (lost update).
1461
1488
  const cache = readClaudeUsageCacheFile(cachePath);
1462
- cache[usageKey] = serializeClaudeUsageSnapshot(snapshot);
1489
+ const prior = cache[usageKey];
1490
+ const priorReset = parseDateValue(prior?.unavailable?.resetsAt);
1491
+ cache[usageKey] = serializeClaudeUsageSnapshot({
1492
+ ...snapshot,
1493
+ unavailable: priorReset && priorReset.getTime() > Date.now()
1494
+ ? { reason: 'session_limit', resetsAt: priorReset }
1495
+ : snapshot.unavailable,
1496
+ });
1463
1497
  atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1464
1498
  });
1465
1499
  }
@@ -1495,6 +1529,9 @@ function serializeClaudeUsageSnapshot(snapshot) {
1495
1529
  return {
1496
1530
  capturedAt: snapshot.capturedAt?.toISOString() || null,
1497
1531
  plan: snapshot.plan ?? null,
1532
+ unavailable: snapshot.unavailable
1533
+ ? { reason: snapshot.unavailable.reason, resetsAt: snapshot.unavailable.resetsAt.toISOString() }
1534
+ : undefined,
1498
1535
  windows: snapshot.windows.map((window) => ({
1499
1536
  key: window.key,
1500
1537
  label: window.label,
@@ -1505,25 +1542,34 @@ function serializeClaudeUsageSnapshot(snapshot) {
1505
1542
  })),
1506
1543
  };
1507
1544
  }
1508
- /** Deserialize a cached snapshot, zeroing out windows whose reset time has passed. */
1545
+ /**
1546
+ * Deserialize a cached snapshot, dropping windows whose reset time has passed.
1547
+ *
1548
+ * An expired window is UNKNOWN, not 0%: the counter reset, and anything may
1549
+ * have burned since. Zeroing-but-keeping it (the previous behavior) rendered a
1550
+ * weeks-frozen cache as "S: 0% (now)" with `deriveUsageStatusFromSnapshot` →
1551
+ * 'available', so a genuinely rate-limited account read as an idle dispatch
1552
+ * candidate (RUSH-2858). Dropping mirrors the Grok collector, and an all-expired
1553
+ * snapshot deserializes to null so `readClaudeUsageCache` deletes the entry and
1554
+ * callers surface "usage unavailable" plus the recorded throttle reason.
1555
+ */
1509
1556
  function deserializeClaudeUsageSnapshot(snapshot, now) {
1510
1557
  const capturedAt = parseDateValue(snapshot.capturedAt);
1511
1558
  const windows = snapshot.windows
1512
- .map((window) => {
1513
- const w = {
1514
- key: window.key,
1515
- label: window.label,
1516
- shortLabel: window.shortLabel,
1517
- usedPercent: window.usedPercent,
1518
- resetsAt: parseDateValue(window.resetsAt),
1519
- windowMinutes: window.windowMinutes,
1520
- };
1521
- if (!isCachedUsageWindowFresh(w, capturedAt, now)) {
1522
- w.usedPercent = 0;
1523
- }
1524
- return w;
1525
- });
1526
- if (windows.length === 0) {
1559
+ .map((window) => ({
1560
+ key: window.key,
1561
+ label: window.label,
1562
+ shortLabel: window.shortLabel,
1563
+ usedPercent: window.usedPercent,
1564
+ resetsAt: parseDateValue(window.resetsAt),
1565
+ windowMinutes: window.windowMinutes,
1566
+ }))
1567
+ .filter((window) => isCachedUsageWindowFresh(window, capturedAt, now));
1568
+ const unavailableReset = parseDateValue(snapshot.unavailable?.resetsAt);
1569
+ const unavailable = unavailableReset && unavailableReset.getTime() > now.getTime()
1570
+ ? { reason: 'session_limit', resetsAt: unavailableReset }
1571
+ : undefined;
1572
+ if (windows.length === 0 && !unavailable) {
1527
1573
  return null;
1528
1574
  }
1529
1575
  return {
@@ -1532,8 +1578,73 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
1532
1578
  capturedAt,
1533
1579
  windows,
1534
1580
  plan: snapshot.plan ?? null,
1581
+ unavailable,
1535
1582
  };
1536
1583
  }
1584
+ /**
1585
+ * Persist a Claude session-limit refusal from a real run until its stated reset.
1586
+ * This quota is not part of Anthropic's five-hour/weekly usage response.
1587
+ */
1588
+ export function noteClaudeSessionLimit(usageKey, resetsAt, cachePath = getClaudeUsageCachePath()) {
1589
+ try {
1590
+ ensureLockTarget(cachePath, '{}');
1591
+ withFileLock(cachePath, () => {
1592
+ const cache = readClaudeUsageCacheFile(cachePath);
1593
+ const existing = cache[usageKey] ?? { capturedAt: null, windows: [] };
1594
+ cache[usageKey] = {
1595
+ ...existing,
1596
+ unavailable: { reason: 'session_limit', resetsAt: resetsAt.toISOString() },
1597
+ };
1598
+ atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1599
+ });
1600
+ }
1601
+ catch {
1602
+ /* best-effort cache write — lock busy or disk full */
1603
+ }
1604
+ }
1605
+ /** Parse Claude's `hit your session limit · resets …` refusal. */
1606
+ export function parseClaudeSessionLimitReset(text, nowMs = Date.now()) {
1607
+ if (!/hit your session limit/i.test(text))
1608
+ return null;
1609
+ const match = /resets\s+([^.;!\n]+)/i.exec(text);
1610
+ if (!match)
1611
+ return null;
1612
+ const segment = match[1].trim();
1613
+ const timeZone = /\(([A-Za-z_]+\/[A-Za-z_]+)\)/.exec(segment)?.[1];
1614
+ const timePart = segment.replace(/\([A-Za-z_]+\/[A-Za-z_]+\)/, '').trim();
1615
+ const absolute = Date.parse(timePart);
1616
+ if (!Number.isNaN(absolute) && absolute > nowMs)
1617
+ return new Date(absolute);
1618
+ const clock = /(\d{1,2})(?::(\d{2}))?\s*(am|pm)/i.exec(timePart);
1619
+ if (!clock)
1620
+ return null;
1621
+ let hour = Number(clock[1]) % 12;
1622
+ if (clock[3].toLowerCase() === 'pm')
1623
+ hour += 12;
1624
+ const minute = clock[2] ? Number(clock[2]) : 0;
1625
+ try {
1626
+ if (!timeZone) {
1627
+ const result = new Date(nowMs);
1628
+ result.setHours(hour, minute, 0, 0);
1629
+ if (result.getTime() <= nowMs)
1630
+ result.setDate(result.getDate() + 1);
1631
+ return result;
1632
+ }
1633
+ const parts = new Intl.DateTimeFormat('en-US', {
1634
+ timeZone, hour12: false, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
1635
+ }).formatToParts(new Date(nowMs));
1636
+ const part = (type) => Number(parts.find((item) => item.type === type)?.value ?? 0);
1637
+ const wallNow = Date.UTC(part('year'), part('month') - 1, part('day'), part('hour') % 24, part('minute'));
1638
+ const offset = wallNow - nowMs;
1639
+ let result = Date.UTC(part('year'), part('month') - 1, part('day'), hour, minute) - offset;
1640
+ if (result <= nowMs)
1641
+ result += 24 * 60 * 60 * 1000;
1642
+ return new Date(result);
1643
+ }
1644
+ catch {
1645
+ return null;
1646
+ }
1647
+ }
1537
1648
  /** Check whether a cached usage window is still relevant (not expired or reset). */
1538
1649
  function isCachedUsageWindowFresh(window, capturedAt, now) {
1539
1650
  if (window.resetsAt && window.resetsAt.getTime() <= now.getTime()) {
@@ -85,15 +85,15 @@ export declare const AGENTS: Record<AgentId, AgentRegistryConfig>;
85
85
  /** All current and legacy agent IDs derived from the AGENTS registry. */
86
86
  export declare const ALL_AGENT_IDS: AgentId[];
87
87
  /**
88
- * CLI command templates per agent for daemon-fired routine jobs, with
89
- * {prompt} as a placeholder. Lives here (not runner.ts) so routines.ts can
90
- * import ROUTINE_AGENT_IDS for schedule-time validation without a circular
88
+ * Agents the routine daemon can fire locally. Lives here (not runner.ts) so
89
+ * routines.ts can import it for schedule-time validation without a circular
91
90
  * import (runner.ts already imports from routines.ts).
91
+ *
92
+ * This is a curated subset of AGENT_COMMANDS, not "every harness that can run
93
+ * headlessly". Expanding it is a product change (gemini is hard-deprecated and
94
+ * is deliberately absent). Argv itself is baked from AGENT_COMMANDS in
95
+ * daemon/runner.ts bakeRoutineArgv — do not reintroduce a second token table.
92
96
  */
93
- export declare const ROUTINE_AGENT_COMMANDS: Record<string, string[]>;
94
- /** Agents the routine daemon can actually run when firing locally, derived
95
- * from the command table above so the `--agent` help and validateJob's
96
- * schedule-time check can never drift from it. */
97
97
  export declare const ROUTINE_AGENT_IDS: readonly string[];
98
98
  /** Agents retained only for legacy reads, not install/import/sync targets. */
99
99
  export declare const HARD_DEPRECATED_AGENT_IDS: AgentId[];
@@ -1087,26 +1087,26 @@ export const AGENTS = {
1087
1087
  /** All current and legacy agent IDs derived from the AGENTS registry. */
1088
1088
  export const ALL_AGENT_IDS = Object.keys(AGENTS);
1089
1089
  /**
1090
- * CLI command templates per agent for daemon-fired routine jobs, with
1091
- * {prompt} as a placeholder. Lives here (not runner.ts) so routines.ts can
1092
- * import ROUTINE_AGENT_IDS for schedule-time validation without a circular
1090
+ * Agents the routine daemon can fire locally. Lives here (not runner.ts) so
1091
+ * routines.ts can import it for schedule-time validation without a circular
1093
1092
  * import (runner.ts already imports from routines.ts).
1093
+ *
1094
+ * This is a curated subset of AGENT_COMMANDS, not "every harness that can run
1095
+ * headlessly". Expanding it is a product change (gemini is hard-deprecated and
1096
+ * is deliberately absent). Argv itself is baked from AGENT_COMMANDS in
1097
+ * daemon/runner.ts bakeRoutineArgv — do not reintroduce a second token table.
1094
1098
  */
1095
- export const ROUTINE_AGENT_COMMANDS = {
1096
- claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
1097
- codex: ['codex', 'exec', '{prompt}', '--json'],
1099
+ export const ROUTINE_AGENT_IDS = Object.freeze([
1100
+ 'claude',
1101
+ 'codex',
1098
1102
  // gemini is hard-deprecated (Antigravity replaced it) — no routine target. A
1099
1103
  // legacy gemini routine now fails validateJob loud instead of firing a
1100
1104
  // retired backend; the id survives only for reading old sessions/config.
1101
- cursor: ['cursor-agent', '-p', '{prompt}', '--output-format', 'stream-json'],
1102
- kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
1103
- droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
1104
- muse: ['muse', 'exec', '{prompt}', '--json'],
1105
- };
1106
- /** Agents the routine daemon can actually run when firing locally, derived
1107
- * from the command table above so the `--agent` help and validateJob's
1108
- * schedule-time check can never drift from it. */
1109
- export const ROUTINE_AGENT_IDS = Object.freeze(Object.keys(ROUTINE_AGENT_COMMANDS));
1105
+ 'cursor',
1106
+ 'kimi',
1107
+ 'droid',
1108
+ 'muse',
1109
+ ]);
1110
1110
  /** Agents retained only for legacy reads, not install/import/sync targets. */
1111
1111
  export const HARD_DEPRECATED_AGENT_IDS = ALL_AGENT_IDS.filter((id) => AGENTS[id].deprecated?.hard);
1112
1112
  /** Agents that can receive managed installs, imports, and resource sync writes. */