@maudecode/talaria-web 1.16.1 → 1.18.0

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 (445) hide show
  1. package/_release.json +8 -8
  2. package/dist/api/auth-raw.d.ts +1 -1
  3. package/dist/api/auth-raw.js +4 -4
  4. package/dist/api/auth-raw.js.map +1 -1
  5. package/dist/api/automation-raw.d.ts +6 -0
  6. package/dist/api/automation-raw.js +59 -9
  7. package/dist/api/automation-raw.js.map +1 -1
  8. package/dist/api/automation-router.d.ts +163 -10
  9. package/dist/api/automation-router.js +25 -8
  10. package/dist/api/automation-router.js.map +1 -1
  11. package/dist/api/chat-router.d.ts +700 -15
  12. package/dist/api/chat-router.js +74 -55
  13. package/dist/api/chat-router.js.map +1 -1
  14. package/dist/api/git-router.d.ts +47 -2
  15. package/dist/api/git-router.js +31 -22
  16. package/dist/api/git-router.js.map +1 -1
  17. package/dist/api/raw-routes.d.ts +14 -0
  18. package/dist/api/raw-routes.js +277 -36
  19. package/dist/api/raw-routes.js.map +1 -1
  20. package/dist/api/router.d.ts +10 -0
  21. package/dist/api/router.js +10 -8
  22. package/dist/api/router.js.map +1 -1
  23. package/dist/api/sessions-router.d.ts +1710 -73
  24. package/dist/api/sessions-router.js +164 -10
  25. package/dist/api/sessions-router.js.map +1 -1
  26. package/dist/api/settings-router.d.ts +132 -2
  27. package/dist/api/settings-router.js +15 -5
  28. package/dist/api/settings-router.js.map +1 -1
  29. package/dist/api/sse-routes.d.ts +16 -3
  30. package/dist/api/sse-routes.js +194 -67
  31. package/dist/api/sse-routes.js.map +1 -1
  32. package/dist/api/tools-raw.js +12 -25
  33. package/dist/api/tools-raw.js.map +1 -1
  34. package/dist/api/tools-router.d.ts +425 -19
  35. package/dist/api/tools-router.js +157 -26
  36. package/dist/api/tools-router.js.map +1 -1
  37. package/dist/app.d.ts +0 -9377
  38. package/dist/app.js +42 -17
  39. package/dist/app.js.map +1 -1
  40. package/dist/auth/gate.js +8 -2
  41. package/dist/auth/gate.js.map +1 -1
  42. package/dist/auth/oidc.d.ts +5 -2
  43. package/dist/auth/oidc.js +8 -7
  44. package/dist/auth/oidc.js.map +1 -1
  45. package/dist/bin/talaria-web.js +1 -1
  46. package/dist/bin/talaria-web.js.map +1 -1
  47. package/dist/cli/dotenv.js +1 -0
  48. package/dist/cli/dotenv.js.map +1 -1
  49. package/dist/config/agent-config.d.ts +2 -0
  50. package/dist/config/agent-config.js +12 -2
  51. package/dist/config/agent-config.js.map +1 -1
  52. package/dist/config.d.ts +2 -0
  53. package/dist/config.js +5 -1
  54. package/dist/config.js.map +1 -1
  55. package/dist/http/context.d.ts +20 -3
  56. package/dist/http/context.js +41 -8
  57. package/dist/http/context.js.map +1 -1
  58. package/dist/http/csp.d.ts +2 -1
  59. package/dist/http/csp.js +3 -2
  60. package/dist/http/csp.js.map +1 -1
  61. package/dist/onboarding.js +4 -0
  62. package/dist/onboarding.js.map +1 -1
  63. package/dist/profiles/profiles.d.ts +10 -0
  64. package/dist/profiles/profiles.js +40 -2
  65. package/dist/profiles/profiles.js.map +1 -1
  66. package/dist/providers/catalog.d.ts +101 -8
  67. package/dist/providers/catalog.js +463 -123
  68. package/dist/providers/catalog.js.map +1 -1
  69. package/dist/raster-image.d.ts +7 -0
  70. package/dist/raster-image.js +267 -0
  71. package/dist/raster-image.js.map +1 -0
  72. package/dist/redact.d.ts +1 -0
  73. package/dist/redact.js +354 -44
  74. package/dist/redact.js.map +1 -1
  75. package/dist/runtime.js +111 -27
  76. package/dist/runtime.js.map +1 -1
  77. package/dist/server.js +5 -0
  78. package/dist/server.js.map +1 -1
  79. package/dist/sessions/anchor.d.ts +2 -0
  80. package/dist/sessions/anchor.js +8 -3
  81. package/dist/sessions/anchor.js.map +1 -1
  82. package/dist/sessions/background-tasks.d.ts +2 -9
  83. package/dist/sessions/background-tasks.js +2 -12
  84. package/dist/sessions/background-tasks.js.map +1 -1
  85. package/dist/sessions/claude-code.d.ts +19 -0
  86. package/dist/sessions/claude-code.js +250 -0
  87. package/dist/sessions/claude-code.js.map +1 -0
  88. package/dist/sessions/cli-sessions.d.ts +15 -4
  89. package/dist/sessions/cli-sessions.js +120 -29
  90. package/dist/sessions/cli-sessions.js.map +1 -1
  91. package/dist/sessions/completions.d.ts +61 -9
  92. package/dist/sessions/completions.js +282 -34
  93. package/dist/sessions/completions.js.map +1 -1
  94. package/dist/sessions/compress.d.ts +14 -1
  95. package/dist/sessions/compress.js +52 -11
  96. package/dist/sessions/compress.js.map +1 -1
  97. package/dist/sessions/export.d.ts +5 -0
  98. package/dist/sessions/export.js +30 -0
  99. package/dist/sessions/export.js.map +1 -1
  100. package/dist/sessions/gateway-watcher.d.ts +7 -3
  101. package/dist/sessions/gateway-watcher.js +31 -19
  102. package/dist/sessions/gateway-watcher.js.map +1 -1
  103. package/dist/sessions/handoff.d.ts +32 -0
  104. package/dist/sessions/handoff.js +114 -0
  105. package/dist/sessions/handoff.js.map +1 -0
  106. package/dist/sessions/journal.d.ts +8 -0
  107. package/dist/sessions/journal.js +44 -10
  108. package/dist/sessions/journal.js.map +1 -1
  109. package/dist/sessions/list.d.ts +13 -3
  110. package/dist/sessions/list.js +46 -15
  111. package/dist/sessions/list.js.map +1 -1
  112. package/dist/sessions/merge.d.ts +53 -5
  113. package/dist/sessions/merge.js +219 -23
  114. package/dist/sessions/merge.js.map +1 -1
  115. package/dist/sessions/recovery.d.ts +22 -0
  116. package/dist/sessions/recovery.js +506 -0
  117. package/dist/sessions/recovery.js.map +1 -0
  118. package/dist/sessions/relay.d.ts +21 -2
  119. package/dist/sessions/relay.js +156 -11
  120. package/dist/sessions/relay.js.map +1 -1
  121. package/dist/sessions/service.d.ts +152 -8
  122. package/dist/sessions/service.js +640 -72
  123. package/dist/sessions/service.js.map +1 -1
  124. package/dist/sessions/session.d.ts +17 -1
  125. package/dist/sessions/session.js +63 -4
  126. package/dist/sessions/session.js.map +1 -1
  127. package/dist/sessions/state-db-worker.d.ts +1 -0
  128. package/dist/sessions/state-db-worker.js +33 -0
  129. package/dist/sessions/state-db-worker.js.map +1 -0
  130. package/dist/sessions/state-db.d.ts +87 -4
  131. package/dist/sessions/state-db.js +417 -8
  132. package/dist/sessions/state-db.js.map +1 -1
  133. package/dist/sessions/store.d.ts +7 -0
  134. package/dist/sessions/store.js +36 -22
  135. package/dist/sessions/store.js.map +1 -1
  136. package/dist/sessions/streams.d.ts +10 -0
  137. package/dist/sessions/streams.js +14 -3
  138. package/dist/sessions/streams.js.map +1 -1
  139. package/dist/sessions/titles.d.ts +2 -0
  140. package/dist/sessions/titles.js +4 -0
  141. package/dist/sessions/titles.js.map +1 -1
  142. package/dist/sessions/todo.d.ts +6 -1
  143. package/dist/sessions/todo.js +11 -4
  144. package/dist/sessions/todo.js.map +1 -1
  145. package/dist/sessions/tool-display.d.ts +12 -1
  146. package/dist/sessions/tool-display.js +50 -1
  147. package/dist/sessions/tool-display.js.map +1 -1
  148. package/dist/sessions/turn-context.d.ts +1 -1
  149. package/dist/sessions/turn-context.js +11 -7
  150. package/dist/sessions/turn-context.js.map +1 -1
  151. package/dist/sessions/turn.d.ts +67 -11
  152. package/dist/sessions/turn.js +413 -111
  153. package/dist/sessions/turn.js.map +1 -1
  154. package/dist/sessions/wakeup-pause.d.ts +59 -0
  155. package/dist/sessions/wakeup-pause.js +181 -0
  156. package/dist/sessions/wakeup-pause.js.map +1 -0
  157. package/dist/settings.d.ts +20 -2
  158. package/dist/settings.js +45 -9
  159. package/dist/settings.js.map +1 -1
  160. package/dist/sidecar/fake.d.ts +4 -4
  161. package/dist/sidecar/fake.js +5 -2
  162. package/dist/sidecar/fake.js.map +1 -1
  163. package/dist/spa.js +2 -1
  164. package/dist/spa.js.map +1 -1
  165. package/dist/startup.d.ts +13 -5
  166. package/dist/startup.js +48 -0
  167. package/dist/startup.js.map +1 -1
  168. package/dist/test/harness.js +2 -0
  169. package/dist/test/harness.js.map +1 -1
  170. package/dist/test/ts-worker-hooks.d.ts +1 -0
  171. package/dist/test/ts-worker-hooks.js +24 -0
  172. package/dist/test/ts-worker-hooks.js.map +1 -0
  173. package/dist/text/diff.d.ts +9 -14
  174. package/dist/text/diff.js +74 -108
  175. package/dist/text/diff.js.map +1 -1
  176. package/dist/tools/crons.js +45 -8
  177. package/dist/tools/crons.js.map +1 -1
  178. package/dist/tools/dashboard-plugins.d.ts +53 -0
  179. package/dist/tools/dashboard-plugins.js +188 -0
  180. package/dist/tools/dashboard-plugins.js.map +1 -0
  181. package/dist/tools/extensions.d.ts +13 -1
  182. package/dist/tools/extensions.js +132 -60
  183. package/dist/tools/extensions.js.map +1 -1
  184. package/dist/tools/health.d.ts +32 -0
  185. package/dist/tools/health.js +111 -19
  186. package/dist/tools/health.js.map +1 -1
  187. package/dist/tools/insights.d.ts +8 -2
  188. package/dist/tools/insights.js +18 -2
  189. package/dist/tools/insights.js.map +1 -1
  190. package/dist/tools/kanban.d.ts +41 -1
  191. package/dist/tools/kanban.js +113 -2
  192. package/dist/tools/kanban.js.map +1 -1
  193. package/dist/tools/mcp.d.ts +15 -1
  194. package/dist/tools/mcp.js +99 -5
  195. package/dist/tools/mcp.js.map +1 -1
  196. package/dist/tools/project-os.d.ts +11 -0
  197. package/dist/tools/project-os.js +267 -0
  198. package/dist/tools/project-os.js.map +1 -0
  199. package/dist/tools/skills.d.ts +5 -1
  200. package/dist/tools/skills.js +5 -3
  201. package/dist/tools/skills.js.map +1 -1
  202. package/dist/tools/terminal.d.ts +1 -0
  203. package/dist/tools/terminal.js +1 -1
  204. package/dist/tools/terminal.js.map +1 -1
  205. package/dist/tools/updates.d.ts +33 -8
  206. package/dist/tools/updates.js +236 -57
  207. package/dist/tools/updates.js.map +1 -1
  208. package/dist/tools/wiki.d.ts +68 -0
  209. package/dist/tools/wiki.js +413 -0
  210. package/dist/tools/wiki.js.map +1 -0
  211. package/dist/workspace/escape.d.ts +61 -0
  212. package/dist/workspace/escape.js +149 -0
  213. package/dist/workspace/escape.js.map +1 -0
  214. package/dist/workspace/extract.d.ts +22 -0
  215. package/dist/workspace/extract.js +308 -0
  216. package/dist/workspace/extract.js.map +1 -0
  217. package/dist/workspace/fs.d.ts +19 -3
  218. package/dist/workspace/fs.js +96 -23
  219. package/dist/workspace/fs.js.map +1 -1
  220. package/dist/workspace/git.d.ts +18 -0
  221. package/dist/workspace/git.js +38 -25
  222. package/dist/workspace/git.js.map +1 -1
  223. package/dist/workspace/media.d.ts +4 -4
  224. package/dist/workspace/media.js +18 -45
  225. package/dist/workspace/media.js.map +1 -1
  226. package/dist/workspace/preview.d.ts +28 -0
  227. package/dist/workspace/preview.js +110 -0
  228. package/dist/workspace/preview.js.map +1 -0
  229. package/dist/workspace/rollback.d.ts +36 -21
  230. package/dist/workspace/rollback.js +179 -270
  231. package/dist/workspace/rollback.js.map +1 -1
  232. package/dist/workspace/unzip.js +4 -1
  233. package/dist/workspace/unzip.js.map +1 -1
  234. package/dist/workspace/upload.d.ts +9 -4
  235. package/dist/workspace/upload.js +9 -3
  236. package/dist/workspace/upload.js.map +1 -1
  237. package/dist/workspace/worktrees.d.ts +2 -1
  238. package/dist/workspace/worktrees.js +14 -15
  239. package/dist/workspace/worktrees.js.map +1 -1
  240. package/package.json +4 -2
  241. package/sidecar/agent_dependency.json +4 -4
  242. package/sidecar/scripts/replay_sidecar.py +25 -1
  243. package/sidecar/talaria_sidecar/__init__.py +1 -1
  244. package/sidecar/talaria_sidecar/__main__.py +7 -1
  245. package/sidecar/talaria_sidecar/home.py +52 -2
  246. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  247. package/sidecar/talaria_sidecar/methods/aux.py +39 -5
  248. package/sidecar/talaria_sidecar/methods/chat.py +431 -43
  249. package/sidecar/talaria_sidecar/methods/checkpoints.py +89 -0
  250. package/sidecar/talaria_sidecar/methods/commands.py +96 -2
  251. package/sidecar/talaria_sidecar/methods/cron.py +39 -8
  252. package/sidecar/talaria_sidecar/methods/gateway.py +32 -15
  253. package/sidecar/talaria_sidecar/methods/kanban.py +140 -19
  254. package/sidecar/talaria_sidecar/methods/mcp.py +1 -1
  255. package/sidecar/talaria_sidecar/methods/process.py +115 -2
  256. package/sidecar/talaria_sidecar/methods/profiles.py +84 -8
  257. package/sidecar/talaria_sidecar/methods/providers.py +27 -8
  258. package/sidecar/talaria_sidecar/methods/runtime.py +3 -0
  259. package/sidecar/talaria_sidecar/methods/state_db.py +39 -5
  260. package/sidecar/talaria_sidecar/methods/text.py +17 -1
  261. package/sidecar/talaria_sidecar/methods/usage.py +257 -4
  262. package/sidecar/talaria_sidecar/runtime.py +17 -1
  263. package/static/dist/FILES.txt +127 -125
  264. package/static/dist/assets/AppShell-g3bkgnvt.js +1 -0
  265. package/static/dist/assets/ChatPage-oo7bdan6.js +25 -0
  266. package/static/dist/assets/Dialog-g355qxhp.js +1 -0
  267. package/static/dist/assets/ExtensionRoute-i2y9fvjm.js +1 -0
  268. package/static/dist/assets/HubRoute-lmkrbw47.js +2 -0
  269. package/static/dist/assets/{Markdown-jqrx5es0.js → Markdown-j4kut4j2.js} +125 -125
  270. package/static/dist/assets/RightPanel-ohlsdzfl.js +1 -0
  271. package/static/dist/assets/Select-kddq75qc.js +1 -0
  272. package/static/dist/assets/SessionListPanel-lu3oy0hw.js +1 -0
  273. package/static/dist/assets/SettingsLayout-o9bv4mgb.js +1 -0
  274. package/static/dist/assets/States-eq6fe29m.js +1 -0
  275. package/static/dist/assets/Toaster-pc247u9f.js +1 -0
  276. package/static/dist/assets/_app-f8n1i0m1.js +1 -0
  277. package/static/dist/assets/_app.ext._extensionId-gfbzpoou.js +1 -0
  278. package/static/dist/assets/_app.index-bzzv80hk.js +1 -0
  279. package/static/dist/assets/_app.insights-dzixbryi.js +1 -0
  280. package/static/dist/assets/_app.kanban-c6hep2us.js +1 -0
  281. package/static/dist/assets/_app.logs-i69qds98.js +1 -0
  282. package/static/dist/assets/_app.memory-kl4vf0gd.js +1 -0
  283. package/static/dist/assets/_app.profiles-b8zinmyp.js +1 -0
  284. package/static/dist/assets/{_app.session._sessionId-hh27wwu2.js → _app.session._sessionId-fn1bhde6.js} +1 -1
  285. package/static/dist/assets/_app.settings-cg8plwmf.js +1 -0
  286. package/static/dist/assets/_app.settings._section-bkwbi9zn.js +3 -0
  287. package/static/dist/assets/_app.skills-cs47sbvs.js +1 -0
  288. package/static/dist/assets/_app.tasks-dwvk64tc.js +1 -0
  289. package/static/dist/assets/_app.todos-c8xjm8p3.js +1 -0
  290. package/static/dist/assets/_app.workspaces-ehvd1gds.js +1 -0
  291. package/static/dist/assets/{abnfDiagram-VCTEODGH-oity9y4o.js → abnfDiagram-VCTEODGH-c8kdixke.js} +1 -1
  292. package/static/dist/assets/architecture-7GRP2DOG-6crvnylg.js +1 -0
  293. package/static/dist/assets/{architectureDiagram-5GKGNRK7-ceaepnyr.js → architectureDiagram-5GKGNRK7-cku06x1p.js} +1 -1
  294. package/static/dist/assets/{back-eu64w89w.js → back-h7aun7ew.js} +1 -1
  295. package/static/dist/assets/{blockDiagram-I7D4REHJ-n79st4pj.js → blockDiagram-I7D4REHJ-jzthp1wa.js} +1 -1
  296. package/static/dist/assets/{c4Diagram-7LVT6UL2-mi73akzk.js → c4Diagram-7LVT6UL2-mwr67mt8.js} +1 -1
  297. package/static/dist/assets/{cancel-mzvkdjg8.js → cancel-fhi0vcnu.js} +1 -1
  298. package/static/dist/assets/channel-b2zo73z1.js +1 -0
  299. package/static/dist/assets/{chunk-4HAMMTFA-laat0g1q.js → chunk-4HAMMTFA-gpq1y228.js} +1 -1
  300. package/static/dist/assets/{chunk-75Z2AOVW-0psy1tr2.js → chunk-75Z2AOVW-ktje58c4.js} +1 -1
  301. package/static/dist/assets/{chunk-DU6HZSFF-lw8uox8x.js → chunk-DU6HZSFF-ofajdixh.js} +3 -3
  302. package/static/dist/assets/{chunk-F27PBJKO-erassib3.js → chunk-F27PBJKO-cmev85ti.js} +1 -1
  303. package/static/dist/assets/{chunk-GMAD6QVW-fz7rx619.js → chunk-GMAD6QVW-htjgt22y.js} +1 -1
  304. package/static/dist/assets/{chunk-GVQU2GXP-gdb0epb2.js → chunk-GVQU2GXP-qndvoxl7.js} +1 -1
  305. package/static/dist/assets/{chunk-IMKFNOWR-ea8t26qw.js → chunk-IMKFNOWR-e5bkd5q6.js} +1 -1
  306. package/static/dist/assets/{chunk-L3NEJ4N5-mb1gu9kz.js → chunk-L3NEJ4N5-mj1155r6.js} +1 -1
  307. package/static/dist/assets/{chunk-OSK3NFVY-eovmccvy.js → chunk-OSK3NFVY-e7gkikcw.js} +1 -1
  308. package/static/dist/assets/{chunk-P2QGCYS3-bo55zjo5.js → chunk-P2QGCYS3-f7t54xfm.js} +1 -1
  309. package/static/dist/assets/{chunk-POPQ4Y6H-ja7ohe4h.js → chunk-POPQ4Y6H-i927v9oa.js} +1 -1
  310. package/static/dist/assets/{chunk-PWAF6VOD-e30g9nus.js → chunk-PWAF6VOD-m84lmnyt.js} +1 -1
  311. package/static/dist/assets/{chunk-SHT3W25Y-gbb798f7.js → chunk-SHT3W25Y-mi0tlcu9.js} +1 -1
  312. package/static/dist/assets/{chunk-SVP7TREG-h2iw8urm.js → chunk-SVP7TREG-dnau702j.js} +1 -1
  313. package/static/dist/assets/{chunk-TICWLB2K-fd2cottz.js → chunk-TICWLB2K-edem1gkg.js} +1 -1
  314. package/static/dist/assets/classDiagram-ZZMXUADV-oe99st8x.js +1 -0
  315. package/static/dist/assets/classDiagram-v2-VYDZK3BY-oe99st8x.js +1 -0
  316. package/static/dist/assets/{copied-h65fc3cx.js → copied-hcplv46p.js} +1 -1
  317. package/static/dist/assets/copy-magtofyp.js +1 -0
  318. package/static/dist/assets/{cynefin-OW5HDTMX-lbltyl5j.js → cynefin-OW5HDTMX-siqlulkl.js} +1 -1
  319. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-lxhsqq4a.js → cynefinDiagram-5FMLGOSQ-i648tah9.js} +1 -1
  320. package/static/dist/assets/{dagre-GXQ25YYZ-gn4nnaln.js → dagre-GXQ25YYZ-mwn6uc17.js} +1 -1
  321. package/static/dist/assets/{diagram-S7CK7UJ4-pa3ij6q7.js → diagram-S7CK7UJ4-l8abxaes.js} +1 -1
  322. package/static/dist/assets/{diagram-UQ7AKVKN-zbipyz57.js → diagram-UQ7AKVKN-mekq7zgq.js} +1 -1
  323. package/static/dist/assets/{diagram-VSXAHHWV-d1kbpzgf.js → diagram-VSXAHHWV-fnky6lvc.js} +1 -1
  324. package/static/dist/assets/{diagram-VX7I27RA-i3ty14xl.js → diagram-VX7I27RA-xe6r5qfw.js} +1 -1
  325. package/static/dist/assets/{diagram-Z3DM3KII-m8xefqa7.js → diagram-Z3DM3KII-nq07dg7k.js} +1 -1
  326. package/static/dist/assets/{ebnfDiagram-PWID7BFC-nz7wj05l.js → ebnfDiagram-PWID7BFC-c62ipm3g.js} +1 -1
  327. package/static/dist/assets/endpoints-klbxod4t.js +64 -0
  328. package/static/dist/assets/{erDiagram-RLTQ6QDP-fa41ib83.js → erDiagram-RLTQ6QDP-h39kmnzy.js} +1 -1
  329. package/static/dist/assets/eventmodeling-NTZA5JFV-obfede4u.js +1 -0
  330. package/static/dist/assets/flowDiagram-HODETNUW-kdpsp1je.js +1 -0
  331. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-n7zq01qu.js → ganttDiagram-EL5Y4UJY-ngbsyp37.js} +1 -1
  332. package/static/dist/assets/{gitGraph-4MIJSDKK-c89emb07.js → gitGraph-4MIJSDKK-fr2i7caa.js} +1 -1
  333. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-cz7cgr6w.js → gitGraphDiagram-WWUBYQGX-eravdti4.js} +1 -1
  334. package/static/dist/assets/{highlighted-body-KPVGNVTW-d9bapmni.js → highlighted-body-KPVGNVTW-jwpop9p8.js} +1 -1
  335. package/static/dist/assets/host-i828xuo7.js +1 -0
  336. package/static/dist/assets/index-eh8zilnv.css +2 -0
  337. package/static/dist/assets/index-r648f4o1.js +11 -0
  338. package/static/dist/assets/{info-A6RAGUB7-ndddhhhh.js → info-A6RAGUB7-h11x0dxo.js} +1 -1
  339. package/static/dist/assets/{infoDiagram-27XIBGKW-ccxigcpf.js → infoDiagram-27XIBGKW-dfh47dw9.js} +1 -1
  340. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-le0z9vtc.js → ishikawaDiagram-5VMMS53U-i6ypq351.js} +1 -1
  341. package/static/dist/assets/{journeyDiagram-3NMN7TZE-ivsmb26o.js → journeyDiagram-3NMN7TZE-lduf1z95.js} +1 -1
  342. package/static/dist/assets/{kanban-definition-UXKFOSKX-h6n2awt0.js → kanban-definition-UXKFOSKX-e18bu4pg.js} +1 -1
  343. package/static/dist/assets/lazyRouteComponent-e1y4t2i9.js +1 -0
  344. package/static/dist/assets/{line-dmzlfe0u.js → line-meizqsvn.js} +1 -1
  345. package/static/dist/assets/link-oot2yn73.js +1 -0
  346. package/static/dist/assets/login-gu28l9ou.js +1 -0
  347. package/static/dist/assets/mermaid-HWGCJPDP-mtwrj8fn.js +1 -0
  348. package/static/dist/assets/{mermaid-parser.core-gwyd5bnq.js → mermaid-parser.core-ntne45i9.js} +3 -3
  349. package/static/dist/assets/{mindmap-definition-YA3MSWOX-l68gg5h7.js → mindmap-definition-YA3MSWOX-27b26eok.js} +1 -1
  350. package/static/dist/assets/onboarding-lkphzu1s.js +1 -0
  351. package/static/dist/assets/onboarding_model_label-ftpjdwqk.js +1 -0
  352. package/static/dist/assets/{packet-AYTQ26CC-gpv06065.js → packet-AYTQ26CC-l6jdka8p.js} +1 -1
  353. package/static/dist/assets/{pegDiagram-XKGWAZYB-e52vs23u.js → pegDiagram-XKGWAZYB-g1er3bxj.js} +1 -1
  354. package/static/dist/assets/{pie-WAS4IAKB-l6m7nz5s.js → pie-WAS4IAKB-i0ylcrt9.js} +1 -1
  355. package/static/dist/assets/{pieDiagram-E7YTZNPT-b4is36bq.js → pieDiagram-E7YTZNPT-fbz1e55z.js} +1 -1
  356. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-m8vt2788.js → quadrantDiagram-AXDQQJYC-hhue7d8o.js} +1 -1
  357. package/static/dist/assets/{radar-RG4KPBEZ-nxbrhiov.js → radar-RG4KPBEZ-f9b70aa4.js} +1 -1
  358. package/static/dist/assets/{railroad-74A4TZTK-222mu9hi.js → railroad-74A4TZTK-lgsuoeda.js} +1 -1
  359. package/static/dist/assets/railroad-abnf-HS5TGJTU-nf9d6pdx.js +1 -0
  360. package/static/dist/assets/railroad-ebnf-LZEXJU2U-f7ummkt5.js +1 -0
  361. package/static/dist/assets/railroad-peg-WCYAUIDC-oczq5tqs.js +1 -0
  362. package/static/dist/assets/{railroadDiagram-O6MQD6OU-o8lldfzv.js → railroadDiagram-O6MQD6OU-eqpy9fnz.js} +1 -1
  363. package/static/dist/assets/refresh-abtu93ke.js +1 -0
  364. package/static/dist/assets/refresh-cw-hlwb02qv.js +1 -0
  365. package/static/dist/assets/{requirementDiagram-BXWQKSXE-ovyq38kv.js → requirementDiagram-BXWQKSXE-ogf6puhi.js} +1 -1
  366. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-ou7lznor.js → sankeyDiagram-P5KCCOFB-fqb8xcjf.js} +1 -1
  367. package/static/dist/assets/{saved-bazmcup9.js → saved-girnabkf.js} +1 -1
  368. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-ovckdzf2.js → sequenceDiagram-WJ2MYXX4-dlcz6qjz.js} +1 -1
  369. package/static/dist/assets/{settings_section_extensions_title-ertwwhlx.js → settings_section_extensions_title-h14cwk6f.js} +1 -1
  370. package/static/dist/assets/share._token-kl0r7ido.js +2 -0
  371. package/static/dist/assets/sliders-horizontal-n1zm34qq.js +1 -0
  372. package/static/dist/assets/sse-iwku0sjo.js +1 -0
  373. package/static/dist/assets/{stateDiagram-D77RDMKH-fcrexkkv.js → stateDiagram-D77RDMKH-fyoznpcs.js} +1 -1
  374. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-ixhx8r46.js +1 -0
  375. package/static/dist/assets/{swimlanes-42K2YHIH-f7usu8fw.js → swimlanes-42K2YHIH-oefldhfy.js} +1 -1
  376. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-h9ersk2e.js +8 -0
  377. package/static/dist/assets/{timeline-definition-24CTP7MA-gp1hvz9f.js → timeline-definition-24CTP7MA-b1vl2zil.js} +1 -1
  378. package/static/dist/assets/todoStore-jjr5yr78.js +1 -0
  379. package/static/dist/assets/{treeView-Q6P3EWNA-gb9gf11u.js → treeView-Q6P3EWNA-f7hcci5o.js} +1 -1
  380. package/static/dist/assets/{treemap-WGGIJYW6-nuvfnvvi.js → treemap-WGGIJYW6-nl85071a.js} +1 -1
  381. package/static/dist/assets/units-kgxzxfhi.js +1 -0
  382. package/static/dist/assets/{untitled-htelhc68.js → untitled-cikfjntn.js} +1 -1
  383. package/static/dist/assets/{useForm-h2t0u14e.js → useForm-kqoqg0k9.js} +1 -1
  384. package/static/dist/assets/useLocale-o59tbiea.js +1 -0
  385. package/static/dist/assets/{useParams-dqd64jv7.js → useParams-b4dq6n4q.js} +1 -1
  386. package/static/dist/assets/{useSearch-j30pvsy7.js → useSearch-ipfmv3he.js} +1 -1
  387. package/static/dist/assets/{vennDiagram-4TSXK5OY-fzchbzaj.js → vennDiagram-4TSXK5OY-h5hzbk0n.js} +1 -1
  388. package/static/dist/assets/{wardley-WFR3VGLG-m47mhfbh.js → wardley-WFR3VGLG-jftvpm3e.js} +1 -1
  389. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-odg6ct1z.js → wardleyDiagram-VM6X3IG4-hedh85ge.js} +1 -1
  390. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-mrhms5um.js → xychartDiagram-S5SC5T6Z-e49523ab.js} +1 -1
  391. package/static/dist/index.html +1 -1
  392. package/static/dist/sw.js +2 -2
  393. package/static/dist/assets/AppShell-ef3bhu32.js +0 -1
  394. package/static/dist/assets/Button-nx37f4u1.js +0 -1
  395. package/static/dist/assets/ChatPage-cdcpykcg.js +0 -25
  396. package/static/dist/assets/Dialog-mec42hvb.js +0 -1
  397. package/static/dist/assets/HubRoute-ecb65zpr.js +0 -2
  398. package/static/dist/assets/RightPanel-d1lgvk4s.js +0 -1
  399. package/static/dist/assets/Select-h4ivl9qf.js +0 -1
  400. package/static/dist/assets/SessionListPanel-ewib046b.js +0 -1
  401. package/static/dist/assets/SettingsLayout-lx654cup.js +0 -1
  402. package/static/dist/assets/States-ehsb6lzo.js +0 -1
  403. package/static/dist/assets/Toaster-mch1pa3t.js +0 -1
  404. package/static/dist/assets/_app-m8d961ef.js +0 -1
  405. package/static/dist/assets/_app.ext._extensionId-mp33hbzi.js +0 -1
  406. package/static/dist/assets/_app.index-dk6zp7e0.js +0 -1
  407. package/static/dist/assets/_app.insights-jt2fk4i1.js +0 -1
  408. package/static/dist/assets/_app.kanban-if4pubeu.js +0 -1
  409. package/static/dist/assets/_app.logs-mfaw9m6g.js +0 -1
  410. package/static/dist/assets/_app.memory-h3eaarb6.js +0 -1
  411. package/static/dist/assets/_app.profiles-ienktovs.js +0 -1
  412. package/static/dist/assets/_app.settings-dpoe0bzf.js +0 -1
  413. package/static/dist/assets/_app.settings._section-l468ynag.js +0 -3
  414. package/static/dist/assets/_app.skills-kcbncc8i.js +0 -1
  415. package/static/dist/assets/_app.tasks-occdrqhw.js +0 -2
  416. package/static/dist/assets/_app.todos-nnke2rcc.js +0 -1
  417. package/static/dist/assets/_app.workspaces-cdlatvwj.js +0 -1
  418. package/static/dist/assets/architecture-7GRP2DOG-hj11333z.js +0 -1
  419. package/static/dist/assets/channel-mbvmf2vv.js +0 -1
  420. package/static/dist/assets/classDiagram-ZZMXUADV-o8r05k0n.js +0 -1
  421. package/static/dist/assets/classDiagram-v2-VYDZK3BY-o8r05k0n.js +0 -1
  422. package/static/dist/assets/copy-e6977uwq.js +0 -1
  423. package/static/dist/assets/endpoints-k7yf25m0.js +0 -64
  424. package/static/dist/assets/eventmodeling-NTZA5JFV-kpbq09bx.js +0 -1
  425. package/static/dist/assets/flowDiagram-HODETNUW-lcz0kx14.js +0 -1
  426. package/static/dist/assets/index-h00usru2.js +0 -11
  427. package/static/dist/assets/index-n73ijkv2.css +0 -2
  428. package/static/dist/assets/lazyRouteComponent-m1rtyk4t.js +0 -1
  429. package/static/dist/assets/link-budeg5t9.js +0 -1
  430. package/static/dist/assets/login-kjhasvp8.js +0 -1
  431. package/static/dist/assets/matchContext-f8oy3lo7.js +0 -1
  432. package/static/dist/assets/mermaid-HWGCJPDP-jb5d211j.js +0 -1
  433. package/static/dist/assets/onboarding-cdfrk0pj.js +0 -1
  434. package/static/dist/assets/railroad-abnf-HS5TGJTU-kdvgl6x8.js +0 -1
  435. package/static/dist/assets/railroad-ebnf-LZEXJU2U-itiqslby.js +0 -1
  436. package/static/dist/assets/railroad-peg-WCYAUIDC-go7kov78.js +0 -1
  437. package/static/dist/assets/refresh-cw-kbcje1uy.js +0 -1
  438. package/static/dist/assets/search-jen74z4u.js +0 -1
  439. package/static/dist/assets/share._token-dan9hgvj.js +0 -2
  440. package/static/dist/assets/sliders-horizontal-f9cb3lh3.js +0 -1
  441. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-lcfraua2.js +0 -1
  442. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-ogmanni0.js +0 -8
  443. package/static/dist/assets/todoStore-eqtid4r4.js +0 -1
  444. package/static/dist/assets/useLocale-hcsb84pj.js +0 -1
  445. package/static/dist/assets/useNavigate-nwy70ztj.js +0 -1
