@north-light/crouter 0.3.311 → 0.3.313

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 (520) hide show
  1. package/dist/api/client.d.ts +27 -30
  2. package/dist/api/client.js +80 -67
  3. package/dist/api/dto/broker-ops.d.ts +10 -20
  4. package/dist/api/dto/canvas.d.ts +1 -10
  5. package/dist/api/dto/health.d.ts +113 -0
  6. package/dist/api/dto/human-requests.d.ts +14 -3
  7. package/dist/api/dto/human.d.ts +2 -48
  8. package/dist/api/dto/human.js +3 -2
  9. package/dist/api/dto/inbox.d.ts +14 -0
  10. package/dist/api/dto/mail.d.ts +43 -0
  11. package/dist/api/dto/nodes.d.ts +11 -8
  12. package/dist/api/dto/recovery.d.ts +96 -0
  13. package/dist/api/dto/reviews.d.ts +2 -2
  14. package/dist/api/routes.d.ts +9 -6
  15. package/dist/api/routes.js +11 -11
  16. package/dist/build-root.d.ts +4 -0
  17. package/dist/build-root.js +11 -1
  18. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +35 -47
  19. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +1 -1
  20. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +12 -5
  21. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +12 -5
  22. package/dist/cli.js +10 -1
  23. package/dist/clients/__tests__/helpers/human-daemon.d.ts +11 -0
  24. package/dist/clients/__tests__/helpers/human-daemon.js +60 -0
  25. package/dist/clients/attach/chrome/canvas-panels.js +1 -1
  26. package/dist/clients/attach/chrome/inbox-strip.d.ts +3 -3
  27. package/dist/clients/attach/chrome/inbox-strip.js +17 -9
  28. package/dist/clients/attach/chrome/ticket-panel.d.ts +2 -2
  29. package/dist/clients/attach/chrome/ticket-panel.js +77 -46
  30. package/dist/clients/attach/overlays/graph.js +6 -9
  31. package/dist/clients/attach/render/chat-view.d.ts +5 -4
  32. package/dist/clients/attach/render/chat-view.js +22 -13
  33. package/dist/clients/attach/render/page-block.d.ts +11 -7
  34. package/dist/clients/attach/render/page-block.js +64 -55
  35. package/dist/clients/attach/viewer.js +746 -735
  36. package/dist/clients/inbox/__tests__/integration/inbox-controller.test.js +56 -56
  37. package/dist/clients/inbox/__tests__/integration/stale-row-activation.test.js +45 -32
  38. package/dist/clients/inbox/controller.d.ts +42 -14
  39. package/dist/clients/inbox/controller.js +240 -124
  40. package/dist/clients/inbox/dto-adapt.d.ts +28 -0
  41. package/dist/clients/inbox/dto-adapt.js +93 -0
  42. package/dist/clients/inbox/page-adapter.d.ts +5 -4
  43. package/dist/clients/inbox/page-adapter.js +12 -12
  44. package/dist/clients/inbox/resolve.d.ts +10 -5
  45. package/dist/clients/inbox/resolve.js +13 -12
  46. package/dist/clients/inbox/review/review-client.d.ts +0 -1
  47. package/dist/clients/inbox/review/review-client.js +0 -4
  48. package/dist/clients/inbox/review-adapter.d.ts +2 -1
  49. package/dist/clients/inbox/review-adapter.js +3 -5
  50. package/dist/clients/inbox/surface.d.ts +2 -2
  51. package/dist/clients/inbox/surface.js +19 -8
  52. package/dist/clients/inbox/tui.d.ts +3 -3
  53. package/dist/commands/__tests__/human.test.js +31 -15
  54. package/dist/commands/__tests__/integration/dashboard-snapshot.test.js +36 -11
  55. package/dist/commands/{sys/__tests__ → __tests__/integration}/migrate.test.js +142 -36
  56. package/dist/commands/__tests__/revive-now-gate.test.js +2 -0
  57. package/dist/commands/api-client.d.ts +5 -4
  58. package/dist/commands/api-client.js +13 -7
  59. package/dist/commands/canvas-history/shared.js +1 -1
  60. package/dist/commands/canvas-history.js +1 -1
  61. package/dist/commands/canvas-roster.js +1 -1
  62. package/dist/commands/canvas.js +1 -2
  63. package/dist/commands/human/feedback.js +20 -17
  64. package/dist/commands/human/prompts.js +45 -36
  65. package/dist/commands/human/queue.js +73 -123
  66. package/dist/commands/human/review.js +6 -16
  67. package/dist/commands/memory/__tests__/command-selector-and-mutation-guards.test.js +2 -0
  68. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.js +2 -0
  69. package/dist/commands/memory/__tests__/repository-root-lint.test.js +2 -0
  70. package/dist/commands/node-inspect-artifacts.js +1 -1
  71. package/dist/commands/node-lifecycle-revive.d.ts +2 -2
  72. package/dist/commands/node-lifecycle-revive.js +9 -19
  73. package/dist/commands/pkg/plugin-manage.js +0 -1
  74. package/dist/commands/profile/show.js +4 -12
  75. package/dist/commands/surface-tmux-spread.js +1 -5
  76. package/dist/commands/sys/__tests__/config-model-ladders.test.js +23 -30
  77. package/dist/commands/sys/__tests__/sync-project-guidance.test.js +37 -11
  78. package/dist/commands/sys/branch.d.ts +2 -0
  79. package/dist/commands/sys/branch.js +19 -0
  80. package/dist/commands/sys/config.js +106 -33
  81. package/dist/commands/sys/daemon.d.ts +2 -0
  82. package/dist/commands/sys/daemon.js +130 -7
  83. package/dist/commands/sys/doctor.js +5 -15
  84. package/dist/commands/sys/migrate.js +21 -92
  85. package/dist/commands/sys/setup-settings-runner.js +4 -2
  86. package/dist/commands/sys.js +2 -15
  87. package/dist/core/__tests__/boot-fault-attempt.test.js +23 -1
  88. package/dist/core/__tests__/boot.test.js +32 -25
  89. package/dist/core/__tests__/broker-launch-candidates.test.js +49 -0
  90. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +149 -176
  91. package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +59 -10
  92. package/dist/core/__tests__/canvas-inbox-watcher.test.js +76 -20
  93. package/dist/core/__tests__/canvas.test.js +18 -47
  94. package/dist/core/__tests__/child-death-wake.test.js +29 -7
  95. package/dist/core/__tests__/cli-help-contract.test.js +2 -0
  96. package/dist/core/__tests__/close.test.js +29 -10
  97. package/dist/core/__tests__/collectible.test.js +2 -1
  98. package/dist/core/__tests__/config-change-delta.test.js +2 -0
  99. package/dist/core/__tests__/context-intro.test.js +17 -5
  100. package/dist/core/__tests__/cron-armed-context-failure.test.js +17 -12
  101. package/dist/core/__tests__/cron-held-settlement.test.js +21 -3
  102. package/dist/core/__tests__/cron-node-sink-parked-root.test.js +29 -10
  103. package/dist/core/__tests__/cron-self-cancel-delivery.test.js +14 -2
  104. package/dist/core/__tests__/cron-timeout-descendants.test.js +13 -1
  105. package/dist/core/__tests__/daemon-boot.test.js +51 -14
  106. package/dist/core/__tests__/fixtures/fake-engine.d.ts +1 -1
  107. package/dist/core/__tests__/fixtures/fake-engine.js +8 -3
  108. package/dist/core/__tests__/focuses.test.js +10 -5
  109. package/dist/core/__tests__/helpers/harness.js +50 -18
  110. package/dist/core/__tests__/history-inbox.test.js +12 -16
  111. package/dist/core/__tests__/human-action-delivery.test.js +48 -28
  112. package/dist/core/__tests__/human-actions.test.js +7 -3
  113. package/dist/core/__tests__/inline-memory-refs.test.js +7 -2
  114. package/dist/core/__tests__/integration/broker-fork-seam.test.js +37 -10
  115. package/dist/core/__tests__/integration/broker-sdk-wiring.test.js +42 -0
  116. package/dist/core/__tests__/integration/broker-snapshot-history.test.js +42 -29
  117. package/dist/core/__tests__/integration/broker-wedge-supervision.test.js +36 -35
  118. package/dist/core/__tests__/integration/command-plugins.test.js +15 -6
  119. package/dist/core/__tests__/integration/deferred-no-wake.test.js +113 -92
  120. package/dist/core/__tests__/integration/host-teardown-process-group.test.js +9 -5
  121. package/dist/core/__tests__/integration/lifecycle-hooks.test.js +312 -1
  122. package/dist/core/__tests__/integration/plugin-revalidate.test.js +4 -0
  123. package/dist/core/__tests__/integration/profile-pause.test.js +4 -4
  124. package/dist/core/__tests__/integration/refresh-stall-recycle.test.js +9 -12
  125. package/dist/core/__tests__/integration/revive.test.js +67 -21
  126. package/dist/core/__tests__/integration/spawn-root.test.js +62 -19
  127. package/dist/core/__tests__/integration/subscription-delivery.test.js +33 -21
  128. package/dist/core/__tests__/integration/worktree-land.test.js +5 -2
  129. package/dist/core/__tests__/integration/worktree-reap.test.js +15 -1
  130. package/dist/core/__tests__/kickoff.test.js +36 -39
  131. package/dist/core/__tests__/lifecycle-terminal-reasons.test.js +2 -1
  132. package/dist/core/__tests__/lifecycle.test.js +2 -1
  133. package/dist/core/__tests__/live-lifecycle-prompt.test.js +13 -1
  134. package/dist/core/__tests__/memory-resolver-precedence.test.js +2 -0
  135. package/dist/core/__tests__/migration.test.js +145 -31
  136. package/dist/core/__tests__/model-pin-durability.test.js +4 -1
  137. package/dist/core/__tests__/model-routes-config.test.js +13 -5
  138. package/dist/core/__tests__/node-outcome.test.js +93 -26
  139. package/dist/core/__tests__/on-read-crouter-home-fence.test.js +4 -3
  140. package/dist/core/__tests__/on-read-dedup-resume.test.js +8 -7
  141. package/dist/core/__tests__/on-read-nested-store.test.js +8 -7
  142. package/dist/core/__tests__/passive-subscription.test.js +50 -157
  143. package/dist/core/__tests__/plugin-kinds.test.js +6 -4
  144. package/dist/core/__tests__/plugin-link-symlinked-scope-root.test.js +6 -1
  145. package/dist/core/__tests__/plugin-page-components.test.js +6 -4
  146. package/dist/core/__tests__/preview-registry-sync.test.js +2 -0
  147. package/dist/core/__tests__/profile-project-memory-delivery.test.js +35 -32
  148. package/dist/core/__tests__/prune.test.js +11 -5
  149. package/dist/core/__tests__/push-final-guard.test.js +14 -7
  150. package/dist/core/__tests__/relaunch-root.test.js +30 -2
  151. package/dist/core/__tests__/remote-canvas-target.test.js +7 -1
  152. package/dist/core/__tests__/repository-root-identity.test.js +4 -0
  153. package/dist/core/__tests__/review-model-floor.test.js +6 -3
  154. package/dist/core/__tests__/revive-all.test.js +13 -3
  155. package/dist/core/__tests__/revive-capacity.test.js +26 -4
  156. package/dist/core/__tests__/revive-parked-fresh.test.js +31 -8
  157. package/dist/core/__tests__/seam/broker-attach-stream.test.js +1 -1
  158. package/dist/core/__tests__/seam/broker-cap-freeze.test.js +4 -3
  159. package/dist/core/__tests__/seam/broker-crash-teardown.test.js +11 -14
  160. package/dist/core/__tests__/seam/broker-provider-retry.test.js +85 -71
  161. package/dist/core/__tests__/seam/dormancy-release.test.js +33 -19
  162. package/dist/core/__tests__/seam/held-deferred-human-prompt.test.js +7 -5
  163. package/dist/core/__tests__/seam/inbox-reference-guidance.test.js +20 -6
  164. package/dist/core/__tests__/seam/node-outcome-delivery.test.js +27 -3
  165. package/dist/core/__tests__/spawn-worktree-id-conflict.test.js +5 -4
  166. package/dist/core/__tests__/stop-guard.test.js +2 -1
  167. package/dist/core/__tests__/subscription-pool-credential-guard.test.js +4 -3
  168. package/dist/core/__tests__/warm-claim-preference-snapshot.test.js +19 -5
  169. package/dist/core/__tests__/yield-ensures-daemon.test.js +2 -1
  170. package/dist/core/bootstrap.js +2 -0
  171. package/dist/core/broker-client/__tests__/transport-socket.test.js +51 -6
  172. package/dist/core/broker-client/transport-socket.d.ts +1 -0
  173. package/dist/core/broker-client/transport-socket.js +13 -5
  174. package/dist/core/canvas/__tests__/attention.test.js +29 -35
  175. package/dist/core/canvas/__tests__/history-transcript.test.js +6 -6
  176. package/dist/core/canvas/__tests__/render-remote.test.js +25 -9
  177. package/dist/core/canvas/attention.d.ts +2 -5
  178. package/dist/core/canvas/attention.js +15 -64
  179. package/dist/core/canvas/boot.d.ts +6 -7
  180. package/dist/core/canvas/boot.js +5 -9
  181. package/dist/core/canvas/canvas.d.ts +13 -109
  182. package/dist/core/canvas/canvas.js +140 -401
  183. package/dist/core/canvas/crons.d.ts +2 -0
  184. package/dist/core/canvas/crons.js +16 -15
  185. package/dist/core/canvas/daemon-owner.js +10 -0
  186. package/dist/core/canvas/db.d.ts +2 -2
  187. package/dist/core/canvas/db.js +2 -18
  188. package/dist/core/canvas/deliveries.d.ts +104 -0
  189. package/dist/core/canvas/deliveries.js +354 -0
  190. package/dist/core/canvas/executions.d.ts +29 -0
  191. package/dist/core/canvas/executions.js +68 -0
  192. package/dist/core/canvas/history.js +35 -35
  193. package/dist/core/canvas/index.d.ts +3 -1
  194. package/dist/core/canvas/index.js +3 -1
  195. package/dist/core/canvas/migrations.js +20 -12
  196. package/dist/core/canvas/nav-model.js +1 -3
  197. package/dist/core/canvas/nav-render.d.ts +4 -4
  198. package/dist/core/canvas/nav-render.js +11 -21
  199. package/dist/core/canvas/node-sql-migration.d.ts +3 -0
  200. package/dist/core/canvas/node-sql-migration.js +261 -0
  201. package/dist/core/canvas/paths.d.ts +2 -16
  202. package/dist/core/canvas/paths.js +2 -31
  203. package/dist/core/canvas/recovery.d.ts +76 -0
  204. package/dist/core/canvas/recovery.js +588 -0
  205. package/dist/core/canvas/remote-canvas-source.js +4 -0
  206. package/dist/core/canvas/render-source.d.ts +16 -27
  207. package/dist/core/canvas/render-source.js +16 -45
  208. package/dist/core/canvas/sessions.d.ts +33 -0
  209. package/dist/core/canvas/sessions.js +78 -0
  210. package/dist/core/canvas/types.d.ts +33 -33
  211. package/dist/core/canvas/types.js +2 -5
  212. package/dist/core/canvas/waits.d.ts +18 -0
  213. package/dist/core/canvas/waits.js +70 -0
  214. package/dist/core/canvas/worktrees.d.ts +40 -0
  215. package/dist/core/canvas/worktrees.js +93 -0
  216. package/dist/core/command-hooks/artifact.d.ts +6 -0
  217. package/dist/core/command-hooks/artifact.js +22 -6
  218. package/dist/core/command-hooks/discovery.d.ts +2 -0
  219. package/dist/core/command-hooks/discovery.js +2 -0
  220. package/dist/core/command-hooks/lifecycle-catalog.d.ts +20 -2
  221. package/dist/core/command-hooks/lifecycle-catalog.js +7 -2
  222. package/dist/core/command-hooks/lifecycle-create.d.ts +4 -0
  223. package/dist/core/command-hooks/lifecycle-create.js +51 -0
  224. package/dist/core/command-hooks/schema.d.ts +2 -2
  225. package/dist/core/command-hooks/schema.js +5 -4
  226. package/dist/core/command-hooks/transport/exec-invoke.js +3 -1
  227. package/dist/core/command-hooks/transport/exec-lifecycle.d.ts +18 -8
  228. package/dist/core/command-hooks/transport/exec-lifecycle.js +89 -30
  229. package/dist/core/config.d.ts +17 -12
  230. package/dist/core/config.js +106 -155
  231. package/dist/core/errors.d.ts +1 -0
  232. package/dist/core/errors.js +4 -0
  233. package/dist/core/feed/__tests__/inbox-migrate.test.js +27 -31
  234. package/dist/core/feed/feed.d.ts +0 -6
  235. package/dist/core/feed/feed.js +68 -56
  236. package/dist/core/feed/inbox.d.ts +7 -81
  237. package/dist/core/feed/inbox.js +46 -378
  238. package/dist/core/feed/messages.d.ts +58 -0
  239. package/dist/core/feed/messages.js +217 -0
  240. package/dist/core/fs-utils.d.ts +4 -0
  241. package/dist/core/fs-utils.js +21 -1
  242. package/dist/core/human/answer.d.ts +1 -1
  243. package/dist/core/human/answer.js +1 -1
  244. package/dist/core/human/claim.d.ts +1 -1
  245. package/dist/core/human/claim.js +2 -4
  246. package/dist/core/human/component-docs.js +1 -1
  247. package/dist/core/human/feedback-companion.js +4 -3
  248. package/dist/core/human/feedback.d.ts +5 -8
  249. package/dist/core/human/feedback.js +57 -85
  250. package/dist/core/human/page-authoring.d.ts +30 -0
  251. package/dist/core/human/page-authoring.js +19 -0
  252. package/dist/core/human/requests.d.ts +136 -0
  253. package/dist/core/human/requests.js +515 -0
  254. package/dist/core/human/root.d.ts +0 -7
  255. package/dist/core/human/root.js +0 -17
  256. package/dist/core/human/scan.d.ts +2 -7
  257. package/dist/core/human/scan.js +48 -132
  258. package/dist/core/human/summary.d.ts +1 -1
  259. package/dist/core/human/summary.js +1 -1
  260. package/dist/core/human/tickets.d.ts +23 -83
  261. package/dist/core/human/tickets.js +92 -243
  262. package/dist/core/human/types.d.ts +5 -1
  263. package/dist/core/installed-plugins.js +8 -3
  264. package/dist/core/keybindings/__tests__/resolve.test.js +19 -17
  265. package/dist/core/locked-file.d.ts +6 -3
  266. package/dist/core/locked-file.js +36 -9
  267. package/dist/core/memory/extensions.js +2 -0
  268. package/dist/core/preview-registry.js +0 -1
  269. package/dist/core/profiles/__tests__/env-store.test.js +2 -0
  270. package/dist/core/profiles/default-binding.d.ts +0 -4
  271. package/dist/core/profiles/default-binding.js +12 -58
  272. package/dist/core/profiles/env-store.js +8 -3
  273. package/dist/core/profiles/manifest.d.ts +2 -37
  274. package/dist/core/profiles/manifest.js +35 -161
  275. package/dist/core/profiles/select.js +3 -0
  276. package/dist/core/review/__tests__/capture-origin.test.js +13 -2
  277. package/dist/core/review/__tests__/comments.test.js +28 -10
  278. package/dist/core/review/__tests__/remap.test.js +28 -10
  279. package/dist/core/review/__tests__/stage-identity.test.js +17 -7
  280. package/dist/core/review/birth.d.ts +1 -1
  281. package/dist/core/review/birth.js +34 -5
  282. package/dist/core/review/comments.js +1 -1
  283. package/dist/core/review/companion.d.ts +1 -1
  284. package/dist/core/review/companion.js +2 -2
  285. package/dist/core/review/document.js +1 -1
  286. package/dist/core/review/project.d.ts +5 -1
  287. package/dist/core/review/project.js +14 -36
  288. package/dist/core/review/realize.d.ts +3 -6
  289. package/dist/core/review/realize.js +51 -168
  290. package/dist/core/review/remap.js +1 -1
  291. package/dist/core/review/stage.d.ts +3 -2
  292. package/dist/core/review/stage.js +2 -3
  293. package/dist/core/review/store.d.ts +3 -21
  294. package/dist/core/review/store.js +140 -186
  295. package/dist/core/review/types.d.ts +0 -1
  296. package/dist/core/runtime/__tests__/install-id-cache-scoping.test.js +9 -5
  297. package/dist/core/runtime/__tests__/spawn-env.test.js +8 -9
  298. package/dist/core/runtime/broker/auth-reload.js +9 -10
  299. package/dist/core/runtime/broker/daemon-ops.d.ts +25 -8
  300. package/dist/core/runtime/broker/daemon-ops.js +42 -9
  301. package/dist/core/runtime/broker/extension-abort.d.ts +3 -0
  302. package/dist/core/runtime/broker/extension-abort.js +11 -0
  303. package/dist/core/runtime/broker/fault-retry.d.ts +15 -6
  304. package/dist/core/runtime/broker/fault-retry.js +142 -95
  305. package/dist/core/runtime/broker/inbox.d.ts +4 -32
  306. package/dist/core/runtime/broker/inbox.js +4 -92
  307. package/dist/core/runtime/broker/rebind.d.ts +1 -0
  308. package/dist/core/runtime/broker/rebind.js +21 -12
  309. package/dist/core/runtime/broker-persona-guidance.d.ts +1 -1
  310. package/dist/core/runtime/broker-persona-guidance.js +2 -13
  311. package/dist/core/runtime/broker.js +28 -22
  312. package/dist/core/runtime/close.d.ts +3 -9
  313. package/dist/core/runtime/close.js +16 -27
  314. package/dist/core/runtime/fault.d.ts +3 -93
  315. package/dist/core/runtime/fault.js +9 -419
  316. package/dist/core/runtime/kickoff.d.ts +3 -6
  317. package/dist/core/runtime/kickoff.js +12 -18
  318. package/dist/core/runtime/launch.js +1 -1
  319. package/dist/core/runtime/lifecycle.js +40 -7
  320. package/dist/core/runtime/nodes.d.ts +5 -1
  321. package/dist/core/runtime/nodes.js +26 -11
  322. package/dist/core/runtime/placement.js +2 -2
  323. package/dist/core/runtime/recycle.js +24 -14
  324. package/dist/core/runtime/reset.js +26 -7
  325. package/dist/core/runtime/revive.js +226 -217
  326. package/dist/core/runtime/spawn.d.ts +4 -1
  327. package/dist/core/runtime/spawn.js +43 -5
  328. package/dist/core/runtime/warm-pool.d.ts +2 -2
  329. package/dist/core/runtime/warm-pool.js +23 -3
  330. package/dist/core/scoped-state/db.d.ts +7 -0
  331. package/dist/core/scoped-state/db.js +92 -0
  332. package/dist/core/scoped-state/migrate.d.ts +11 -0
  333. package/dist/core/scoped-state/migrate.js +198 -0
  334. package/dist/core/scoped-state/paths.d.ts +9 -0
  335. package/dist/core/scoped-state/paths.js +27 -0
  336. package/dist/core/scoped-state/profiles.d.ts +12 -0
  337. package/dist/core/scoped-state/profiles.js +62 -0
  338. package/dist/core/scoped-state/providers.d.ts +16 -0
  339. package/dist/core/scoped-state/providers.js +8 -0
  340. package/dist/core/scoped-state/schema.d.ts +3 -0
  341. package/dist/core/scoped-state/schema.js +26 -0
  342. package/dist/core/scoped-state/settings.d.ts +28 -0
  343. package/dist/core/scoped-state/settings.js +82 -0
  344. package/dist/core/subscription-state.d.ts +29 -48
  345. package/dist/core/subscription-state.js +176 -712
  346. package/dist/core/worktree-close.js +4 -4
  347. package/dist/core/worktree-mutation-async.js +2 -2
  348. package/dist/core/worktree-sweep.js +11 -16
  349. package/dist/core/worktree.js +6 -12
  350. package/dist/daemon/__tests__/autostart-storm.test.js +1 -39
  351. package/dist/daemon/__tests__/helpers/source-daemon.d.ts +2 -2
  352. package/dist/daemon/__tests__/helpers/source-daemon.js +7 -6
  353. package/dist/daemon/__tests__/integration/migration-startup.test.js +59 -26
  354. package/dist/daemon/__tests__/node-deadline-lane.test.js +2 -1
  355. package/dist/daemon/__tests__/node-outcome-birth-invariants.test.js +41 -17
  356. package/dist/daemon/__tests__/node-outcome-delivery.test.js +39 -10
  357. package/dist/daemon/__tests__/reconciler-signature.test.js +2 -2
  358. package/dist/daemon/__tests__/subtree-filter-freshness.test.js +2 -2
  359. package/dist/daemon/api/__tests__/broker-settle-park.test.js +17 -12
  360. package/dist/daemon/api/__tests__/canvas-snapshot-fields.test.js +27 -5
  361. package/dist/daemon/api/__tests__/model-config.test.js +8 -8
  362. package/dist/daemon/api/__tests__/node-create-description.test.js +22 -5
  363. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -1
  364. package/dist/daemon/api/__tests__/nodes-activity-query.test.js +10 -12
  365. package/dist/daemon/api/__tests__/profile-launch-gates.test.js +6 -3
  366. package/dist/daemon/api/__tests__/profile-metadata-route.test.js +4 -1
  367. package/dist/daemon/api/__tests__/reopen-delivery.test.js +26 -8
  368. package/dist/daemon/api/__tests__/seam/api-server.test.js +24 -32
  369. package/dist/daemon/api/__tests__/seam/b10-attach-modelauth.test.js +4 -1
  370. package/dist/daemon/api/__tests__/seam/leaf-api-parity.test.js +5 -4
  371. package/dist/daemon/api/__tests__/seam/profile-delete.test.js +29 -43
  372. package/dist/daemon/api/__tests__/submit-result.test.js +3 -2
  373. package/dist/daemon/api/bridge.js +1 -1
  374. package/dist/daemon/api/handlers/bash-jobs.js +6 -7
  375. package/dist/daemon/api/handlers/broker-ops.js +47 -84
  376. package/dist/daemon/api/handlers/broker-recovery.d.ts +4 -0
  377. package/dist/daemon/api/handlers/broker-recovery.js +251 -0
  378. package/dist/daemon/api/handlers/canvas.js +2 -44
  379. package/dist/daemon/api/handlers/daemon.js +41 -0
  380. package/dist/daemon/api/handlers/feedback-comments.js +2 -2
  381. package/dist/daemon/api/handlers/health.js +10 -5
  382. package/dist/daemon/api/handlers/human-requests.js +168 -188
  383. package/dist/daemon/api/handlers/human.js +3 -148
  384. package/dist/daemon/api/handlers/inbox-page.d.ts +4 -0
  385. package/dist/daemon/api/handlers/inbox-page.js +89 -0
  386. package/dist/daemon/api/handlers/inbox.js +21 -120
  387. package/dist/daemon/api/handlers/messages.d.ts +1 -0
  388. package/dist/daemon/api/handlers/messages.js +73 -15
  389. package/dist/daemon/api/handlers/node-outcomes.js +58 -13
  390. package/dist/daemon/api/handlers/nodes.d.ts +1 -0
  391. package/dist/daemon/api/handlers/nodes.js +41 -42
  392. package/dist/daemon/api/handlers/reviews.js +4 -29
  393. package/dist/daemon/api/map.d.ts +25 -3
  394. package/dist/daemon/api/map.js +84 -14
  395. package/dist/daemon/api/server.js +68 -9
  396. package/dist/daemon/control.d.ts +18 -0
  397. package/dist/daemon/cron/disposition.d.ts +2 -0
  398. package/dist/daemon/cron/disposition.js +8 -5
  399. package/dist/daemon/cron/settle.js +37 -13
  400. package/dist/daemon/cron/sinks.d.ts +18 -1
  401. package/dist/daemon/cron/sinks.js +47 -93
  402. package/dist/daemon/crtrd-cli.js +4 -19
  403. package/dist/daemon/crtrd.d.ts +2 -0
  404. package/dist/daemon/crtrd.js +206 -81
  405. package/dist/daemon/fleet.js +5 -6
  406. package/dist/daemon/human/finish.d.ts +12 -24
  407. package/dist/daemon/human/finish.js +88 -285
  408. package/dist/daemon/human/settle.d.ts +40 -0
  409. package/dist/daemon/human/settle.js +89 -0
  410. package/dist/daemon/human/sweep.d.ts +20 -5
  411. package/dist/daemon/human/sweep.js +56 -60
  412. package/dist/daemon/manage.d.ts +21 -16
  413. package/dist/daemon/manage.js +85 -51
  414. package/dist/daemon/messaging/node-message.d.ts +1 -1
  415. package/dist/daemon/messaging/node-message.js +9 -12
  416. package/dist/daemon/park-activity.js +3 -3
  417. package/dist/daemon/reconcilers/bash-deadline.js +6 -7
  418. package/dist/daemon/reconcilers/broker-supervision.d.ts +1 -1
  419. package/dist/daemon/reconcilers/broker-supervision.js +41 -19
  420. package/dist/daemon/reconcilers/controller-death.js +6 -18
  421. package/dist/daemon/reconcilers/delivery-lane.d.ts +9 -0
  422. package/dist/daemon/reconcilers/delivery-lane.js +130 -0
  423. package/dist/daemon/reconcilers/live-obligation.js +3 -3
  424. package/dist/daemon/reconcilers/node-deadline.js +1 -1
  425. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.d.ts +1 -1
  426. package/dist/daemon/reconcilers/node-lifecycle/freeze-lane.js +10 -12
  427. package/dist/daemon/reconcilers/node-lifecycle/tick.js +5 -5
  428. package/dist/daemon/reconcilers/node-lifecycle/wants-execution.js +1 -1
  429. package/dist/daemon/reconcilers/pending-review-submit.js +3 -3
  430. package/dist/daemon/reconcilers/storage-maintenance.d.ts +2 -8
  431. package/dist/daemon/reconcilers/storage-maintenance.js +13 -43
  432. package/dist/daemon/review/companion.js +5 -11
  433. package/dist/daemon/review/deliver.d.ts +1 -5
  434. package/dist/daemon/review/deliver.js +3 -52
  435. package/dist/daemon/review/finish.d.ts +2 -27
  436. package/dist/daemon/review/finish.js +79 -263
  437. package/dist/daemon/review/sweep.d.ts +3 -10
  438. package/dist/daemon/review/sweep.js +13 -131
  439. package/dist/daemon/startup-policy.d.ts +0 -5
  440. package/dist/daemon/startup-policy.js +0 -5
  441. package/dist/hook-authoring.d.ts +28 -2
  442. package/dist/hook-authoring.js +53 -1
  443. package/dist/index.d.ts +3 -2
  444. package/dist/index.js +1 -1
  445. package/dist/migrations/__tests__/activation-concurrency.test.js +3 -2
  446. package/dist/migrations/__tests__/activation.test.js +9 -6
  447. package/dist/migrations/activation.d.ts +3 -2
  448. package/dist/migrations/activation.js +2 -4
  449. package/dist/migrations/corpus.d.ts +2 -5
  450. package/dist/migrations/corpus.js +9 -20
  451. package/dist/migrations/runner.d.ts +3 -1
  452. package/dist/migrations/runner.js +6 -2
  453. package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +21 -89
  454. package/dist/pi-extensions/__tests__/canvas-goal-capture-envelope.test.js +4 -2
  455. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +51 -33
  456. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +16 -2
  457. package/dist/pi-extensions/__tests__/pre-command-gate.test.js +13 -1
  458. package/dist/pi-extensions/canvas-inbox-watcher.js +98 -244
  459. package/dist/pi-extensions/canvas-passive-context.d.ts +4 -8
  460. package/dist/pi-extensions/canvas-passive-context.js +85 -37
  461. package/dist/pi-extensions/canvas-stophook.js +47 -31
  462. package/dist/shared/env.d.ts +1 -0
  463. package/dist/shared/env.js +3 -0
  464. package/package.json +1 -1
  465. package/runtime.lock.json +5 -5
  466. package/dist/clients/attach/__tests__/page-block.test.js +0 -54
  467. package/dist/clients/inbox/review-visibility.d.ts +0 -5
  468. package/dist/clients/inbox/review-visibility.js +0 -19
  469. package/dist/commands/canvas-rebuild-index.d.ts +0 -2
  470. package/dist/commands/canvas-rebuild-index.js +0 -46
  471. package/dist/core/__tests__/human-cancel-guard.test.js +0 -110
  472. package/dist/core/__tests__/human-deliver.test.d.ts +0 -1
  473. package/dist/core/__tests__/human-deliver.test.js +0 -256
  474. package/dist/core/__tests__/human-node-not-supervised.test.d.ts +0 -1
  475. package/dist/core/__tests__/human-node-not-supervised.test.js +0 -89
  476. package/dist/core/__tests__/integration/human-deliver-e2e.test.d.ts +0 -1
  477. package/dist/core/__tests__/integration/human-deliver-e2e.test.js +0 -131
  478. package/dist/core/__tests__/phase4-review-store.test.d.ts +0 -1
  479. package/dist/core/__tests__/phase4-review-store.test.js +0 -144
  480. package/dist/core/__tests__/watchdog-abort-arms-retry.test.d.ts +0 -1
  481. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +0 -110
  482. package/dist/core/canvas/human-deliveries.d.ts +0 -39
  483. package/dist/core/canvas/human-deliveries.js +0 -75
  484. package/dist/core/canvas/node-outcome-deliveries.d.ts +0 -64
  485. package/dist/core/canvas/node-outcome-deliveries.js +0 -159
  486. package/dist/core/feed/passive.d.ts +0 -3
  487. package/dist/core/feed/passive.js +0 -53
  488. package/dist/core/human/__tests__/inbox-cancel-worker.d.ts +0 -1
  489. package/dist/core/human/__tests__/inbox-cancel-worker.js +0 -7
  490. package/dist/core/human/__tests__/inbox-claim-worker.d.ts +0 -1
  491. package/dist/core/human/__tests__/inbox-claim-worker.js +0 -17
  492. package/dist/core/human/__tests__/inbox-stale-claim-worker.d.ts +0 -1
  493. package/dist/core/human/__tests__/inbox-stale-claim-worker.js +0 -8
  494. package/dist/core/human/__tests__/integration/inbox-core.test.d.ts +0 -1
  495. package/dist/core/human/__tests__/integration/inbox-core.test.js +0 -145
  496. package/dist/core/human/__tests__/page-tickets.test.d.ts +0 -1
  497. package/dist/core/human/__tests__/page-tickets.test.js +0 -161
  498. package/dist/core/review/ticket-filter.d.ts +0 -17
  499. package/dist/core/review/ticket-filter.js +0 -21
  500. package/dist/core/runtime/broker/passive.d.ts +0 -17
  501. package/dist/core/runtime/broker/passive.js +0 -44
  502. package/dist/core/runtime/busy.d.ts +0 -46
  503. package/dist/core/runtime/busy.js +0 -150
  504. package/dist/daemon/__tests__/startup-block-marker.test.d.ts +0 -1
  505. package/dist/daemon/__tests__/startup-block-marker.test.js +0 -86
  506. package/dist/daemon/api/__tests__/review-terminal-conflict.test.d.ts +0 -1
  507. package/dist/daemon/api/__tests__/review-terminal-conflict.test.js +0 -98
  508. package/dist/daemon/human/deliver-action.d.ts +0 -3
  509. package/dist/daemon/human/deliver-action.js +0 -9
  510. package/dist/daemon/node-outcome/deliver-outcome.d.ts +0 -3
  511. package/dist/daemon/node-outcome/deliver-outcome.js +0 -9
  512. package/dist/daemon/reconcilers/human-delivery-lane.d.ts +0 -10
  513. package/dist/daemon/reconcilers/human-delivery-lane.js +0 -41
  514. package/dist/daemon/reconcilers/node-outcome-delivery-lane.d.ts +0 -10
  515. package/dist/daemon/reconcilers/node-outcome-delivery-lane.js +0 -41
  516. package/dist/daemon/startup-block-marker.d.ts +0 -25
  517. package/dist/daemon/startup-block-marker.js +0 -99
  518. /package/dist/{clients/attach/__tests__/page-block.test.d.ts → api/dto/mail.js} +0 -0
  519. /package/dist/{commands/sys/__tests__/migrate.test.d.ts → api/dto/recovery.js} +0 -0
  520. /package/dist/{core/__tests__/human-cancel-guard.test.d.ts → commands/__tests__/integration/migrate.test.d.ts} +0 -0
