@phnx-labs/agents-cli 1.22.46 → 1.22.49

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 (348) hide show
  1. package/CHANGELOG.md +663 -0
  2. package/LICENSE +102 -182
  3. package/README.md +96 -49
  4. package/dist/bootstrap.js +4 -2
  5. package/dist/cli/command-registry.d.ts +2 -1
  6. package/dist/cli/command-registry.js +6 -2
  7. package/dist/commands/accounts.d.ts +58 -0
  8. package/dist/commands/accounts.js +243 -25
  9. package/dist/commands/artifacts.js +7 -6
  10. package/dist/commands/attach.js +1 -1
  11. package/dist/commands/auth.js +7 -5
  12. package/dist/commands/browser-sessions-picker.js +9 -7
  13. package/dist/commands/browser.js +711 -62
  14. package/dist/commands/config.d.ts +0 -1
  15. package/dist/commands/config.js +26 -24
  16. package/dist/commands/cost.d.ts +1 -1
  17. package/dist/commands/daemon.d.ts +21 -0
  18. package/dist/commands/daemon.js +147 -12
  19. package/dist/commands/detach.d.ts +13 -0
  20. package/dist/commands/detach.js +11 -8
  21. package/dist/commands/exec.js +107 -53
  22. package/dist/commands/feed-watch.d.ts +2 -0
  23. package/dist/commands/feed-watch.js +28 -0
  24. package/dist/commands/feed.js +27 -0
  25. package/dist/commands/feedback.js +3 -19
  26. package/dist/commands/focus.d.ts +14 -0
  27. package/dist/commands/focus.js +48 -5
  28. package/dist/commands/go.d.ts +3 -0
  29. package/dist/commands/go.js +16 -3
  30. package/dist/commands/insights.d.ts +1 -1
  31. package/dist/commands/insights.js +2 -2
  32. package/dist/commands/inspect.js +2 -2
  33. package/dist/commands/lease.d.ts +1 -1
  34. package/dist/commands/lease.js +7 -6
  35. package/dist/commands/mailboxes.js +4 -3
  36. package/dist/commands/menubar.js +11 -4
  37. package/dist/commands/projects.d.ts +16 -1
  38. package/dist/commands/projects.js +58 -64
  39. package/dist/commands/resume.js +1 -1
  40. package/dist/commands/route.js +4 -4
  41. package/dist/commands/routines.js +4 -3
  42. package/dist/commands/run-broadcast.d.ts +1 -1
  43. package/dist/commands/run-broadcast.js +1 -1
  44. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  45. package/dist/commands/sessions-picker-factory.js +1 -1
  46. package/dist/commands/sessions-picker.d.ts +22 -0
  47. package/dist/commands/sessions-picker.js +48 -9
  48. package/dist/commands/sessions-stop.d.ts +24 -0
  49. package/dist/commands/sessions-stop.js +66 -0
  50. package/dist/commands/sessions-trace.d.ts +82 -0
  51. package/dist/commands/sessions-trace.js +387 -0
  52. package/dist/commands/sessions.js +5 -0
  53. package/dist/commands/setup-secrets.js +23 -0
  54. package/dist/commands/share.d.ts +20 -5
  55. package/dist/commands/share.js +152 -52
  56. package/dist/commands/ssh.d.ts +15 -0
  57. package/dist/commands/ssh.js +243 -18
  58. package/dist/commands/teams.js +34 -2
  59. package/dist/commands/traces.d.ts +2 -0
  60. package/dist/commands/traces.js +184 -0
  61. package/dist/commands/view.d.ts +2 -0
  62. package/dist/commands/view.js +32 -62
  63. package/dist/commands/watchdog.js +9 -14
  64. package/dist/index.d.ts +1 -0
  65. package/dist/index.js +5 -0
  66. package/dist/lib/account-capabilities.d.ts +4 -0
  67. package/dist/lib/account-capabilities.js +13 -9
  68. package/dist/lib/account-registry.d.ts +2 -0
  69. package/dist/lib/account-registry.js +30 -2
  70. package/dist/lib/accounting/rotate.d.ts +9 -0
  71. package/dist/lib/accounting/rotate.js +12 -2
  72. package/dist/lib/accounting/usage.d.ts +84 -6
  73. package/dist/lib/accounting/usage.js +293 -75
  74. package/dist/lib/agent-modes.js +29 -1
  75. package/dist/lib/agent-spec/agents.d.ts +4 -3
  76. package/dist/lib/agent-spec/agents.js +25 -15
  77. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  78. package/dist/lib/analytics/mix-commands.js +2 -2
  79. package/dist/lib/auth-health.d.ts +13 -3
  80. package/dist/lib/auth-health.js +50 -8
  81. package/dist/lib/browser/devices.d.ts +19 -0
  82. package/dist/lib/browser/devices.js +23 -0
  83. package/dist/lib/browser/drivers/ssh.d.ts +16 -2
  84. package/dist/lib/browser/drivers/ssh.js +36 -9
  85. package/dist/lib/browser/ipc.d.ts +1 -1
  86. package/dist/lib/browser/ipc.js +40 -9
  87. package/dist/lib/browser/profiles.d.ts +104 -65
  88. package/dist/lib/browser/profiles.js +277 -165
  89. package/dist/lib/browser/registry.d.ts +39 -0
  90. package/dist/lib/browser/registry.js +149 -0
  91. package/dist/lib/browser/remote-control.d.ts +28 -2
  92. package/dist/lib/browser/remote-control.js +35 -3
  93. package/dist/lib/browser/resolve-target.d.ts +68 -0
  94. package/dist/lib/browser/resolve-target.js +250 -0
  95. package/dist/lib/browser/runtime-state.d.ts +44 -11
  96. package/dist/lib/browser/runtime-state.js +98 -15
  97. package/dist/lib/browser/service.d.ts +65 -9
  98. package/dist/lib/browser/service.js +196 -61
  99. package/dist/lib/browser/sessions-list.d.ts +1 -3
  100. package/dist/lib/browser/sessions-list.js +3 -16
  101. package/dist/lib/browser/task-index.d.ts +70 -0
  102. package/dist/lib/browser/task-index.js +180 -0
  103. package/dist/lib/browser/types.d.ts +12 -1
  104. package/dist/lib/byok-usage.js +2 -2
  105. package/dist/lib/claude-statusline.d.ts +29 -0
  106. package/dist/lib/claude-statusline.js +139 -0
  107. package/dist/lib/cloud/host.d.ts +1 -1
  108. package/dist/lib/cloud/host.js +1 -1
  109. package/dist/lib/cloud/rush.js +1 -1
  110. package/dist/lib/codex-policy.d.ts +11 -1
  111. package/dist/lib/codex-policy.js +20 -5
  112. package/dist/lib/computer/computer-rpc.js +2 -2
  113. package/dist/lib/computer/download.d.ts +15 -15
  114. package/dist/lib/computer/download.js +35 -98
  115. package/dist/lib/computer/ssh-tunnel.d.ts +0 -3
  116. package/dist/lib/computer/ssh-tunnel.js +12 -22
  117. package/dist/lib/config-keys.d.ts +6 -11
  118. package/dist/lib/config-keys.js +18 -19
  119. package/dist/lib/crabbox/cli.d.ts +1 -1
  120. package/dist/lib/crabbox/cli.js +2 -2
  121. package/dist/lib/crabbox/lease.d.ts +9 -0
  122. package/dist/lib/crabbox/lease.js +70 -15
  123. package/dist/lib/crabbox/runtimes.js +1 -1
  124. package/dist/lib/daemon/account-state-daemon-service.d.ts +16 -0
  125. package/dist/lib/daemon/account-state-daemon-service.js +26 -0
  126. package/dist/lib/daemon/browser-ipc-service.d.ts +20 -0
  127. package/dist/lib/daemon/browser-ipc-service.js +46 -0
  128. package/dist/lib/daemon/daemon.d.ts +3 -0
  129. package/dist/lib/daemon/daemon.js +212 -444
  130. package/dist/lib/daemon/device-probe-service.d.ts +26 -0
  131. package/dist/lib/daemon/device-probe-service.js +47 -0
  132. package/dist/lib/daemon/keychain-reap-service.d.ts +17 -0
  133. package/dist/lib/daemon/keychain-reap-service.js +32 -0
  134. package/dist/lib/daemon/monitor-engine-service.d.ts +22 -0
  135. package/dist/lib/daemon/monitor-engine-service.js +29 -0
  136. package/dist/lib/daemon/runner.js +15 -2
  137. package/dist/lib/daemon/secrets-broker-service.d.ts +20 -0
  138. package/dist/lib/daemon/secrets-broker-service.js +66 -0
  139. package/dist/lib/daemon/self-heal-service.d.ts +27 -0
  140. package/dist/lib/daemon/self-heal-service.js +48 -0
  141. package/dist/lib/daemon/service.d.ts +102 -0
  142. package/dist/lib/daemon/service.js +98 -0
  143. package/dist/lib/daemon/session-index-service.d.ts +22 -0
  144. package/dist/lib/daemon/session-index-service.js +40 -0
  145. package/dist/lib/daemon/state-dir-check-service.d.ts +43 -0
  146. package/dist/lib/daemon/state-dir-check-service.js +63 -0
  147. package/dist/lib/daemon/supervisor.d.ts +87 -0
  148. package/dist/lib/daemon/supervisor.js +298 -0
  149. package/dist/lib/daemon/watchdog-service.d.ts +19 -0
  150. package/dist/lib/daemon/watchdog-service.js +40 -0
  151. package/dist/lib/daemon-health.d.ts +15 -0
  152. package/dist/lib/daemon-health.js +29 -5
  153. package/dist/lib/daemon-services.d.ts +8 -1
  154. package/dist/lib/daemon-services.js +55 -0
  155. package/dist/lib/daemon-ticks.d.ts +13 -2
  156. package/dist/lib/daemon-ticks.js +22 -20
  157. package/dist/lib/device-config.d.ts +25 -10
  158. package/dist/lib/device-config.js +90 -39
  159. package/dist/lib/devices/config-migration.d.ts +7 -0
  160. package/dist/lib/devices/config-migration.js +67 -3
  161. package/dist/lib/devices/health.d.ts +27 -2
  162. package/dist/lib/devices/health.js +39 -9
  163. package/dist/lib/devices/interactive-host.d.ts +15 -0
  164. package/dist/lib/devices/interactive-host.js +53 -0
  165. package/dist/lib/devices/registry.d.ts +61 -4
  166. package/dist/lib/devices/registry.js +94 -38
  167. package/dist/lib/devices/stats-cache.d.ts +26 -0
  168. package/dist/lib/devices/stats-cache.js +59 -7
  169. package/dist/lib/devices/worker-pick.d.ts +48 -0
  170. package/dist/lib/devices/worker-pick.js +113 -0
  171. package/dist/lib/exec.d.ts +70 -14
  172. package/dist/lib/exec.js +110 -38
  173. package/dist/lib/feed/answer.d.ts +29 -0
  174. package/dist/lib/feed/answer.js +145 -0
  175. package/dist/lib/feed/attention.d.ts +83 -0
  176. package/dist/lib/feed/attention.js +0 -0
  177. package/dist/lib/feed/feed.d.ts +109 -0
  178. package/dist/lib/feed/feed.js +154 -3
  179. package/dist/lib/feed/pr-status.d.ts +14 -0
  180. package/dist/lib/feed/pr-status.js +47 -0
  181. package/dist/lib/feed/watch.d.ts +59 -0
  182. package/dist/lib/feed/watch.js +180 -0
  183. package/dist/lib/fleet/capture.js +6 -0
  184. package/dist/lib/fleet/remote-login.js +8 -3
  185. package/dist/lib/fleet/types.d.ts +17 -0
  186. package/dist/lib/harness/adapter.d.ts +11 -0
  187. package/dist/lib/harness/adapters/claude.js +38 -24
  188. package/dist/lib/harness/adapters/codex.js +2 -4
  189. package/dist/lib/harness/adapters/cursor.js +8 -11
  190. package/dist/lib/helper-download.d.ts +108 -0
  191. package/dist/lib/helper-download.js +194 -0
  192. package/dist/lib/helper-versions.d.ts +52 -0
  193. package/dist/lib/helper-versions.js +58 -0
  194. package/dist/lib/hooks/cache.js +19 -0
  195. package/dist/lib/hooks/install.js +2 -2
  196. package/dist/lib/hooks/match.d.ts +3 -0
  197. package/dist/lib/hooks/match.js +25 -0
  198. package/dist/lib/hosts/credentials.d.ts +1 -1
  199. package/dist/lib/hosts/credentials.js +1 -1
  200. package/dist/lib/hosts/dispatch.d.ts +7 -1
  201. package/dist/lib/hosts/dispatch.js +24 -5
  202. package/dist/lib/hosts/passthrough.d.ts +8 -15
  203. package/dist/lib/hosts/passthrough.js +35 -16
  204. package/dist/lib/hosts/providers/devices.d.ts +1 -1
  205. package/dist/lib/hosts/providers/devices.js +1 -1
  206. package/dist/lib/hosts/reconnect.d.ts +179 -21
  207. package/dist/lib/hosts/reconnect.js +266 -45
  208. package/dist/lib/hosts/registry.js +7 -0
  209. package/dist/lib/installations/migrate.d.ts +4 -5
  210. package/dist/lib/installations/migrate.js +6 -7
  211. package/dist/lib/installations/shims.d.ts +1 -1
  212. package/dist/lib/installations/shims.js +23 -13
  213. package/dist/lib/installations/versions.js +25 -2
  214. package/dist/lib/linear-autoclose.d.ts +1 -1
  215. package/dist/lib/linear-autoclose.js +1 -1
  216. package/dist/lib/loop.d.ts +2 -2
  217. package/dist/lib/loop.js +1 -1
  218. package/dist/lib/menubar/download-menubar.d.ts +47 -0
  219. package/dist/lib/menubar/download-menubar.js +60 -0
  220. package/dist/lib/menubar/install-menubar.d.ts +44 -7
  221. package/dist/lib/menubar/install-menubar.js +105 -23
  222. package/dist/lib/menubar/notify-desktop.d.ts +3 -3
  223. package/dist/lib/menubar/notify-desktop.js +6 -6
  224. package/dist/lib/model-tiers.d.ts +1 -1
  225. package/dist/lib/open-url.d.ts +60 -2
  226. package/dist/lib/open-url.js +167 -12
  227. package/dist/lib/project-probe.d.ts +1 -1
  228. package/dist/lib/project-probe.js +1 -1
  229. package/dist/lib/project-pull.d.ts +1 -1
  230. package/dist/lib/project-pull.js +2 -2
  231. package/dist/lib/projects.d.ts +1 -1
  232. package/dist/lib/projects.js +1 -1
  233. package/dist/lib/refresh-coordinator.d.ts +1 -1
  234. package/dist/lib/refresh-coordinator.js +1 -1
  235. package/dist/lib/sandbox.js +6 -7
  236. package/dist/lib/secrets/download-keychain.d.ts +47 -0
  237. package/dist/lib/secrets/download-keychain.js +70 -0
  238. package/dist/lib/secrets/install-helper.d.ts +20 -1
  239. package/dist/lib/secrets/install-helper.js +44 -3
  240. package/dist/lib/session/active.d.ts +67 -0
  241. package/dist/lib/session/active.js +86 -2
  242. package/dist/lib/session/db.d.ts +11 -1
  243. package/dist/lib/session/db.js +142 -8
  244. package/dist/lib/session/discover.d.ts +23 -1
  245. package/dist/lib/session/discover.js +44 -10
  246. package/dist/lib/session/highlights.d.ts +49 -1
  247. package/dist/lib/session/highlights.js +95 -0
  248. package/dist/lib/session/host-link.d.ts +16 -2
  249. package/dist/lib/session/host-link.js +7 -1
  250. package/dist/lib/session/parse.js +33 -1
  251. package/dist/lib/session/prompt.d.ts +35 -0
  252. package/dist/lib/session/prompt.js +56 -0
  253. package/dist/lib/session/remote/remote-list.d.ts +9 -0
  254. package/dist/lib/session/remote/remote-list.js +19 -2
  255. package/dist/lib/session/remote/watch.d.ts +1 -1
  256. package/dist/lib/session/remote/watch.js +17 -3
  257. package/dist/lib/session/render.js +7 -3
  258. package/dist/lib/session/session-cache.d.ts +53 -0
  259. package/dist/lib/session/session-cache.js +93 -1
  260. package/dist/lib/session/state.js +1 -1
  261. package/dist/lib/session/tool-calls.d.ts +2 -0
  262. package/dist/lib/session/tool-calls.js +1 -1
  263. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  264. package/dist/lib/session/trajectory-compare.js +116 -0
  265. package/dist/lib/session/trajectory-html.d.ts +19 -0
  266. package/dist/lib/session/trajectory-html.js +770 -0
  267. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  268. package/dist/lib/session/trajectory-lineage.js +195 -0
  269. package/dist/lib/session/trajectory-text.d.ts +37 -0
  270. package/dist/lib/session/trajectory-text.js +284 -0
  271. package/dist/lib/session/trajectory.d.ts +102 -0
  272. package/dist/lib/session/trajectory.js +384 -0
  273. package/dist/lib/session/types.d.ts +15 -0
  274. package/dist/lib/sha256-asset.d.ts +26 -0
  275. package/dist/lib/sha256-asset.js +41 -0
  276. package/dist/lib/share/backend.d.ts +95 -0
  277. package/dist/lib/share/backend.js +139 -0
  278. package/dist/lib/share/delete.d.ts +6 -2
  279. package/dist/lib/share/delete.js +8 -9
  280. package/dist/lib/share/html.d.ts +22 -0
  281. package/dist/lib/share/html.js +88 -0
  282. package/dist/lib/share/provision.d.ts +18 -4
  283. package/dist/lib/share/provision.js +38 -4
  284. package/dist/lib/share/publish.d.ts +39 -15
  285. package/dist/lib/share/publish.js +55 -26
  286. package/dist/lib/share/worker-template.js +171 -11
  287. package/dist/lib/sqlite.js +16 -3
  288. package/dist/lib/ssh-exec.d.ts +65 -0
  289. package/dist/lib/ssh-exec.js +120 -4
  290. package/dist/lib/startup/command-registry.d.ts +3 -1
  291. package/dist/lib/startup/command-registry.js +6 -3
  292. package/dist/lib/startup/dev-build.js +3 -3
  293. package/dist/lib/state.d.ts +2 -2
  294. package/dist/lib/state.js +11 -5
  295. package/dist/lib/subagents-registry.js +1 -1
  296. package/dist/lib/teams/agents.js +9 -0
  297. package/dist/lib/terminal/index.d.ts +1 -1
  298. package/dist/lib/terminal/inject.d.ts +19 -0
  299. package/dist/lib/terminal/inject.js +19 -9
  300. package/dist/lib/terminal/types.d.ts +1 -1
  301. package/dist/lib/terminal/types.js +1 -1
  302. package/dist/lib/tmux/orphan-reap.js +1 -1
  303. package/dist/lib/tmux/session.d.ts +46 -13
  304. package/dist/lib/tmux/session.js +204 -27
  305. package/dist/lib/traces/backend.d.ts +29 -0
  306. package/dist/lib/traces/backend.js +46 -0
  307. package/dist/lib/traces/classify.d.ts +52 -0
  308. package/dist/lib/traces/classify.js +72 -0
  309. package/dist/lib/traces/config.d.ts +10 -0
  310. package/dist/lib/traces/config.js +2 -0
  311. package/dist/lib/traces/provision.d.ts +19 -0
  312. package/dist/lib/traces/provision.js +30 -0
  313. package/dist/lib/traces/sync.d.ts +150 -0
  314. package/dist/lib/traces/sync.js +447 -0
  315. package/dist/lib/traces/worker-template.d.ts +4 -0
  316. package/dist/lib/traces/worker-template.js +159 -0
  317. package/dist/lib/types.d.ts +34 -19
  318. package/dist/lib/types.js +14 -0
  319. package/dist/lib/usage-backoff.d.ts +2 -1
  320. package/dist/lib/usage-backoff.js +60 -19
  321. package/dist/lib/usage-refresh.d.ts +18 -4
  322. package/dist/lib/usage-refresh.js +68 -19
  323. package/dist/lib/watchdog/log.d.ts +1 -1
  324. package/dist/lib/watchdog/log.js +1 -1
  325. package/dist/lib/watchdog/runner.d.ts +36 -45
  326. package/dist/lib/watchdog/runner.js +150 -119
  327. package/dist/lib/watchdog/service.d.ts +0 -1
  328. package/dist/lib/watchdog/service.js +0 -1
  329. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  330. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  331. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  332. package/dist/lib/watchdog/watchdog.js +37 -104
  333. package/dist/lib/workflows.d.ts +1 -1
  334. package/package.json +5 -7
  335. package/dist/commands/usage.d.ts +0 -27
  336. package/dist/commands/usage.js +0 -96
  337. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  338. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +0 -24
  339. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  340. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  341. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +0 -128
  342. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  343. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -24
  344. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  345. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +0 -123
  346. package/dist/lib/secrets/Agents CLI.app/Contents/embedded.provisionprofile +0 -0
  347. package/dist/lib/usage-fleet.d.ts +0 -32
  348. package/dist/lib/usage-fleet.js +0 -125