@@ -8,13 +8,13 @@
8
8
  * here. ponytail: one live-id cache with a 24h TTL replaces the Python
9
9
  * publisher/provenance machinery; `refresh()` evicts it.
10
10
  */
11
- import { readCapped } from '../http/capped.js';
12
11
  import { homeDotenvKeys } from '../cli/dotenv.js';
13
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
14
13
  import { createHash, randomUUID } from 'node:crypto';
15
14
  import { join } from 'node:path';
16
15
  import { atomicWriteText } from '../fs/atomic.js';
17
16
  import { str } from '../util.js';
17
+ import { QUOTA_THRESHOLD_DEFAULTS } from '../settings.js';
18
18
  import { loadEnvFile } from './env-file.js';
19
19
  import { ACCOUNT_USAGE_PROVIDERS, FALLBACK_MODELS, OAUTH_PROVIDERS, PORTAL_PROVIDERS, PROVIDER_DISPLAY, PROVIDER_ENV_VAR, PROVIDER_ENV_VAR_ALIASES, PROVIDER_MODELS, SELF_HOSTED_PROVIDER_IDS, } from './tables.js';
20
20
  import { activeProviderFromConfig, canonicaliseProviderId, configuredModelIds, configuredModelOptions, customProviderEntries, customProviderSlug, dict, effectiveDefaultModel, isDict, isOpenAiFamilyProvider, mainModelSupportsServiceTier, modelSection, parseProviderQualifiedModel, providerIdentity, resolveProviderAlias, } from '../config/agent-config.js';