@@ -1,729 +1,193 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { createHash } from 'node:crypto';
3
- import { homedir } from 'node:os';
1
+ import { createHash, randomUUID } from 'node:crypto';
4
2
  import { join } from 'node:path';
5
- import { atomicWriteJson } from './fs-utils.js';
6
3
  import { LockedJsonFile } from './locked-file.js';
7
- export const ANTHROPIC_PROVIDER_ID = "anthropic";
8
- export const OPENAI_CODEX_PROVIDER_ID = "openai-codex";
4
+ import { piAgentDir } from './runtime/package-health.js';
5
+ import { openScopedStore } from './scoped-state/db.js';
6
+ import { providerStore } from './scoped-state/paths.js';
7
+ import { providerRowByReference, providerRows, providerWrite } from './scoped-state/providers.js';
8
+ export const ANTHROPIC_PROVIDER_ID = 'anthropic';
9
+ export const OPENAI_CODEX_PROVIDER_ID = 'openai-codex';
9
10
  export const INVALID_REFRESH_TOKEN_BACKOFF_MS = 30 * 24 * 60 * 60 * 1000;
10
- /**
11
- * Select the first viable model from an explicitly ordered set. Callers own ordering
12
- * (requested model first, then their configured fallback order) and side effects;
13
- * this policy owns the shared viability rules so launch and rotation cannot diverge.
14
- *
15
- * `pinnedToFirst` marks the FIRST candidate as an explicit provider pin: the caller
16
- * demanded that exact provider, so an unregistered or unauthenticated first candidate
17
- * forbids cross-provider fallback (selection stops, reporting only that one rejection).
18
- * A merely COOLING pin is transient, so fallback to a viable alternative is still
19
- * allowed. When false, every candidate is considered in order.
20
- */
21
- export function resolveProviderCandidates(candidates, now = Date.now(), options = {}) {
22
- const viable = [];
23
- const rejected = [];
24
- const pinnedToFirst = options.pinnedToFirst === true;
25
- for (let i = 0; i < candidates.length; i++) {
26
- const candidate = candidates[i];
27
- let reason;
28
- if (!candidate.registered)
29
- reason = "unregistered";
30
- else if (candidate.automaticFallback === false)
31
- reason = "non-managed-fallback";
32
- else if (!candidate.authenticated)
33
- reason = "unauthenticated";
34
- else if ((candidate.coolingUntil ?? 0) > now)
35
- reason = "cooling";
36
- if (reason === undefined) {
37
- viable.push(candidate);
38
- continue;
39
- }
11
+ const DEFAULT_FALLBACK_STRENGTH = 'strong';
12
+ const PROVIDERS = { anthropic: { label: 'Claude', credentials: 'anthropic-credentials.json' }, 'openai-codex': { label: 'OpenAI Codex', credentials: 'openai-codex-credentials.json' } };
13
+ function agentDir() { return piAgentDir(); }
14
+ function credentialFile(provider) { return join(agentDir(), PROVIDERS[provider].credentials); }
15
+ function normalizeStrength(value) { return value === 'ultra' || value === 'strong' || value === 'medium' || value === 'light' ? value : undefined; }
16
+ function key(label) { return label.toLowerCase(); }
17
+ function credential(input) { const label = typeof input.label === 'string' ? input.label.trim() : ''; const refresh = typeof input.refresh === 'string' ? input.refresh.trim() : ''; const access = typeof input.access === 'string' ? input.access.trim() : ''; const expires = Number(input.expires); if (!label || !refresh || !access || !Number.isFinite(expires))
18
+ return undefined; const accountId = typeof input.accountId === 'string' && input.accountId.trim() ? input.accountId.trim() : undefined; return { label, refresh, access, expires, rateLimitedUntil: Number.isFinite(Number(input.rateLimitedUntil)) ? Number(input.rateLimitedUntil) : 0, lastAttemptAt: Number.isFinite(Number(input.lastAttemptAt)) ? Number(input.lastAttemptAt) : 0, lastRateLimitedAt: Number.isFinite(Number(input.lastRateLimitedAt)) ? Number(input.lastRateLimitedAt) : 0, ...(input.authFailure === 'invalid_grant' ? { authFailure: 'invalid_grant' } : {}), ...(accountId ? { accountId } : {}) }; }
19
+ function assertCredential(provider, entry) { if (!entry.label?.trim())
20
+ throw new Error(`${getProviderLabel(provider)} subscription label cannot be empty`); const result = credential(entry); if (!result)
21
+ throw new Error(`Refusing to persist incomplete ${getProviderLabel(provider)} subscription "${entry.label.trim()}"; pool rows require both OAuth tokens.`); return result; }
22
+ function parseCredentials(path, current) { if (!current || current.trim() === '')
23
+ return { version: 1, accounts: {} }; let parsed; try {
24
+ parsed = JSON.parse(current);
25
+ }
26
+ catch (error) {
27
+ throw new Error(`managed credential file ${path} is corrupt: ${error instanceof Error ? error.message : String(error)}`);
28
+ } if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed) || parsed.version !== 1 || typeof parsed.accounts !== 'object' || parsed.accounts === null || Array.isArray(parsed.accounts))
29
+ throw new Error(`managed credential file ${path} is corrupt`); return parsed; }
30
+ function toPublic(row, file) { const stored = file.accounts[row.id]; if (!stored || typeof stored.refresh !== 'string' || typeof stored.access !== 'string' || !Number.isFinite(stored.expires))
31
+ throw new Error(`managed ${row.providerId} account ${row.id} has no credential record`); return { label: row.label, refresh: stored.refresh, access: stored.access, expires: stored.expires, rateLimitedUntil: row.rateLimitedUntil, lastAttemptAt: row.lastAttemptAt, lastRateLimitedAt: row.lastRateLimitedAt, ...(row.authFailure ? { authFailure: row.authFailure } : {}), ...(stored.accountId ? { accountId: stored.accountId } : {}) }; }
32
+ function rowsWithCredentials(provider, file) { return providerRows(agentDir(), provider).map((row) => ({ row, entry: toPublic(row, file) })); }
33
+ function managedCredentialFile(provider) { return new LockedJsonFile(credentialFile(provider), true, '{"version":1,"accounts":{}}\n'); }
34
+ function readFile(provider) { return parseCredentials(credentialFile(provider), managedCredentialFile(provider).readUnlocked()); }
35
+ function identity(entry) { return entry.accountId?.trim() || (entry.refresh.trim() ? `refresh:${createHash('sha256').update(entry.refresh.trim()).digest('hex')}` : undefined); }
36
+ function assertTokens(provider, label, value) { const refresh = value.refresh?.trim() ?? ''; const access = value.access?.trim() ?? ''; const expires = Number(value.expires); if (!refresh || !access || !Number.isFinite(expires))
37
+ throw new Error(`${getProviderLabel(provider)} subscription "${label}" returned an incomplete credential; the stored credential was left unchanged.`); return { refresh, access, expires }; }
38
+ export function isManagedProvider(providerId) { return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID; }
39
+ export function getProviderLabel(providerId) { return PROVIDERS[providerId].label; }
40
+ export function resolveProviderCandidates(candidates, now = Date.now(), options = {}) { const viable = []; const rejected = []; for (let i = 0; i < candidates.length; i++) {
41
+ const candidate = candidates[i];
42
+ const reason = !candidate.registered ? 'unregistered' : candidate.automaticFallback === false ? 'non-managed-fallback' : !candidate.authenticated ? 'unauthenticated' : (candidate.coolingUntil ?? 0) > now ? 'cooling' : undefined;
43
+ if (!reason)
44
+ viable.push(candidate);
45
+ else {
40
46
  rejected.push({ candidate, reason });
41
- // A hard provider pin on the requested (first) candidate forbids alternatives
42
- // — unless the pin is merely cooling, a transient managed cooldown that still
43
- // prefers a viable fallback over stalling.
44
- if (pinnedToFirst && i === 0 && reason !== "cooling")
45
- break;
46
- }
47
- return { candidates: viable, rejected };
48
- }
49
- // pi's agent dir, resolved PER ACCESS. Deliberately mirrors pi's own `getAgentDir()`
50
- // (PI_CODING_AGENT_DIR, tilde-expanded, else ~/.pi/agent) rather than importing it: a
51
- // value import from @earendil-works/pi-coding-agent would drag the whole pi SDK into
52
- // every CLI leaf that touches subscriptions. Resolving per access (never once at module
53
- // load) is what makes PI_CODING_AGENT_DIR and a per-process HOME actually take effect.
54
- function agentDir() {
55
- const configured = process.env["PI_CODING_AGENT_DIR"];
56
- if (configured) {
57
- return configured === "~" || configured.startsWith("~/") ? join(homedir(), configured.slice(1)) : configured;
58
- }
59
- return join(homedir(), ".pi", "agent");
60
- }
61
- function rotationConfigFile() {
62
- return join(agentDir(), "provider-rotation.json");
63
- }
64
- const DEFAULT_FALLBACK_STRENGTH = "strong";
65
- const PROVIDERS = {
66
- [ANTHROPIC_PROVIDER_ID]: {
67
- label: "Claude",
68
- poolFileName: "anthropic-subscriptions.json",
69
- },
70
- [OPENAI_CODEX_PROVIDER_ID]: {
71
- label: "OpenAI Codex",
72
- poolFileName: "openai-codex-subscriptions.json",
73
- },
74
- };
75
- /** The pool file backing one managed provider's accounts, in the CURRENT agent dir. */
76
- function poolFileFor(providerId) {
77
- return join(agentDir(), PROVIDERS[providerId].poolFileName);
78
- }
79
- function readJsonFile(path) {
80
- if (!existsSync(path))
81
- return undefined;
82
- try {
83
- const raw = readFileSync(path, "utf8").trim();
84
- if (!raw)
85
- return undefined;
86
- return JSON.parse(raw);
87
- }
88
- catch {
89
- return undefined;
90
- }
91
- }
92
- /**
93
- * The pool file is read strictly: an absent or empty file represents no accounts, while a
94
- * present file that cannot be read or parsed is an operational error. Treating the latter as
95
- * an empty pool would incorrectly report an authenticated provider as unconfigured.
96
- */
97
- function readPoolFile(path) {
98
- if (!existsSync(path))
99
- return undefined;
100
- try {
101
- const raw = readFileSync(path, "utf8").trim();
102
- if (!raw)
103
- return undefined;
104
- return JSON.parse(raw);
105
- }
106
- catch (error) {
107
- throw new Error(`Subscription pool ${path} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
108
- }
109
- }
110
- /**
111
- * The pool contents a locked transaction is about to act on. An empty file is genuinely no
112
- * accounts; a non-empty file that will not parse is CORRUPT, and treating it as an empty pool
113
- * is what let `modifyElectedSubscription` answer pi's refresh with `undefined` -- the exact
114
- * shape pi reports as `Provider is not configured: <id>`, a fatal fault no agent can clear
115
- * from inside its own turn. Raise instead, so the failure names itself.
116
- */
117
- function parsePoolUnderLock(path, current) {
118
- if (!current || current.trim() === "")
119
- return undefined;
120
- try {
121
- return JSON.parse(current);
122
- }
123
- catch (error) {
124
- throw new Error(`Subscription pool ${path} is corrupt: ${error instanceof Error ? error.message : String(error)}`);
125
- }
126
- }
127
- function normalizeStrength(value) {
128
- return value === "ultra" || value === "strong" || value === "medium" || value === "light" ? value : undefined;
129
- }
130
- function normalizeTiming(value) {
131
- const numeric = Number(value);
132
- return Number.isFinite(numeric) ? numeric : 0;
133
- }
134
- // A pool row IS a credential: an account whose OAuth token pair is missing cannot service a
135
- // turn and cannot be refreshed, so it is not a lesser row, it is not a row at all. Normalization
136
- // drops it here, which is what lets every consumer treat pool occupancy as authentication.
137
- function normalizeCredential(value) {
138
- const label = typeof value.label === "string" ? value.label.trim() : "";
139
- if (!label)
140
- return undefined;
141
- const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
142
- const access = typeof value.access === "string" ? value.access.trim() : "";
143
- if (!refresh || !access)
144
- return undefined;
145
- const accountId = typeof value.accountId === "string" ? value.accountId.trim() : "";
146
- const expires = Number(value.expires);
147
- const rateLimitedUntil = Number(value.rateLimitedUntil);
148
- return {
149
- label,
150
- refresh,
151
- access,
152
- expires: Number.isFinite(expires) ? expires : 0,
153
- ...(accountId ? { accountId } : {}),
154
- rateLimitedUntil: Number.isFinite(rateLimitedUntil) ? rateLimitedUntil : 0,
155
- ...(value.authFailure === "invalid_grant" ? { authFailure: "invalid_grant" } : {}),
156
- lastAttemptAt: normalizeTiming(value.lastAttemptAt),
157
- lastRateLimitedAt: normalizeTiming(value.lastRateLimitedAt),
158
- };
159
- }
160
- function normalizeCredentials(values) {
161
- if (!Array.isArray(values))
162
- return [];
163
- return values.map((entry) => normalizeCredential(entry)).filter((entry) => Boolean(entry));
164
- }
165
- // Announced only where the drop is about to be PERSISTED. Normalization also runs on the
166
- // lock-free read path, which discards nothing from storage, so tracing inside it reports a
167
- // loss that has not happened and repeats on every read. The account-set log compares
168
- // normalized state on both sides and so can never see this row; say it here instead.
169
- function traceDroppedRows(providerId, stored, normalized) {
170
- if (!Array.isArray(stored) || stored.length === normalized.length)
171
- return;
172
- process.stderr.write(`[subscription-pool] ${getProviderLabel(providerId)}: dropping ${stored.length - normalized.length} stored row(s) with no label or no token pair\n`);
173
- }
174
- // A refreshed/modified token set is only storable when it carries BOTH halves and a usable
175
- // expiry. Persisting a partial one is silently destructive: `JSON.stringify` drops the missing
176
- // key, and `normalizeCredential` (which requires refresh AND access together) then discards the
177
- // whole row on the very next read — silently logging that account out until the user logs in
178
- // again, at which point the next refresh repeats it. Providers do not all validate their own refresh response
179
- // (pi's Anthropic flow forwards `data.refresh_token` verbatim, absent or not), so the storage
180
- // layer refuses the write instead: the previous credential stays intact and the caller sees a
181
- // real error it can classify (cooldown -> reauth -> fallback).
182
- function assertStorableTokens(providerId, label, value) {
183
- const refresh = typeof value.refresh === "string" ? value.refresh.trim() : "";
184
- const access = typeof value.access === "string" ? value.access.trim() : "";
185
- const expires = Number(value.expires);
186
- if (!refresh || !access || !Number.isFinite(expires)) {
187
- const missing = [!refresh ? "refresh token" : undefined, !access ? "access token" : undefined, Number.isFinite(expires) ? undefined : "expiry"]
188
- .filter((part) => part !== undefined)
189
- .join(", ");
190
- throw new Error(`${getProviderLabel(providerId)} subscription "${label}" returned an incomplete credential (missing ${missing}); the stored credential was left unchanged.`);
191
- }
192
- return { refresh, access, expires };
193
- }
194
- // Every candidate row must survive normalization before it can reach disk. Existing on-disk
195
- // rows remain tolerant of malformed historical data during reads, but a new transform cannot
196
- // acknowledge a row that persistence would silently discard.
197
- function assertCandidateRowsStorable(providerId, candidate) {
198
- for (const entry of candidate) {
199
- if (normalizeCredential(entry) !== undefined)
200
- continue;
201
- const label = typeof entry.label === "string" && entry.label.trim() ? ` "${entry.label.trim()}"` : "";
202
- throw new Error(`Refusing to persist incomplete ${getProviderLabel(providerId)} subscription${label}; pool rows require a label and both OAuth tokens.`);
203
- }
204
- }
205
- // The ONE door every write to a pool file goes through. Both pool invariants -- one account
206
- // identity per pool, and every candidate row carrying a storable credential -- are properties
207
- // of the FILE, not of whichever code path happens to be writing, so they are asserted here
208
- // against the state this write replaces, under the caller's already-held lock. `atomicWriteJson`
209
- // is reached for the pool NOWHERE ELSE in this file: that makes the invariants unbypassable.
210
- function persistPool(providerId, poolFile, base, candidate) {
211
- assertCandidateRowsStorable(providerId, candidate);
212
- const next = normalizeCredentials(candidate);
213
- logCredentialSetChange(providerId, base, next);
214
- atomicWriteJson(poolFile, next, { mode: 0o600 });
215
- return next;
216
- }
217
- // Accounts vanishing from a pool is the failure users actually feel ("I keep having to log in
218
- // again"), and a shrinking file says nothing about which process shrank it. Every write that
219
- // CHANGES the set of accounts announces itself on stderr -- which lands in the broker log next to the rotation lines -- with the pid
220
- // and argv of whoever did it. Unchanged sets (the constant cooldown/timestamp bookkeeping) stay
221
- // silent, so this is a rare, high-signal line, not a log firehose.
222
- function logCredentialSetChange(providerId, base, next) {
223
- const labels = (pool) => pool.map((entry) => entry.label).sort();
224
- const before = labels(base);
225
- const after = labels(next);
226
- if (before.length === after.length && before.every((label, index) => label === after[index]))
227
- return;
228
- const who = `pid ${process.pid} ${process.argv.slice(1).join(" ")}`;
229
- process.stderr.write(`[subscription-pool] ${getProviderLabel(providerId)} accounts changed: [${before.join(", ")}] -> [${after.join(", ")}] (${who})\n`);
230
- }
231
- export function isManagedProvider(providerId) {
232
- return providerId === ANTHROPIC_PROVIDER_ID || providerId === OPENAI_CODEX_PROVIDER_ID;
233
- }
234
- export function getProviderLabel(providerId) {
235
- return PROVIDERS[providerId].label;
236
- }
237
- // The ONE locked read/modify/write primitive for a provider pool file. It acquires a
238
- // cross-process lock on the pool file, re-reads + normalizes the pool under that lock, awaits
239
- // the caller's transform, and persists atomically through `persistPool` before releasing.
240
- // Every pool mutation goes through this door, so causal cooldown guards compare against
241
- // fresh-under-lock state and all pool invariants share one write path.
242
- //
243
- // `fn` returning `undefined` means "no change, skip the write" (used by timestamp guards and
244
- // refreshes that find a fresh credential). `withLockAsync` seeds a missing file with `{}`;
245
- // `normalizeCredentials` coerces that first-lock seed to an empty pool. Pool writes happen via
246
- // the callback's atomic tmp+rename and return no backend `next`, so lock-free readers always see
247
- // a whole snapshot. Throws (rather than proceeding unlocked) if lock acquisition is exhausted.
248
- export async function mutateSubscriptionPool(providerId, fn) {
249
- const poolFile = poolFileFor(providerId);
250
- return new LockedJsonFile(poolFile).withLockAsync(async (current, assertLockHeld) => {
251
- const parsed = parsePoolUnderLock(poolFile, current);
252
- const base = normalizeCredentials(parsed);
253
- const transformed = await fn(base, assertLockHeld);
254
- if (transformed !== undefined)
255
- traceDroppedRows(providerId, parsed, base);
256
- if (transformed === undefined)
257
- return { result: base };
258
- return { result: persistPool(providerId, poolFile, base, transformed) };
259
- });
260
- }
261
- // The token exchange for ONE account, serialized across processes on the pool file's own lock.
262
- // A refresh token is single-use: without this, two brokers holding the same expired credential
263
- // both call the provider, and the loser gets a genuine `invalid_grant` that bricks its turn. The
264
- // whole read -> check-expiry -> exchange -> write transaction therefore happens under the lock,
265
- // so a second refresher that arrives after a winner observes the rotated value and never makes
266
- // its own (now doomed) call. Every account stores its value in this shared pool.
267
- //
268
- // The exchange is awaited while the shared pool transaction holds the lock.
269
- export async function refreshSubscriptionCredential(providerId, label, exchange) {
270
- const committed = await mutateSubscriptionPool(providerId, async (pool, assertLockHeld) => {
271
- const index = pool.findIndex((entry) => entry.label === label);
272
- if (index === -1)
273
- throw new Error(`${getProviderLabel(providerId)} subscription "${label}" no longer exists`);
274
- const entry = pool[index];
275
- if (entry.expires > Date.now())
276
- return undefined;
277
- const refreshed = assertStorableTokens(providerId, label, await exchange(entry.refresh));
278
- const next = [...pool];
279
- next[index] = { ...entry, refresh: refreshed.refresh, access: refreshed.access, expires: refreshed.expires };
280
- assertLockHeld();
281
- return next;
282
- });
283
- const result = committed.find((entry) => entry.label === label);
284
- if (result === undefined)
285
- throw new Error(`${getProviderLabel(providerId)} subscription "${label}" did not survive the write`);
286
- return result;
287
- }
288
- // The serialized read-modify-write pi's `CredentialStore.modify` contract demands. Pi's OAuth
289
- // refresh runs inside `apply`, and refresh tokens are single-use, so election, exchange, and
290
- // persistence remain under one pool lock. A credential whose provider rejects its refresh token
291
- // is marked auth-failed before the next eligible pool account is tried; this lets pi's eager
292
- // credential resolution recover through the same account pool as turn-time rotation. If every
293
- // candidate fails, those auth-failure marks are persisted before the final error is rethrown.
294
- //
295
- // `apply` returning `undefined` means "no token change". Only the token triple is replaced on a
296
- // successful refresh; account identity and rotation bookkeeping remain attached to their row.
297
- export function isInvalidRefreshTokenError(error) {
298
- const value = error;
299
- const typedStatus = typeof value?.status === "number" ? value.status : undefined;
300
- const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : ""]
301
- .filter((part) => typeof part === "string" && part.length > 0)
302
- .join(" ");
303
- const messageStatusMatch = message.match(/\bstatus[=:]\s*(\d+)/i);
304
- const status = typedStatus ?? (messageStatusMatch ? Number(messageStatusMatch[1]) : Number.NaN);
305
- return status === 400 && /invalid_grant|refresh token .*invalid|refresh token .*not found/i.test(message);
306
- }
307
- export async function modifyElectedSubscription(providerId, elect, apply) {
308
- let label;
309
- let pendingError;
310
- const committed = await mutateSubscriptionPool(providerId, async (pool, assertLockHeld) => {
311
- const next = [...pool];
312
- const attempted = new Set();
313
- let changed = false;
314
- while (attempted.size < next.length) {
315
- const elected = elect(next.filter((entry) => !attempted.has(entry.label)));
316
- if (elected === undefined)
317
- break;
318
- const index = next.findIndex((entry) => entry.label === elected.label);
319
- if (index === -1)
320
- break;
321
- attempted.add(next[index].label);
322
- let applied;
323
- try {
324
- applied = await apply(next[index]);
325
- }
326
- catch (error) {
327
- if (!isInvalidRefreshTokenError(error)) {
328
- pendingError = error;
329
- break;
330
- }
331
- const attemptAt = Date.now();
332
- next[index] = {
333
- ...next[index],
334
- authFailure: "invalid_grant",
335
- lastAttemptAt: Math.max(next[index].lastAttemptAt, attemptAt),
336
- rateLimitedUntil: Math.max(next[index].rateLimitedUntil, attemptAt + INVALID_REFRESH_TOKEN_BACKOFF_MS),
337
- };
338
- changed = true;
339
- pendingError = error;
340
- continue;
341
- }
342
- label = next[index].label;
343
- pendingError = undefined;
344
- if (applied !== undefined) {
345
- const stored = assertStorableTokens(providerId, next[index].label, applied);
346
- next[index] = { ...next[index], refresh: stored.refresh, access: stored.access, expires: stored.expires };
347
- changed = true;
348
- }
47
+ if (options.pinnedToFirst && i === 0 && reason !== 'cooling')
349
48
  break;
350
- }
351
- if (!changed)
352
- return undefined;
353
- assertLockHeld();
354
- return next;
355
- });
356
- if (pendingError !== undefined)
357
- throw pendingError;
358
- return label === undefined ? undefined : committed.find((entry) => entry.label === label);
359
- }
360
- export function readSubscriptionPool(providerId) {
361
- // Reads are lock-free: whole-file tmp+rename persistence means a reader always sees a complete
362
- // snapshot.
363
- return normalizeCredentials(readPoolFile(poolFileFor(providerId)));
364
- }
365
- // Seed/replace the entire pool under the lock. Production mutation goes through the specific
366
- // mutators below (each a thin transform over `mutateSubscriptionPool`); this whole-array replace
367
- // exists for test seeding and is still locked, never an unlocked write surface.
368
- export async function writeSubscriptionPool(providerId, next) {
369
- return mutateSubscriptionPool(providerId, () => next);
370
- }
371
- function resolveCredentialIndex(pool, ref) {
372
- const needle = ref.trim().toLowerCase();
373
- const numeric = Number.parseInt(needle, 10);
374
- if (Number.isFinite(numeric) && String(numeric) === needle && numeric >= 1 && numeric <= pool.length) {
375
- return numeric - 1;
376
49
  }
377
- return pool.findIndex((entry) => entry.label.toLowerCase() === needle);
378
- }
379
- // Merge an incoming upsert onto an existing same-label entry (design §1). Credential VALUE
380
- // fields always come from the incoming entry (that is the point of the upsert); timing/cooldown
381
- // state is reconciled causally so a refresh/reauth upsert built from a snapshot taken before a
382
- // concurrent 429 can never erase that newer cooldown. The `>=` on `lastRateLimitedAt` is
383
- // deliberate: reauth clears a cooldown after a successful re-login while preserving
384
- // `lastRateLimitedAt`, so its equal-timestamp write wins and clears -- but a 429 that landed
385
- // AFTER the reauth snapshot has a strictly greater `lastRateLimitedAt` and survives.
386
- function mergeUpsert(existing, incoming) {
387
- const cooldownFromIncoming = incoming.lastRateLimitedAt >= existing.lastRateLimitedAt;
388
- const accountId = incoming.accountId ?? existing.accountId;
389
- return {
390
- ...incoming,
391
- ...(accountId ? { accountId } : {}),
392
- lastAttemptAt: Math.max(existing.lastAttemptAt, incoming.lastAttemptAt),
393
- lastRateLimitedAt: cooldownFromIncoming ? incoming.lastRateLimitedAt : existing.lastRateLimitedAt,
394
- rateLimitedUntil: cooldownFromIncoming ? incoming.rateLimitedUntil : existing.rateLimitedUntil,
395
- };
396
- }
397
- export async function upsertSubscription(providerId, next) {
398
- return mutateSubscriptionPool(providerId, (pool) => {
399
- const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
400
- if (index === -1)
401
- return [...pool, next];
402
- const merged = [...pool];
403
- merged[index] = mergeUpsert(pool[index], next);
404
- return merged;
405
- });
406
- }
407
- // Thrown from inside the locked add transform when a label or Codex account already exists. A
408
- // typed error so the /provider-sub handler surfaces a precise, user-facing message while the
409
- // uniqueness decision itself stays atomic under the pool lock.
50
+ } return { candidates: viable, rejected }; }
410
51
  export class DuplicateSubscriptionError extends Error {
411
- constructor(message) {
412
- super(message);
413
- this.name = "DuplicateSubscriptionError";
414
- }
415
- }
416
- // The identity an account dedupes on. Codex logins carry the provider's own account claim, which
417
- // is a true account identity. Anthropic's OAuth token carries none, so the CREDENTIAL stands in
418
- // for one -- a one-way digest of the refresh token, never logged and never persisted (derived on
419
- // demand, compared in memory). That catches the same stored credential landing under a second
420
- // label; it does NOT catch two independent logins to one Anthropic account, which mint different
421
- // refresh tokens. Tightening that needs an account claim Anthropic does not currently supply.
422
- function accountIdentity(entry) {
423
- const accountId = entry.accountId?.trim();
424
- if (accountId)
425
- return accountId;
426
- const refresh = entry.refresh?.trim();
427
- return refresh ? `refresh:${createHash("sha256").update(refresh).digest("hex")}` : undefined;
428
- }
429
- // The one account-uniqueness gate, shared by every identity write. Called inside a
430
- // `mutateSubscriptionPool` transform so it sees
431
- // fresh-under-lock state. `exceptLabel` is the slot being updated -- excluded from the scan so
432
- // a reauth/relogin of a slot into the SAME account it already holds is not a self-collision.
433
- // An undefined `identity` makes no dedupe claim, so it is a no-op. Throws the typed error the
434
- // handler surfaces to the user.
435
- function findAccountCollision(pool, identity, exceptLabel) {
436
- if (!identity)
437
- return undefined;
438
- return pool.find((entry) => entry.label.toLowerCase() !== exceptLabel.toLowerCase() && accountIdentity(entry) === identity);
439
- }
440
- function assertAccountAvailable(pool, providerId, identity, exceptLabel) {
441
- const collision = findAccountCollision(pool, identity, exceptLabel);
442
- if (collision) {
443
- throw new DuplicateSubscriptionError(`This ${getProviderLabel(providerId)} account is already added as “${collision.label}” — it shares the same quota, so a second slot adds no redundancy.`);
444
- }
445
- }
446
- // Atomically append a new subscription, enforcing label and account uniqueness inside the pool
447
- // lock. The long OAuth flow stays outside the lock; only this final validate-then-append is
448
- // serialized, so two brokers that both finish OAuth for the same account cannot add two labels.
449
- export async function addSubscription(providerId, entry) {
450
- return mutateSubscriptionPool(providerId, (pool) => {
451
- // Normalize the candidate label BEFORE the locked uniqueness check: `normalizeCredentials`
452
- // trims on persist, so a raw " work " that passed an untrimmed check would land as a
453
- // duplicate "work". Reject an empty/whitespace label here too -- the storage primitive
454
- // stays defensive regardless of what the handler validated.
455
- const label = entry.label.trim();
456
- if (!label)
457
- throw new Error(`${getProviderLabel(providerId)} subscription label cannot be empty`);
458
- if (pool.some((existing) => existing.label.toLowerCase() === label.toLowerCase())) {
459
- throw new DuplicateSubscriptionError(`${getProviderLabel(providerId)} subscription already exists: ${label}`);
460
- }
461
- assertAccountAvailable(pool, providerId, accountIdentity(entry), label);
462
- return [...pool, { ...entry, label }];
463
- });
464
- }
465
- // Identity-checked upsert for reauthentication. Same merge semantics as `upsertSubscription`
466
- // (append-or-`mergeUpsert`, preserving cooldown causality), but gated by `assertAccountAvailable`
467
- // first: reauthenticating a row into an account held by another row throws instead of creating a
468
- // duplicate quota entry. `next.label` is excluded so reauth into its own row passes.
469
- export async function upsertSubscriptionWithUniqueAccount(providerId, next) {
470
- return mutateSubscriptionPool(providerId, (pool) => {
471
- assertAccountAvailable(pool, providerId, accountIdentity(next), next.label);
472
- const index = pool.findIndex((entry) => entry.label.toLowerCase() === next.label.toLowerCase());
473
- if (index === -1)
474
- return [...pool, next];
475
- const merged = [...pool];
476
- const value = mergeUpsert(pool[index], next);
477
- // A reauthentication without a stable identity must not attach the old account's identity to
478
- // newly issued tokens. Refreshes use `modifyElectedSubscription`, which preserves identity.
479
- if (next.accountId === undefined) {
480
- const { accountId: _accountId, ...withoutAccountId } = value;
481
- merged[index] = withoutAccountId;
482
- }
483
- else {
484
- merged[index] = value;
485
- }
486
- return merged;
487
- });
488
- }
489
- export async function renameSubscription(providerId, ref, nextLabel) {
490
- return mutateSubscriptionPool(providerId, (pool) => {
491
- const index = resolveCredentialIndex(pool, ref);
492
- if (index === -1)
493
- throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
494
- const label = nextLabel.trim();
495
- if (!label)
496
- throw new Error(`${getProviderLabel(providerId)} subscription label cannot be empty`);
497
- if (pool.some((entry, entryIndex) => entryIndex !== index && entry.label.toLowerCase() === label.toLowerCase())) {
498
- throw new DuplicateSubscriptionError(`${getProviderLabel(providerId)} subscription already exists: ${label}`);
52
+ constructor(message) { super(message); this.name = 'DuplicateSubscriptionError'; }
53
+ }
54
+ export function readSubscriptionPool(provider) { return rowsWithCredentials(provider, readFile(provider)).map(({ entry }) => entry); }
55
+ async function locked(provider, run) { const path = credentialFile(provider); return managedCredentialFile(provider).withLockAsync(async (current, _held, publish) => { const file = parseCredentials(path, current); return { result: await run(file, rowsWithCredentials(provider, file), (next) => publish(`${JSON.stringify(next)}\n`)) }; }); }
56
+ function insert(db, provider, row) { db.prepare('INSERT INTO managed_accounts(provider_id,id,label,label_key,position,last_attempt_at,last_rate_limited_at,rate_limited_until,auth_failure) VALUES(?,?,?,?,?,?,?,?,?)').run(provider, row.id, row.label, row.labelKey, row.position, row.lastAttemptAt, row.lastRateLimitedAt, row.rateLimitedUntil, row.authFailure ?? null); }
57
+ function update(db, row) { db.prepare('UPDATE managed_accounts SET label=?,label_key=?,position=?,last_attempt_at=?,last_rate_limited_at=?,rate_limited_until=?,auth_failure=? WHERE provider_id=? AND id=?').run(row.label, row.labelKey, row.position, row.lastAttemptAt, row.lastRateLimitedAt, row.rateLimitedUntil, row.authFailure ?? null, row.providerId, row.id); }
58
+ function publicPool(provider, file) { return rowsWithCredentials(provider, file).map(({ entry }) => entry); }
59
+ function writeMembershipAfterCredentialPublish(provider, write) { try {
60
+ write();
61
+ }
62
+ catch (error) {
63
+ throw new Error(`managed ${getProviderLabel(provider)} credential file was published, but its SQL membership update failed; SQL membership remains authoritative: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
64
+ } }
65
+ export async function writeSubscriptionPool(provider, next) { return locked(provider, async (file, rows, publish) => { const normalized = next.map((entry) => assertCredential(provider, entry)); const ids = normalized.map((entry, index) => ({ entry, id: rows[index]?.row.id ?? randomUUID() })); const accounts = {}; for (const { entry, id } of ids)
66
+ accounts[id] = { refresh: entry.refresh, access: entry.access, expires: entry.expires, ...(entry.accountId ? { accountId: entry.accountId } : {}) }; publish({ version: 1, accounts }); writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => { db.prepare('DELETE FROM managed_accounts WHERE provider_id=?').run(provider); ids.forEach(({ entry, id }, position) => insert(db, provider, { providerId: provider, id, label: entry.label, labelKey: key(entry.label), position, lastAttemptAt: entry.lastAttemptAt, lastRateLimitedAt: entry.lastRateLimitedAt, rateLimitedUntil: entry.rateLimitedUntil, ...(entry.authFailure ? { authFailure: entry.authFailure } : {}) })); })); return normalized; }); }
67
+ function resolve(rows, ref) { const needle = ref.trim().toLowerCase(); const number = Number.parseInt(needle, 10); if (Number.isFinite(number) && String(number) === needle && number >= 1 && number <= rows.length)
68
+ return number - 1; return rows.findIndex(({ entry }) => key(entry.label) === needle); }
69
+ export async function addSubscription(provider, entry) { const checked = assertCredential(provider, entry); return locked(provider, async (file, rows, publish) => { if (rows.some(({ entry: existing }) => key(existing.label) === key(checked.label)))
70
+ throw new DuplicateSubscriptionError(`${getProviderLabel(provider)} subscription already exists: ${checked.label}`); const same = identity(checked); const collision = rows.find(({ entry: existing }) => identity(existing) === same); if (same && collision)
71
+ throw new DuplicateSubscriptionError(`This ${getProviderLabel(provider)} account is already added as “${collision.entry.label}” — it shares the same quota, so a second slot adds no redundancy.`); const id = randomUUID(); file.accounts[id] = { refresh: checked.refresh, access: checked.access, expires: checked.expires, ...(checked.accountId ? { accountId: checked.accountId } : {}) }; publish(file); writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => insert(db, provider, { providerId: provider, id, label: checked.label, labelKey: key(checked.label), position: rows.length, lastAttemptAt: checked.lastAttemptAt, lastRateLimitedAt: checked.lastRateLimitedAt, rateLimitedUntil: checked.rateLimitedUntil, ...(checked.authFailure ? { authFailure: checked.authFailure } : {}) }))); return publicPool(provider, file); }); }
72
+ export async function upsertSubscription(provider, entry) { const checked = assertCredential(provider, entry); const existing = readSubscriptionPool(provider).find((x) => key(x.label) === key(checked.label)); return existing ? upsertSubscriptionWithUniqueAccount(provider, checked) : addSubscription(provider, checked); }
73
+ export async function upsertSubscriptionWithUniqueAccount(provider, entry) {
74
+ const checked = assertCredential(provider, entry);
75
+ return locked(provider, async (file, rows, publish) => {
76
+ const index = rows.findIndex(({ entry: existing }) => key(existing.label) === key(checked.label));
77
+ const existing = rows[index];
78
+ const duplicate = rows.find(({ entry: other }, i) => i !== index && identity(other) === identity(checked));
79
+ if (identity(checked) && duplicate)
80
+ throw new DuplicateSubscriptionError(`This ${getProviderLabel(provider)} account is already added as “${duplicate.entry.label}” — it shares the same quota, so a second slot adds no redundancy.`);
81
+ if (!existing) {
82
+ const id = randomUUID();
83
+ file.accounts[id] = { refresh: checked.refresh, access: checked.access, expires: checked.expires, ...(checked.accountId ? { accountId: checked.accountId } : {}) };
84
+ publish(file);
85
+ writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => insert(db, provider, { providerId: provider, id, label: checked.label, labelKey: key(checked.label), position: rows.length, lastAttemptAt: checked.lastAttemptAt, lastRateLimitedAt: checked.lastRateLimitedAt, rateLimitedUntil: checked.rateLimitedUntil })));
86
+ return publicPool(provider, file);
499
87
  }
500
- const next = [...pool];
501
- next[index] = { ...next[index], label };
502
- return next;
503
- });
504
- }
505
- // Remove one account under the pool lock. The pool is the only home a managed credential has.
506
- export async function removeManagedAccount(providerId, ref) {
507
- return mutateSubscriptionPool(providerId, (pool) => {
508
- const index = resolveCredentialIndex(pool, ref);
509
- if (index === -1)
510
- throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
511
- const next = [...pool];
512
- next.splice(index, 1);
513
- return next;
514
- });
515
- }
516
- /** Fetch Anthropic's stable OAuth account identity without making login depend on it. */
517
- export async function enrichManagedLoginIdentity(providerId, credential) {
518
- const supplied = credential.accountId?.trim();
519
- if (providerId !== ANTHROPIC_PROVIDER_ID)
520
- return supplied ? { accountId: supplied } : {};
88
+ const row = existing.row;
89
+ const cooldownIncoming = checked.lastRateLimitedAt >= row.lastRateLimitedAt;
90
+ file.accounts[row.id] = { refresh: checked.refresh, access: checked.access, expires: checked.expires, ...(checked.accountId ? { accountId: checked.accountId } : {}) };
91
+ publish(file);
92
+ writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => update(db, { ...row, label: checked.label, labelKey: key(checked.label), lastAttemptAt: Math.max(row.lastAttemptAt, checked.lastAttemptAt), lastRateLimitedAt: cooldownIncoming ? checked.lastRateLimitedAt : row.lastRateLimitedAt, rateLimitedUntil: cooldownIncoming ? checked.rateLimitedUntil : row.rateLimitedUntil })));
93
+ return publicPool(provider, file);
94
+ });
95
+ }
96
+ export async function renameSubscription(provider, ref, nextLabel) { return locked(provider, async (file, rows) => { const index = resolve(rows, ref); if (index < 0)
97
+ throw new Error(`Unknown ${getProviderLabel(provider)} subscription: ${ref}`); const label = nextLabel.trim(); if (!label)
98
+ throw new Error(`${getProviderLabel(provider)} subscription label cannot be empty`); if (rows.some((value, i) => i !== index && key(value.entry.label) === key(label)))
99
+ throw new DuplicateSubscriptionError(`${getProviderLabel(provider)} subscription already exists: ${label}`); providerWrite(agentDir(), (db) => update(db, { ...rows[index].row, label, labelKey: key(label) })); return publicPool(provider, file); }); }
100
+ async function remove(provider, ref) { return locked(provider, async (file, rows, publish) => { const index = resolve(rows, ref); if (index < 0)
101
+ throw new Error(`Unknown ${getProviderLabel(provider)} subscription: ${ref}`); const removed = rows[index].row; providerWrite(agentDir(), (db) => db.prepare('DELETE FROM managed_accounts WHERE provider_id=? AND id=?').run(provider, removed.id)); delete file.accounts[removed.id]; publish(file); return publicPool(provider, file); }); }
102
+ export async function removeManagedAccount(provider, ref) { return remove(provider, ref); }
103
+ export async function removeSubscription(provider, ref) { return remove(provider, ref); }
104
+ export async function removeAllManagedAccounts(provider) { return locked(provider, async (file, rows, publish) => { providerWrite(agentDir(), (db) => db.prepare('DELETE FROM managed_accounts WHERE provider_id=?').run(provider)); for (const { row } of rows)
105
+ delete file.accounts[row.id]; publish(file); return rows.length; }); }
106
+ export async function removeManagedProvider(provider, label) { if (label === undefined) {
107
+ await removeAllManagedAccounts(provider);
108
+ return [];
109
+ } return remove(provider, label); }
110
+ export async function promoteSubscription(provider, ref) { return locked(provider, async (file, rows) => { const index = resolve(rows, ref); if (index < 0)
111
+ throw new Error(`Unknown ${getProviderLabel(provider)} subscription: ${ref}`); providerWrite(agentDir(), (db) => { const reordered = [rows[index], ...rows.filter((_, i) => i !== index)]; reordered.forEach((item, position) => update(db, { ...item.row, position })); }); return publicPool(provider, file); }); }
112
+ async function mutateHealth(provider, ref, mutate) { providerWrite(agentDir(), (db) => { const row = providerRowByReference(db, provider, ref); if (!row)
113
+ throw new Error(`Unknown ${getProviderLabel(provider)} subscription: ${ref}`); const next = mutate(row); if (next)
114
+ update(db, next); }); return readSubscriptionPool(provider); }
115
+ export async function markSubscriptionAttempt(provider, ref, attemptAt = Date.now()) { return mutateHealth(provider, ref, (row) => ({ ...row, lastAttemptAt: Math.max(row.lastAttemptAt, attemptAt) })); }
116
+ export async function markSubscriptionAuthenticationFailed(provider, ref, retryAfterMs, attemptAt = Date.now()) { return mutateHealth(provider, ref, (row) => ({ ...row, authFailure: 'invalid_grant', lastAttemptAt: Math.max(row.lastAttemptAt, attemptAt), rateLimitedUntil: Math.max(row.rateLimitedUntil, attemptAt + Math.max(0, retryAfterMs)) })); }
117
+ export async function markSubscriptionRateLimited(provider, ref, retryAfterMs, attemptAt = Date.now(), rateLimitedAt = Date.now()) { return mutateHealth(provider, ref, (row) => rateLimitedAt < row.lastRateLimitedAt ? null : { ...row, lastAttemptAt: Math.max(row.lastAttemptAt, attemptAt), lastRateLimitedAt: rateLimitedAt, rateLimitedUntil: rateLimitedAt + Math.max(0, retryAfterMs) }); }
118
+ export async function markSubscriptionSuccess(provider, ref, attemptAt = Date.now()) { return mutateHealth(provider, ref, (row) => attemptAt < row.lastRateLimitedAt ? null : { ...row, authFailure: undefined, lastAttemptAt: Math.max(row.lastAttemptAt, attemptAt), rateLimitedUntil: 0 }); }
119
+ export async function refreshSubscriptionCredential(provider, label, exchange) { return locked(provider, async (file, rows, publish) => { const index = resolve(rows, label); if (index < 0)
120
+ throw new Error(`${getProviderLabel(provider)} subscription "${label}" no longer exists`); const current = rows[index]; if (current.entry.expires > Date.now())
121
+ return current.entry; const tokens = assertTokens(provider, current.entry.label, await exchange(current.entry.refresh)); file.accounts[current.row.id] = { ...file.accounts[current.row.id], ...tokens }; publish(file); return { ...current.entry, ...tokens }; }); }
122
+ export function isInvalidRefreshTokenError(error) { const value = error; const message = [value?.errorMessage, value?.message, error instanceof Error ? error.message : ''].filter((part) => typeof part === 'string').join(' '); const status = typeof value?.status === 'number' ? value.status : Number(message.match(/\bstatus[=:]\s*(\d+)/i)?.[1]); return status === 400 && /invalid_grant|refresh token .*invalid|refresh token .*not found/i.test(message); }
123
+ export async function modifyElectedSubscription(provider, elect, apply) { return locked(provider, async (file, rows, publish) => { const attempted = new Set(); let lastError; while (attempted.size < rows.length) {
124
+ const chosen = elect(rows.filter(({ entry }) => !attempted.has(entry.label)).map(({ entry }) => entry));
125
+ if (!chosen)
126
+ break;
127
+ const current = rows.find(({ entry }) => entry.label === chosen.label);
128
+ attempted.add(current.entry.label);
521
129
  try {
522
- const response = await fetch("https://api.anthropic.com/api/oauth/profile", {
523
- headers: { Authorization: `Bearer ${credential.access}`, Accept: "application/json" },
524
- signal: AbortSignal.timeout(3_000),
525
- });
526
- if (!response.ok)
527
- return supplied ? { accountId: supplied } : {};
528
- const body = await response.json();
529
- const accountId = typeof body.account?.uuid === "string" && body.account.uuid.trim() ? body.account.uuid.trim() : undefined;
530
- const email = typeof body.account?.email === "string" && body.account.email.trim() ? body.account.email.trim() : undefined;
531
- return { ...(accountId ? { accountId } : {}), ...(email ? { email } : {}) };
532
- }
533
- catch {
534
- return supplied ? { accountId: supplied } : {};
535
- }
536
- }
537
- function nextAutomaticLabel(pool, email, fallback) {
538
- const base = email || fallback?.trim() || `Account ${pool.length + 1}`;
539
- const used = new Set(pool.map((entry) => entry.label.toLowerCase()));
540
- if (!used.has(base.toLowerCase()))
541
- return base;
542
- for (let index = 2;; index++) {
543
- const candidate = `${base} (${index})`;
544
- if (!used.has(candidate.toLowerCase()))
545
- return candidate;
546
- }
547
- }
548
- // Coordinated persistence for a freshly acquired managed OAuth credential. An explicit label
549
- // selects that row for reauthentication; otherwise a matching identity updates its row, and a new
550
- // identity receives an automatic email-derived label. UI must never persist managed OAuth into
551
- // auth.json because the pool owns refresh and account uniqueness.
552
- export async function commitManagedLogin(providerId, label, credential) {
553
- const identity = await enrichManagedLoginIdentity(providerId, credential);
554
- return mutateSubscriptionPool(providerId, (pool) => {
555
- const accountId = identity.accountId;
556
- const selectedLabel = credential.autoLabel ? undefined : label?.trim();
557
- const existing = (selectedLabel ? pool.find((entry) => entry.label.toLowerCase() === selectedLabel.toLowerCase()) : undefined) ??
558
- pool.find((entry) => accountIdentity(entry) === accountIdentity({ accountId, refresh: credential.refresh }));
559
- const resolvedLabel = existing?.label ?? (credential.autoLabel ? nextAutomaticLabel(pool, identity.email, label) : selectedLabel);
560
- if (!resolvedLabel)
561
- throw new Error(`${getProviderLabel(providerId)} subscription label cannot be empty`);
562
- if (!existing && pool.some((entry) => entry.label.toLowerCase() === resolvedLabel.toLowerCase())) {
563
- throw new DuplicateSubscriptionError(`${getProviderLabel(providerId)} subscription already exists: ${resolvedLabel}`);
130
+ const next = await apply(current.entry);
131
+ if (next) {
132
+ const tokens = assertTokens(provider, current.entry.label, next);
133
+ file.accounts[current.row.id] = { ...file.accounts[current.row.id], ...tokens };
134
+ publish(file);
135
+ return { ...current.entry, ...tokens };
564
136
  }
565
- assertAccountAvailable(pool, providerId, accountIdentity({ accountId, refresh: credential.refresh }), resolvedLabel);
566
- const next = {
567
- label: resolvedLabel,
568
- refresh: credential.refresh,
569
- access: credential.access,
570
- expires: credential.expires,
571
- ...(accountId ? { accountId } : {}),
572
- rateLimitedUntil: 0,
573
- lastAttemptAt: Date.now(),
574
- lastRateLimitedAt: credential.lastRateLimitedAt,
575
- };
576
- if (!existing)
577
- return [...pool, next];
578
- const index = pool.indexOf(existing);
579
- const merged = [...pool];
580
- merged[index] = mergeUpsert(existing, next);
581
- return merged;
582
- });
583
- }
584
- // Remove every account for one managed provider through a single locked transform. The
585
- // returned count belongs to that locked snapshot, so callers do not race a concurrent login.
586
- export async function removeAllManagedAccounts(providerId) {
587
- let removed = 0;
588
- await mutateSubscriptionPool(providerId, (pool) => {
589
- removed = pool.length;
590
- return [];
591
- });
592
- return removed;
593
- }
594
- // Coordinated removal for the human UI. Removes a single account when `label` is given; the ENTIRE
595
- // provider (every pool account) when `label` is omitted — always through a pool transform. UI
596
- // must NEVER reach for a raw auth.json delete on a managed provider: that strips a file pi no
597
- // longer reads for managed providers and orphans the whole pool.
598
- export async function removeManagedProvider(providerId, label) {
599
- if (label === undefined) {
600
- await removeAllManagedAccounts(providerId);
601
- return [];
137
+ return current.entry;
602
138
  }
603
- return mutateSubscriptionPool(providerId, (pool) => {
604
- const index = resolveCredentialIndex(pool, label);
605
- if (index === -1)
606
- throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${label}`);
607
- const next = [...pool];
608
- next.splice(index, 1);
609
- return next;
610
- });
611
- }
612
- export async function removeSubscription(providerId, ref) {
613
- return mutateSubscriptionPool(providerId, (pool) => {
614
- const index = resolveCredentialIndex(pool, ref);
615
- if (index === -1)
616
- throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
617
- const next = [...pool];
618
- next.splice(index, 1);
619
- return next;
620
- });
621
- }
622
- export async function promoteSubscription(providerId, ref) {
623
- return mutateSubscriptionPool(providerId, (pool) => {
624
- const index = resolveCredentialIndex(pool, ref);
625
- if (index === -1)
626
- throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
627
- if (index === 0)
628
- return pool;
629
- const next = [...pool];
630
- next.unshift(next.splice(index, 1)[0]);
631
- return next;
632
- });
633
- }
634
- export async function markSubscriptionAttempt(providerId, ref, attemptAt = Date.now()) {
635
- return mutateSubscriptionPool(providerId, (pool) => {
636
- const index = resolveCredentialIndex(pool, ref);
637
- if (index === -1)
638
- throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
639
- const next = [...pool];
640
- next[index] = { ...pool[index], lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt) };
641
- return next;
642
- });
643
- }
644
- export async function markSubscriptionAuthenticationFailed(providerId, ref, retryAfterMs, attemptAt = Date.now()) {
645
- return mutateSubscriptionPool(providerId, (pool) => {
646
- const index = resolveCredentialIndex(pool, ref);
647
- if (index === -1)
648
- throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
649
- const next = [...pool];
650
- next[index] = {
651
- ...pool[index],
652
- authFailure: "invalid_grant",
653
- lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
654
- // This is only an exclusion backoff, never a rate-limit observation.
655
- rateLimitedUntil: Math.max(pool[index].rateLimitedUntil, attemptAt + Math.max(0, retryAfterMs)),
656
- };
657
- return next;
658
- });
659
- }
660
- export async function markSubscriptionRateLimited(providerId, ref, retryAfterMs, attemptAt = Date.now(), rateLimitedAt = Date.now()) {
661
- return mutateSubscriptionPool(providerId, (pool) => {
662
- const index = resolveCredentialIndex(pool, ref);
663
- if (index === -1)
664
- throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
665
- // A later rate-limit is authoritative until it expires: an out-of-order (stale) 429 whose
666
- // basis predates the slot's newest cooldown must not reopen or shorten it.
667
- if (rateLimitedAt < pool[index].lastRateLimitedAt)
668
- return undefined;
669
- const next = [...pool];
670
- next[index] = {
671
- ...pool[index],
672
- lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt),
673
- lastRateLimitedAt: Math.max(pool[index].lastRateLimitedAt, rateLimitedAt),
674
- rateLimitedUntil: rateLimitedAt + Math.max(0, retryAfterMs),
675
- };
676
- return next;
677
- });
678
- }
679
- export async function markSubscriptionSuccess(providerId, ref, attemptAt = Date.now()) {
680
- return mutateSubscriptionPool(providerId, (pool) => {
681
- const index = resolveCredentialIndex(pool, ref);
682
- if (index === -1)
683
- throw new Error(`Unknown ${getProviderLabel(providerId)} subscription: ${ref}`);
684
- // A success whose attempt started before the slot's newest 429 must not clear that cooldown
685
- // (the memory rule): treat the later rate-limit as authoritative until it expires.
686
- if (attemptAt < pool[index].lastRateLimitedAt)
687
- return undefined;
688
- const next = [...pool];
689
- const { authFailure: _authFailure, ...healthy } = pool[index];
690
- next[index] = { ...healthy, lastAttemptAt: Math.max(pool[index].lastAttemptAt, attemptAt), rateLimitedUntil: 0 };
691
- return next;
692
- });
693
- }
694
- export function parseRetryAfterHeader(value, now = Date.now()) {
695
- if (!value)
696
- return undefined;
697
- const trimmed = value.trim();
698
- if (!trimmed)
699
- return undefined;
700
- const seconds = Number(trimmed);
701
- if (Number.isFinite(seconds))
702
- return Math.max(0, Math.round(seconds * 1000));
703
- const parsed = Date.parse(trimmed);
704
- return Number.isFinite(parsed) ? Math.max(0, parsed - now) : undefined;
705
- }
139
+ catch (error) {
140
+ if (!isInvalidRefreshTokenError(error))
141
+ throw error;
142
+ lastError = error;
143
+ const at = Date.now();
144
+ providerWrite(agentDir(), (db) => update(db, { ...current.row, authFailure: 'invalid_grant', lastAttemptAt: Math.max(current.row.lastAttemptAt, at), rateLimitedUntil: Math.max(current.row.rateLimitedUntil, at + INVALID_REFRESH_TOKEN_BACKOFF_MS) }));
145
+ }
146
+ } if (lastError)
147
+ throw lastError; return undefined; }); }
148
+ export async function enrichManagedLoginIdentity(provider, value) { const supplied = value.accountId?.trim(); if (provider !== ANTHROPIC_PROVIDER_ID)
149
+ return supplied ? { accountId: supplied } : {}; try {
150
+ const response = await fetch('https://api.anthropic.com/api/oauth/profile', { headers: { Authorization: `Bearer ${value.access}`, Accept: 'application/json' }, signal: AbortSignal.timeout(3_000) });
151
+ if (!response.ok)
152
+ return supplied ? { accountId: supplied } : {};
153
+ const body = await response.json();
154
+ const accountId = typeof body.account?.uuid === 'string' && body.account.uuid.trim() ? body.account.uuid.trim() : undefined;
155
+ const email = typeof body.account?.email === 'string' && body.account.email.trim() ? body.account.email.trim() : undefined;
156
+ return { ...(accountId ? { accountId } : {}), ...(email ? { email } : {}) };
157
+ }
158
+ catch {
159
+ return supplied ? { accountId: supplied } : {};
160
+ } }
161
+ export async function commitManagedLogin(provider, label, value) { const identityInfo = await enrichManagedLoginIdentity(provider, value); return locked(provider, async (file, rows, publish) => { const selected = value.autoLabel ? undefined : label?.trim(); const incomingIdentity = identity({ refresh: value.refresh, accountId: identityInfo.accountId }); const matching = (selected ? rows.find(({ entry }) => key(entry.label) === key(selected)) : undefined) ?? rows.find(({ entry }) => identity(entry) === incomingIdentity); let resolved = matching?.entry.label ?? selected; if (!resolved && value.autoLabel) {
162
+ const base = identityInfo.email || label?.trim() || `Account ${rows.length + 1}`;
163
+ resolved = base;
164
+ for (let n = 2; rows.some(({ entry }) => key(entry.label) === key(resolved)); n++)
165
+ resolved = `${base} (${n})`;
166
+ } if (!resolved)
167
+ throw new Error(`${getProviderLabel(provider)} subscription label cannot be empty`); const entry = assertCredential(provider, { label: resolved, refresh: value.refresh, access: value.access, expires: value.expires, ...(identityInfo.accountId ? { accountId: identityInfo.accountId } : {}), rateLimitedUntil: 0, lastAttemptAt: Date.now(), lastRateLimitedAt: value.lastRateLimitedAt }); if (!matching) {
168
+ if (rows.some(({ entry: existing }) => key(existing.label) === key(entry.label)))
169
+ throw new DuplicateSubscriptionError(`${getProviderLabel(provider)} subscription already exists: ${entry.label}`);
170
+ const duplicate = rows.find(({ entry: existing }) => identity(existing) === identity(entry));
171
+ if (identity(entry) && duplicate)
172
+ throw new DuplicateSubscriptionError(`This ${getProviderLabel(provider)} account is already added as “${duplicate.entry.label}” — it shares the same quota, so a second slot adds no redundancy.`);
173
+ const id = randomUUID();
174
+ file.accounts[id] = { refresh: entry.refresh, access: entry.access, expires: entry.expires, ...(entry.accountId ? { accountId: entry.accountId } : {}) };
175
+ publish(file);
176
+ writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => insert(db, provider, { providerId: provider, id, label: entry.label, labelKey: key(entry.label), position: rows.length, lastAttemptAt: entry.lastAttemptAt, lastRateLimitedAt: entry.lastRateLimitedAt, rateLimitedUntil: entry.rateLimitedUntil })));
177
+ return publicPool(provider, file);
178
+ } const row = matching.row; const duplicate = rows.find(({ entry: other }) => other.label !== matching.entry.label && identity(other) === identity(entry)); if (identity(entry) && duplicate)
179
+ throw new DuplicateSubscriptionError(`This ${getProviderLabel(provider)} account is already added as “${duplicate.entry.label}” — it shares the same quota, so a second slot adds no redundancy.`); file.accounts[row.id] = { refresh: entry.refresh, access: entry.access, expires: entry.expires, ...(entry.accountId ? { accountId: entry.accountId } : {}) }; publish(file); const clear = entry.lastRateLimitedAt >= row.lastRateLimitedAt; writeMembershipAfterCredentialPublish(provider, () => providerWrite(agentDir(), (db) => update(db, { ...row, lastAttemptAt: Math.max(row.lastAttemptAt, entry.lastAttemptAt), lastRateLimitedAt: clear ? entry.lastRateLimitedAt : row.lastRateLimitedAt, rateLimitedUntil: clear ? 0 : row.rateLimitedUntil }))); return publicPool(provider, file); }); }
180
+ export function parseRetryAfterHeader(value, now = Date.now()) { if (!value?.trim())
181
+ return undefined; const seconds = Number(value.trim()); if (Number.isFinite(seconds))
182
+ return Math.max(0, Math.round(seconds * 1000)); const parsed = Date.parse(value); return Number.isFinite(parsed) ? Math.max(0, parsed - now) : undefined; }
706
183
  export function readRotationConfig() {
707
- const raw = readJsonFile(rotationConfigFile()) ?? {};
708
- return {
709
- defaultFallbackStrength: normalizeStrength(raw.defaultFallbackStrength) ?? DEFAULT_FALLBACK_STRENGTH,
710
- revertWhenAvailable: raw.revertWhenAvailable ?? true,
711
- };
184
+ const row = openScopedStore(providerStore(agentDir())).prepare('SELECT default_fallback_strength,revert_when_available FROM rotation_settings WHERE singleton=1').get();
185
+ return { defaultFallbackStrength: normalizeStrength(row?.default_fallback_strength) ?? DEFAULT_FALLBACK_STRENGTH, revertWhenAvailable: row?.revert_when_available === undefined || row.revert_when_available === null ? true : Boolean(row.revert_when_available) };
712
186
  }
713
187
  export function writeRotationConfig(next) {
714
188
  const current = readRotationConfig();
715
- const merged = {
716
- defaultFallbackStrength: normalizeStrength(next.defaultFallbackStrength) ?? current.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH,
717
- revertWhenAvailable: next.revertWhenAvailable ?? current.revertWhenAvailable ?? true,
718
- };
719
- atomicWriteJson(rotationConfigFile(), merged);
189
+ const merged = { defaultFallbackStrength: normalizeStrength(next.defaultFallbackStrength) ?? current.defaultFallbackStrength ?? DEFAULT_FALLBACK_STRENGTH, revertWhenAvailable: next.revertWhenAvailable ?? current.revertWhenAvailable ?? true };
190
+ providerWrite(agentDir(), (db) => db.prepare(`INSERT INTO rotation_settings(singleton,default_fallback_strength,revert_when_available) VALUES(1,?,?) ON CONFLICT(singleton) DO UPDATE SET default_fallback_strength=excluded.default_fallback_strength,revert_when_available=excluded.revert_when_available`).run(merged.defaultFallbackStrength, merged.revertWhenAvailable ? 1 : 0));
720
191
  return merged;
721
192
  }
722
- export function formatStatusLine(providerId, now = Date.now()) {
723
- const pool = readSubscriptionPool(providerId);
724
- const ready = pool.filter((entry) => entry.authFailure === undefined && (!entry.rateLimitedUntil || entry.rateLimitedUntil <= now));
725
- const label = getProviderLabel(providerId);
726
- return ready.length === 0
727
- ? `${label} pool exhausted`
728
- : `${label} ${ready[0]?.label ?? providerId} (${ready.length}/${pool.length} ready)`;
729
- }
193
+ export function formatStatusLine(provider, now = Date.now()) { const pool = readSubscriptionPool(provider); const ready = pool.filter((entry) => entry.authFailure === undefined && entry.rateLimitedUntil <= now); const label = getProviderLabel(provider); return ready.length === 0 ? `${label} pool exhausted` : `${label} ${ready[0]?.label ?? provider} (${ready.length}/${pool.length} ready)`; }