@@ -0,0 +1,159 @@
1
+ // The Cloudflare Worker that fronts the R2 traces bucket.
2
+ //
3
+ // SECURITY: unlike agents-share, there is NO public GET path here. Every route
4
+ // — PUT and GET — requires a Phoenix bearer. The caller must be the record's
5
+ // owner (path prefix == verified userId). A missing bearer → 401; a wrong
6
+ // owner → 403; public cache headers are NEVER emitted.
7
+ //
8
+ // Routes (all require Phoenix bearer + userId owner match):
9
+ // PUT /<userId>/<device>/index.json — per-device stats shard
10
+ // PUT /<userId>/<device>/sessions/<id>.json — per-session SessionDetail
11
+ // GET /<userId>/... — returns the stored object
12
+ // GET / — 200 description (no data)
13
+ //
14
+ // Emitted as a string so it compiles into dist/** and ships with no
15
+ // package.json#files change. `provisionTraces.ts` uploads this verbatim as an
16
+ // ES-module Worker with a BUCKET (R2) binding + a PHOENIX_ID_BASE secret.
17
+ /**
18
+ * Render the traces Worker source. Pure — bindings are wired at deploy time.
19
+ */
20
+ export function renderTracesWorkerScript() {
21
+ return `// GENERATED by agents-cli agents traces — do not edit; edit
22
+ // src/lib/traces/worker-template.ts and re-provision.
23
+ export const hooks = {
24
+ verifyPhoenixToken: defaultVerifyPhoenixToken,
25
+ };
26
+
27
+ export default {
28
+ async fetch(request, env) {
29
+ const url = new URL(request.url);
30
+ const path = decodeURIComponent(url.pathname.replace(/^\\/+/, ''));
31
+
32
+ if (!path) {
33
+ return new Response(
34
+ 'agents traces — PUT /<userId>/<device>/index.json or sessions/<id>.json to upload; GET the same path to read. All routes require a Phoenix bearer.',
35
+ { status: 200, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'private, no-store' } },
36
+ );
37
+ }
38
+
39
+ if (request.method === 'PUT') {
40
+ return handlePut(request, env, path);
41
+ }
42
+
43
+ if (request.method === 'GET' || request.method === 'HEAD') {
44
+ return handleGet(request, env, path);
45
+ }
46
+
47
+ return json({ error: 'method not allowed' }, 405);
48
+ },
49
+ };
50
+
51
+ async function handlePut(request, env, path) {
52
+ const auth = await authorizeWrite(request, env);
53
+ if (auth.error) return auth.error;
54
+
55
+ const segments = path.split('/').filter(Boolean);
56
+ const expected = auth.owner;
57
+ if (!expected || segments[0] !== expected) {
58
+ return json({ error: 'namespace mismatch', owner: expected }, 403);
59
+ }
60
+
61
+ const contentType = request.headers.get('content-type') || 'application/json; charset=utf-8';
62
+ await env.BUCKET.put(path, request.body, {
63
+ httpMetadata: { contentType },
64
+ customMetadata: {
65
+ owner: auth.owner,
66
+ uploadedAt: new Date().toISOString(),
67
+ },
68
+ });
69
+
70
+ const headers = new Headers();
71
+ headers.set('cache-control', 'private, no-store');
72
+ return new Response(JSON.stringify({ ok: true, key: path }), {
73
+ status: 200,
74
+ headers,
75
+ });
76
+ }
77
+
78
+ async function handleGet(request, env, path) {
79
+ const auth = await authorizeRead(request, env, path);
80
+ if (auth.error) return auth.error;
81
+
82
+ const object = await env.BUCKET.get(path);
83
+ if (!object) return json({ error: 'not found' }, 404);
84
+
85
+ const headers = new Headers(object.httpMetadata ?? {});
86
+ headers.set('cache-control', 'private, no-store');
87
+ return new Response(object.body, { headers });
88
+ }
89
+
90
+ // Require a Phoenix bearer that owns the path prefix.
91
+ async function authorizeRead(request, env, path) {
92
+ const claims = await hooks.verifyPhoenixToken(request, env);
93
+ if (!claims) return { error: json({ error: 'unauthorized' }, 401) };
94
+ const ownerId = path.split('/').filter(Boolean)[0] || '';
95
+ if (!ownerId || ownerId !== claims.userId) {
96
+ return { error: json({ error: 'forbidden' }, 403) };
97
+ }
98
+ return { claims };
99
+ }
100
+
101
+ // Two legitimate principals for writes: static WRITE_TOKEN first (BYO),
102
+ // otherwise Phoenix bearer. 401 when neither authenticates.
103
+ async function authorizeWrite(request, env) {
104
+ const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
105
+ if (!presented) return { error: json({ error: 'unauthorized' }, 401) };
106
+ if (env.WRITE_TOKEN && safeEqual(presented, env.WRITE_TOKEN)) {
107
+ return { kind: 'byo', owner: env.TRACES_NAMESPACE || 'byo' };
108
+ }
109
+ const claims = await hooks.verifyPhoenixToken(request, env);
110
+ if (claims && typeof claims.userId === 'string' && claims.userId) {
111
+ return { kind: 'phoenix', owner: claims.userId };
112
+ }
113
+ return { error: json({ error: 'unauthorized' }, 401) };
114
+ }
115
+
116
+ async function defaultVerifyPhoenixToken(request, env) {
117
+ const presented = (request.headers.get('authorization') || '').replace(/^Bearer\\s+/i, '');
118
+ if (!presented) return null;
119
+ const base = typeof env.PHOENIX_ID_BASE === 'string' ? env.PHOENIX_ID_BASE.replace(/\\/+$/, '') : '';
120
+ if (!base) return null;
121
+ let res;
122
+ try {
123
+ res = await fetch(base + '/api/v1/auth/me', {
124
+ method: 'GET',
125
+ headers: { Authorization: 'Bearer ' + presented },
126
+ });
127
+ } catch {
128
+ return null;
129
+ }
130
+ if (!res.ok) return null;
131
+ let body;
132
+ try {
133
+ body = await res.json();
134
+ } catch {
135
+ return null;
136
+ }
137
+ if (!body || typeof body.userId !== 'string' || !body.userId) return null;
138
+ return { userId: body.userId, email: typeof body.email === 'string' ? body.email : '' };
139
+ }
140
+
141
+ function json(body, status = 200) {
142
+ return new Response(JSON.stringify(body), {
143
+ status,
144
+ headers: {
145
+ 'content-type': 'application/json; charset=utf-8',
146
+ 'cache-control': 'private, no-store',
147
+ },
148
+ });
149
+ }
150
+
151
+ // Constant-time comparison so timing attacks can't probe a static WRITE_TOKEN.
152
+ function safeEqual(a, b) {
153
+ if (a.length !== b.length) return false;
154
+ let diff = 0;
155
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
156
+ return diff === 0;
157
+ }
158
+ `;
159
+ }
@@ -29,6 +29,20 @@ export declare const RUN_AUTO_KEYWORD = "auto";
29
29
  * process.env (which exec.ts `runAutoDefaultsToAffinity` reads) never sees them.