@@ -49,6 +49,58 @@ export function catalogOptionId(catalog, model, provider) {
49
49
  return e.id;
50
50
  return null;
51
51
  }
52
+ /** TAL-542: the vendor a first-party provider serves, or a model id names by prefix; '' when neither says. */
53
+ const PROVIDER_VENDOR = { openai: 'openai', 'openai-api': 'openai', 'openai-codex': 'openai', anthropic: 'anthropic', gemini: 'google', google: 'google' };
54
+ function modelVendor(model) {
55
+ const m = model.trim().toLowerCase();
56
+ if (m.includes('/'))
57
+ return { openai: 'openai', anthropic: 'anthropic', google: 'google', gemini: 'google' }[m.slice(0, m.indexOf('/'))] ?? '';
58
+ return m.startsWith('gpt') ? 'openai' : m.startsWith('claude') ? 'anthropic' : m.startsWith('gemini') ? 'google' : '';
59
+ }
60
+ /**
61
+ * TAL-542 (Python `_resolve_compatible_session_model_state` + `_repair_foreign_session_model_provider`): the pair a
62
+ * session's stale model starts on. A model its provider's catalog group does not list moves to the one other provider
63
+ * that lists it; else, when the model names another vendor than a first-party provider, to the profile default. No
64
+ * evidence (no group for the provider, a group built from a failed live lookup in `unlisted`, which never names an owner either) keeps the pair, and so does a stored
65
+ * provider that is the profile's own unless the model names another vendor. `null` keeps the pair.
66
+ */
67
+ export function repairSessionModel(catalog, model, provider, unlisted = new Set()) {
68
+ const active = canonicaliseProviderId(catalog.active_provider);
69
+ const pid = canonicaliseProviderId(provider) || active;
70
+ if (!model.trim() || !pid || unlisted.has(pid) || !catalog.groups.some((g) => canonicaliseProviderId(g.provider_id) === pid))
71
+ return null;
72
+ const loose = (id) => id.trim().toLowerCase().replaceAll('-', '.');
73
+ const want = loose(model);
74
+ const entries = catalog.groups.flatMap((g) => [...g.models, ...(g.extra_models ?? [])]);
75
+ const tail = (id) => loose(id.slice(id.indexOf('/') + 1));
76
+ const own = entries.filter((e) => canonicaliseProviderId(e.provider_id) === pid).map((e) => str(e.bare_id));
77
+ if (own.some((bare) => loose(bare) === want))
78
+ return null;
79
+ // Equivalence across ids only drops a recognised vendor prefix; another namespace is no evidence. A match starts on the
80
+ // matching entry's own id, so the turn runs on the id that provider advertises.
81
+ const vendorless = (id) => loose(id.replace(/^(openai|anthropic|google|gemini)\//i, ''));
82
+ const same = (bare) => vendorless(bare) === vendorless(model);
83
+ const here = own.find(same);
84
+ if (here)
85
+ return [here, pid];
86
+ // Python `_catalog_model_id_matches`: a model the provider lists under another namespace is kept, never moved.
87
+ if (own.some((bare) => tail(bare) === want))
88
+ return null;
89
+ if (!provider || pid !== active) {
90
+ const owners = new Map(entries.filter((e) => same(str(e.bare_id)) && !unlisted.has(canonicaliseProviderId(e.provider_id))).map((e) => [canonicaliseProviderId(e.provider_id), str(e.bare_id)]));
91
+ const [only] = owners.size === 1 ? [...owners] : [];
92
+ if (only)
93
+ return [only[1], only[0]];
94
+ }
95
+ const vendor = PROVIDER_VENDOR[pid] ?? '';
96
+ const named = modelVendor(model);
97
+ // Python: Codex takes bare ids, so an `openai/` id on it is stale too.
98
+ if (!vendor || !named || (named === vendor && !(pid === 'openai-codex' && model.includes('/'))))
99
+ return null;
100
+ const defaultModel = str(catalog.default_bare_id).trim();
101
+ const defaultProvider = canonicaliseProviderId(catalog.default_provider_id) || active;
102
+ return defaultModel && defaultProvider && (defaultModel !== model || defaultProvider !== pid) ? [defaultModel, defaultProvider] : null;
103
+ }
52
104
  /** Python `_split_picker_overflow_models`: past 25 rows the picker shows 15, keeping the selected model visible. */
53
105
  export function splitPickerOverflow(models, selected, providerId) {
54
106
  if (models.length <= MODEL_PICKER_OVERFLOW_THRESHOLD)
@@ -98,6 +150,17 @@ export function stampAuxiliarySelections(aux, catalog) {
98
150
  }
99
151
  /** Python `_BESPOKE_CATALOG_PROVIDERS`: cards whose catalog is resolved by their own rule, never the generic live probe. */
100
152
  const BESPOKE_CATALOG_PROVIDERS = new Set(['openai-codex', 'nous', 'xai-oauth', 'lmstudio', 'opencode-go']);
153
+ /** A provider's configured endpoint: its `providers.<id>.base_url` (alias-aware), else the active model's `base_url`. */
154
+ const configuredBaseUrl = (pid, config) => {
155
+ const providers = dict(config.providers);
156
+ const cfg = dict(providers[pid] ?? providers[Object.keys(providers).find((k) => providerIdentity(k) === providerIdentity(pid)) ?? '']);
157
+ return str(cfg.base_url).trim() || (activeProviderFromConfig(config) === pid ? str(modelSection(config).base_url).trim() : '');
158
+ };
159
+ /** TAL-603: the credential family a card's badge names; `null` when the card holds no credential. */
160
+ const KEY_SOURCE_KINDS = { env_file: 'env', env_var: 'env', env: 'env', oauth: 'oauth', token: 'oauth', config_yaml: 'config', config: 'config', credential_pool: 'pool', plugin: 'plugin' };
161
+ const keySourceKind = (row) => row.has_key === true && row.key_source !== 'none' ? KEY_SOURCE_KINDS[str(row.key_source)] ?? 'other' : null;
162
+ /** TAL-570: Ollama and LM Studio run keyless, so a configured endpoint makes them ready the way a key does. */
163
+ const selfHostedEndpoint = (pid, config) => SELF_HOSTED_PROVIDER_IDS.has(pid) && Boolean(configuredBaseUrl(pid, config));
101
164
  /** Python `_unqualified_model_id`: strip a picker routing hint (`@provider:`). */
102
165
  const unqualifiedModelId = (id) => parseProviderQualifiedModel(id)?.[0] ?? id.trim();
103
166
  /** Python `_MODEL_PICKER_OVERFLOW_THRESHOLD` / `_MODEL_PICKER_VISIBLE_TARGET`. */
@@ -106,8 +169,8 @@ const MODEL_PICKER_VISIBLE_TARGET = 15;
106
169
  const ACCOUNT_USAGE_CACHE_TTL_S = 45;
107
170
  const LIVE_TTL_S = 86_400;
108
171
  const PROVIDERS_TTL_S = 30;
109
- const QUOTA_TIMEOUT_MS = 15_000;
110
- const OPENROUTER_KEY_URL = 'https://openrouter.ai/api/v1/key';
172
+ /** The sidecar reads a balance endpoint with a 15 s timeout of its own. */
173
+ const BALANCE_TIMEOUT_MS = 20_000;
111
174
  const PLUGIN_LIST_TIMEOUT_MS = 15_000;
112
175
  /** TAL-288: the card text for a plugin provider the Agent does not report ready. */
113
176
  const PLUGIN_SETUP_ERRORS = {
@@ -118,6 +181,34 @@ const PLUGIN_SETUP_ERRORS = {
118
181
  };
119
182
  const PLUGIN_NO_MODELS = 'This provider listed no models.';
120
183
  const COST_SNAPSHOT_MAX_DAYS = 365;
184
+ /**
185
+ * The last `days` daily snapshots with their spend deltas, bar heights, monthly pace, and budget standing (the legacy
186
+ * Web chart's formulas). A drop in `used` is a credit reset, so that day's delta is its `used`.
187
+ */
188
+ function costHistoryView(rows, days, budget) {
189
+ const window = rows.slice(-days);
190
+ const deltas = window.map((e, i) => {
191
+ const prev = window[i - 1];
192
+ if (i === 0 || e.used === null || prev?.used === null || prev?.used === undefined)
193
+ return null;
194
+ const delta = e.used - prev.used < 0 ? e.used : e.used - prev.used;
195
+ return Math.abs(delta) < 1e-9 ? 0 : Math.round(delta * 1e6) / 1e6;
196
+ });
197
+ const known = deltas.filter((d) => d !== null);
198
+ const maxDelta = Math.max(...deltas.map((d) => d ?? 0), 1e-9);
199
+ const pace = known.length ? (known.reduce((a, b) => a + b, 0) / known.length) * 30 : null;
200
+ const budgetPercent = pace !== null && pace > 0 && budget !== null && budget > 0 ? Math.round((pace / budget) * 100) : null;
201
+ return {
202
+ snapshots: window.map((e, i) => {
203
+ const delta = deltas[i] ?? null;
204
+ return { date: e.date, used: e.used, delta, bar_percent: delta === null ? 0 : Number(Math.max((delta / maxDelta) * 100, delta > 0 ? 2 : 0).toFixed(1)) };
205
+ }),
206
+ monthly_pace: pace === null ? null : Math.round(pace * 100) / 100,
207
+ has_enough_data: known.length > 0,
208
+ budget_percent: budgetPercent,
209
+ budget_level: budgetPercent === null ? null : budgetPercent >= 100 ? 'over' : budgetPercent >= 80 ? 'warn' : 'ok',
210
+ };
211
+ }
121
212
  const FIVE_HOUR_WINDOW_S = 18_000;
122
213
  const WEEK_WINDOW_S = 604_800;
123
214
  /** Swift `rounded()`: half away from zero. */
@@ -212,6 +303,46 @@ export function normalizeQuotaWindows(raw, nowSeconds) {
212
303
  weekly_window_index: index(labelHas('week')),
213
304
  };
214
305
  }
306
+ /** The burn-rate breakpoints apply once the window is old and used enough and the projection empties it before reset. */
307
+ function projectionEligible(pace, usedPercent, minimumElapsedHours) {
308
+ return pace.elapsed_minutes >= Math.max(0, minimumElapsedHours) * 60 && usedPercent >= 5 && pace.minutes_to_reset > 20
309
+ && (pace.projected_minutes_to_empty ?? Infinity) < pace.minutes_to_reset;
310
+ }
311
+ /** The App's former `ProviderQuotaUrgencyCalculator.urgency` for one colour basis; a pace basis without a pace falls back to remaining. */
312
+ function quotaLevel(window, status, basis, t) {
313
+ if (status === 'stale')
314
+ return 'stale';
315
+ const remaining = window?.remaining_percent ?? null;
316
+ if (status !== 'available' || remaining === null)
317
+ return status === 'available' ? 'healthy' : 'unavailable';
318
+ const pace = window?.pace ?? null;
319
+ if (basis === 'pace' && pace) {
320
+ const eligible = projectionEligible(pace, 100 - remaining, t.pace_minimum_elapsed_hours);
321
+ if (eligible && pace.burn_rate >= Math.max(0, t.pace_critical_burn_rate_percent) / 100)
322
+ return 'critical';
323
+ if (eligible && pace.burn_rate >= Math.max(0, t.pace_warning_burn_rate_percent) / 100)
324
+ return 'warning';
325
+ return pace.pace_delta_percent <= -Math.max(0, t.pace_tolerance_percent) ? 'warning' : 'healthy';
326
+ }
327
+ if (remaining <= Math.max(0, t.critical_remaining_percent))
328
+ return 'critical';
329
+ if (remaining <= Math.max(0, t.warning_remaining_percent))
330
+ return 'warning';
331
+ return 'healthy';
332
+ }
333
+ export function classifyQuotaSource(source, t) {
334
+ const urgency = (w) => ({ remaining: quotaLevel(w, source.status, 'remaining', t), pace: quotaLevel(w, source.status, 'pace', t) });
335
+ return {
336
+ ...source,
337
+ windows: source.windows.map((w) => ({
338
+ ...w,
339
+ pace: w.pace && { ...w.pace, status: w.pace.pace_delta_percent <= -Math.max(0, t.pace_tolerance_percent) ? 'over' : w.pace.pace_delta_percent > 1 ? 'under' : 'on' },
340
+ projection_eligible: w.pace !== null && w.remaining_percent !== null && projectionEligible(w.pace, 100 - w.remaining_percent, t.pace_minimum_elapsed_hours),
341
+ urgency: urgency(w),
342
+ })),
343
+ urgency: { remaining: urgency(source.windows[0]).remaining, pace: urgency(source.windows[source.pace_window_index ?? 0]).pace },
344
+ };
345
+ }
215
346
  export function displayName(pid) {
216
347
  return PROVIDER_DISPLAY[pid] ?? pid.split('-').map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
217
348
  }
@@ -300,6 +431,25 @@ export function deduplicateModelIds(groups) {
300
431
  m.id = `@${groups[gi].provider_id}:${m.id}`;
301
432
  }
302
433
  }
434
+ /** OpenCode Go's usage windows: their label and length (Python `_sanitize_opencode_go_account_limits`). */
435
+ const OPENCODE_GO_WINDOWS = { rolling: ['5-hour', FIVE_HOUR_WINDOW_S], weekly: ['Weekly', WEEK_WINDOW_S], monthly: ['Monthly', null] };
436
+ /** One pool account's local state as account limits; `status` is the account's, which its quota source shows. */
437
+ function poolEntryLimits(entry, at) {
438
+ return {
439
+ source: 'local_pool', title: 'Credential pool', status: entry.status, available: entry.status === 'available', plan: null, details: [],
440
+ unavailable_reason: entry.unavailable_reason, retry_after: entry.retry_after, fetched_at: null, ...normalizeQuotaWindows([], at),
441
+ };
442
+ }
443
+ /** Python `_local_pool_snapshot`: a provider's pool accounts summarised as account limits. */
444
+ function poolLimits(entries, at) {
445
+ const count = (status) => entries.filter((e) => e.status === status).length;
446
+ const [available, exhausted, dead] = [count('available'), count('exhausted'), count('dead')];
447
+ const details = [`${String(available)}/${String(entries.length)} credentials available`, ...(exhausted ? [`${String(exhausted)} exhausted`] : []), ...(dead ? [`${String(dead)} dead`] : [])];
448
+ return {
449
+ source: 'local_pool', title: 'Credential pool', available: available > 0, plan: null, details, unavailable_reason: available ? null : 'All pool credentials are unavailable.', fetched_at: null,
450
+ ...normalizeQuotaWindows([], at),
451
+ };
452
+ }
303
453
  /** Quota sources keep the first row per source id, ordered by provider id, account label, then source id (TAL-272). */
304
454
  export function uniqueQuotaSources(sources) {
305
455
  const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
@@ -308,12 +458,17 @@ export function uniqueQuotaSources(sources) {
308
458
  }
309
459
  export class ProviderCatalog {
310
460
  deps;
461
+ /** Failed live lookups by home, provider, and the `sourceFingerprint` they ran under (TAL-542). */
311
462
  liveFailed = new Set();
463
+ /** `source`: the `sourceFingerprint` the ids were fetched under; another one makes them stale (TAL-542). */
312
464
  liveIds = new Map();
313
465
  liveInflight = new Map();
466
+ /** `pooled`: the fill's `poolProviders` answer, null when unknown (TAL-638). */
314
467
  providersCache = new Map();
315
468
  /** TAL-301: the last catalog served per profile home, for the sync session payloads' `model_option_id`. */
316
469
  lastModels = new Map();
470
+ /** TAL-542: per `lastModels` catalog, the `sourceFingerprint` it was built from, when, and the providers whose group came from a failed live lookup. */
471
+ lastModelsMeta = new Map();
317
472
  constructor(deps) {
318
473
  this.deps = deps;
319
474
  }
@@ -336,11 +491,19 @@ export class ProviderCatalog {
336
491
  this.liveIds.delete(key);
337
492
  }
338
493
  this.providersCache.clear();
494
+ // Credentials changed: the last pool answers no longer stand in for a failed lookup.
495
+ for (const key of [...this.lastPoolAnswers.keys()])
496
+ if (!profileHome || key.startsWith(`${profileHome}\0`))
497
+ this.lastPoolAnswers.delete(key);
339
498
  // The picker catalog changed: option ids rebuild on the next read rather than pairing against the old one.
340
- if (profileHome)
499
+ if (profileHome) {
341
500
  this.lastModels.delete(profileHome);
342
- else
501
+ this.lastModelsMeta.delete(profileHome);
502
+ }
503
+ else {
343
504
  this.lastModels.clear();
505
+ this.lastModelsMeta.clear();
506
+ }
344
507
  }
345
508
  /** TAL-301: the entry id a stored `(model, provider)` pair selects in the last catalog built for this home; a cold home starts building one. */
346
509
  modelOptionFor(profileHome, model, provider) {
@@ -351,9 +514,58 @@ export class ProviderCatalog {
351
514
  }
352
515
  return catalogOptionId(catalog, model, provider);
353
516
  }
354
- /** Builds the catalog `modelOptionFor` reads once per home; an unavailable catalog leaves every option id null. */
517
+ /**
518
+ * TAL-542: the identity of the files a catalog and its live ids derive from: config.yaml, `.env`, the Agent's sign-in
519
+ * store (`auth.json`, rewritten by an account switch), and the installed plugins directory, so a change outside Web
520
+ * makes both stale.
521
+ */
522
+ sourceFingerprint(profileHome) {
523
+ const stamp = (name) => {
524
+ try {
525
+ const st = statSync(join(profileHome, name), { bigint: true });
526
+ return `${String(st.mtimeNs)}:${String(st.size)}:${String(st.ino)}`;
527
+ }
528
+ catch {
529
+ return 'missing';
530
+ }
531
+ };
532
+ return [this.deps.config.fingerprint(profileHome), ...['.env', 'auth.json', 'plugins'].map(stamp)].join('|');
533
+ }
534
+ /** The last catalog built for this home, younger than `maxAgeS`, while its source files are unchanged since (TAL-542). */
535
+ currentModels(profileHome, maxAgeS = Infinity) {
536
+ const meta = this.lastModelsMeta.get(profileHome);
537
+ return meta?.source === this.sourceFingerprint(profileHome) && this.deps.now() - meta.at < maxAgeS ? this.lastModels.get(profileHome) : undefined;
538
+ }
539
+ /**
540
+ * TAL-542: `repairSessionModel` against this home's catalog. Agent-owned sign-in and plugin state has no file to
541
+ * fingerprint, so the repair also needs a catalog no older than the providers listing it read (`PROVIDERS_TTL_S`);
542
+ * a cold or stale one keeps the pair and starts a rebuild.
543
+ */
544
+ sessionModelRepair(profileHome, model, provider) {
545
+ const catalog = this.currentModels(profileHome, PROVIDERS_TTL_S);
546
+ if (!catalog) {
547
+ void this.warmSessionModelRepair(profileHome);
548
+ return null;
549
+ }
550
+ return repairSessionModel(catalog, model, provider, this.lastModelsMeta.get(profileHome)?.unconfirmed);
551
+ }
552
+ /**
553
+ * Builds the catalog `sessionModelRepair` reads unless a fresh enough one exists; call it after the request's last
554
+ * await. A source that changed while the build awaited makes that build stale, so it is rebuilt once.
555
+ */
556
+ async warmSessionModelRepair(profileHome) {
557
+ for (let attempt = 0; attempt < 2 && !this.currentModels(profileHome, PROVIDERS_TTL_S); attempt += 1) {
558
+ try {
559
+ await this.models(profileHome);
560
+ }
561
+ catch {
562
+ return; /* fail closed: the pair is kept */
563
+ }
564
+ }
565
+ }
566
+ /** Builds the catalog `modelOptionFor` reads once per home, again after config.yaml or `.env` changes; an unavailable catalog leaves every option id null. */
355
567
  async warmModelOptions(profileHome) {
356
- if (this.lastModels.has(profileHome))
568
+ if (this.currentModels(profileHome))
357
569
  return;
358
570
  try {
359
571
  await this.models(profileHome);
@@ -364,16 +576,20 @@ export class ProviderCatalog {
364
576
  * The process-environment value of `name` as a named profile may see it: values `loadStartupEnv` copied from the
365
577
  * default profile's `.env` belong to that profile only, so they never count for another profile.
366
578
  */
367
- processEnv(name, profileHome) {
368
- if (!this.deps.isRootProfileHome(profileHome) && homeDotenvKeys(this.deps.env).has(name))
579
+ /** `cleared`: the variable a key delete clears, which also retires its `.env`-owned startup copy (TAL-641). */
580
+ processEnv(name, profileHome, cleared) {
581
+ if ((name === cleared || !this.deps.isRootProfileHome(profileHome)) && homeDotenvKeys(this.deps.env).has(name))
369
582
  return undefined;
370
583
  return this.deps.env[name];
371
584
  }
372
- /** Python `_provider_has_key` minus the credential pool (the sidecar answers OAuth/pool state). */
373
- providerHasKey(pid, config, envValues, profileHome) {
585
+ /**
586
+ * Python `_provider_has_key` minus the credential pool, which `probeKey` reads from the sidecar.
587
+ * `cleared` (TAL-641) answers for after `setProviderKey(…, null)`: that variable's `.env` entry and startup copy are gone.
588
+ */
589
+ providerHasKey(pid, config, envValues, profileHome, cleared) {
374
590
  const envVar = providerEnvVar(pid);
375
591
  if (envVar) {
376
- if (valueCountsAsApiKey(pid, envValues[envVar]) || valueCountsAsApiKey(pid, this.processEnv(envVar, profileHome)))
592
+ if ((envVar !== cleared && valueCountsAsApiKey(pid, envValues[envVar])) || valueCountsAsApiKey(pid, this.processEnv(envVar, profileHome, cleared)))
377
593
  return true;
378
594
  for (const alias of PROVIDER_ENV_VAR_ALIASES[pid] ?? [])
379
595
  if (valueCountsAsApiKey(pid, envValues[alias]) || valueCountsAsApiKey(pid, this.processEnv(alias, profileHome)))
@@ -407,11 +623,13 @@ export class ProviderCatalog {
407
623
  return null;
408
624
  }
409
625
  }
410
- async probeKey(profileHome, pid, config, envValues) {
626
+ /** `pooled`: the providers whose persisted credential pool holds an added account (TAL-638). */
627
+ async probeKey(profileHome, pid, config, envValues, pooled) {
411
628
  let hasKey = this.providerHasKey(pid, config, envValues, profileHome);
412
629
  let isOauth = OAUTH_PROVIDERS.has(pid);
413
630
  let keySource = 'none';
414
631
  let authError = null;
632
+ let removable = false;
415
633
  if (isOauth) {
416
634
  keySource = 'oauth';
417
635
  const status = await this.authStatus(profileHome, pid);
@@ -431,8 +649,10 @@ export class ProviderCatalog {
431
649
  else if (hasKey) {
432
650
  const envVar = providerEnvVar(pid);
433
651
  if (envVar) {
434
- if (valueCountsAsApiKey(pid, envValues[envVar]))
652
+ if (valueCountsAsApiKey(pid, envValues[envVar])) {
435
653
  keySource = 'env_file';
654
+ removable = !pooled.has(pid) && !this.providerHasKey(pid, config, envValues, profileHome, envVar);
655
+ }
436
656
  else if (valueCountsAsApiKey(pid, this.processEnv(envVar, profileHome)))
437
657
  keySource = 'env_var';
438
658
  else {
@@ -452,6 +672,11 @@ export class ProviderCatalog {
452
672
  else
453
673
  keySource = 'config_yaml';
454
674
  }
675
+ else if (pooled.has(pid)) {
676
+ // Python `_has_explicit_pool_credentials`: keys added with `hermes auth add` live only in the Agent's pool.
677
+ hasKey = true;
678
+ keySource = 'credential_pool';
679
+ }
455
680
  else if (!providerEnvVar(pid) && /^[a-z][a-z0-9_-]{0,63}$/.test(pid)) {
456
681
  const status = await this.authStatus(profileHome, pid);
457
682
  if (status?.logged_in) {
@@ -461,25 +686,31 @@ export class ProviderCatalog {
461
686
  isOauth = true;
462
687
  }
463
688
  }
464
- return { hasKey, keySource, authError, isOauth };
689
+ return { hasKey, keySource, authError, isOauth, removable };
465
690
  }
466
691
  /** Live model ids from the Agent for one provider, cached per profile home. */
467
692
  async liveModelIds(profileHome, pid, opts = {}) {
468
693
  const key = `${profileHome}\0${pid}`;
694
+ const source = this.sourceFingerprint(profileHome);
469
695
  const hit = this.liveIds.get(key);
470
- if (hit && !opts.force && this.deps.now() - hit.at < LIVE_TTL_S)
696
+ if (hit?.source === source && !opts.force && this.deps.now() - hit.at < LIVE_TTL_S)
471
697
  return hit.ids;
472
- const inflight = this.liveInflight.get(key);
698
+ // A lookup started under another source answers for the old endpoint or credential; this one starts its own.
699
+ const flightKey = `${key}\0${source}`;
700
+ const inflight = this.liveInflight.get(flightKey);
473
701
  if (inflight && !opts.force)
474
702
  return inflight;
475
703
  const sidecar = this.deps.sidecar();
476
- if (!sidecar)
704
+ // No sidecar is a failed lookup for this source: the ids it keeps showing never confirm a stale-model repair (TAL-542).
705
+ if (!sidecar) {
706
+ this.liveFailed.add(flightKey);
477
707
  return hit?.ids ?? [];
708
+ }
478
709
  const run = sidecar.call('providers.model_ids', { profile_home: profileHome, provider: pid, ...(opts.force ? { force_refresh: true } : {}) }, { timeoutMs: 30_000 })
479
- .then((r) => { this.liveIds.set(key, { at: this.deps.now(), ids: r.model_ids }); this.liveFailed.delete(key); return r.model_ids; })
480
- .catch((error) => { this.deps.log(`[catalog] live model ids for ${pid} failed: ${str(error.message)}`); this.liveFailed.add(key); return hit?.ids ?? []; })
481
- .finally(() => { this.liveInflight.delete(key); });
482
- this.liveInflight.set(key, run);
710
+ .then((r) => { this.liveIds.set(key, { at: this.deps.now(), ids: r.model_ids, source }); this.liveFailed.delete(flightKey); return r.model_ids; })
711
+ .catch((error) => { this.deps.log(`[catalog] live model ids for ${pid} failed: ${str(error.message)}`); this.liveFailed.add(flightKey); return hit?.ids ?? []; })
712
+ .finally(() => { this.liveInflight.delete(flightKey); });
713
+ this.liveInflight.set(flightKey, run);
483
714
  return run;
484
715
  }
485
716
  /**
@@ -501,12 +732,18 @@ export class ProviderCatalog {
501
732
  }
502
733
  /** Python `get_providers`. */
503
734
  async providers(profileHome) {
735
+ return structuredClone((await this.providerRows(profileHome)).payload);
736
+ }
737
+ /** The cached `providers` answer, shared with `quotas`; callers never mutate it. */
738
+ async providerRows(profileHome) {
504
739
  const config = await this.deps.config.read(profileHome);
505
740
  const envValues = loadEnvFile(join(profileHome, '.env'));
506
741
  const cacheKey = JSON.stringify([config, envValues]);
507
742
  const hit = this.providersCache.get(profileHome);
508
743
  if (hit?.key === cacheKey && this.deps.now() - hit.at < PROVIDERS_TTL_S)
509
- return structuredClone(hit.payload);
744
+ return hit;
745
+ const pooledIds = await this.poolProviders(profileHome);
746
+ const pooled = new Set(pooledIds ?? []);
510
747
  const active = activeProviderFromConfig(config);
511
748
  const known = new Set([...Object.keys(PROVIDER_DISPLAY), ...Object.keys(PROVIDER_MODELS), ...OAUTH_PROVIDERS]);
512
749
  // A plugin never stands in for a built-in provider, by id or alias (that would route to the built-in's billing); it
@@ -522,14 +759,15 @@ export class ProviderCatalog {
522
759
  }
523
760
  const rows = [];
524
761
  for (const pid of [...known].sort()) {
525
- const probe = await this.probeKey(profileHome, pid, config, envValues);
762
+ const probe = await this.probeKey(profileHome, pid, config, envValues, pooled);
526
763
  if (pid === 'openai' && !probe.hasKey && looksLikeCodexOauthToken(str(envValues[providerEnvVar('openai') ?? ''] ?? this.deps.env.OPENAI_API_KEY)))
527
764
  continue;
765
+ const configured = probe.hasKey || selfHostedEndpoint(pid, config);
528
766
  let models = pid === 'openrouter' ? FALLBACK_MODELS.map((m) => ({ id: m.id, label: m.label })) : [...(PROVIDER_MODELS[pid] ?? [])];
529
767
  let modelsTotal = models.length;
530
768
  // Python: the card prefers the live catalog for every keyed provider (exactly like the picker), keeping the
531
769
  // static list as the cold/failed-probe fallback; Nous renders a featured subset with the full count.
532
- if (probe.hasKey && (probe.isOauth || pid === 'lmstudio' || pid === 'nous' || !BESPOKE_CATALOG_PROVIDERS.has(pid))) {
770
+ if (configured && (probe.isOauth || pid === 'lmstudio' || pid === 'nous' || !BESPOKE_CATALOG_PROVIDERS.has(pid))) {
533
771
  const live = await this.liveModelIds(profileHome, pid);
534
772
  if (live.length) {
535
773
  models = pid === 'nous' ? live.slice(0, 25).map((id) => ({ id: `@nous:${id}`, label: `${formatOllamaLabel(id.includes('/') ? id.slice(id.indexOf('/') + 1) : id)} (via Nous)` })) : live.map((id) => ({ id, label: labelForModel(id, []) }));
@@ -545,17 +783,19 @@ export class ProviderCatalog {
545
783
  if (pid !== 'nous')
546
784
  modelsTotal += added.length;
547
785
  }
548
- const baseUrl = str(providerCfg.base_url).trim() || (active === pid ? str(modelSection(config).base_url).trim() : '') || null;
786
+ const baseUrl = configuredBaseUrl(pid, config) || null;
549
787
  rows.push({
550
788
  id: pid,
551
789
  display_name: displayName(pid),
552
790
  has_key: probe.hasKey,
791
+ configured,
553
792
  configurable: !probe.isOauth && Boolean(providerEnvVar(pid)),
554
793
  is_oauth: probe.isOauth,
555
794
  is_plugin_provider: false,
556
795
  is_self_hosted: SELF_HOSTED_PROVIDER_IDS.has(pid),
557
796
  is_custom: false,
558
797
  key_source: probe.keySource,
798
+ removable: probe.removable && !SELF_HOSTED_PROVIDER_IDS.has(pid),
559
799
  base_url: baseUrl,
560
800
  auth_error: probe.authError,
561
801
  env_var: providerEnvVar(pid),
@@ -568,8 +808,8 @@ export class ProviderCatalog {
568
808
  const ready = plugin.setup === 'ready';
569
809
  const live = ready ? await this.liveModelIds(profileHome, plugin.name) : [];
570
810
  rows.push({
571
- id: plugin.name, display_name: plugin.display_name || plugin.name, has_key: ready,
572
- configurable: false, is_oauth: false, is_plugin_provider: true, is_self_hosted: false, is_custom: false, key_source: ready ? 'plugin' : 'none',
811
+ id: plugin.name, display_name: plugin.display_name || plugin.name, has_key: ready, configured: ready,
812
+ configurable: false, is_oauth: false, is_plugin_provider: true, is_self_hosted: false, is_custom: false, key_source: ready ? 'plugin' : 'none', removable: false,
573
813
  base_url: null, auth_error: plugin.setup === 'ready' ? (live.length ? null : PLUGIN_NO_MODELS) : PLUGIN_SETUP_ERRORS[plugin.setup], env_var: null,
574
814
  models: live.map((id) => ({ id, label: labelForModel(id, []) })), models_total: live.length,
575
815
  });
@@ -583,24 +823,34 @@ export class ProviderCatalog {
583
823
  // Python: an `api_key: ${VAR}` reference counts when the variable resolves.
584
824
  const cpKey = str(cp.api_key).trim();
585
825
  const envRef = /^\$\{([^}]+)\}$/.exec(cpKey)?.[1] ?? '';
586
- const hasKey = envRef ? Boolean((this.processEnv(envRef, profileHome) ?? '').trim()) : valueCountsAsApiKey(slug, cp.api_key) || Boolean(str(cp.key_env).trim() && this.processEnv(str(cp.key_env).trim(), profileHome));
826
+ const yamlKey = !envRef && valueCountsAsApiKey(slug, cp.api_key);
827
+ const hasKey = envRef ? Boolean((this.processEnv(envRef, profileHome) ?? '').trim()) : yamlKey || Boolean(str(cp.key_env).trim() && this.processEnv(str(cp.key_env).trim(), profileHome));
828
+ // TAL-603: a `${VAR}` reference or `key_env` credential comes from the environment.
829
+ const keySource = yamlKey ? 'config_yaml' : hasKey ? 'env_var' : cpKey ? 'config_yaml' : 'none';
587
830
  rows.push({
588
- id: slug, display_name: name, has_key: hasKey,
589
- configurable: false, is_oauth: false, is_plugin_provider: false, is_self_hosted: false, is_custom: true, key_source: str(cp.api_key).trim() ? 'config_yaml' : 'none',
831
+ id: slug, display_name: name, has_key: hasKey, configured: hasKey,
832
+ configurable: false, is_oauth: false, is_plugin_provider: false, is_self_hosted: false, is_custom: true, key_source: keySource, removable: false,
590
833
  base_url: str(cp.base_url).trim() || null, auth_error: null, env_var: null, models: ids.map((id) => ({ id, label: labelForModel(id, []) })), models_total: ids.length,
591
834
  });
592
835
  }
593
836
  // Python `_provider_sort_key`: active first, then `custom:*`, then keyed providers, then the rest (alphabetical within).
594
837
  const rank = (p) => (str(p.id) === active ? 0 : str(p.id).startsWith('custom:') ? 1 : p.has_key ? 2 : 3);
595
838
  rows.sort((a, b) => rank(a) - rank(b) || (str(a.id) < str(b.id) ? -1 : str(a.id) > str(b.id) ? 1 : 0));
596
- for (const row of rows)
839
+ for (const row of rows) {
597
840
  row.models = stampModelEntries(row.models, str(row.id));
598
- const payload = { providers: rows, active_provider: active };
599
- this.providersCache.set(profileHome, { at: this.deps.now(), key: cacheKey, payload });
600
- return structuredClone(payload);
841
+ // TAL-603: the server decides the Active badge, the key badge and the count; a featured list never outnumbers its total.
842
+ row.is_active = str(row.id) === active;
843
+ row.key_source_kind = keySourceKind(row);
844
+ row.models_total = Math.max(Number(row.models_total), row.models.length);
845
+ }
846
+ const entry = { at: this.deps.now(), key: cacheKey, payload: { providers: rows, active_provider: active }, pooled: pooledIds };
847
+ this.providersCache.set(profileHome, entry);
848
+ return entry;
601
849
  }
602
850
  /** Python `get_available_models` (static catalog + live ids for keyed providers). */
603
851
  async models(profileHome) {
852
+ const source = this.sourceFingerprint(profileHome);
853
+ const unconfirmed = new Set();
604
854
  const config = await this.deps.config.read(profileHome);
605
855
  const envValues = loadEnvFile(join(profileHome, '.env'));
606
856
  const active = activeProviderFromConfig(config);
@@ -644,11 +894,11 @@ export class ProviderCatalog {
644
894
  detected.add(canonical);
645
895
  }
646
896
  // Python: OAuth providers the Agent reports as logged in join the picker with their live catalog (#1567, #2545); so do
647
- // ready plugin providers (TAL-288), under their own name.
897
+ // ready plugin providers (TAL-288), under their own name, and providers keyed only through the credential pool (TAL-638).
648
898
  const signedIn = new Set();
649
899
  const pluginNames = new Map();
650
- for (const row of (await this.providers(profileHome)).providers) {
651
- if (row.has_key !== true || (row.is_oauth !== true && row.is_plugin_provider !== true))
900
+ for (const row of (await this.providerRows(profileHome)).payload.providers) {
901
+ if (row.has_key !== true || (row.is_oauth !== true && row.is_plugin_provider !== true && row.key_source !== 'credential_pool'))
652
902
  continue;
653
903
  signedIn.add(str(row.id));
654
904
  if (row.is_plugin_provider === true)
@@ -708,12 +958,15 @@ export class ProviderCatalog {
708
958
  let raw = [];
709
959
  if ('models' in providerCfg && providerCfg.models_discovered !== true)
710
960
  raw = configuredModelOptions(providerCfg.models);
711
- if (!raw.length && (this.providerHasKey(pid, config, envValues, profileHome) || signedIn.has(pid))) {
961
+ if (!raw.length && (this.providerHasKey(pid, config, envValues, profileHome) || selfHostedEndpoint(pid, config) || signedIn.has(pid))) {
712
962
  const live = await this.liveModelIds(profileHome, pid);
963
+ // TAL-542: a failed lookup leaves this group's list unconfirmed, so the stale-model repair never trusts it.
964
+ if (this.liveFailed.has(`${profileHome}\0${pid}\0${source}`))
965
+ unconfirmed.add(pid);
713
966
  if (live.length)
714
967
  raw = live.map((id) => ({ id, label: pid === 'nous' ? `${formatOllamaLabel(id.includes('/') ? id.slice(id.indexOf('/') + 1) : id)} (via Nous)` : labelForModel(id, []) }));
715
968
  // Python (#1567): an authenticated Nous account with an empty live catalog shows no group; only a failed lookup falls back to the curated list.
716
- else if (pid === 'nous' && !this.liveFailed.has(`${profileHome}\0${pid}`))
969
+ else if (pid === 'nous' && !this.liveFailed.has(`${profileHome}\0${pid}\0${source}`))
717
970
  continue;
718
971
  }
719
972
  if (!raw.length)
@@ -772,15 +1025,16 @@ export class ProviderCatalog {
772
1025
  const defaults = { active_provider: active, default_model: defaultModel, default_provider_id: defaultProvider, default_bare_id: defaultBare };
773
1026
  if (!kept.length && defaultModel) {
774
1027
  const providerId = active ?? 'default';
775
- return this.remember(profileHome, { ...defaults, groups: [{ provider: 'Default', provider_id: providerId, models: stampModelEntries([{ id: defaultModel, label: labelForModel(defaultModel, []) }], providerId) }], aliases: {}, configured_model_badges: {} });
1028
+ return this.remember(profileHome, { source, unconfirmed }, { ...defaults, groups: [{ provider: 'Default', provider_id: providerId, models: stampModelEntries([{ id: defaultModel, label: labelForModel(defaultModel, []) }], providerId) }], aliases: {}, configured_model_badges: {} });
776
1029
  }
777
1030
  const stamped = kept.map((g) => ({ ...g, models: stampModelEntries(g.models, g.provider_id), ...(g.extra_models ? { extra_models: stampModelEntries(g.extra_models, g.provider_id) } : {}) }));
778
- return this.remember(profileHome, { ...defaults, groups: stamped, aliases, configured_model_badges: this.badges(kept, active, defaultModel, fallbackCfg) });
1031
+ return this.remember(profileHome, { source, unconfirmed }, { ...defaults, groups: stamped, aliases, configured_model_badges: this.badges(kept, active, defaultModel, fallbackCfg) });
779
1032
  }
780
1033
  /** Stamps the default's entry id and keeps the catalog for `modelOptionFor`. */
781
- remember(profileHome, catalog) {
1034
+ remember(profileHome, meta, catalog) {
782
1035
  catalog.default_option_id = catalogOptionId(catalog, catalog.default_bare_id ?? null, catalog.default_provider_id ?? null);
783
1036
  this.lastModels.set(profileHome, catalog);
1037
+ this.lastModelsMeta.set(profileHome, { ...meta, at: this.deps.now() });
784
1038
  return structuredClone(catalog);
785
1039
  }
786
1040
  /** Python `_configured_model_badges_from_static_catalog`: which picker rows are the main/fallback selections. */
@@ -905,11 +1159,22 @@ export class ProviderCatalog {
905
1159
  return v;
906
1160
  }
907
1161
  }
908
- const p = dict(dict(config.providers)[pid]);
909
- return str(p.api_key).trim() || null;
1162
+ // The same config sources `providerHasKey` counts, so a provider shown as keyed resolves its key.
1163
+ const model = modelSection(config);
1164
+ if (canonicaliseProviderId(model.provider) === canonicaliseProviderId(pid) && valueCountsAsApiKey(pid, model.api_key))
1165
+ return str(model.api_key).trim();
1166
+ for (const [key, value] of Object.entries(dict(config.providers))) {
1167
+ if (providerIdentity(key) === providerIdentity(pid) && isDict(value) && valueCountsAsApiKey(pid, value.api_key))
1168
+ return str(value.api_key).trim();
1169
+ }
1170
+ return null;
910
1171
  }
911
- /** Python `get_provider_quota`. */
1172
+ /** Account-usage snapshots by profile home, provider, and pool account. */
912
1173
  accountUsageCache = new Map();
1174
+ /**
1175
+ * Python `get_provider_quota`. `credential` scopes the read to one credential-pool account (TAL-548): a benched account
1176
+ * answers with its local pool state, and every other read uses that account's key.
1177
+ */
913
1178
  async quota(profileHome, providerRaw, opts = {}) {
914
1179
  const at = opts.at ?? this.deps.now();
915
1180
  const computed_at = isoAt(at);
@@ -918,17 +1183,23 @@ export class ProviderCatalog {
918
1183
  if (!provider)
919
1184
  return { computed_at, ok: false, provider: null, display_name: null, supported: false, status: 'unavailable', quota: null, message: 'No active provider is configured.' };
920
1185
  const name = displayName(provider);
1186
+ const credential = opts.credential ?? null;
1187
+ const answer = (ok, status, message, extra = {}) => ({ computed_at, ok, provider, display_name: name, supported: true, status, quota: null, ...extra, message });
1188
+ if (credential && credential.status !== 'available') {
1189
+ return answer(false, 'unavailable', `${name} credential is unavailable. ${str(credential.unavailable_reason)}`.trim(), { account_limits: poolEntryLimits(credential, at) });
1190
+ }
921
1191
  if (ACCOUNT_USAGE_PROVIDERS.has(provider)) {
922
1192
  const sidecar = this.deps.sidecar();
923
1193
  let limits = null;
924
1194
  // Python `_ACCOUNT_USAGE_CACHE_TTL_SECONDS`: a snapshot answers repeat polls for 45 s unless `refresh` is set.
925
- const cacheKey = `${profileHome}\0${provider}`;
1195
+ const cacheKey = `${profileHome}\0${provider}\0${credential?.credential_id ?? ''}`;
926
1196
  const cached = this.accountUsageCache.get(cacheKey);
927
1197
  if (cached && !opts.refresh && this.deps.now() - cached.at <= ACCOUNT_USAGE_CACHE_TTL_S)
928
1198
  limits = cached.limits;
929
1199
  else if (sidecar) {
930
1200
  try {
931
- const snapshot = (await sidecar.call('usage.account', { profile_home: profileHome, provider, ...(opts.refresh ? { refresh: true } : {}) }, { timeoutMs: 35_000 })).snapshot;
1201
+ const params = { profile_home: profileHome, provider, ...(opts.refresh ? { refresh: true } : {}), ...(credential ? { credential_id: credential.credential_id } : {}) };
1202
+ const snapshot = (await sidecar.call('usage.account', params, { timeoutMs: 35_000 })).snapshot;
932
1203
  if (snapshot)
933
1204
  limits = { ...snapshot, title: str(snapshot.title) || 'Account limits', available: snapshot.available && !str(snapshot.unavailable_reason) };
934
1205
  }
@@ -941,40 +1212,98 @@ export class ProviderCatalog {
941
1212
  if (limits)
942
1213
  limits = { ...limits, ...normalizeQuotaWindows(limits.windows, at), fetched_at: isoUtc(limits.fetched_at) };
943
1214
  if (limits?.available)
944
- return { computed_at, ok: true, provider, display_name: name, supported: true, status: limits.stale ? 'stale' : 'available', label: limits.title, quota: null, account_limits: limits, message: limits.stale ? `${name} refresh failed; showing last-known account limits.` : `${name} account limits loaded.` };
1215
+ return answer(true, limits.stale ? 'stale' : 'available', limits.stale ? `${name} refresh failed; showing last-known account limits.` : `${name} account limits loaded.`, { label: limits.title, account_limits: limits });
945
1216
  const reason = str(limits?.unavailable_reason).trim();
946
- return { computed_at, ok: false, provider, display_name: name, supported: true, status: 'unavailable', quota: null, account_limits: limits, message: reason ? `${name} account limits are unavailable. ${reason}` : `${name} account limits are unavailable. Confirm provider authentication and try again.` };
1217
+ return answer(false, 'unavailable', reason ? `${name} account limits are unavailable. ${reason}` : `${name} account limits are unavailable. Confirm provider authentication and try again.`, { account_limits: limits });
1218
+ }
1219
+ if (provider === 'openrouter' || provider === 'deepseek' || provider === 'opencode-go') {
1220
+ const apiKey = this.apiKeyFor(provider, profileHome, config);
1221
+ const key = credential ? { credential_id: credential.credential_id } : apiKey ? { api_key: apiKey } : null;
1222
+ const read = key ? await this.balance(profileHome, provider, key) : null;
1223
+ const status = !key || read?.status === 'no_key' ? 'no_key' : read?.status === 'ok' ? 'ok' : read?.status === 'http_error' ? read.http_status : null;
1224
+ if (provider === 'openrouter') {
1225
+ if (status === 'no_key')
1226
+ return answer(false, 'no_key', 'OpenRouter quota status needs an OPENROUTER_API_KEY configured on the server.');
1227
+ if (status === 'ok' && read?.quota) {
1228
+ // The cost chart follows the configured key, so another pool account's spend never lands in it.
1229
+ if (!credential || credential.matches_api_key)
1230
+ this.recordCostSnapshot(profileHome, read.quota, at);
1231
+ return answer(true, 'available', 'OpenRouter quota status loaded.', { label: 'OpenRouter credits', quota: read.quota });
1232
+ }
1233
+ return status === 401 || status === 403 ? answer(false, 'invalid_key', 'OpenRouter rejected the configured API key.') : answer(false, 'unavailable', 'OpenRouter quota status is temporarily unavailable.');
1234
+ }
1235
+ if (provider === 'deepseek') {
1236
+ if (status === 'no_key')
1237
+ return answer(false, 'no_key', 'DeepSeek balance needs a DEEPSEEK_API_KEY configured on the server.', { balances: [] });
1238
+ if (status === 'ok' && read) {
1239
+ const available = read.is_available === true;
1240
+ return answer(available, available ? 'available' : 'exhausted', available ? 'DeepSeek balance loaded.' : 'DeepSeek balance is exhausted.', { label: 'DeepSeek balance', balances: read.balances });
1241
+ }
1242
+ return status === 401 || status === 403 ? answer(false, 'invalid_key', 'DeepSeek rejected the configured API key.', { balances: [] }) : answer(false, 'unavailable', 'DeepSeek balance is temporarily unavailable.', { balances: [] });
1243
+ }
1244
+ if (status === 'no_key')
1245
+ return answer(false, 'no_key', 'OpenCode Go account limits need an API key configured on the server.', { account_limits: null });
1246
+ if (status === 'ok' && read) {
1247
+ // Python `_sanitize_opencode_go_account_limits`: the rolling, weekly and monthly windows of the Go plan.
1248
+ const windows = read.windows.map((w) => ({ label: OPENCODE_GO_WINDOWS[w.key][0], window_seconds: OPENCODE_GO_WINDOWS[w.key][1], used_percent: w.used_percent, reset_at: w.reset_at, detail: w.rate_limited ? 'Rate limited' : null }));
1249
+ const limits = { provider, source: 'usage_api', title: 'Account limits', plan: 'Go', details: [], available: true, unavailable_reason: null, fetched_at: isoAt(at), ...normalizeQuotaWindows(windows, at) };
1250
+ return answer(true, 'available', 'OpenCode Go account limits loaded.', { label: 'OpenCode Go usage', account_limits: limits });
1251
+ }
1252
+ if (status === 401)
1253
+ return answer(false, 'invalid_key', 'OpenCode Go rejected the configured API key.', { account_limits: null });
1254
+ return answer(false, 'unavailable', status === 403 ? 'OpenCode Go account limits require an active subscription.' : 'OpenCode Go account limits are temporarily unavailable.', { account_limits: null });
947
1255
  }
948
- if (provider === 'openrouter') {
949
- const apiKey = this.apiKeyFor('openrouter', profileHome, config);
950
- if (!apiKey)
951
- return { computed_at, ok: false, provider, display_name: name, supported: true, status: 'no_key', quota: null, message: 'OpenRouter quota status needs an OPENROUTER_API_KEY configured on the server.' };
952
- const info = await this.fetchOpenRouterKey(apiKey);
953
- if (info.kind === 'ok')
954
- return { computed_at, ok: true, provider, display_name: name, supported: true, status: 'available', label: 'OpenRouter credits', quota: info.quota, message: 'OpenRouter quota status loaded.' };
955
- const status = info.kind === 'invalid_key' ? 'invalid_key' : 'unavailable';
956
- return { computed_at, ok: false, provider, display_name: name, supported: true, status, quota: null, message: status === 'invalid_key' ? 'OpenRouter rejected the configured API key.' : 'OpenRouter quota status is temporarily unavailable.' };
1256
+ // Python `_local_pool_snapshot`: any other provider with a credential pool shows its accounts' local state.
1257
+ const entries = credential ? [credential] : (await this.poolEntries(profileHome, provider, null)) ?? [];
1258
+ if (entries.length) {
1259
+ const limits = credential ? poolEntryLimits(credential, at) : poolLimits(entries, at);
1260
+ return limits.available ? answer(true, 'available', `${name} credential pool status loaded.`, { label: 'Credential pool', account_limits: limits }) : answer(false, 'unavailable', `${name} credential pool: all credentials are unavailable.`, { account_limits: limits });
957
1261
  }
958
1262
  return { computed_at, ok: false, provider, display_name: name, supported: false, status: 'unsupported', quota: null, message: `No verified server-side quota or balance endpoint is available for ${name}.` };
959
1263
  }
960
- async fetchOpenRouterKey(apiKey) {
961
- const f = this.deps.fetch ?? fetch;
1264
+ /**
1265
+ * TAL-548: the provider's credential-pool accounts as the sidecar reads them locally. Each says whether it holds
1266
+ * `apiKey`, the provider's configured key. Null when the pool is unknown (see `poolLookup`).
1267
+ */
1268
+ poolEntries(profileHome, pid, apiKey) {
1269
+ return this.poolLookup(profileHome, `pool\0${pid}`, async (sidecar) => (await sidecar.call('usage.pool', { profile_home: profileHome, provider: pid, ...(apiKey ? { api_key: apiKey } : {}) })).entries);
1270
+ }
1271
+ /** The last answer of each credential-pool lookup, by profile home and lookup. */
1272
+ lastPoolAnswers = new Map();
1273
+ /**
1274
+ * TAL-548: one credential-pool lookup through the sidecar. A failed lookup answers with the last answer to the same
1275
+ * lookup, else null: an unknown pool, never an empty one, so no pool account is ever reported as removed.
1276
+ */
1277
+ async poolLookup(profileHome, lookup, call) {
1278
+ const key = `${profileHome}\0${lookup}`;
962
1279
  try {
963
- const res = await f(OPENROUTER_KEY_URL, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' }, signal: AbortSignal.timeout(QUOTA_TIMEOUT_MS) });
964
- if (!res.ok)
965
- return { kind: res.status === 401 || res.status === 403 ? 'invalid_key' : 'unavailable' };
966
- const raw = await readCapped(res, 256 * 1024);
967
- if (!raw)
968
- return { kind: 'unavailable' };
969
- let payload = JSON.parse(raw.toString('utf8'));
970
- if (isDict(payload) && isDict(payload.data))
971
- payload = payload.data;
972
- const d = dict(payload);
973
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
974
- return { kind: 'ok', quota: { limit_remaining: num(d.limit_remaining), usage: num(d.usage), limit: num(d.limit) }, label: str(d.label).trim() || null };
1280
+ const sidecar = this.deps.sidecar();
1281
+ if (!sidecar)
1282
+ throw new Error('the sidecar is not running');
1283
+ const answer = await call(sidecar);
1284
+ this.lastPoolAnswers.set(key, answer);
1285
+ return answer;
975
1286
  }
976
- catch {
977
- return { kind: 'unavailable' };
1287
+ catch (error) {
1288
+ this.deps.log(`[catalog] credential pool lookup ${lookup.replace('\0', ' ')} failed: ${str(error.message)}`);
1289
+ return this.lastPoolAnswers.get(key) ?? null;
1290
+ }
1291
+ }
1292
+ /** TAL-548: the providers whose persisted credential pool holds an added account; none when the sidecar cannot say. */
1293
+ poolProviders(profileHome) {
1294
+ return this.poolLookup(profileHome, 'providers', async (sidecar) => (await sidecar.call('usage.pool_providers', { profile_home: profileHome })).providers);
1295
+ }
1296
+ /** TAL-548: one key-based balance read through the sidecar, which resolves a pool account's key itself; null when it fails. */
1297
+ async balance(profileHome, provider, key) {
1298
+ const sidecar = this.deps.sidecar();
1299
+ if (!sidecar)
1300
+ return null;
1301
+ try {
1302
+ return await sidecar.call('usage.balance', { profile_home: profileHome, provider, ...key }, { timeoutMs: BALANCE_TIMEOUT_MS });
1303
+ }
1304
+ catch (error) {
1305
+ this.deps.log(`[catalog] ${provider} balance failed: ${str(error.message)}`);
1306
+ return null;
978
1307
  }
979
1308
  }
980
1309
  quotaScopeCache = null;
@@ -1010,31 +1339,52 @@ export class ProviderCatalog {
1010
1339
  return `qscope_${createHash('sha256').update(`${this.quotaServerScopeId()}\0${profile}`).digest('hex').slice(0, 32)}`;
1011
1340
  }
1012
1341
  /**
1013
- * Python `get_provider_quotas`: one source per keyed provider, in the stable-identity envelope the iOS widget persists.
1014
- * Each source id appears once (two custom providers can share a slug), ordered by provider, account label, then source id.
1342
+ * Python `get_provider_quotas`: the stable-identity envelope the iOS widget persists. A keyed provider is one source per
1343
+ * credential-pool account, else one for its own key (Python `_quota_source_descriptors`, TAL-548). Each source id appears
1344
+ * once (two custom providers can share a slug), ordered by provider, account label, then source id.
1015
1345
  */
1016
1346
  async quotas(profileHome, profile, opts = {}) {
1017
1347
  const at = this.deps.now();
1018
- const status = await this.providers(profileHome);
1348
+ const { payload: status, pooled: pooledIds } = await this.providerRows(profileHome);
1019
1349
  const active = status.active_provider;
1020
1350
  const scopeId = this.quotaProfileScopeId(profile);
1021
- // Python `_quota_source_id(profile, provider, "provider")`: the single-credential descriptor per provider.
1022
- const sourceId = (pid) => `qsrc_${createHash('sha256').update(`${scopeId}\0${pid}\0provider`).digest('hex').slice(0, 32)}`;
1023
- let descriptors = uniqueQuotaSources(status.providers.filter((p) => p.has_key || p.is_custom).map((p) => ({ source_id: sourceId(str(p.id)), provider_id: str(p.id), provider_label: str(p.display_name) || str(p.id), account_label: str(p.display_name) || str(p.id) })));
1351
+ // Python `_quota_source_id(profile, provider, credential_id)`: a pool account's id, or "provider" for the provider's own key.
1352
+ const sourceId = (pid, credential) => `qsrc_${createHash('sha256').update(`${scopeId}\0${pid}\0${credential}`).digest('hex').slice(0, 32)}`;
1353
+ // A provider configured only through `hermes auth add` has no key in .env or config.yaml, yet its pool accounts are
1354
+ // sources, including a signed-out OAuth provider's. An unknown pool (a failed lookup with no earlier answer) never
1355
+ // reports a requested source as missing.
1356
+ let poolUnknown = pooledIds === null;
1357
+ const pooled = new Set(pooledIds ?? []);
1358
+ const config = await this.deps.config.read(profileHome);
1359
+ const perProvider = await Promise.all(status.providers.filter((p) => p.has_key || p.is_custom || pooled.has(str(p.id))).map(async (p) => {
1360
+ const pid = str(p.id);
1361
+ const label = str(p.display_name) || pid;
1362
+ const apiKey = this.apiKeyFor(pid, profileHome, config);
1363
+ const found = await this.poolEntries(profileHome, pid, apiKey);
1364
+ if (!found)
1365
+ poolUnknown = true;
1366
+ const entries = found ?? [];
1367
+ const accounts = entries.map((e) => ({ source_id: sourceId(pid, e.credential_id), provider_id: pid, provider_label: label, account_label: e.label, credential: e }));
1368
+ // The provider's own key is a source unless a pool account already holds it.
1369
+ const ownKey = !entries.length || (apiKey !== null && !entries.some((e) => e.matches_api_key));
1370
+ return ownKey ? [{ source_id: sourceId(pid, 'provider'), provider_id: pid, provider_label: label, account_label: label, credential: null }, ...accounts] : accounts;
1371
+ }));
1372
+ let descriptors = uniqueQuotaSources(perProvider.flat());
1024
1373
  const requested = str(opts.sourceId).trim() || null;
1025
1374
  if (requested)
1026
1375
  descriptors = descriptors.filter((d) => d.source_id === requested);
1376
+ const thresholds = this.deps.quotaThresholds?.(profile) ?? QUOTA_THRESHOLD_DEFAULTS;
1027
1377
  const sources = await Promise.all(descriptors.map(async (d) => {
1028
- const q = await this.quota(profileHome, d.provider_id, { refresh: opts.refresh ?? false, at });
1378
+ const q = await this.quota(profileHome, d.provider_id, { refresh: opts.refresh ?? false, at, credential: d.credential });
1029
1379
  const limits = dict(q.account_limits);
1030
- return {
1380
+ return classifyQuotaSource({
1031
1381
  source_id: d.source_id, provider_id: d.provider_id, provider_label: d.provider_label, account_label: d.account_label,
1032
1382
  is_active_provider: d.provider_id === active, supported: q.supported === true, status: str(limits.status) || str(q.status) || 'unavailable',
1033
- plan: limits.plan ?? null, windows: limits.windows ?? [], pace_window_index: limits.pace_window_index ?? null, session_window_index: limits.session_window_index ?? null, weekly_window_index: limits.weekly_window_index ?? null, quota: q.quota ?? null, balances: q.balances ?? [], details: limits.details ?? [],
1383
+ plan: limits.plan ?? null, windows: (limits.windows ?? []), pace_window_index: (limits.pace_window_index ?? null), session_window_index: limits.session_window_index ?? null, weekly_window_index: limits.weekly_window_index ?? null, quota: q.quota ?? null, balances: q.balances ?? [], details: limits.details ?? [],
1034
1384
  unavailable_reason: limits.unavailable_reason ?? null, retry_after: limits.retry_after ?? null, fetched_at: limits.fetched_at ?? null, message: q.message ?? null,
1035
- };
1385
+ }, thresholds);
1036
1386
  }));
1037
- return { version: 1, computed_at: isoAt(at), scope_id: scopeId, profile_id: profile, active_provider: active, requested_source_id: requested, missing_source: Boolean(requested && !descriptors.length), sources };
1387
+ return { version: 1, computed_at: isoAt(at), scope_id: scopeId, profile_id: profile, active_provider: active, requested_source_id: requested, missing_source: Boolean(requested && !descriptors.length && !poolUnknown), sources };
1038
1388
  }
1039
1389
  /** Python `get_provider_cost_history` (OpenRouter only; daily snapshots under `<home>/cost-snapshots`). */
1040
1390
  async costHistory(profileHome, providerRaw, days) {
@@ -1049,56 +1399,46 @@ export class ProviderCatalog {
1049
1399
  const apiKey = this.apiKeyFor('openrouter', profileHome, config);
1050
1400
  if (!apiKey)
1051
1401
  return { ok: false, provider, display_name: name, supported: true, status: 'no_key', monthly_budget: budget, message: 'OpenRouter cost history needs an OPENROUTER_API_KEY configured on the server.' };
1052
- const file = join(profileHome, 'cost-snapshots', 'openrouter.json');
1053
- const read = () => {
1054
- try {
1055
- const data = JSON.parse(readFileSync(file, 'utf8'));
1056
- return (Array.isArray(data.snapshots) ? data.snapshots : []).filter(isDict).map((e) => ({ date: str(e.date).trim(), used: typeof e.used === 'number' ? e.used : null, limit: typeof e.limit === 'number' ? e.limit : null })).filter((e) => e.date).sort((a, b) => (a.date < b.date ? -1 : 1));
1057
- }
1058
- catch {
1059
- return [];
1060
- }
1061
- };
1062
- const deltas = (rows) => {
1063
- const window = rows.slice(-days);
1064
- return window.map((e, i) => {
1065
- let delta = null;
1066
- const prev = window[i - 1];
1067
- if (i > 0 && e.used !== null && prev?.used !== null && prev?.used !== undefined) {
1068
- delta = e.used - prev.used;
1069
- if (delta < 0)
1070
- delta = e.used;
1071
- delta = Math.abs(delta) < 1e-9 ? 0 : Math.round(delta * 1e6) / 1e6;
1072
- }
1073
- return { date: e.date, used: e.used, delta };
1074
- });
1075
- };
1076
- const info = await this.fetchOpenRouterKey(apiKey);
1077
- if (info.kind !== 'ok') {
1078
- return { ok: false, provider, display_name: name, supported: true, status: 'unavailable', window_days: days, snapshots: deltas(read()), limit: null, label: null, monthly_budget: budget, message: 'OpenRouter cost history is temporarily unavailable. Showing last known data.' };
1402
+ const info = await this.balance(profileHome, 'openrouter', { api_key: apiKey });
1403
+ if (info?.status !== 'ok' || !info.quota) {
1404
+ return { ok: false, provider, display_name: name, supported: true, status: 'unavailable', window_days: days, ...costHistoryView(this.readCostSnapshots(profileHome), days, budget), limit: null, label: null, monthly_budget: budget, message: 'OpenRouter cost history is temporarily unavailable. Showing last known data.' };
1079
1405
  }
1080
- let snapshots = read();
1406
+ const snapshots = this.recordCostSnapshot(profileHome, info.quota, this.deps.now());
1407
+ return { ok: true, provider, display_name: name, supported: true, status: 'available', window_days: days, ...costHistoryView(snapshots, days, budget), limit: info.quota.limit, label: info.label ?? 'OpenRouter credits', monthly_budget: budget, message: 'OpenRouter cost history loaded.' };
1408
+ }
1409
+ readCostSnapshots(profileHome) {
1410
+ try {
1411
+ const data = JSON.parse(readFileSync(join(profileHome, 'cost-snapshots', 'openrouter.json'), 'utf8'));
1412
+ return (Array.isArray(data.snapshots) ? data.snapshots : []).filter(isDict).map((e) => ({ date: str(e.date).trim(), used: typeof e.used === 'number' ? e.used : null, limit: typeof e.limit === 'number' ? e.limit : null })).filter((e) => e.date).sort((a, b) => (a.date < b.date ? -1 : 1));
1413
+ }
1414
+ catch {
1415
+ return [];
1416
+ }
1417
+ }
1418
+ /** Writes or updates the UTC day's OpenRouter usage snapshot; every successful key read (quota or cost history) records one. */
1419
+ recordCostSnapshot(profileHome, quota, at) {
1420
+ let snapshots = this.readCostSnapshots(profileHome);
1081
1421
  try {
1082
- const today = new Date().toISOString().slice(0, 10);
1083
- const usage = info.quota.usage;
1084
- const limit = info.quota.limit;
1422
+ const today = isoAt(at).slice(0, 10);
1423
+ const used = finite(quota.usage);
1424
+ const limit = finite(quota.limit);
1085
1425
  const existing = snapshots.find((e) => e.date === today);
1086
1426
  if (existing) {
1087
- existing.used = usage;
1427
+ existing.used = used;
1088
1428
  existing.limit = limit;
1089
1429
  }
1090
1430
  else
1091
- snapshots.push({ date: today, used: usage, limit });
1431
+ snapshots.push({ date: today, used, limit });
1092
1432
  snapshots.sort((a, b) => (a.date < b.date ? -1 : 1));
1093
1433
  if (snapshots.length > COST_SNAPSHOT_MAX_DAYS)
1094
1434
  snapshots = snapshots.slice(-COST_SNAPSHOT_MAX_DAYS);
1095
1435
  mkdirSync(join(profileHome, 'cost-snapshots'), { recursive: true });
1096
- atomicWriteText(file, JSON.stringify({ provider, snapshots }, null, 2));
1436
+ atomicWriteText(join(profileHome, 'cost-snapshots', 'openrouter.json'), JSON.stringify({ provider: 'openrouter', snapshots }, null, 2));
1437
+ return snapshots;
1097
1438
  }
1098
1439
  catch {
1099
- snapshots = read();
1440
+ return this.readCostSnapshots(profileHome);
1100
1441
  }
1101
- return { ok: true, provider, display_name: name, supported: true, status: 'available', window_days: days, snapshots: deltas(snapshots), limit: info.quota.limit, label: info.label ?? 'OpenRouter credits', monthly_budget: budget, message: 'OpenRouter cost history loaded.' };
1102
1442
  }
1103
1443
  }
1104
1444
  export const envFileExists = (profileHome) => existsSync(join(profileHome, '.env'));