30
30
  */
31
31
  export declare const RUN_AUTO_HOST_RESOLVED_ENV = "AGENTS_RUN_AUTO_HOST_RESOLVED";
32
+ /**
33
+ * Env var a host dispatcher exports into the remote SHELL when it opens an
34
+ * INTERACTIVE `--device` run: tells the remote CLI that this agent's stdio is a
35
+ * network link, so the run must be detached (tmux-wrapped) to outlive it.
36
+ *
37
+ * Rides the same shell-export prelude as {@link RUN_AUTO_HOST_RESOLVED_ENV},
38
+ * and for the same reason — exec.ts `resolveTmuxWrap` reads the remote CLI's own
39
+ * `process.env`, which `--env` flags (they reach only the spawned AGENT) never
40
+ * touch.
41
+ *
42
+ * Set ONLY on the interactive path. A headless `--device` dispatch is already
43
+ * detached with setsid by `launchDetached`, so it neither needs nor gets this.
44
+ */
45
+ export declare const REMOTE_INTERACTIVE_ENV = "AGENTS_REMOTE_INTERACTIVE";
32
46
  /** Per-agent run strategy config. */
33
47
  export interface AgentRunConfig {
34
48
  strategy?: RunStrategy;
@@ -277,6 +291,22 @@ export interface HookMatches {
277
291
  git_dirty?: boolean;
278
292
  cwd_includes?: string | string[];
279
293
  project_has?: string;
294
+ /**
295
+ * Permission modes the hook fires in (e.g. `plan`). Unlike the other
296
+ * predicates this one is fail-open on absence: an input that carries no
297
+ * permission_mode/permissionMode field passes, because only some harnesses
298
+ * (Claude Code) report the live mode — an explicit non-listed value skips.
299
+ */
300
+ permission_mode?: string | string[];
301
+ /**
302
+ * Permission modes the hook must NOT fire in (e.g. `plan`). The inverse of
303
+ * `permission_mode`, and the correct predicate for gating a guard off in one
304
+ * mode: expressing that with the allowlist means enumerating every other
305
+ * mode, which silently stops firing when a harness adds or renames one. Same
306
+ * fail-open-on-absence rule — an input with no mode field still fires — so an
307
+ * unknown mode errs toward running the hook, never toward skipping it.
308
+ */
309
+ permission_mode_not?: string | string[];
280
310
  }
281
311
  /**
282
312
  * Cache scoping. Determines which cache file a hook invocation reads/writes:
@@ -942,30 +972,15 @@ export interface Meta {
942
972
  */
943
973
  hooks?: Record<string, ManifestHook>;
944
974
  /**
945
- * Browser profile definitions keyed by profile name. Portable user config
946
- * that syncs with `agents repo push/pull`. Runtime state (chrome-data, pids)
947
- * lives separately in ~/.agents/.cache/browser/<profile>/.
948
- */
949
- browser?: Record<string, BrowserProfileConfig>;
950
- /**
951
- * Machine-local browser profiles — written to `config:`-adjacent
952
- * `browser:` in `~/.agents/devices/<machine>/agents.yaml`, never the synced
953
- * file.
954
- *
955
- * The auto-detected `default` profile lives here because it is inherently
956
- * machine-specific: it carries an absolute `binary:` path and a port chosen by
957
- * scanning THIS machine. Sharing it fleet-wide meant a macOS box wrote
958
- * `/Applications/...`, a Linux box found it unlaunchable and rewrote
959
- * `/usr/bin/chromium-browser`, and the two flipped the tracked agents.yaml back
960
- * and forth on every `agents browser start` — the single largest source of
961
- * churn on that file. User-created named profiles stay central and still sync.
975
+ * Browser profiles declared by this machine. Written as `browser:` in
976
+ * `~/.agents/devices/<machine>/agents.yaml`. The fleet registry is the union
977
+ * of every device file; central `agents.yaml` is not a browser-profile store.
962
978
  */
963
979
  deviceBrowser?: Record<string, BrowserProfileConfig>;
964
980
  /**
965
981
  * User-scope config block (`config:` in central agents.yaml). Holds the
966
982
  * user-scope keys from the device-config registry (`lib/device-config.ts`) —
967
- * currently `interactiveHost` and `usagePrimaryHost`. Syncs fleet-wide via
968
- * `agents repo push/pull`.
983
+ * currently `interactiveHost`. Syncs fleet-wide via `agents repo push/pull`.
969
984
  * Device-scope keys live in the per-device doc
970
985
  * `devices/<name>/agents.yaml` `config:` block, layered over the fleet-wide
971
986
  * defaults in {@link Meta.fleet} (`fleet.defaults.config`).
package/dist/lib/types.js CHANGED
@@ -22,6 +22,20 @@ export const RUN_AUTO_KEYWORD = 'auto';
22
22
  * process.env (which exec.ts `runAutoDefaultsToAffinity` reads) never sees them.
23
23
  */
24
24
  export const RUN_AUTO_HOST_RESOLVED_ENV = 'AGENTS_RUN_AUTO_HOST_RESOLVED';
25
+ /**
26
+ * Env var a host dispatcher exports into the remote SHELL when it opens an
27
+ * INTERACTIVE `--device` run: tells the remote CLI that this agent's stdio is a
28
+ * network link, so the run must be detached (tmux-wrapped) to outlive it.
29
+ *
30
+ * Rides the same shell-export prelude as {@link RUN_AUTO_HOST_RESOLVED_ENV},
31
+ * and for the same reason — exec.ts `resolveTmuxWrap` reads the remote CLI's own
32
+ * `process.env`, which `--env` flags (they reach only the spawned AGENT) never
33
+ * touch.
34
+ *
35
+ * Set ONLY on the interactive path. A headless `--device` dispatch is already
36
+ * detached with setsid by `launchDetached`, so it neither needs nor gets this.
37
+ */
38
+ export const REMOTE_INTERACTIVE_ENV = 'AGENTS_REMOTE_INTERACTIVE';
25
39
  /** Every canonical mode in declaration order. Useful for iteration / validation. */
26
40
  export const ALL_MODES = ['plan', 'edit', 'auto', 'skip'];
27
41
  /** Canonical system repo cloned into ~/.agents/.system/. */
@@ -15,6 +15,7 @@ export declare function parseRetryAfterMs(header: string | null | undefined, now
15
15
  export declare function noteUsageRateLimited(agent: AgentId, retryAfter: string | null | undefined, opts?: {
16
16
  now?: number;
17
17
  fallbackMs?: number;
18
+ account?: string | null;
18
19
  }): void;
19
20
  /**
20
21
  * Epoch ms until which `agent`'s usage endpoint should not be called, or null
@@ -24,6 +25,6 @@ export declare function noteUsageRateLimited(agent: AgentId, retryAfter: string
24
25
  * Sweeps elapsed files while it is here: they can only accumulate at the rate
25
26
  * penalties are issued, and this is the one place that already lists them.
26
27
  */
27
- export declare function usageRateLimitedUntil(agent: AgentId, now?: number): number | null;
28
+ export declare function usageRateLimitedUntil(agent: AgentId, now?: number, account?: string | null): number | null;
28
29
  /** Human-readable remaining backoff, for the error a skipped read returns. */
29
30
  export declare function formatBackoffRemaining(untilMs: number, now?: number): string;
@@ -39,9 +39,15 @@
39
39
  * one — monotonicity is structural rather than argued, and there is no lock to
40
40
  * go stale on a path every usage read touches. Elapsed files are swept on read.
41
41
  *
42
- * Deliberately per-provider, not per-account: the endpoint throttles the caller,
43
- * and the observed 429 hit all five accounts on the box at once. Backing off one
44
- * account while the others keep firing would not clear the penalty.
42
+ * Scoped per ACCOUNT when the caller knows one, per provider otherwise
43
+ * (RUSH-3036). The first cut was deliberately provider-wide the 2026-08-03
44
+ * incident 429'd every account on the box at once, so per-provider looked
45
+ * sufficient. It was not: the quotas proved per-account, and a provider-wide
46
+ * park let the first throttled account in a refresh pass starve every account
47
+ * after it in the loop's fixed iteration order — the same four accounts read
48
+ * 'usage unavailable' across passes while their siblings refreshed. An
49
+ * account-scoped penalty parks only that account; a penalty recorded with no
50
+ * account identity stays provider-wide and still parks everything.
45
51
  */
46
52
  import * as fs from 'fs';
47
53
  import * as path from 'path';
@@ -84,8 +90,26 @@ export function parseRetryAfterMs(header, now = Date.now()) {
84
90
  const ms = at - now;
85
91
  return ms > 0 ? Math.min(ms, MAX_BACKOFF_MS) : null;
86
92
  }
87
- /** Every recorded deadline for `agent`, newest-first. Never throws. */
88
- function deadlinesFor(agent) {
93
+ /**
94
+ * File-name scope for a penalty. Provider-wide penalties are `<agent>`;
95
+ * account-scoped ones are `<agent>@<slug>` (RUSH-3036), where the slug is the
96
+ * account's usage key with path separators neutralized (usage keys carry `:`,
97
+ * `=`, `.` and `@` freely — all filename-safe on the platforms we run on).
98
+ * `@` cannot collide with the provider scope because provider files are always
99
+ * `<agent>.<digits>` and agent ids never contain `@`.
100
+ */
101
+ function backoffScope(agent, account) {
102
+ if (!account)
103
+ return agent;
104
+ return `${agent}@${account.replace(/[/\\]/g, '_')}`;
105
+ }
106
+ /**
107
+ * Every recorded deadline for `scope`. A file belongs to the scope only when
108
+ * everything after `<scope>.` is pure digits — account slugs contain dots
109
+ * (emails inside usage keys), so a bare prefix match would let scope
110
+ * `claude@a` swallow `claude@a.b`'s files.
111
+ */
112
+ function deadlinesFor(scope) {
89
113
  let names;
90
114
  try {
91
115
  names = fs.readdirSync(backoffDir());
@@ -94,12 +118,15 @@ function deadlinesFor(agent) {
94
118
  // No directory yet: nothing is throttled.
95
119
  return [];
96
120
  }
97
- const prefix = `${agent}.`;
121
+ const prefix = `${scope}.`;
98
122
  const out = [];
99
123
  for (const name of names) {
100
124
  if (!name.startsWith(prefix))
101
125
  continue;
102
- const at = Number(name.slice(prefix.length));
126
+ const rest = name.slice(prefix.length);
127
+ if (!/^\d+$/.test(rest))
128
+ continue;
129
+ const at = Number(rest);
103
130
  if (Number.isFinite(at))
104
131
  out.push(at);
105
132
  }
@@ -120,7 +147,14 @@ export function noteUsageRateLimited(agent, retryAfter, opts) {
120
147
  fs.mkdirSync(backoffDir(), { recursive: true });
121
148
  // Empty file: the name carries the whole value, so there is no content a
122
149
  // concurrent reader could catch half-written, and no document to merge.
123
- fs.writeFileSync(path.join(backoffDir(), `${agent}.${deadline}`), '');
150
+ //
151
+ // With an account, the penalty is scoped to THAT account (RUSH-3036): the
152
+ // observed 429s are per-account quotas, and a provider-wide park let the
153
+ // first throttled account starve every account after it in the refresh
154
+ // loop's fixed order — the same 4 accounts stayed 'usage unavailable'
155
+ // across passes while their siblings refreshed. Callers with no account
156
+ // identity still record provider-wide, which continues to park everything.
157
+ fs.writeFileSync(path.join(backoffDir(), `${backoffScope(agent, opts?.account)}.${deadline}`), '');
124
158
  }
125
159
  catch {
126
160
  // Best-effort. An unwritable cache dir costs the cross-process backoff, not
@@ -135,19 +169,26 @@ export function noteUsageRateLimited(agent, retryAfter, opts) {
135
169
  * Sweeps elapsed files while it is here: they can only accumulate at the rate
136
170
  * penalties are issued, and this is the one place that already lists them.
137
171
  */
138
- export function usageRateLimitedUntil(agent, now = Date.now()) {
172
+ export function usageRateLimitedUntil(agent, now = Date.now(), account) {
173
+ // A provider-wide penalty parks every account; an account-scoped read also
174
+ // honors that account's own penalties. A bare (no-account) read deliberately
175
+ // ignores account-scoped penalties — one pinned account must not park its
176
+ // siblings (RUSH-3036).
177
+ const scopes = account ? [backoffScope(agent, null), backoffScope(agent, account)] : [backoffScope(agent, null)];
139
178
  let latest = null;
140
- for (const at of deadlinesFor(agent)) {
141
- if (at > now) {
142
- if (latest === null || at > latest)
143
- latest = at;
144
- }
145
- else {
146
- try {
147
- fs.rmSync(path.join(backoffDir(), `${agent}.${at}`), { force: true });
179
+ for (const scope of scopes) {
180
+ for (const at of deadlinesFor(scope)) {
181
+ if (at > now) {
182
+ if (latest === null || at > latest)
183
+ latest = at;
148
184
  }
149
- catch {
150
- /* another process may have swept it already */
185
+ else {
186
+ try {
187
+ fs.rmSync(path.join(backoffDir(), `${scope}.${at}`), { force: true });
188
+ }
189
+ catch {
190
+ /* another process may have swept it already */
191
+ }
151
192
  }
152
193
  }
153
194
  }
@@ -18,6 +18,10 @@ export declare const REFRESH_BURN_DIVISOR = 4;
18
18
  export declare const HOURLY_CALL_CAP = 12;
19
19
  /** How often the daemon wakes to *consider* a refresh pass (due accounts only). */
20
20
  export declare const USAGE_REFRESH_TICK_MS: number;
21
+ /** Consecutive failed live reads before one broken account is quarantined. */
22
+ export declare const FAILURE_QUARANTINE_THRESHOLD = 3;
23
+ /** A chronic offender waits this long while healthy siblings keep their cadence. */
24
+ export declare const FAILURE_QUARANTINE_MS: number;
21
25
  /**
22
26
  * One account's refresh state + published headroom. `sessionUsedPercent` /
23
27
  * `capturedAt` are the prior sample the NEXT tick projects the burn rate from;
@@ -36,6 +40,8 @@ export interface HeadroomEntry {
36
40
  callTimestamps: number[];
37
41
  /** Epoch ms this entry was written. */
38
42
  computedAt: number;
43
+ /** Consecutive live-fetch misses; absent on entries written before this field. */
44
+ consecutiveFailures?: number;
39
45
  }
40
46
  export declare function setHeadroomCachePathForTest(cachePath: string | null): string | null;
41
47
  /** Read the whole headroom cache (best-effort; missing/corrupt → empty map). */
@@ -79,13 +85,15 @@ export interface LocalUsageAccount {
79
85
  /** Live-fetch this account's usage; the daemon passes the real network fetch. */
80
86
  fetch: () => Promise<UsageInfo>;
81
87
  }
88
+ /** Cold accounts lead each pass; both cold and cached groups rotate every tick. */
89
+ export declare function orderUsageAccounts(accounts: LocalUsageAccount[], cache: Record<string, HeadroomEntry>, tick: number): LocalUsageAccount[];
82
90
  /**
83
91
  * Enumerate the usage accounts whose credentials live on THIS host — one
84
92
  * per unique usage key, deduped to the most-recently-active version (the same
85
93
  * canonicalization `getUsageInfoByIdentity` uses). Each carries a closure that
86
94
  * live-fetches its usage. This is the daemon's `listAccounts`; because it only
87
95
  * ever lists local, signed-in accounts, each host is the sole writer for its own
88
- * accounts' caches. `runUsageRefreshTick` calls this only on the fleet publisher.
96
+ * accounts' caches. `runUsageRefreshTick` calls this on every host.
89
97
  */
90
98
  export declare function buildLocalUsageAccounts(): Promise<LocalUsageAccount[]>;
91
99
  /** Injectable side effects, so `runUsageRefresh` is drivable without the daemon. */
@@ -95,8 +103,14 @@ export interface UsageRefreshDeps {
95
103
  listAccounts: () => Promise<LocalUsageAccount[]>;
96
104
  /** Persist a fresh snapshot to the usage cache (writeClaudeUsageCache). */
97
105
  writeUsageCache: (usageKey: string, snapshot: UsageSnapshot) => void;
98
- /** Epoch ms a provider is backed off until, or null when free (usageRateLimitedUntil). */
99
- backoffUntil: (agentId: AgentId) => number | null;
106
+ /**
107
+ * Epoch ms this provider or, when `usageKey` is given, this specific
108
+ * account — is backed off until; null when free (usageRateLimitedUntil).
109
+ * Per-account scope (RUSH-3036): one throttled account must not park its
110
+ * siblings, which previously starved every account after the first 429 in
111
+ * this loop's fixed iteration order.
112
+ */
113
+ backoffUntil: (agentId: AgentId, usageKey?: string) => number | null;
100
114
  }
101
115
  export interface UsageRefreshResult {
102
116
  refreshed: number;
@@ -107,7 +121,7 @@ export interface UsageRefreshResult {
107
121
  }
108
122
  /**
109
123
  * One refresher tick: for each local account that is due, under its hourly cap,
110
- * and not provider-backed-off, live-fetch its usage, update the cache, and
124
+ * and not backed off, live-fetch its usage, update the cache, and
111
125
  * reschedule from the new burn projection. Never throws — a single account's
112
126
  * failed fetch leaves its cache untouched and counts as `failed`.
113
127
  */
@@ -1,17 +1,17 @@
1
1
  /**
2
- * Daemon-owned usage refresher (publisher host).
2
+ * Daemon-owned usage refresher runs on every host.
3
3
  *
4
4
  * The routing hot path (`agents run` → collectRunCandidates) reads usage
5
5
  * CACHE-ONLY (`getUsageInfoForIdentity`, RUSH-2061) and never blocks
6
6
  * on a provider fetch. Something still has to keep that cache fresh — this
7
- * module is that something, running inside the daemon on the configured
8
- * `usage.primary-host` (or locally when no primary is configured).
7
+ * module is that something, running inside the daemon on every host
8
+ * (RUSH-3193 #15: no primary/subscriber split — each host refreshes only
9
+ * the accounts it holds credentials for).
9
10
  *
10
11
  * Design:
11
12
  *
12
- * - **Fleet sole writer.** Only the configured primary lists credential-backed
13
- * accounts and calls providers. It exports derived snapshots/headroom;
14
- * subscriber hosts import that token-free envelope (`usage-fleet.ts`).
13
+ * - **Per-host writer.** Each host lists its own credential-backed accounts
14
+ * and calls providers directly; there is no cross-host broadcast.
15
15
  * - **Fixed 5-minute cadence** (`REFRESH_INTERVAL_MS`). Enough to keep
16
16
  * balanced/`agents view` off multi-hour stale data without thrashing
17
17
  * provider APIs when the user runs agents frequently. The delay helpers
@@ -19,8 +19,8 @@
19
19
  * floor and ceiling are both 5 minutes.
20
20
  * - **Hard hourly cap** (`HOURLY_CALL_CAP`) so a stuck "due" loop cannot
21
21
  * hammer an endpoint past ~12 calls/account/hour.
22
- * - **429 backoff.** A provider under `usageRateLimitedUntil` is skipped
23
- * entirely — no live fetch, no Touch ID, no re-armed penalty.
22
+ * - **429 backoff.** An account (or provider-wide scope) under
23
+ * `usageRateLimitedUntil` is skipped — no live fetch or re-armed penalty.
24
24
  * - **File-only credentials on the daemon path.** Refresh never opens the
25
25
  * ACL-bound macOS keychain item (Touch ID storm). It uses the no-ACL
26
26
  * access-token cache / setup-token / `.credentials.json` only
@@ -37,8 +37,8 @@
37
37
  * serializes read-modify-write; no lost updates.
38
38
  * 3. **macOS keychain ACL / Touch ID** — fileOnly refresh never calls
39
39
  * `security find-generic-password` on Claude's ACL item.
40
- * 4. **Provider 429** — whole provider skipped until Retry-After; cache
41
- * freezes (visible as aged `capturedAt`) rather than hammering.
40
+ * 4. **Account 429** — that account is skipped until Retry-After while its
41
+ * siblings continue; a provider-wide penalty still skips all accounts.
42
42
  * 5. **Expired access token (no refresh)** — usage path never rotates
43
43
  * single-use refresh tokens; counts as `failed`, reschedules 5m later.
44
44
  * 6. **No file credential on this host** — account skipped / failed; no
@@ -71,6 +71,12 @@ export const REFRESH_BURN_DIVISOR = 4;
71
71
  export const HOURLY_CALL_CAP = 12;
72
72
  /** How often the daemon wakes to *consider* a refresh pass (due accounts only). */
73
73
  export const USAGE_REFRESH_TICK_MS = 60 * 1000;
74
+ /** Consecutive failed live reads before one broken account is quarantined. */
75
+ export const FAILURE_QUARANTINE_THRESHOLD = 3;
76
+ /** A chronic offender waits this long while healthy siblings keep their cadence. */
77
+ export const FAILURE_QUARANTINE_MS = 30 * 60 * 1000;
78
+ const SKIP_JITTER_MIN_MS = 2_000;
79
+ const SKIP_JITTER_RANGE_MS = 3_001;
74
80
  const HOUR_MS = 60 * 60 * 1000;
75
81
  /** Test seam for the headroom cache path (see usage.ts `setClaudeUsageCachePathForTest`). */
76
82
  let headroomCachePathOverride = null;
@@ -169,15 +175,54 @@ export function nextHeadroomEntry(prev, snapshot, now) {
169
175
  nextRefreshAt: now + computeNextRefreshDelayMs(headroom.minutesToLimit),
170
176
  callTimestamps,
171
177
  computedAt: now,
178
+ consecutiveFailures: snapshot ? 0 : (prev?.consecutiveFailures ?? 0) + 1,
172
179
  };
173
180
  }
181
+ /** Stable per-account delay in [2s, 5s], used to spread skipped accounts. */
182
+ function skipJitterMs(usageKey) {
183
+ let hash = 0;
184
+ for (const char of usageKey)
185
+ hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
186
+ return SKIP_JITTER_MIN_MS + (hash % SKIP_JITTER_RANGE_MS);
187
+ }
188
+ function skippedHeadroomEntry(prev, usageKey, now, index) {
189
+ return {
190
+ status: prev?.status ?? null,
191
+ minutesToLimit: prev?.minutesToLimit ?? null,
192
+ sessionUsedPercent: prev?.sessionUsedPercent ?? null,
193
+ capturedAt: prev?.capturedAt ?? null,
194
+ nextRefreshAt: now + (index + 1) * skipJitterMs(usageKey),
195
+ callTimestamps: pruneCallTimestamps(prev?.callTimestamps ?? [], now),
196
+ computedAt: now,
197
+ consecutiveFailures: prev?.consecutiveFailures ?? 0,
198
+ };
199
+ }
200
+ function failedHeadroomEntry(prev, now) {
201
+ const next = nextHeadroomEntry(prev, null, now);
202
+ if ((next.consecutiveFailures ?? 0) >= FAILURE_QUARANTINE_THRESHOLD) {
203
+ next.nextRefreshAt = now + FAILURE_QUARANTINE_MS;
204
+ }
205
+ return next;
206
+ }
207
+ /** Cold accounts lead each pass; both cold and cached groups rotate every tick. */
208
+ export function orderUsageAccounts(accounts, cache, tick) {
209
+ const rotate = (group) => {
210
+ if (group.length < 2)
211
+ return group;
212
+ const start = tick % group.length;
213
+ return [...group.slice(start), ...group.slice(0, start)];
214
+ };
215
+ const cold = accounts.filter((account) => cache[account.usageKey] == null);
216
+ const cached = accounts.filter((account) => cache[account.usageKey] != null);
217
+ return [...rotate(cold), ...rotate(cached)];
218
+ }
174
219
  /**
175
220
  * Enumerate the usage accounts whose credentials live on THIS host — one
176
221
  * per unique usage key, deduped to the most-recently-active version (the same
177
222
  * canonicalization `getUsageInfoByIdentity` uses). Each carries a closure that
178
223
  * live-fetches its usage. This is the daemon's `listAccounts`; because it only
179
224
  * ever lists local, signed-in accounts, each host is the sole writer for its own
180
- * accounts' caches. `runUsageRefreshTick` calls this only on the fleet publisher.
225
+ * accounts' caches. `runUsageRefreshTick` calls this on every host.
181
226
  */
182
227
  export async function buildLocalUsageAccounts() {
183
228
  const accounts = [];
@@ -217,7 +262,7 @@ export async function buildLocalUsageAccounts() {
217
262
  }
218
263
  /**
219
264
  * One refresher tick: for each local account that is due, under its hourly cap,
220
- * and not provider-backed-off, live-fetch its usage, update the cache, and
265
+ * and not backed off, live-fetch its usage, update the cache, and
221
266
  * reschedule from the new burn projection. Never throws — a single account's
222
267
  * failed fetch leaves its cache untouched and counts as `failed`.
223
268
  */
@@ -230,14 +275,15 @@ export async function runUsageRefresh(deps) {
230
275
  skippedCap: 0,
231
276
  failed: 0,
232
277
  };
233
- const accounts = await deps.listAccounts();
234
278
  const cache = readHeadroomCache();
279
+ const accounts = orderUsageAccounts(await deps.listAccounts(), cache, Math.floor(now / USAGE_REFRESH_TICK_MS));
235
280
  const updates = {};
236
- for (const account of accounts) {
281
+ for (const [index, account] of accounts.entries()) {
237
282
  const entry = cache[account.usageKey] ?? null;
238
- // A provider under a 429 penalty is off-limits — poking it re-arms the
283
+ // A penalized account/provider is off-limits — poking it re-arms the
239
284
  // penalty (the whole reason usage-backoff exists).
240
- if ((deps.backoffUntil(account.agentId) ?? 0) > now) {
285
+ if ((deps.backoffUntil(account.agentId, account.usageKey) ?? 0) > now) {
286
+ updates[account.usageKey] = skippedHeadroomEntry(entry, account.usageKey, now, index);
241
287
  result.skippedBackoff += 1;
242
288
  continue;
243
289
  }
@@ -250,7 +296,10 @@ export async function runUsageRefresh(deps) {
250
296
  }
251
297
  try {
252
298
  const usage = await account.fetch();
253
- if (usage.snapshot?.source === 'live') {
299
+ if (usage.snapshot) {
300
+ // `source` is provenance, not freshness. A forced collection that just
301
+ // reread a local harness event returns `last_seen`; that is still a
302
+ // successful collection and belongs in the shared read cache.
254
303
  deps.writeUsageCache(account.usageKey, usage.snapshot);
255
304
  updates[account.usageKey] = nextHeadroomEntry(entry, usage.snapshot, now);
256
305
  result.refreshed += 1;
@@ -259,12 +308,12 @@ export async function runUsageRefresh(deps) {
259
308
  // No live snapshot (expired token / fetch miss): don't rewrite the usage
260
309
  // cache, but still record the call + reschedule so a broken account
261
310
  // isn't retried every tick.
262
- updates[account.usageKey] = nextHeadroomEntry(entry, null, now);
311
+ updates[account.usageKey] = failedHeadroomEntry(entry, now);
263
312
  result.failed += 1;
264
313
  }
265
314
  }
266
315
  catch {
267
- updates[account.usageKey] = nextHeadroomEntry(entry, null, now);
316
+ updates[account.usageKey] = failedHeadroomEntry(entry, now);
268
317
  result.failed += 1;
269
318
  }
270
319
  }
@@ -1,7 +1,7 @@
1
1
  /** Same path the Fleet reader pulls (apps/ext/src/core/watchdogLog.ts). */
2
2
  export declare const WATCHDOG_LOG_PATH: string;
3
3
  /** Kinds the Factory reader accepts. Keep in lockstep with watchdogLog.ts. */
4
- export type WatchdogEventKind = 'tick' | 'decision' | 'nudge' | 'rotate' | 'error';
4
+ export type WatchdogEventKind = 'tick' | 'decision' | 'nudge' | 'undelivered' | 'rotate' | 'error';
5
5
  /** JSONL row shape — a verbatim replica of the Factory reader's WatchdogEvent. */
6
6
  export interface WatchdogEvent {
7
7
  ts: number;
@@ -18,7 +18,7 @@ import * as path from 'path';
18
18
  import { withFileLock, atomicWriteFileSync, ensureLockTarget } from '../fs-atomic.js';
19
19
  /** Same path the Fleet reader pulls (apps/ext/src/core/watchdogLog.ts). */
20
20
  export const WATCHDOG_LOG_PATH = path.join(os.homedir(), '.agents', '.cache', 'logs', 'watchdog.log');
21
- const WATCHDOG_EVENT_KINDS = new Set(['tick', 'decision', 'nudge', 'rotate', 'error']);
21
+ const WATCHDOG_EVENT_KINDS = new Set(['tick', 'decision', 'nudge', 'undelivered', 'rotate', 'error']);
22
22
  /** Parse the audit log without letting a partial final append hide valid rows. */
23
23
  export function parseWatchdogEvents(text) {
24
24
  const events = [];