@maudecode/talaria-web 1.15.0 → 1.17.2

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 (386) hide show
  1. package/_release.json +4 -4
  2. package/dist/api/automation-raw.js +6 -3
  3. package/dist/api/automation-raw.js.map +1 -1
  4. package/dist/api/automation-router.d.ts +158 -16
  5. package/dist/api/automation-router.js +12 -6
  6. package/dist/api/automation-router.js.map +1 -1
  7. package/dist/api/chat-router.d.ts +193 -6
  8. package/dist/api/chat-router.js +39 -16
  9. package/dist/api/chat-router.js.map +1 -1
  10. package/dist/api/git-router.d.ts +12 -1
  11. package/dist/api/git-router.js +60 -38
  12. package/dist/api/git-router.js.map +1 -1
  13. package/dist/api/raw-routes.d.ts +6 -0
  14. package/dist/api/raw-routes.js +35 -58
  15. package/dist/api/raw-routes.js.map +1 -1
  16. package/dist/api/router.d.ts +4 -2
  17. package/dist/api/router.js +8 -8
  18. package/dist/api/router.js.map +1 -1
  19. package/dist/api/sessions-router.d.ts +974 -42
  20. package/dist/api/sessions-router.js +87 -25
  21. package/dist/api/sessions-router.js.map +1 -1
  22. package/dist/api/settings-router.d.ts +254 -11
  23. package/dist/api/settings-router.js +68 -18
  24. package/dist/api/settings-router.js.map +1 -1
  25. package/dist/api/sse-routes.d.ts +11 -3
  26. package/dist/api/sse-routes.js +145 -65
  27. package/dist/api/sse-routes.js.map +1 -1
  28. package/dist/api/tools-raw.js +6 -2
  29. package/dist/api/tools-raw.js.map +1 -1
  30. package/dist/api/tools-router.d.ts +86 -9
  31. package/dist/api/tools-router.js +19 -15
  32. package/dist/api/tools-router.js.map +1 -1
  33. package/dist/app.d.ts +0 -8397
  34. package/dist/app.js +7 -2
  35. package/dist/app.js.map +1 -1
  36. package/dist/auth/gate.js +11 -4
  37. package/dist/auth/gate.js.map +1 -1
  38. package/dist/auth/oidc.d.ts +6 -2
  39. package/dist/auth/oidc.js +16 -11
  40. package/dist/auth/oidc.js.map +1 -1
  41. package/dist/auth/store.d.ts +14 -2
  42. package/dist/auth/store.js +26 -1
  43. package/dist/auth/store.js.map +1 -1
  44. package/dist/cli/dotenv.d.ts +1 -1
  45. package/dist/cli/dotenv.js +36 -3
  46. package/dist/cli/dotenv.js.map +1 -1
  47. package/dist/config/agent-config.d.ts +2 -0
  48. package/dist/config/agent-config.js +9 -0
  49. package/dist/config/agent-config.js.map +1 -1
  50. package/dist/http/addresses.d.ts +6 -0
  51. package/dist/http/addresses.js +26 -0
  52. package/dist/http/addresses.js.map +1 -1
  53. package/dist/http/context.d.ts +6 -3
  54. package/dist/http/context.js +10 -4
  55. package/dist/http/context.js.map +1 -1
  56. package/dist/links.d.ts +26 -0
  57. package/dist/links.js +60 -0
  58. package/dist/links.js.map +1 -0
  59. package/dist/onboarding.js +30 -7
  60. package/dist/onboarding.js.map +1 -1
  61. package/dist/profiles/profiles.js +1 -1
  62. package/dist/profiles/profiles.js.map +1 -1
  63. package/dist/providers/catalog.d.ts +167 -5
  64. package/dist/providers/catalog.js +662 -135
  65. package/dist/providers/catalog.js.map +1 -1
  66. package/dist/providers/tables.d.ts +3 -1
  67. package/dist/providers/tables.js +9 -4
  68. package/dist/providers/tables.js.map +1 -1
  69. package/dist/redact.d.ts +1 -0
  70. package/dist/redact.js +518 -15
  71. package/dist/redact.js.map +1 -1
  72. package/dist/runtime.js +79 -37
  73. package/dist/runtime.js.map +1 -1
  74. package/dist/sessions/anchor.d.ts +24 -3
  75. package/dist/sessions/anchor.js +94 -44
  76. package/dist/sessions/anchor.js.map +1 -1
  77. package/dist/sessions/background-tasks.d.ts +4 -1
  78. package/dist/sessions/background-tasks.js +7 -4
  79. package/dist/sessions/background-tasks.js.map +1 -1
  80. package/dist/sessions/claude-code.d.ts +19 -0
  81. package/dist/sessions/claude-code.js +250 -0
  82. package/dist/sessions/claude-code.js.map +1 -0
  83. package/dist/sessions/completions.d.ts +13 -1
  84. package/dist/sessions/completions.js +64 -12
  85. package/dist/sessions/completions.js.map +1 -1
  86. package/dist/sessions/compress.d.ts +14 -1
  87. package/dist/sessions/compress.js +52 -11
  88. package/dist/sessions/compress.js.map +1 -1
  89. package/dist/sessions/journal.d.ts +8 -0
  90. package/dist/sessions/journal.js +44 -10
  91. package/dist/sessions/journal.js.map +1 -1
  92. package/dist/sessions/list.d.ts +13 -5
  93. package/dist/sessions/list.js +56 -26
  94. package/dist/sessions/list.js.map +1 -1
  95. package/dist/sessions/merge.d.ts +122 -9
  96. package/dist/sessions/merge.js +544 -33
  97. package/dist/sessions/merge.js.map +1 -1
  98. package/dist/sessions/pending.d.ts +5 -0
  99. package/dist/sessions/pending.js +30 -10
  100. package/dist/sessions/pending.js.map +1 -1
  101. package/dist/sessions/service.d.ts +100 -4
  102. package/dist/sessions/service.js +567 -102
  103. package/dist/sessions/service.js.map +1 -1
  104. package/dist/sessions/session.d.ts +16 -1
  105. package/dist/sessions/session.js +29 -2
  106. package/dist/sessions/session.js.map +1 -1
  107. package/dist/sessions/shares.js +11 -2
  108. package/dist/sessions/shares.js.map +1 -1
  109. package/dist/sessions/state-db.d.ts +26 -4
  110. package/dist/sessions/state-db.js +126 -8
  111. package/dist/sessions/state-db.js.map +1 -1
  112. package/dist/sessions/store.js +6 -1
  113. package/dist/sessions/store.js.map +1 -1
  114. package/dist/sessions/streams.d.ts +10 -0
  115. package/dist/sessions/streams.js +14 -3
  116. package/dist/sessions/streams.js.map +1 -1
  117. package/dist/sessions/todo.d.ts +6 -1
  118. package/dist/sessions/todo.js +11 -4
  119. package/dist/sessions/todo.js.map +1 -1
  120. package/dist/sessions/tool-display.d.ts +12 -1
  121. package/dist/sessions/tool-display.js +76 -1
  122. package/dist/sessions/tool-display.js.map +1 -1
  123. package/dist/sessions/turn.d.ts +49 -10
  124. package/dist/sessions/turn.js +459 -185
  125. package/dist/sessions/turn.js.map +1 -1
  126. package/dist/settings.d.ts +20 -2
  127. package/dist/settings.js +49 -2
  128. package/dist/settings.js.map +1 -1
  129. package/dist/sidecar/fake.d.ts +2 -2
  130. package/dist/sidecar/fake.js +7 -2
  131. package/dist/sidecar/fake.js.map +1 -1
  132. package/dist/spa.js +2 -1
  133. package/dist/spa.js.map +1 -1
  134. package/dist/test/harness.js +2 -0
  135. package/dist/test/harness.js.map +1 -1
  136. package/dist/tools/commands.d.ts +15 -0
  137. package/dist/tools/commands.js +52 -0
  138. package/dist/tools/commands.js.map +1 -0
  139. package/dist/tools/crons.d.ts +4 -1
  140. package/dist/tools/crons.js +58 -11
  141. package/dist/tools/crons.js.map +1 -1
  142. package/dist/tools/insights.d.ts +8 -2
  143. package/dist/tools/insights.js +18 -2
  144. package/dist/tools/insights.js.map +1 -1
  145. package/dist/tools/kanban.d.ts +41 -1
  146. package/dist/tools/kanban.js +113 -2
  147. package/dist/tools/kanban.js.map +1 -1
  148. package/dist/tools/mcp.d.ts +2 -1
  149. package/dist/tools/mcp.js +16 -6
  150. package/dist/tools/mcp.js.map +1 -1
  151. package/dist/tools/terminal.d.ts +1 -0
  152. package/dist/tools/terminal.js +1 -1
  153. package/dist/tools/terminal.js.map +1 -1
  154. package/dist/tools/update-notifications.d.ts +1 -1
  155. package/dist/tools/update-notifications.js +1 -1
  156. package/dist/tools/update-notifications.js.map +1 -1
  157. package/dist/tools/updates.d.ts +15 -3
  158. package/dist/tools/updates.js +207 -41
  159. package/dist/tools/updates.js.map +1 -1
  160. package/dist/workspace/fs.d.ts +7 -3
  161. package/dist/workspace/fs.js +5 -1
  162. package/dist/workspace/fs.js.map +1 -1
  163. package/dist/workspace/git.d.ts +57 -10
  164. package/dist/workspace/git.js +302 -195
  165. package/dist/workspace/git.js.map +1 -1
  166. package/dist/workspace/media-refs.d.ts +13 -0
  167. package/dist/workspace/media-refs.js +165 -0
  168. package/dist/workspace/media-refs.js.map +1 -0
  169. package/dist/workspace/media.d.ts +19 -0
  170. package/dist/workspace/media.js +63 -2
  171. package/dist/workspace/media.js.map +1 -1
  172. package/dist/workspace/preview.d.ts +28 -0
  173. package/dist/workspace/preview.js +110 -0
  174. package/dist/workspace/preview.js.map +1 -0
  175. package/dist/workspace/rollback.d.ts +5 -4
  176. package/dist/workspace/rollback.js +12 -9
  177. package/dist/workspace/rollback.js.map +1 -1
  178. package/dist/workspace/upload.d.ts +2 -0
  179. package/dist/workspace/upload.js +4 -1
  180. package/dist/workspace/upload.js.map +1 -1
  181. package/dist/workspace/workspaces.d.ts +7 -1
  182. package/dist/workspace/workspaces.js +30 -21
  183. package/dist/workspace/workspaces.js.map +1 -1
  184. package/dist/workspace/worktrees.d.ts +2 -4
  185. package/dist/workspace/worktrees.js +28 -33
  186. package/dist/workspace/worktrees.js.map +1 -1
  187. package/package.json +4 -2
  188. package/sidecar/scripts/replay_sidecar.py +4 -2
  189. package/sidecar/talaria_sidecar/__init__.py +1 -1
  190. package/sidecar/talaria_sidecar/__main__.py +7 -1
  191. package/sidecar/talaria_sidecar/home.py +98 -2
  192. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  193. package/sidecar/talaria_sidecar/methods/aux.py +19 -3
  194. package/sidecar/talaria_sidecar/methods/chat.py +551 -61
  195. package/sidecar/talaria_sidecar/methods/commands.py +61 -33
  196. package/sidecar/talaria_sidecar/methods/config.py +14 -10
  197. package/sidecar/talaria_sidecar/methods/cron.py +10 -6
  198. package/sidecar/talaria_sidecar/methods/kanban.py +115 -15
  199. package/sidecar/talaria_sidecar/methods/oauth.py +439 -0
  200. package/sidecar/talaria_sidecar/methods/process.py +60 -1
  201. package/sidecar/talaria_sidecar/methods/profiles.py +12 -5
  202. package/sidecar/talaria_sidecar/methods/providers.py +161 -1
  203. package/sidecar/talaria_sidecar/methods/runtime.py +5 -0
  204. package/sidecar/talaria_sidecar/methods/text.py +18 -21
  205. package/sidecar/talaria_sidecar/methods/usage.py +264 -5
  206. package/static/dist/FILES.txt +126 -121
  207. package/static/dist/assets/AppShell-fb2td9zi.js +1 -0
  208. package/static/dist/assets/ChatPage-cj96kiy9.js +25 -0
  209. package/static/dist/assets/Dialog-kxcg7ard.js +1 -0
  210. package/static/dist/assets/ExtensionRoute-jf7h0754.js +1 -0
  211. package/static/dist/assets/HubRoute-gmanpd5y.js +2 -0
  212. package/static/dist/assets/{Markdown-dy8t4dy7.js → Markdown-mdwlvj3w.js} +125 -125
  213. package/static/dist/assets/RightPanel-ftvrjlcp.js +1 -0
  214. package/static/dist/assets/Select-bilaypej.js +1 -0
  215. package/static/dist/assets/SessionListPanel-og8n36qs.js +1 -0
  216. package/static/dist/assets/SettingsLayout-jonjefmj.js +1 -0
  217. package/static/dist/assets/States-nmejrwrm.js +1 -0
  218. package/static/dist/assets/Toaster-nnl8zkt9.js +1 -0
  219. package/static/dist/assets/_app-gdidzd94.js +1 -0
  220. package/static/dist/assets/_app.ext._extensionId-opyh9y0v.js +1 -0
  221. package/static/dist/assets/_app.index-hbarzbjx.js +1 -0
  222. package/static/dist/assets/_app.insights-luywlarz.js +1 -0
  223. package/static/dist/assets/_app.kanban-kincnbr8.js +1 -0
  224. package/static/dist/assets/_app.logs-oi2gr3gs.js +1 -0
  225. package/static/dist/assets/_app.memory-b6jfivt6.js +1 -0
  226. package/static/dist/assets/_app.profiles-lpvixmqp.js +1 -0
  227. package/static/dist/assets/{_app.session._sessionId-gyoh5ylx.js → _app.session._sessionId-bfd4h3id.js} +1 -1
  228. package/static/dist/assets/_app.settings-vowx3iq0.js +1 -0
  229. package/static/dist/assets/_app.settings._section-cnntg1tu.js +3 -0
  230. package/static/dist/assets/_app.skills-fb57lpau.js +1 -0
  231. package/static/dist/assets/_app.tasks-eszpx1q7.js +2 -0
  232. package/static/dist/assets/_app.todos-nr05ac6w.js +1 -0
  233. package/static/dist/assets/_app.workspaces-d2luomu6.js +1 -0
  234. package/static/dist/assets/{abnfDiagram-VCTEODGH-cjqletra.js → abnfDiagram-VCTEODGH-b2x1lqot.js} +1 -1
  235. package/static/dist/assets/architecture-7GRP2DOG-hd0o7bde.js +1 -0
  236. package/static/dist/assets/architectureDiagram-5GKGNRK7-h38o5l7b.js +36 -0
  237. package/static/dist/assets/{back-e4m6nsnm.js → back-bemaer09.js} +1 -1
  238. package/static/dist/assets/{blockDiagram-I7D4REHJ-b195z021.js → blockDiagram-I7D4REHJ-bqquxv2u.js} +1 -1
  239. package/static/dist/assets/{c4Diagram-7LVT6UL2-olu212k1.js → c4Diagram-7LVT6UL2-f3fb8w6z.js} +1 -1
  240. package/static/dist/assets/cancel-drd0g6d9.js +1 -0
  241. package/static/dist/assets/channel-cpdgf867.js +1 -0
  242. package/static/dist/assets/{chunk-4HAMMTFA-b8o1nufs.js → chunk-4HAMMTFA-bzfyyzp3.js} +1 -1
  243. package/static/dist/assets/{chunk-75Z2AOVW-l1seddto.js → chunk-75Z2AOVW-ly99rfx5.js} +1 -1
  244. package/static/dist/assets/{chunk-DU6HZSFF-lo5zg15v.js → chunk-DU6HZSFF-bg6ecfqf.js} +3 -3
  245. package/static/dist/assets/{chunk-F27PBJKO-dsdzrp16.js → chunk-F27PBJKO-e8k7mmqw.js} +1 -1
  246. package/static/dist/assets/{chunk-GMAD6QVW-f3fxqq8b.js → chunk-GMAD6QVW-cmalnoaj.js} +1 -1
  247. package/static/dist/assets/{chunk-GVQU2GXP-h8egxtf6.js → chunk-GVQU2GXP-gmif5o01.js} +1 -1
  248. package/static/dist/assets/{chunk-IMKFNOWR-iolwd8s0.js → chunk-IMKFNOWR-f1nwwh6u.js} +1 -1
  249. package/static/dist/assets/{chunk-L3NEJ4N5-b88bbtwi.js → chunk-L3NEJ4N5-oktfh5v7.js} +1 -1
  250. package/static/dist/assets/{chunk-OSK3NFVY-e3e7nr6s.js → chunk-OSK3NFVY-b1sdx96z.js} +1 -1
  251. package/static/dist/assets/{chunk-P2QGCYS3-mdc46rt1.js → chunk-P2QGCYS3-fhu4nzxt.js} +1 -1
  252. package/static/dist/assets/{chunk-POPQ4Y6H-kqlllrrm.js → chunk-POPQ4Y6H-em31rnef.js} +1 -1
  253. package/static/dist/assets/{chunk-PWAF6VOD-he585k2x.js → chunk-PWAF6VOD-bxhhyftw.js} +1 -1
  254. package/static/dist/assets/{chunk-SHT3W25Y-gh94y9a4.js → chunk-SHT3W25Y-fmrvwcav.js} +3 -3
  255. package/static/dist/assets/{chunk-SVP7TREG-euxp9y7e.js → chunk-SVP7TREG-lymukl2j.js} +1 -1
  256. package/static/dist/assets/{chunk-TICWLB2K-c8cxes1q.js → chunk-TICWLB2K-lmza6k23.js} +1 -1
  257. package/static/dist/assets/classDiagram-ZZMXUADV-ko09sguk.js +1 -0
  258. package/static/dist/assets/classDiagram-v2-VYDZK3BY-ko09sguk.js +1 -0
  259. package/static/dist/assets/{copied-kq0u3ht7.js → copied-ima3pysb.js} +1 -1
  260. package/static/dist/assets/copy-fw5b1pc6.js +1 -0
  261. package/static/dist/assets/{cynefin-OW5HDTMX-lxd44ux4.js → cynefin-OW5HDTMX-czlhbyhs.js} +1 -1
  262. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-njpiwat8.js → cynefinDiagram-5FMLGOSQ-gq7mvswb.js} +1 -1
  263. package/static/dist/assets/{dagre-GXQ25YYZ-nvgy6yju.js → dagre-GXQ25YYZ-cqdbf7dr.js} +4 -4
  264. package/static/dist/assets/{diagram-S7CK7UJ4-g98nyv72.js → diagram-S7CK7UJ4-o6arxa58.js} +3 -3
  265. package/static/dist/assets/{diagram-UQ7AKVKN-ovgm6lvv.js → diagram-UQ7AKVKN-nhnd68tz.js} +1 -1
  266. package/static/dist/assets/{diagram-VSXAHHWV-pb2bn2e4.js → diagram-VSXAHHWV-dk2sclzn.js} +1 -1
  267. package/static/dist/assets/{diagram-VX7I27RA-6j4mvfqo.js → diagram-VX7I27RA-n3k5nyx3.js} +1 -1
  268. package/static/dist/assets/{diagram-Z3DM3KII-dqmb982r.js → diagram-Z3DM3KII-m6q9cwdh.js} +1 -1
  269. package/static/dist/assets/{ebnfDiagram-PWID7BFC-ozbpzb6d.js → ebnfDiagram-PWID7BFC-lsa5641p.js} +1 -1
  270. package/static/dist/assets/endpoints-d33siokx.js +64 -0
  271. package/static/dist/assets/{erDiagram-RLTQ6QDP-hl7m8vua.js → erDiagram-RLTQ6QDP-fo2ckbe6.js} +1 -1
  272. package/static/dist/assets/eventmodeling-NTZA5JFV-emctlo64.js +1 -0
  273. package/static/dist/assets/flowDiagram-HODETNUW-bfi3moqa.js +1 -0
  274. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-ompixqmh.js → ganttDiagram-EL5Y4UJY-nzqz6x9g.js} +1 -1
  275. package/static/dist/assets/{gitGraph-4MIJSDKK-b6vxwp9q.js → gitGraph-4MIJSDKK-gukvcxe6.js} +1 -1
  276. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-k98f57on.js → gitGraphDiagram-WWUBYQGX-lqhdp8f0.js} +1 -1
  277. package/static/dist/assets/{highlighted-body-KPVGNVTW-cd3ff6ge.js → highlighted-body-KPVGNVTW-igw6cp39.js} +1 -1
  278. package/static/dist/assets/index-cxq58xb1.js +11 -0
  279. package/static/dist/assets/index-hgtx08n5.css +2 -0
  280. package/static/dist/assets/{info-A6RAGUB7-hjcwjami.js → info-A6RAGUB7-gvu7a93l.js} +1 -1
  281. package/static/dist/assets/infoDiagram-27XIBGKW-m8t8g6w1.js +2 -0
  282. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-bxak4mwu.js → ishikawaDiagram-5VMMS53U-l07vkbgj.js} +1 -1
  283. package/static/dist/assets/{journeyDiagram-3NMN7TZE-khmihclr.js → journeyDiagram-3NMN7TZE-hybkgj8l.js} +1 -1
  284. package/static/dist/assets/{kanban-definition-UXKFOSKX-h1r32wdx.js → kanban-definition-UXKFOSKX-by13uvtn.js} +1 -1
  285. package/static/dist/assets/{lazyRouteComponent-b2eg0fws.js → lazyRouteComponent-af0lvgjx.js} +1 -1
  286. package/static/dist/assets/{line-friyjof3.js → line-osxq6fq7.js} +1 -1
  287. package/static/dist/assets/link-jyxicdhg.js +1 -0
  288. package/static/dist/assets/login-ec21t39x.js +1 -0
  289. package/static/dist/assets/mermaid-HWGCJPDP-gfl8z3ep.js +1 -0
  290. package/static/dist/assets/{mermaid-parser.core-f1gvpwrt.js → mermaid-parser.core-i91oxmou.js} +3 -3
  291. package/static/dist/assets/{mindmap-definition-YA3MSWOX-mt2oxwue.js → mindmap-definition-YA3MSWOX-m82mx8ww.js} +1 -1
  292. package/static/dist/assets/onboarding-btnltvyz.js +1 -0
  293. package/static/dist/assets/onboarding_model_label-dp7xffoh.js +1 -0
  294. package/static/dist/assets/{packet-AYTQ26CC-jik4ixlf.js → packet-AYTQ26CC-nbtgge2o.js} +1 -1
  295. package/static/dist/assets/{pegDiagram-XKGWAZYB-erxlv6cc.js → pegDiagram-XKGWAZYB-f1zxzga1.js} +1 -1
  296. package/static/dist/assets/{pie-WAS4IAKB-mnkoldfl.js → pie-WAS4IAKB-of8vuhxs.js} +1 -1
  297. package/static/dist/assets/{pieDiagram-E7YTZNPT-i7lwrada.js → pieDiagram-E7YTZNPT-o7y8ftio.js} +3 -3
  298. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-fr8ejdoy.js → quadrantDiagram-AXDQQJYC-iqiwia1t.js} +1 -1
  299. package/static/dist/assets/{radar-RG4KPBEZ-otarjte2.js → radar-RG4KPBEZ-delnsy2v.js} +1 -1
  300. package/static/dist/assets/{railroad-74A4TZTK-lw362oto.js → railroad-74A4TZTK-nspnyq9u.js} +1 -1
  301. package/static/dist/assets/railroad-abnf-HS5TGJTU-nst1qxyn.js +1 -0
  302. package/static/dist/assets/railroad-ebnf-LZEXJU2U-ci37x8qy.js +1 -0
  303. package/static/dist/assets/railroad-peg-WCYAUIDC-aimkk07z.js +1 -0
  304. package/static/dist/assets/{railroadDiagram-O6MQD6OU-esyn75pw.js → railroadDiagram-O6MQD6OU-z41tqx5z.js} +1 -1
  305. package/static/dist/assets/refresh-b4uams1i.js +1 -0
  306. package/static/dist/assets/refresh-cw-nt1dtsx4.js +1 -0
  307. package/static/dist/assets/{requirementDiagram-BXWQKSXE-cyhi5u29.js → requirementDiagram-BXWQKSXE-bdvif194.js} +1 -1
  308. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-jljpbo0h.js → sankeyDiagram-P5KCCOFB-h73e0rol.js} +1 -1
  309. package/static/dist/assets/{saved-do49si3v.js → saved-kg52csy7.js} +1 -1
  310. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-fz0dhlu7.js → sequenceDiagram-WJ2MYXX4-iv95mb1z.js} +1 -1
  311. package/static/dist/assets/{settings_section_extensions_title-b7lojn0o.js → settings_section_extensions_title-ej0iapxe.js} +1 -1
  312. package/static/dist/assets/share._token-f97e94r0.js +2 -0
  313. package/static/dist/assets/sliders-horizontal-fj18it47.js +1 -0
  314. package/static/dist/assets/sse-fwpqtzbg.js +1 -0
  315. package/static/dist/assets/{stateDiagram-D77RDMKH-kdnithdx.js → stateDiagram-D77RDMKH-k417ixmf.js} +1 -1
  316. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-hwn4apct.js +1 -0
  317. package/static/dist/assets/{swimlanes-42K2YHIH-esd6gmz3.js → swimlanes-42K2YHIH-esola70w.js} +1 -1
  318. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-d1h97oyg.js +8 -0
  319. package/static/dist/assets/{timeline-definition-24CTP7MA-g0qygccv.js → timeline-definition-24CTP7MA-getrz2y8.js} +1 -1
  320. package/static/dist/assets/todoStore-eginf9h4.js +1 -0
  321. package/static/dist/assets/{treeView-Q6P3EWNA-ndchzkuu.js → treeView-Q6P3EWNA-hc7ap1q7.js} +1 -1
  322. package/static/dist/assets/{treemap-WGGIJYW6-hwjxj0zz.js → treemap-WGGIJYW6-b7k1syy5.js} +1 -1
  323. package/static/dist/assets/tts-lxdxnmjd.js +1 -0
  324. package/static/dist/assets/{untitled-mbr6tuhu.js → untitled-ovpfthg4.js} +1 -1
  325. package/static/dist/assets/useForm-bcblv1rx.js +1 -0
  326. package/static/dist/assets/useLocale-fi5jb8ma.js +1 -0
  327. package/static/dist/assets/{useParams-lo9u33d1.js → useParams-lhg566ew.js} +1 -1
  328. package/static/dist/assets/{useSearch-lq1mr090.js → useSearch-lbrh6ejn.js} +1 -1
  329. package/static/dist/assets/{vennDiagram-4TSXK5OY-gubovd1r.js → vennDiagram-4TSXK5OY-n6pcy5qb.js} +1 -1
  330. package/static/dist/assets/{wardley-WFR3VGLG-ir3om65j.js → wardley-WFR3VGLG-lpa18vz1.js} +1 -1
  331. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-ce0or1ie.js → wardleyDiagram-VM6X3IG4-kpclwwg7.js} +2 -2
  332. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-dexylcjg.js → xychartDiagram-S5SC5T6Z-paa2n8y0.js} +1 -1
  333. package/static/dist/index.html +1 -1
  334. package/static/dist/sw.js +2 -2
  335. package/static/dist/assets/AppShell-orat1s43.js +0 -1
  336. package/static/dist/assets/ChatPage-ojhka3hy.js +0 -22
  337. package/static/dist/assets/HubRoute-gxgw1t33.js +0 -2
  338. package/static/dist/assets/RightPanel-c66ani1s.js +0 -1
  339. package/static/dist/assets/Select-fswf66up.js +0 -1
  340. package/static/dist/assets/SessionListPanel-goh5mtt8.js +0 -1
  341. package/static/dist/assets/SettingsLayout-n5d4wq4m.js +0 -1
  342. package/static/dist/assets/States-kxyu50at.js +0 -1
  343. package/static/dist/assets/Toaster-6zmbcqgg.js +0 -1
  344. package/static/dist/assets/_app-fowyruiu.js +0 -1
  345. package/static/dist/assets/_app.ext._extensionId-nvjyrwhy.js +0 -1
  346. package/static/dist/assets/_app.index-c9zv4y7w.js +0 -1
  347. package/static/dist/assets/_app.insights-jf12nofo.js +0 -1
  348. package/static/dist/assets/_app.kanban-lri5a4yu.js +0 -1
  349. package/static/dist/assets/_app.logs-md1p0zqo.js +0 -1
  350. package/static/dist/assets/_app.memory-bzvf2e28.js +0 -1
  351. package/static/dist/assets/_app.profiles-jpg7zmbn.js +0 -1
  352. package/static/dist/assets/_app.settings-mpuwlx4j.js +0 -1
  353. package/static/dist/assets/_app.settings._section-knz8rwoj.js +0 -1
  354. package/static/dist/assets/_app.skills-fwu1dkir.js +0 -1
  355. package/static/dist/assets/_app.tasks-h9gphtl3.js +0 -2
  356. package/static/dist/assets/_app.todos-hd1dnvrh.js +0 -1
  357. package/static/dist/assets/_app.workspaces-l8toyn5f.js +0 -1
  358. package/static/dist/assets/architecture-7GRP2DOG-cavnusaw.js +0 -1
  359. package/static/dist/assets/architectureDiagram-5GKGNRK7-k5a7v36n.js +0 -36
  360. package/static/dist/assets/channel-gozy3dp3.js +0 -1
  361. package/static/dist/assets/classDiagram-ZZMXUADV-n0vs2f2q.js +0 -1
  362. package/static/dist/assets/classDiagram-v2-VYDZK3BY-n0vs2f2q.js +0 -1
  363. package/static/dist/assets/endpoints-k8agnro8.js +0 -9
  364. package/static/dist/assets/eventmodeling-NTZA5JFV-j1y2ekec.js +0 -1
  365. package/static/dist/assets/flowDiagram-HODETNUW-g2cvcbm5.js +0 -1
  366. package/static/dist/assets/index-nfy0ezs6.css +0 -2
  367. package/static/dist/assets/index-nqed3oum.js +0 -11
  368. package/static/dist/assets/infoDiagram-27XIBGKW-fm06rj36.js +0 -2
  369. package/static/dist/assets/link-m90ahx3n.js +0 -1
  370. package/static/dist/assets/login-bw7m2fzo.js +0 -1
  371. package/static/dist/assets/matchContext-f8oy3lo7.js +0 -1
  372. package/static/dist/assets/mermaid-HWGCJPDP-njwcl62n.js +0 -1
  373. package/static/dist/assets/onboarding-ret37j1e.js +0 -1
  374. package/static/dist/assets/railroad-abnf-HS5TGJTU-m741owar.js +0 -1
  375. package/static/dist/assets/railroad-ebnf-LZEXJU2U-noa4fbia.js +0 -1
  376. package/static/dist/assets/railroad-peg-WCYAUIDC-o4bpw4nb.js +0 -1
  377. package/static/dist/assets/refresh-cw-k66keho6.js +0 -1
  378. package/static/dist/assets/search-j5ki6a9o.js +0 -1
  379. package/static/dist/assets/share._token-kg8w647b.js +0 -2
  380. package/static/dist/assets/sliders-horizontal-c4wya4dy.js +0 -1
  381. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-k2wg2645.js +0 -1
  382. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-n5kvjtgr.js +0 -8
  383. package/static/dist/assets/tabBuild-bjmyvmn5.js +0 -56
  384. package/static/dist/assets/todoStore-kjk5l0sy.js +0 -1
  385. package/static/dist/assets/useForm-ocxu460f.js +0 -1
  386. package/static/dist/assets/useLocale-fzy5jhg6.js +0 -1
@@ -8,17 +8,17 @@
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';
21
- /** Python `_model_matches_picker_selection`: same bare id, and the routing hints agree when both name one. */
21
+ /** Python `_model_matches_picker_selection`: same bare id, and the routing hints agree when the selection names one. */
22
22
  function modelMatchesPickerSelection(modelId, selected, providerId) {
23
23
  const candidate = modelId.trim();
24
24
  const sel = selected.trim();
@@ -26,12 +26,80 @@ function modelMatchesPickerSelection(modelId, selected, providerId) {
26
26
  return false;
27
27
  if (candidate === sel)
28
28
  return true;
29
- const bare = (v) => (v.startsWith('@') && v.includes(':') ? v.slice(v.indexOf(':') + 1) : v);
30
- if (bare(sel) !== bare(candidate))
31
- return false;
32
- const selProvider = sel.startsWith('@') && sel.includes(':') ? sel.slice(1, sel.indexOf(':')).toLowerCase() : '';
33
- const candProvider = candidate.startsWith('@') && candidate.includes(':') ? candidate.slice(1, candidate.indexOf(':')).toLowerCase() : providerId.trim().toLowerCase();
34
- return !selProvider || !candProvider || selProvider === candProvider;
29
+ const [candBare, candProvider] = parseProviderQualifiedModel(candidate) ?? [candidate, providerId.trim()];
30
+ const [selBare, selProvider] = parseProviderQualifiedModel(sel) ?? [sel, ''];
31
+ return selBare === candBare && (!selProvider || selProvider.toLowerCase() === candProvider.toLowerCase());
32
+ }
33
+ /** TAL-301: every entry names its routing provider and bare id, split once by `parseProviderQualifiedModel`. */
34
+ function stampModelEntries(models, providerId) {
35
+ return models.map((m) => {
36
+ const [bare, provider] = parseProviderQualifiedModel(m.id) ?? [m.id, providerId];
37
+ return { ...m, bare_id: bare, provider_id: provider };
38
+ });
39
+ }
40
+ /** TAL-301: the catalog entry a stored `(model, provider)` pair selects, so clients render the pairing instead of computing it. */
41
+ export function catalogOptionId(catalog, model, provider) {
42
+ const bare = str(model).trim();
43
+ const pid = str(provider).trim().toLowerCase();
44
+ if (!bare || !pid)
45
+ return null;
46
+ for (const g of catalog.groups)
47
+ for (const e of [...g.models, ...(g.extra_models ?? [])])
48
+ if (e.bare_id === bare && str(e.provider_id).toLowerCase() === pid)
49
+ return e.id;
50
+ return null;
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;
35
103
  }
36
104
  /** Python `_split_picker_overflow_models`: past 25 rows the picker shows 15, keeping the selected model visible. */
37
105
  export function splitPickerOverflow(models, selected, providerId) {
@@ -82,20 +150,196 @@ export function stampAuxiliarySelections(aux, catalog) {
82
150
  }
83
151
  /** Python `_BESPOKE_CATALOG_PROVIDERS`: cards whose catalog is resolved by their own rule, never the generic live probe. */
84
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-570: Ollama and LM Studio run keyless, so a configured endpoint makes them ready the way a key does. */
160
+ const selfHostedEndpoint = (pid, config) => SELF_HOSTED_PROVIDER_IDS.has(pid) && Boolean(configuredBaseUrl(pid, config));
85
161
  /** Python `_unqualified_model_id`: strip a picker routing hint (`@provider:`). */
86
- function unqualifiedModelId(id) {
87
- const raw = id.trim();
88
- return raw.startsWith('@') && raw.includes(':') ? raw.slice(raw.indexOf(':') + 1) : raw;
89
- }
162
+ const unqualifiedModelId = (id) => parseProviderQualifiedModel(id)?.[0] ?? id.trim();
90
163
  /** Python `_MODEL_PICKER_OVERFLOW_THRESHOLD` / `_MODEL_PICKER_VISIBLE_TARGET`. */
91
164
  const MODEL_PICKER_OVERFLOW_THRESHOLD = 25;
92
165
  const MODEL_PICKER_VISIBLE_TARGET = 15;
93
166
  const ACCOUNT_USAGE_CACHE_TTL_S = 45;
94
167
  const LIVE_TTL_S = 86_400;
95
168
  const PROVIDERS_TTL_S = 30;
96
- const QUOTA_TIMEOUT_MS = 15_000;
97
- const OPENROUTER_KEY_URL = 'https://openrouter.ai/api/v1/key';
169
+ /** The sidecar reads a balance endpoint with a 15 s timeout of its own. */
170
+ const BALANCE_TIMEOUT_MS = 20_000;
171
+ const PLUGIN_LIST_TIMEOUT_MS = 15_000;
172
+ /** TAL-288: the card text for a plugin provider the Agent does not report ready. */
173
+ const PLUGIN_SETUP_ERRORS = {
174
+ missing_cli: "This provider's CLI was not found on the server. Install it and sign in, then refresh.",
175
+ needs_setup: "This provider is not set up yet. Finish its setup in Hermes, then refresh.",
176
+ not_loaded: 'This provider is installed but could not be loaded. Check it with hermes plugins doctor, then restart Talaria Web.',
177
+ unavailable: "This provider's setup status is unavailable.",
178
+ };
179
+ const PLUGIN_NO_MODELS = 'This provider listed no models.';
98
180
  const COST_SNAPSHOT_MAX_DAYS = 365;
181
+ /**
182
+ * The last `days` daily snapshots with their spend deltas, bar heights, monthly pace, and budget standing (the legacy
183
+ * Web chart's formulas). A drop in `used` is a credit reset, so that day's delta is its `used`.
184
+ */
185
+ function costHistoryView(rows, days, budget) {
186
+ const window = rows.slice(-days);
187
+ const deltas = window.map((e, i) => {
188
+ const prev = window[i - 1];
189
+ if (i === 0 || e.used === null || prev?.used === null || prev?.used === undefined)
190
+ return null;
191
+ const delta = e.used - prev.used < 0 ? e.used : e.used - prev.used;
192
+ return Math.abs(delta) < 1e-9 ? 0 : Math.round(delta * 1e6) / 1e6;
193
+ });
194
+ const known = deltas.filter((d) => d !== null);
195
+ const maxDelta = Math.max(...deltas.map((d) => d ?? 0), 1e-9);
196
+ const pace = known.length ? (known.reduce((a, b) => a + b, 0) / known.length) * 30 : null;
197
+ const budgetPercent = pace !== null && pace > 0 && budget !== null && budget > 0 ? Math.round((pace / budget) * 100) : null;
198
+ return {
199
+ snapshots: window.map((e, i) => {
200
+ const delta = deltas[i] ?? null;
201
+ 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)) };
202
+ }),
203
+ monthly_pace: pace === null ? null : Math.round(pace * 100) / 100,
204
+ has_enough_data: known.length > 0,
205
+ budget_percent: budgetPercent,
206
+ budget_level: budgetPercent === null ? null : budgetPercent >= 100 ? 'over' : budgetPercent >= 80 ? 'warn' : 'ok',
207
+ };
208
+ }
209
+ const FIVE_HOUR_WINDOW_S = 18_000;
210
+ const WEEK_WINDOW_S = 604_800;
211
+ /** Swift `rounded()`: half away from zero. */
212
+ function roundTo(value, digits) {
213
+ const scale = 10 ** digits;
214
+ return (Math.sign(value) * Math.round(Math.abs(value) * scale)) / scale;
215
+ }
216
+ const finite = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
217
+ /** ISO-8601 UTC for epoch seconds, without a `.000` fraction. */
218
+ export const isoAt = (seconds) => new Date(seconds * 1000).toISOString().replace('.000Z', 'Z');
219
+ /** ISO-8601 UTC (`…Z`) or null. Accepts the pre-TAL-409 sidecar's `str(datetime)` space form; a zone-less stamp is UTC. */
220
+ export function isoUtc(value) {
221
+ if (typeof value !== 'string' || !value.trim())
222
+ return null;
223
+ let text = value.trim().replace(' ', 'T');
224
+ if (text.includes('T') && !/(Z|[+-]\d{2}:?\d{2})$/i.test(text))
225
+ text += 'Z';
226
+ const ms = Date.parse(text);
227
+ return Number.isFinite(ms) ? isoAt(ms / 1000) : null;
228
+ }
229
+ /** The forecast a pace implies: per-hour or per-day budget until reset, and whether the projection empties the window first. */
230
+ export function quotaForecast(pace, remaining) {
231
+ const daily = pace.minutes_to_reset >= 24 * 60;
232
+ const margin = pace.projected_minutes_to_empty === null ? null : pace.projected_minutes_to_empty - pace.minutes_to_reset;
233
+ return {
234
+ outcome: margin === null || margin >= 0 ? 'safe' : 'warning',
235
+ budget_unit: daily ? 'day' : 'hour',
236
+ budget_percent: remaining !== null && pace.minutes_to_reset > 0 ? remaining / (pace.minutes_to_reset / (daily ? 24 * 60 : 60)) : null,
237
+ depletion_margin_minutes: margin,
238
+ };
239
+ }
240
+ /**
241
+ * TAL-409: the one normaliser for account-usage windows (Python `_snapshot_windows_payload` plus the pace math the
242
+ * App used to run). Blank labels drop; `reset_at` becomes ISO `Z`; each window gets its duration, pace and forecast
243
+ * as of `nowSeconds`; the indexes name the windows the App shows for pace, session and weekly selections.
244
+ */
245
+ export function normalizeQuotaWindows(raw, nowSeconds) {
246
+ const nowMs = nowSeconds * 1000;
247
+ const windows = [];
248
+ for (const entry of Array.isArray(raw) ? raw : []) {
249
+ if (!isDict(entry))
250
+ continue;
251
+ const label = str(entry.label).trim();
252
+ if (!label)
253
+ continue;
254
+ const usedRaw = finite(entry.used_percent);
255
+ const remainingRaw = finite(entry.remaining_percent);
256
+ const clamp = (v) => Math.min(Math.max(v, 0), 100);
257
+ const used = usedRaw !== null ? clamp(usedRaw) : remainingRaw !== null ? clamp(100 - remainingRaw) : null;
258
+ const remaining = used === null ? null : 100 - used;
259
+ const resetAt = isoUtc(entry.reset_at);
260
+ const resetMs = resetAt === null ? null : Date.parse(resetAt);
261
+ const minutesToReset = resetMs === null ? null : Math.max(0, roundTo((resetMs - nowMs) / 60_000, 0));
262
+ const lower = label.toLowerCase();
263
+ const providedSeconds = finite(entry.window_seconds);
264
+ const windowSeconds = providedSeconds
265
+ ?? (lower.includes('week') ? WEEK_WINDOW_S
266
+ : lower.includes('5h') ? FIVE_HOUR_WINDOW_S
267
+ // A "Session" window is 5h, or the weekly window a provider collapsed into one session bucket.
268
+ : lower.includes('session') && minutesToReset !== null ? (minutesToReset > 5 * 60 ? WEEK_WINDOW_S : FIVE_HOUR_WINDOW_S)
269
+ : null);
270
+ let pace = null;
271
+ if (resetAt !== null && resetMs !== null && minutesToReset !== null && resetMs > nowMs && used !== null && remaining !== null
272
+ && (windowSeconds === FIVE_HOUR_WINDOW_S || windowSeconds === WEEK_WINDOW_S)) {
273
+ const windowMinutes = windowSeconds / 60;
274
+ const elapsed = Math.max(0, windowMinutes - minutesToReset);
275
+ const expectedRemaining = roundTo(Math.min(Math.max((minutesToReset / windowMinutes) * 100, 0), 100), 1);
276
+ const expectedUsed = roundTo(100 - expectedRemaining, 1);
277
+ const usagePerMinute = elapsed > 0 ? used / elapsed : 0;
278
+ pace = {
279
+ expected_remaining_percent: expectedRemaining,
280
+ pace_delta_percent: roundTo(remaining - expectedRemaining, 1),
281
+ burn_rate: expectedUsed > 0 ? roundTo(used / expectedUsed, 2) : 0,
282
+ minutes_to_reset: minutesToReset,
283
+ projected_minutes_to_empty: usagePerMinute > 0 ? roundTo(remaining / usagePerMinute, 0) : null,
284
+ elapsed_minutes: elapsed,
285
+ valid_until: resetAt,
286
+ };
287
+ }
288
+ windows.push({
289
+ label, used_percent: usedRaw, remaining_percent: remaining, reset_at: resetAt, detail: typeof entry.detail === 'string' ? entry.detail : null,
290
+ window_seconds: windowSeconds, pace, forecast: pace ? quotaForecast(pace, remaining) : null,
291
+ });
292
+ }
293
+ const index = (match) => { const i = windows.findIndex(match); return i < 0 ? null : i; };
294
+ const labelHas = (needle) => (w) => w.label.toLowerCase().includes(needle);
295
+ const knownPace = (w) => w.window_seconds === FIVE_HOUR_WINDOW_S || w.window_seconds === WEEK_WINDOW_S || ['week', 'session', '5h'].some((n) => labelHas(n)(w));
296
+ return {
297
+ windows,
298
+ pace_window_index: index(labelHas('week')) ?? index(knownPace),
299
+ session_window_index: index(labelHas('session')) ?? index(labelHas('5h')),
300
+ weekly_window_index: index(labelHas('week')),
301
+ };
302
+ }
303
+ /** The burn-rate breakpoints apply once the window is old and used enough and the projection empties it before reset. */
304
+ function projectionEligible(pace, usedPercent, minimumElapsedHours) {
305
+ return pace.elapsed_minutes >= Math.max(0, minimumElapsedHours) * 60 && usedPercent >= 5 && pace.minutes_to_reset > 20
306
+ && (pace.projected_minutes_to_empty ?? Infinity) < pace.minutes_to_reset;
307
+ }
308
+ /** The App's former `ProviderQuotaUrgencyCalculator.urgency` for one colour basis; a pace basis without a pace falls back to remaining. */
309
+ function quotaLevel(window, status, basis, t) {
310
+ if (status === 'stale')
311
+ return 'stale';
312
+ const remaining = window?.remaining_percent ?? null;
313
+ if (status !== 'available' || remaining === null)
314
+ return status === 'available' ? 'healthy' : 'unavailable';
315
+ const pace = window?.pace ?? null;
316
+ if (basis === 'pace' && pace) {
317
+ const eligible = projectionEligible(pace, 100 - remaining, t.pace_minimum_elapsed_hours);
318
+ if (eligible && pace.burn_rate >= Math.max(0, t.pace_critical_burn_rate_percent) / 100)
319
+ return 'critical';
320
+ if (eligible && pace.burn_rate >= Math.max(0, t.pace_warning_burn_rate_percent) / 100)
321
+ return 'warning';
322
+ return pace.pace_delta_percent <= -Math.max(0, t.pace_tolerance_percent) ? 'warning' : 'healthy';
323
+ }
324
+ if (remaining <= Math.max(0, t.critical_remaining_percent))
325
+ return 'critical';
326
+ if (remaining <= Math.max(0, t.warning_remaining_percent))
327
+ return 'warning';
328
+ return 'healthy';
329
+ }
330
+ export function classifyQuotaSource(source, t) {
331
+ const urgency = (w) => ({ remaining: quotaLevel(w, source.status, 'remaining', t), pace: quotaLevel(w, source.status, 'pace', t) });
332
+ return {
333
+ ...source,
334
+ windows: source.windows.map((w) => ({
335
+ ...w,
336
+ 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' },
337
+ projection_eligible: w.pace !== null && w.remaining_percent !== null && projectionEligible(w.pace, 100 - w.remaining_percent, t.pace_minimum_elapsed_hours),
338
+ urgency: urgency(w),
339
+ })),
340
+ urgency: { remaining: urgency(source.windows[0]).remaining, pace: urgency(source.windows[source.pace_window_index ?? 0]).pace },
341
+ };
342
+ }
99
343
  export function displayName(pid) {
100
344
  return PROVIDER_DISPLAY[pid] ?? pid.split('-').map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
101
345
  }
@@ -115,9 +359,7 @@ export function formatOllamaLabel(mid) {
115
359
  }
116
360
  /** Python `_get_label_for_model`: reuse a known label, else prettify the bare id. */
117
361
  export function labelForModel(modelId, groups) {
118
- let lookup = modelId;
119
- if (lookup.startsWith('@') && lookup.includes(':'))
120
- lookup = lookup.slice(lookup.indexOf(':') + 1);
362
+ const lookup = unqualifiedModelId(modelId);
121
363
  const hasScheme = (s) => s.includes('://');
122
364
  const norm = (s) => (s.includes('/') && !hasScheme(s) ? s.slice(s.indexOf('/') + 1) : s).replaceAll('-', '.').toLowerCase();
123
365
  const target = norm(lookup);
@@ -186,12 +428,44 @@ export function deduplicateModelIds(groups) {
186
428
  m.id = `@${groups[gi].provider_id}:${m.id}`;
187
429
  }
188
430
  }
431
+ /** OpenCode Go's usage windows: their label and length (Python `_sanitize_opencode_go_account_limits`). */
432
+ const OPENCODE_GO_WINDOWS = { rolling: ['5-hour', FIVE_HOUR_WINDOW_S], weekly: ['Weekly', WEEK_WINDOW_S], monthly: ['Monthly', null] };
433
+ /** One pool account's local state as account limits; `status` is the account's, which its quota source shows. */
434
+ function poolEntryLimits(entry, at) {
435
+ return {
436
+ source: 'local_pool', title: 'Credential pool', status: entry.status, available: entry.status === 'available', plan: null, details: [],
437
+ unavailable_reason: entry.unavailable_reason, retry_after: entry.retry_after, fetched_at: null, ...normalizeQuotaWindows([], at),
438
+ };
439
+ }
440
+ /** Python `_local_pool_snapshot`: a provider's pool accounts summarised as account limits. */
441
+ function poolLimits(entries, at) {
442
+ const count = (status) => entries.filter((e) => e.status === status).length;
443
+ const [available, exhausted, dead] = [count('available'), count('exhausted'), count('dead')];
444
+ const details = [`${String(available)}/${String(entries.length)} credentials available`, ...(exhausted ? [`${String(exhausted)} exhausted`] : []), ...(dead ? [`${String(dead)} dead`] : [])];
445
+ return {
446
+ source: 'local_pool', title: 'Credential pool', available: available > 0, plan: null, details, unavailable_reason: available ? null : 'All pool credentials are unavailable.', fetched_at: null,
447
+ ...normalizeQuotaWindows([], at),
448
+ };
449
+ }
450
+ /** Quota sources keep the first row per source id, ordered by provider id, account label, then source id (TAL-272). */
451
+ export function uniqueQuotaSources(sources) {
452
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
453
+ return sources.filter((q, i) => sources.findIndex((o) => o.source_id === q.source_id) === i)
454
+ .sort((a, b) => cmp(a.provider_id, b.provider_id) || cmp(a.account_label, b.account_label) || cmp(a.source_id, b.source_id));
455
+ }
189
456
  export class ProviderCatalog {
190
457
  deps;
458
+ /** Failed live lookups by home, provider, and the `sourceFingerprint` they ran under (TAL-542). */
191
459
  liveFailed = new Set();
460
+ /** `source`: the `sourceFingerprint` the ids were fetched under; another one makes them stale (TAL-542). */
192
461
  liveIds = new Map();
193
462
  liveInflight = new Map();
463
+ /** `pooled`: the fill's `poolProviders` answer, null when unknown (TAL-638). */
194
464
  providersCache = new Map();
465
+ /** TAL-301: the last catalog served per profile home, for the sync session payloads' `model_option_id`. */
466
+ lastModels = new Map();
467
+ /** TAL-542: per `lastModels` catalog, the `sourceFingerprint` it was built from, when, and the providers whose group came from a failed live lookup. */
468
+ lastModelsMeta = new Map();
195
469
  constructor(deps) {
196
470
  this.deps = deps;
197
471
  }
@@ -214,6 +488,86 @@ export class ProviderCatalog {
214
488
  this.liveIds.delete(key);
215
489
  }
216
490
  this.providersCache.clear();
491
+ // Credentials changed: the last pool answers no longer stand in for a failed lookup.
492
+ for (const key of [...this.lastPoolAnswers.keys()])
493
+ if (!profileHome || key.startsWith(`${profileHome}\0`))
494
+ this.lastPoolAnswers.delete(key);
495
+ // The picker catalog changed: option ids rebuild on the next read rather than pairing against the old one.
496
+ if (profileHome) {
497
+ this.lastModels.delete(profileHome);
498
+ this.lastModelsMeta.delete(profileHome);
499
+ }
500
+ else {
501
+ this.lastModels.clear();
502
+ this.lastModelsMeta.clear();
503
+ }
504
+ }
505
+ /** 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. */
506
+ modelOptionFor(profileHome, model, provider) {
507
+ const catalog = this.lastModels.get(profileHome);
508
+ if (!catalog) {
509
+ void this.warmModelOptions(profileHome);
510
+ return null;
511
+ }
512
+ return catalogOptionId(catalog, model, provider);
513
+ }
514
+ /**
515
+ * TAL-542: the identity of the files a catalog and its live ids derive from: config.yaml, `.env`, the Agent's sign-in
516
+ * store (`auth.json`, rewritten by an account switch), and the installed plugins directory, so a change outside Web
517
+ * makes both stale.
518
+ */
519
+ sourceFingerprint(profileHome) {
520
+ const stamp = (name) => {
521
+ try {
522
+ const st = statSync(join(profileHome, name), { bigint: true });
523
+ return `${String(st.mtimeNs)}:${String(st.size)}:${String(st.ino)}`;
524
+ }
525
+ catch {
526
+ return 'missing';
527
+ }
528
+ };
529
+ return [this.deps.config.fingerprint(profileHome), ...['.env', 'auth.json', 'plugins'].map(stamp)].join('|');
530
+ }
531
+ /** The last catalog built for this home, younger than `maxAgeS`, while its source files are unchanged since (TAL-542). */
532
+ currentModels(profileHome, maxAgeS = Infinity) {
533
+ const meta = this.lastModelsMeta.get(profileHome);
534
+ return meta?.source === this.sourceFingerprint(profileHome) && this.deps.now() - meta.at < maxAgeS ? this.lastModels.get(profileHome) : undefined;
535
+ }
536
+ /**
537
+ * TAL-542: `repairSessionModel` against this home's catalog. Agent-owned sign-in and plugin state has no file to
538
+ * fingerprint, so the repair also needs a catalog no older than the providers listing it read (`PROVIDERS_TTL_S`);
539
+ * a cold or stale one keeps the pair and starts a rebuild.
540
+ */
541
+ sessionModelRepair(profileHome, model, provider) {
542
+ const catalog = this.currentModels(profileHome, PROVIDERS_TTL_S);
543
+ if (!catalog) {
544
+ void this.warmSessionModelRepair(profileHome);
545
+ return null;
546
+ }
547
+ return repairSessionModel(catalog, model, provider, this.lastModelsMeta.get(profileHome)?.unconfirmed);
548
+ }
549
+ /**
550
+ * Builds the catalog `sessionModelRepair` reads unless a fresh enough one exists; call it after the request's last
551
+ * await. A source that changed while the build awaited makes that build stale, so it is rebuilt once.
552
+ */
553
+ async warmSessionModelRepair(profileHome) {
554
+ for (let attempt = 0; attempt < 2 && !this.currentModels(profileHome, PROVIDERS_TTL_S); attempt += 1) {
555
+ try {
556
+ await this.models(profileHome);
557
+ }
558
+ catch {
559
+ return; /* fail closed: the pair is kept */
560
+ }
561
+ }
562
+ }
563
+ /** Builds the catalog `modelOptionFor` reads once per home, again after config.yaml or `.env` changes; an unavailable catalog leaves every option id null. */
564
+ async warmModelOptions(profileHome) {
565
+ if (this.currentModels(profileHome))
566
+ return;
567
+ try {
568
+ await this.models(profileHome);
569
+ }
570
+ catch { /* fail closed: no option ids */ }
217
571
  }
218
572
  /**
219
573
  * The process-environment value of `name` as a named profile may see it: values `loadStartupEnv` copied from the
@@ -224,7 +578,7 @@ export class ProviderCatalog {
224
578
  return undefined;
225
579
  return this.deps.env[name];
226
580
  }
227
- /** Python `_provider_has_key` minus the credential pool (the sidecar answers OAuth/pool state). */
581
+ /** Python `_provider_has_key` minus the credential pool, which `probeKey` reads from the sidecar. */
228
582
  providerHasKey(pid, config, envValues, profileHome) {
229
583
  const envVar = providerEnvVar(pid);
230
584
  if (envVar) {
@@ -262,7 +616,8 @@ export class ProviderCatalog {
262
616
  return null;
263
617
  }
264
618
  }
265
- async probeKey(profileHome, pid, config, envValues) {
619
+ /** `pooled`: the providers whose persisted credential pool holds an added account (TAL-638). */
620
+ async probeKey(profileHome, pid, config, envValues, pooled) {
266
621
  let hasKey = this.providerHasKey(pid, config, envValues, profileHome);
267
622
  let isOauth = OAUTH_PROVIDERS.has(pid);
268
623
  let keySource = 'none';
@@ -307,6 +662,11 @@ export class ProviderCatalog {
307
662
  else
308
663
  keySource = 'config_yaml';
309
664
  }
665
+ else if (pooled.has(pid)) {
666
+ // Python `_has_explicit_pool_credentials`: keys added with `hermes auth add` live only in the Agent's pool.
667
+ hasKey = true;
668
+ keySource = 'credential_pool';
669
+ }
310
670
  else if (!providerEnvVar(pid) && /^[a-z][a-z0-9_-]{0,63}$/.test(pid)) {
311
671
  const status = await this.authStatus(profileHome, pid);
312
672
  if (status?.logged_in) {
@@ -321,47 +681,83 @@ export class ProviderCatalog {
321
681
  /** Live model ids from the Agent for one provider, cached per profile home. */
322
682
  async liveModelIds(profileHome, pid, opts = {}) {
323
683
  const key = `${profileHome}\0${pid}`;
684
+ const source = this.sourceFingerprint(profileHome);
324
685
  const hit = this.liveIds.get(key);
325
- if (hit && !opts.force && this.deps.now() - hit.at < LIVE_TTL_S)
686
+ if (hit?.source === source && !opts.force && this.deps.now() - hit.at < LIVE_TTL_S)
326
687
  return hit.ids;
327
- const inflight = this.liveInflight.get(key);
688
+ // A lookup started under another source answers for the old endpoint or credential; this one starts its own.
689
+ const flightKey = `${key}\0${source}`;
690
+ const inflight = this.liveInflight.get(flightKey);
328
691
  if (inflight && !opts.force)
329
692
  return inflight;
330
693
  const sidecar = this.deps.sidecar();
331
- if (!sidecar)
694
+ // No sidecar is a failed lookup for this source: the ids it keeps showing never confirm a stale-model repair (TAL-542).
695
+ if (!sidecar) {
696
+ this.liveFailed.add(flightKey);
332
697
  return hit?.ids ?? [];
698
+ }
333
699
  const run = sidecar.call('providers.model_ids', { profile_home: profileHome, provider: pid, ...(opts.force ? { force_refresh: true } : {}) }, { timeoutMs: 30_000 })
334
- .then((r) => { this.liveIds.set(key, { at: this.deps.now(), ids: r.model_ids }); this.liveFailed.delete(key); return r.model_ids; })
335
- .catch((error) => { this.deps.log(`[catalog] live model ids for ${pid} failed: ${str(error.message)}`); this.liveFailed.add(key); return hit?.ids ?? []; })
336
- .finally(() => { this.liveInflight.delete(key); });
337
- this.liveInflight.set(key, run);
700
+ .then((r) => { this.liveIds.set(key, { at: this.deps.now(), ids: r.model_ids, source }); this.liveFailed.delete(flightKey); return r.model_ids; })
701
+ .catch((error) => { this.deps.log(`[catalog] live model ids for ${pid} failed: ${str(error.message)}`); this.liveFailed.add(flightKey); return hit?.ids ?? []; })
702
+ .finally(() => { this.liveInflight.delete(flightKey); });
703
+ this.liveInflight.set(flightKey, run);
338
704
  return run;
339
705
  }
706
+ /**
707
+ * TAL-288: the model-provider plugins installed and enabled in this profile, as the sidecar reads them from the Agent.
708
+ * A failed lookup lists none, so every other provider stays usable; an id that cannot be a provider routing hint is dropped.
709
+ */
710
+ async pluginProviders(profileHome) {
711
+ const sidecar = this.deps.sidecar();
712
+ if (!sidecar)
713
+ return [];
714
+ try {
715
+ const { providers } = await sidecar.call('plugins.providers', { profile_home: profileHome }, { timeoutMs: PLUGIN_LIST_TIMEOUT_MS });
716
+ return providers.filter((p, i, all) => /^[a-z0-9][a-z0-9._-]{0,63}$/.test(p.name) && all.findIndex((q) => q.name === p.name) === i);
717
+ }
718
+ catch (error) {
719
+ this.deps.log(`[catalog] plugin providers failed: ${str(error.message)}`);
720
+ return [];
721
+ }
722
+ }
340
723
  /** Python `get_providers`. */
341
724
  async providers(profileHome) {
725
+ return structuredClone((await this.providerRows(profileHome)).payload);
726
+ }
727
+ /** The cached `providers` answer, shared with `quotas`; callers never mutate it. */
728
+ async providerRows(profileHome) {
342
729
  const config = await this.deps.config.read(profileHome);
343
730
  const envValues = loadEnvFile(join(profileHome, '.env'));
344
731
  const cacheKey = JSON.stringify([config, envValues]);
345
732
  const hit = this.providersCache.get(profileHome);
346
733
  if (hit?.key === cacheKey && this.deps.now() - hit.at < PROVIDERS_TTL_S)
347
- return structuredClone(hit.payload);
734
+ return hit;
735
+ const pooledIds = await this.poolProviders(profileHome);
736
+ const pooled = new Set(pooledIds ?? []);
348
737
  const active = activeProviderFromConfig(config);
349
738
  const known = new Set([...Object.keys(PROVIDER_DISPLAY), ...Object.keys(PROVIDER_MODELS), ...OAUTH_PROVIDERS]);
739
+ // A plugin never stands in for a built-in provider, by id or alias (that would route to the built-in's billing); it
740
+ // does own a `providers.<id>` config entry.
741
+ const plugins = (await this.pluginProviders(profileHome)).filter((p) => !known.has(p.name) && !known.has(providerIdentity(p.name)));
742
+ const pluginIds = new Set(plugins.map((p) => p.name));
350
743
  const providersCfg = dict(config.providers);
351
744
  for (const key of Object.keys(providersCfg)) {
352
745
  const identity = canonicaliseProviderId(key);
353
- known.add(known.has(identity) ? identity : key);
746
+ const id = known.has(identity) ? identity : key;
747
+ if (!pluginIds.has(id))
748
+ known.add(id);
354
749
  }
355
750
  const rows = [];
356
751
  for (const pid of [...known].sort()) {
357
- const probe = await this.probeKey(profileHome, pid, config, envValues);
752
+ const probe = await this.probeKey(profileHome, pid, config, envValues, pooled);
358
753
  if (pid === 'openai' && !probe.hasKey && looksLikeCodexOauthToken(str(envValues[providerEnvVar('openai') ?? ''] ?? this.deps.env.OPENAI_API_KEY)))
359
754
  continue;
755
+ const configured = probe.hasKey || selfHostedEndpoint(pid, config);
360
756
  let models = pid === 'openrouter' ? FALLBACK_MODELS.map((m) => ({ id: m.id, label: m.label })) : [...(PROVIDER_MODELS[pid] ?? [])];
361
757
  let modelsTotal = models.length;
362
758
  // Python: the card prefers the live catalog for every keyed provider (exactly like the picker), keeping the
363
759
  // static list as the cold/failed-probe fallback; Nous renders a featured subset with the full count.
364
- if (probe.hasKey && (probe.isOauth || pid === 'lmstudio' || pid === 'nous' || !BESPOKE_CATALOG_PROVIDERS.has(pid))) {
760
+ if (configured && (probe.isOauth || pid === 'lmstudio' || pid === 'nous' || !BESPOKE_CATALOG_PROVIDERS.has(pid))) {
365
761
  const live = await this.liveModelIds(profileHome, pid);
366
762
  if (live.length) {
367
763
  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, []) }));
@@ -377,11 +773,12 @@ export class ProviderCatalog {
377
773
  if (pid !== 'nous')
378
774
  modelsTotal += added.length;
379
775
  }
380
- const baseUrl = str(providerCfg.base_url).trim() || (active === pid ? str(modelSection(config).base_url).trim() : '') || null;
776
+ const baseUrl = configuredBaseUrl(pid, config) || null;
381
777
  rows.push({
382
778
  id: pid,
383
779
  display_name: displayName(pid),
384
780
  has_key: probe.hasKey,
781
+ configured,
385
782
  configurable: !probe.isOauth && Boolean(providerEnvVar(pid)),
386
783
  is_oauth: probe.isOauth,
387
784
  is_plugin_provider: false,
@@ -395,6 +792,17 @@ export class ProviderCatalog {
395
792
  models_total: modelsTotal,
396
793
  });
397
794
  }
795
+ // TAL-288: a plugin is selectable only once the Agent reports its setup ready; it never borrows a built-in's key.
796
+ for (const plugin of plugins) {
797
+ const ready = plugin.setup === 'ready';
798
+ const live = ready ? await this.liveModelIds(profileHome, plugin.name) : [];
799
+ rows.push({
800
+ id: plugin.name, display_name: plugin.display_name || plugin.name, has_key: ready, configured: ready,
801
+ configurable: false, is_oauth: false, is_plugin_provider: true, is_self_hosted: false, is_custom: false, key_source: ready ? 'plugin' : 'none',
802
+ base_url: null, auth_error: plugin.setup === 'ready' ? (live.length ? null : PLUGIN_NO_MODELS) : PLUGIN_SETUP_ERRORS[plugin.setup], env_var: null,
803
+ models: live.map((id) => ({ id, label: labelForModel(id, []) })), models_total: live.length,
804
+ });
805
+ }
398
806
  for (const cp of customProviderEntries(config)) {
399
807
  const name = str(cp.name).trim();
400
808
  if (!name)
@@ -406,7 +814,7 @@ export class ProviderCatalog {
406
814
  const envRef = /^\$\{([^}]+)\}$/.exec(cpKey)?.[1] ?? '';
407
815
  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));
408
816
  rows.push({
409
- id: slug, display_name: name, has_key: hasKey,
817
+ id: slug, display_name: name, has_key: hasKey, configured: hasKey,
410
818
  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',
411
819
  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,
412
820
  });
@@ -414,12 +822,16 @@ export class ProviderCatalog {
414
822
  // Python `_provider_sort_key`: active first, then `custom:*`, then keyed providers, then the rest (alphabetical within).
415
823
  const rank = (p) => (str(p.id) === active ? 0 : str(p.id).startsWith('custom:') ? 1 : p.has_key ? 2 : 3);
416
824
  rows.sort((a, b) => rank(a) - rank(b) || (str(a.id) < str(b.id) ? -1 : str(a.id) > str(b.id) ? 1 : 0));
417
- const payload = { providers: rows, active_provider: active };
418
- this.providersCache.set(profileHome, { at: this.deps.now(), key: cacheKey, payload });
419
- return structuredClone(payload);
825
+ for (const row of rows)
826
+ row.models = stampModelEntries(row.models, str(row.id));
827
+ const entry = { at: this.deps.now(), key: cacheKey, payload: { providers: rows, active_provider: active }, pooled: pooledIds };
828
+ this.providersCache.set(profileHome, entry);
829
+ return entry;
420
830
  }
421
831
  /** Python `get_available_models` (static catalog + live ids for keyed providers). */
422
832
  async models(profileHome) {
833
+ const source = this.sourceFingerprint(profileHome);
834
+ const unconfirmed = new Set();
423
835
  const config = await this.deps.config.read(profileHome);
424
836
  const envValues = loadEnvFile(join(profileHome, '.env'));
425
837
  const active = activeProviderFromConfig(config);
@@ -462,12 +874,18 @@ export class ProviderCatalog {
462
874
  if (canonical && this.providerHasKey(canonical, config, envValues, profileHome))
463
875
  detected.add(canonical);
464
876
  }
465
- // Python: OAuth providers the Agent reports as logged in join the picker with their live catalog (#1567, #2545).
466
- const oauthLoggedIn = new Set();
467
- for (const row of (await this.providers(profileHome)).providers)
468
- if (row.is_oauth === true && row.has_key === true)
469
- oauthLoggedIn.add(str(row.id));
470
- for (const pid of oauthLoggedIn)
877
+ // Python: OAuth providers the Agent reports as logged in join the picker with their live catalog (#1567, #2545); so do
878
+ // ready plugin providers (TAL-288), under their own name, and providers keyed only through the credential pool (TAL-638).
879
+ const signedIn = new Set();
880
+ const pluginNames = new Map();
881
+ for (const row of (await this.providerRows(profileHome)).payload.providers) {
882
+ if (row.has_key !== true || (row.is_oauth !== true && row.is_plugin_provider !== true && row.key_source !== 'credential_pool'))
883
+ continue;
884
+ signedIn.add(str(row.id));
885
+ if (row.is_plugin_provider === true)
886
+ pluginNames.set(str(row.id), str(row.display_name) || str(row.id));
887
+ }
888
+ for (const pid of signedIn)
471
889
  detected.add(pid);
472
890
  const fallbackCfg = Array.isArray(config.fallback_providers) ? config.fallback_providers.filter(isDict) : [];
473
891
  for (const entry of fallbackCfg) {
@@ -500,7 +918,7 @@ export class ProviderCatalog {
500
918
  detected.add(named?.[0] ?? active ?? 'custom');
501
919
  }
502
920
  const groups = [];
503
- for (const pid of [...detected].map((p) => (p.startsWith('custom') ? p : canonicaliseProviderId(p) || p)).filter((v, i, a) => v && a.indexOf(v) === i).sort()) {
921
+ for (const pid of [...detected].map((p) => (p.startsWith('custom') || pluginNames.has(p) ? p : canonicaliseProviderId(p) || p)).filter((v, i, a) => v && a.indexOf(v) === i).sort()) {
504
922
  if (pid.startsWith('custom:')) {
505
923
  const g = namedCustom.get(pid);
506
924
  const models = [...(g?.models ?? [])];
@@ -521,12 +939,15 @@ export class ProviderCatalog {
521
939
  let raw = [];
522
940
  if ('models' in providerCfg && providerCfg.models_discovered !== true)
523
941
  raw = configuredModelOptions(providerCfg.models);
524
- if (!raw.length && (this.providerHasKey(pid, config, envValues, profileHome) || oauthLoggedIn.has(pid))) {
942
+ if (!raw.length && (this.providerHasKey(pid, config, envValues, profileHome) || selfHostedEndpoint(pid, config) || signedIn.has(pid))) {
525
943
  const live = await this.liveModelIds(profileHome, pid);
944
+ // TAL-542: a failed lookup leaves this group's list unconfirmed, so the stale-model repair never trusts it.
945
+ if (this.liveFailed.has(`${profileHome}\0${pid}\0${source}`))
946
+ unconfirmed.add(pid);
526
947
  if (live.length)
527
948
  raw = live.map((id) => ({ id, label: pid === 'nous' ? `${formatOllamaLabel(id.includes('/') ? id.slice(id.indexOf('/') + 1) : id)} (via Nous)` : labelForModel(id, []) }));
528
949
  // Python (#1567): an authenticated Nous account with an empty live catalog shows no group; only a failed lookup falls back to the curated list.
529
- else if (pid === 'nous' && !this.liveFailed.has(`${profileHome}\0${pid}`))
950
+ else if (pid === 'nous' && !this.liveFailed.has(`${profileHome}\0${pid}\0${source}`))
530
951
  continue;
531
952
  }
532
953
  if (!raw.length)
@@ -535,7 +956,7 @@ export class ProviderCatalog {
535
956
  if (!raw.some((m) => m.id === id))
536
957
  raw.push({ id, label: labelForModel(id, groups) });
537
958
  if (raw.length)
538
- groups.push({ provider: displayName(pid), provider_id: pid, models: applyProviderPrefix(raw, pid, active) });
959
+ groups.push({ provider: pluginNames.get(pid) ?? displayName(pid), provider_id: pid, models: applyProviderPrefix(raw, pid, active) });
539
960
  }
540
961
  if (defaultModel) {
541
962
  const all = new Set(groups.flatMap((g) => g.models.map((m) => m.id)));
@@ -581,10 +1002,21 @@ export class ProviderCatalog {
581
1002
  for (const [k, v] of Object.entries(rawAliases))
582
1003
  if (k && v)
583
1004
  aliases[k.trim()] = str(v).trim();
1005
+ const [defaultBare, defaultProvider] = parseProviderQualifiedModel(defaultModel) ?? [defaultModel, active];
1006
+ const defaults = { active_provider: active, default_model: defaultModel, default_provider_id: defaultProvider, default_bare_id: defaultBare };
584
1007
  if (!kept.length && defaultModel) {
585
- return { active_provider: active, default_model: defaultModel, groups: [{ provider: 'Default', provider_id: active ?? 'default', models: [{ id: defaultModel, label: labelForModel(defaultModel, []) }] }], aliases: {}, configured_model_badges: {} };
1008
+ const providerId = active ?? 'default';
1009
+ 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: {} });
586
1010
  }
587
- return { active_provider: active, default_model: defaultModel, groups: kept, aliases, configured_model_badges: this.badges(kept, active, defaultModel, fallbackCfg) };
1011
+ 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) } : {}) }));
1012
+ return this.remember(profileHome, { source, unconfirmed }, { ...defaults, groups: stamped, aliases, configured_model_badges: this.badges(kept, active, defaultModel, fallbackCfg) });
1013
+ }
1014
+ /** Stamps the default's entry id and keeps the catalog for `modelOptionFor`. */
1015
+ remember(profileHome, meta, catalog) {
1016
+ catalog.default_option_id = catalogOptionId(catalog, catalog.default_bare_id ?? null, catalog.default_provider_id ?? null);
1017
+ this.lastModels.set(profileHome, catalog);
1018
+ this.lastModelsMeta.set(profileHome, { ...meta, at: this.deps.now() });
1019
+ return structuredClone(catalog);
588
1020
  }
589
1021
  /** Python `_configured_model_badges_from_static_catalog`: which picker rows are the main/fallback selections. */
590
1022
  badges(groups, active, defaultModel, fallback) {
@@ -640,7 +1072,7 @@ export class ProviderCatalog {
640
1072
  if (provider !== 'nous' && ids.length > MODEL_PICKER_OVERFLOW_THRESHOLD)
641
1073
  ids = ids.slice(0, MODEL_PICKER_VISIBLE_TARGET);
642
1074
  const annotateFastTier = isOpenAiFamilyProvider(provider);
643
- const models = ids.filter(Boolean).map((id) => ({ id, label: labelForModel(id, []), ...(annotateFastTier ? { supports_fast_tier: mainModelSupportsServiceTier(id, provider) } : {}) }));
1075
+ const models = stampModelEntries(ids.filter(Boolean).map((id) => ({ id, label: labelForModel(id, []), ...(annotateFastTier ? { supports_fast_tier: mainModelSupportsServiceTier(id, provider) } : {}) })), provider);
644
1076
  return { provider, source, models, count: models.length };
645
1077
  }
646
1078
  /**
@@ -652,7 +1084,7 @@ export class ProviderCatalog {
652
1084
  const config = await this.deps.config.read(profileHome);
653
1085
  const section = modelSection(config);
654
1086
  const pid = canonicaliseProviderId(provider ?? section.provider) || null;
655
- const bare = model.replace(/^@[^:]+:/, '');
1087
+ const bare = unqualifiedModelId(model);
656
1088
  const positive = (v) => { const n = Math.trunc(Number(v)); return Number.isFinite(n) && n > 0 ? n : null; };
657
1089
  const fromModels = (models) => {
658
1090
  if (isDict(models)) {
@@ -675,7 +1107,7 @@ export class ProviderCatalog {
675
1107
  let baseUrl = '';
676
1108
  let configContextLength = null;
677
1109
  const sectionModel = str(section.default || (typeof config.model === 'string' ? config.model : '')).trim();
678
- if (sectionModel && [model, bare].includes(sectionModel.replace(/^@[^:]+:/, '')))
1110
+ if (sectionModel && [model, bare].includes(unqualifiedModelId(sectionModel)))
679
1111
  configContextLength = positive(section.context_length);
680
1112
  if (!pid || canonicaliseProviderId(section.provider) === pid)
681
1113
  baseUrl = str(section.base_url).trim();
@@ -708,28 +1140,47 @@ export class ProviderCatalog {
708
1140
  return v;
709
1141
  }
710
1142
  }
711
- const p = dict(dict(config.providers)[pid]);
712
- return str(p.api_key).trim() || null;
1143
+ // The same config sources `providerHasKey` counts, so a provider shown as keyed resolves its key.
1144
+ const model = modelSection(config);
1145
+ if (canonicaliseProviderId(model.provider) === canonicaliseProviderId(pid) && valueCountsAsApiKey(pid, model.api_key))
1146
+ return str(model.api_key).trim();
1147
+ for (const [key, value] of Object.entries(dict(config.providers))) {
1148
+ if (providerIdentity(key) === providerIdentity(pid) && isDict(value) && valueCountsAsApiKey(pid, value.api_key))
1149
+ return str(value.api_key).trim();
1150
+ }
1151
+ return null;
713
1152
  }
714
- /** Python `get_provider_quota`. */
1153
+ /** Account-usage snapshots by profile home, provider, and pool account. */
715
1154
  accountUsageCache = new Map();
1155
+ /**
1156
+ * Python `get_provider_quota`. `credential` scopes the read to one credential-pool account (TAL-548): a benched account
1157
+ * answers with its local pool state, and every other read uses that account's key.
1158
+ */
716
1159
  async quota(profileHome, providerRaw, opts = {}) {
1160
+ const at = opts.at ?? this.deps.now();
1161
+ const computed_at = isoAt(at);
717
1162
  const config = await this.deps.config.read(profileHome);
718
1163
  const provider = (providerRaw ?? activeProviderFromConfig(config) ?? '').trim().toLowerCase();
719
1164
  if (!provider)
720
- return { ok: false, provider: null, display_name: null, supported: false, status: 'unavailable', quota: null, message: 'No active provider is configured.' };
1165
+ return { computed_at, ok: false, provider: null, display_name: null, supported: false, status: 'unavailable', quota: null, message: 'No active provider is configured.' };
721
1166
  const name = displayName(provider);
1167
+ const credential = opts.credential ?? null;
1168
+ const answer = (ok, status, message, extra = {}) => ({ computed_at, ok, provider, display_name: name, supported: true, status, quota: null, ...extra, message });
1169
+ if (credential && credential.status !== 'available') {
1170
+ return answer(false, 'unavailable', `${name} credential is unavailable. ${str(credential.unavailable_reason)}`.trim(), { account_limits: poolEntryLimits(credential, at) });
1171
+ }
722
1172
  if (ACCOUNT_USAGE_PROVIDERS.has(provider)) {
723
1173
  const sidecar = this.deps.sidecar();
724
1174
  let limits = null;
725
1175
  // Python `_ACCOUNT_USAGE_CACHE_TTL_SECONDS`: a snapshot answers repeat polls for 45 s unless `refresh` is set.
726
- const cacheKey = `${profileHome}\0${provider}`;
1176
+ const cacheKey = `${profileHome}\0${provider}\0${credential?.credential_id ?? ''}`;
727
1177
  const cached = this.accountUsageCache.get(cacheKey);
728
1178
  if (cached && !opts.refresh && this.deps.now() - cached.at <= ACCOUNT_USAGE_CACHE_TTL_S)
729
1179
  limits = cached.limits;
730
1180
  else if (sidecar) {
731
1181
  try {
732
- const snapshot = (await sidecar.call('usage.account', { profile_home: profileHome, provider, ...(opts.refresh ? { refresh: true } : {}) }, { timeoutMs: 35_000 })).snapshot;
1182
+ const params = { profile_home: profileHome, provider, ...(opts.refresh ? { refresh: true } : {}), ...(credential ? { credential_id: credential.credential_id } : {}) };
1183
+ const snapshot = (await sidecar.call('usage.account', params, { timeoutMs: 35_000 })).snapshot;
733
1184
  if (snapshot)
734
1185
  limits = { ...snapshot, title: str(snapshot.title) || 'Account limits', available: snapshot.available && !str(snapshot.unavailable_reason) };
735
1186
  }
@@ -738,41 +1189,102 @@ export class ProviderCatalog {
738
1189
  }
739
1190
  this.accountUsageCache.set(cacheKey, { at: this.deps.now(), limits });
740
1191
  }
1192
+ // The cache keeps the Agent's raw windows; pace is recomputed as of every response.
1193
+ if (limits)
1194
+ limits = { ...limits, ...normalizeQuotaWindows(limits.windows, at), fetched_at: isoUtc(limits.fetched_at) };
741
1195
  if (limits?.available)
742
- return { 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.` };
1196
+ 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 });
743
1197
  const reason = str(limits?.unavailable_reason).trim();
744
- return { 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.` };
745
- }
746
- if (provider === 'openrouter') {
747
- const apiKey = this.apiKeyFor('openrouter', profileHome, config);
748
- if (!apiKey)
749
- return { 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.' };
750
- const info = await this.fetchOpenRouterKey(apiKey);
751
- if (info.kind === 'ok')
752
- return { ok: true, provider, display_name: name, supported: true, status: 'available', label: 'OpenRouter credits', quota: info.quota, message: 'OpenRouter quota status loaded.' };
753
- const status = info.kind === 'invalid_key' ? 'invalid_key' : 'unavailable';
754
- return { 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.' };
755
- }
756
- return { 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}.` };
757
- }
758
- async fetchOpenRouterKey(apiKey) {
759
- const f = this.deps.fetch ?? fetch;
1198
+ 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 });
1199
+ }
1200
+ if (provider === 'openrouter' || provider === 'deepseek' || provider === 'opencode-go') {
1201
+ const apiKey = this.apiKeyFor(provider, profileHome, config);
1202
+ const key = credential ? { credential_id: credential.credential_id } : apiKey ? { api_key: apiKey } : null;
1203
+ const read = key ? await this.balance(profileHome, provider, key) : null;
1204
+ const status = !key || read?.status === 'no_key' ? 'no_key' : read?.status === 'ok' ? 'ok' : read?.status === 'http_error' ? read.http_status : null;
1205
+ if (provider === 'openrouter') {
1206
+ if (status === 'no_key')
1207
+ return answer(false, 'no_key', 'OpenRouter quota status needs an OPENROUTER_API_KEY configured on the server.');
1208
+ if (status === 'ok' && read?.quota) {
1209
+ // The cost chart follows the configured key, so another pool account's spend never lands in it.
1210
+ if (!credential || credential.matches_api_key)
1211
+ this.recordCostSnapshot(profileHome, read.quota, at);
1212
+ return answer(true, 'available', 'OpenRouter quota status loaded.', { label: 'OpenRouter credits', quota: read.quota });
1213
+ }
1214
+ return status === 401 || status === 403 ? answer(false, 'invalid_key', 'OpenRouter rejected the configured API key.') : answer(false, 'unavailable', 'OpenRouter quota status is temporarily unavailable.');
1215
+ }
1216
+ if (provider === 'deepseek') {
1217
+ if (status === 'no_key')
1218
+ return answer(false, 'no_key', 'DeepSeek balance needs a DEEPSEEK_API_KEY configured on the server.', { balances: [] });
1219
+ if (status === 'ok' && read) {
1220
+ const available = read.is_available === true;
1221
+ return answer(available, available ? 'available' : 'exhausted', available ? 'DeepSeek balance loaded.' : 'DeepSeek balance is exhausted.', { label: 'DeepSeek balance', balances: read.balances });
1222
+ }
1223
+ 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: [] });
1224
+ }
1225
+ if (status === 'no_key')
1226
+ return answer(false, 'no_key', 'OpenCode Go account limits need an API key configured on the server.', { account_limits: null });
1227
+ if (status === 'ok' && read) {
1228
+ // Python `_sanitize_opencode_go_account_limits`: the rolling, weekly and monthly windows of the Go plan.
1229
+ 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 }));
1230
+ const limits = { provider, source: 'usage_api', title: 'Account limits', plan: 'Go', details: [], available: true, unavailable_reason: null, fetched_at: isoAt(at), ...normalizeQuotaWindows(windows, at) };
1231
+ return answer(true, 'available', 'OpenCode Go account limits loaded.', { label: 'OpenCode Go usage', account_limits: limits });
1232
+ }
1233
+ if (status === 401)
1234
+ return answer(false, 'invalid_key', 'OpenCode Go rejected the configured API key.', { account_limits: null });
1235
+ return answer(false, 'unavailable', status === 403 ? 'OpenCode Go account limits require an active subscription.' : 'OpenCode Go account limits are temporarily unavailable.', { account_limits: null });
1236
+ }
1237
+ // Python `_local_pool_snapshot`: any other provider with a credential pool shows its accounts' local state.
1238
+ const entries = credential ? [credential] : (await this.poolEntries(profileHome, provider, null)) ?? [];
1239
+ if (entries.length) {
1240
+ const limits = credential ? poolEntryLimits(credential, at) : poolLimits(entries, at);
1241
+ 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 });
1242
+ }
1243
+ 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}.` };
1244
+ }
1245
+ /**
1246
+ * TAL-548: the provider's credential-pool accounts as the sidecar reads them locally. Each says whether it holds
1247
+ * `apiKey`, the provider's configured key. Null when the pool is unknown (see `poolLookup`).
1248
+ */
1249
+ poolEntries(profileHome, pid, apiKey) {
1250
+ return this.poolLookup(profileHome, `pool\0${pid}`, async (sidecar) => (await sidecar.call('usage.pool', { profile_home: profileHome, provider: pid, ...(apiKey ? { api_key: apiKey } : {}) })).entries);
1251
+ }
1252
+ /** The last answer of each credential-pool lookup, by profile home and lookup. */
1253
+ lastPoolAnswers = new Map();
1254
+ /**
1255
+ * TAL-548: one credential-pool lookup through the sidecar. A failed lookup answers with the last answer to the same
1256
+ * lookup, else null: an unknown pool, never an empty one, so no pool account is ever reported as removed.
1257
+ */
1258
+ async poolLookup(profileHome, lookup, call) {
1259
+ const key = `${profileHome}\0${lookup}`;
760
1260
  try {
761
- const res = await f(OPENROUTER_KEY_URL, { headers: { Authorization: `Bearer ${apiKey}`, Accept: 'application/json' }, signal: AbortSignal.timeout(QUOTA_TIMEOUT_MS) });
762
- if (!res.ok)
763
- return { kind: res.status === 401 || res.status === 403 ? 'invalid_key' : 'unavailable' };
764
- const raw = await readCapped(res, 256 * 1024);
765
- if (!raw)
766
- return { kind: 'unavailable' };
767
- let payload = JSON.parse(raw.toString('utf8'));
768
- if (isDict(payload) && isDict(payload.data))
769
- payload = payload.data;
770
- const d = dict(payload);
771
- const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
772
- return { kind: 'ok', quota: { limit_remaining: num(d.limit_remaining), usage: num(d.usage), limit: num(d.limit) }, label: str(d.label).trim() || null };
1261
+ const sidecar = this.deps.sidecar();
1262
+ if (!sidecar)
1263
+ throw new Error('the sidecar is not running');
1264
+ const answer = await call(sidecar);
1265
+ this.lastPoolAnswers.set(key, answer);
1266
+ return answer;
773
1267
  }
774
- catch {
775
- return { kind: 'unavailable' };
1268
+ catch (error) {
1269
+ this.deps.log(`[catalog] credential pool lookup ${lookup.replace('\0', ' ')} failed: ${str(error.message)}`);
1270
+ return this.lastPoolAnswers.get(key) ?? null;
1271
+ }
1272
+ }
1273
+ /** TAL-548: the providers whose persisted credential pool holds an added account; none when the sidecar cannot say. */
1274
+ poolProviders(profileHome) {
1275
+ return this.poolLookup(profileHome, 'providers', async (sidecar) => (await sidecar.call('usage.pool_providers', { profile_home: profileHome })).providers);
1276
+ }
1277
+ /** TAL-548: one key-based balance read through the sidecar, which resolves a pool account's key itself; null when it fails. */
1278
+ async balance(profileHome, provider, key) {
1279
+ const sidecar = this.deps.sidecar();
1280
+ if (!sidecar)
1281
+ return null;
1282
+ try {
1283
+ return await sidecar.call('usage.balance', { profile_home: profileHome, provider, ...key }, { timeoutMs: BALANCE_TIMEOUT_MS });
1284
+ }
1285
+ catch (error) {
1286
+ this.deps.log(`[catalog] ${provider} balance failed: ${str(error.message)}`);
1287
+ return null;
776
1288
  }
777
1289
  }
778
1290
  quotaScopeCache = null;
@@ -807,28 +1319,53 @@ export class ProviderCatalog {
807
1319
  quotaProfileScopeId(profile) {
808
1320
  return `qscope_${createHash('sha256').update(`${this.quotaServerScopeId()}\0${profile}`).digest('hex').slice(0, 32)}`;
809
1321
  }
810
- /** Python `get_provider_quotas`: one source per keyed provider, in the stable-identity envelope the iOS widget persists. */
1322
+ /**
1323
+ * Python `get_provider_quotas`: the stable-identity envelope the iOS widget persists. A keyed provider is one source per
1324
+ * credential-pool account, else one for its own key (Python `_quota_source_descriptors`, TAL-548). Each source id appears
1325
+ * once (two custom providers can share a slug), ordered by provider, account label, then source id.
1326
+ */
811
1327
  async quotas(profileHome, profile, opts = {}) {
812
- const status = await this.providers(profileHome);
1328
+ const at = this.deps.now();
1329
+ const { payload: status, pooled: pooledIds } = await this.providerRows(profileHome);
813
1330
  const active = status.active_provider;
814
1331
  const scopeId = this.quotaProfileScopeId(profile);
815
- // Python `_quota_source_id(profile, provider, "provider")`: the single-credential descriptor per provider.
816
- const sourceId = (pid) => `qsrc_${createHash('sha256').update(`${scopeId}\0${pid}\0provider`).digest('hex').slice(0, 32)}`;
817
- let descriptors = 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) }));
1332
+ // Python `_quota_source_id(profile, provider, credential_id)`: a pool account's id, or "provider" for the provider's own key.
1333
+ const sourceId = (pid, credential) => `qsrc_${createHash('sha256').update(`${scopeId}\0${pid}\0${credential}`).digest('hex').slice(0, 32)}`;
1334
+ // A provider configured only through `hermes auth add` has no key in .env or config.yaml, yet its pool accounts are
1335
+ // sources, including a signed-out OAuth provider's. An unknown pool (a failed lookup with no earlier answer) never
1336
+ // reports a requested source as missing.
1337
+ let poolUnknown = pooledIds === null;
1338
+ const pooled = new Set(pooledIds ?? []);
1339
+ const config = await this.deps.config.read(profileHome);
1340
+ const perProvider = await Promise.all(status.providers.filter((p) => p.has_key || p.is_custom || pooled.has(str(p.id))).map(async (p) => {
1341
+ const pid = str(p.id);
1342
+ const label = str(p.display_name) || pid;
1343
+ const apiKey = this.apiKeyFor(pid, profileHome, config);
1344
+ const found = await this.poolEntries(profileHome, pid, apiKey);
1345
+ if (!found)
1346
+ poolUnknown = true;
1347
+ const entries = found ?? [];
1348
+ const accounts = entries.map((e) => ({ source_id: sourceId(pid, e.credential_id), provider_id: pid, provider_label: label, account_label: e.label, credential: e }));
1349
+ // The provider's own key is a source unless a pool account already holds it.
1350
+ const ownKey = !entries.length || (apiKey !== null && !entries.some((e) => e.matches_api_key));
1351
+ return ownKey ? [{ source_id: sourceId(pid, 'provider'), provider_id: pid, provider_label: label, account_label: label, credential: null }, ...accounts] : accounts;
1352
+ }));
1353
+ let descriptors = uniqueQuotaSources(perProvider.flat());
818
1354
  const requested = str(opts.sourceId).trim() || null;
819
1355
  if (requested)
820
1356
  descriptors = descriptors.filter((d) => d.source_id === requested);
1357
+ const thresholds = this.deps.quotaThresholds?.(profile) ?? QUOTA_THRESHOLD_DEFAULTS;
821
1358
  const sources = await Promise.all(descriptors.map(async (d) => {
822
- const q = await this.quota(profileHome, d.provider_id, { refresh: opts.refresh ?? false });
1359
+ const q = await this.quota(profileHome, d.provider_id, { refresh: opts.refresh ?? false, at, credential: d.credential });
823
1360
  const limits = dict(q.account_limits);
824
- return {
825
- source_id: d.source_id, provider_id: d.provider_id, provider_label: d.provider_label, account_label: d.provider_label,
1361
+ return classifyQuotaSource({
1362
+ source_id: d.source_id, provider_id: d.provider_id, provider_label: d.provider_label, account_label: d.account_label,
826
1363
  is_active_provider: d.provider_id === active, supported: q.supported === true, status: str(limits.status) || str(q.status) || 'unavailable',
827
- plan: limits.plan ?? null, windows: limits.windows ?? [], quota: q.quota ?? null, balances: q.balances ?? [], details: limits.details ?? [],
1364
+ 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 ?? [],
828
1365
  unavailable_reason: limits.unavailable_reason ?? null, retry_after: limits.retry_after ?? null, fetched_at: limits.fetched_at ?? null, message: q.message ?? null,
829
- };
1366
+ }, thresholds);
830
1367
  }));
831
- return { version: 1, scope_id: scopeId, profile_id: profile, active_provider: active, requested_source_id: requested, missing_source: Boolean(requested && !descriptors.length), sources };
1368
+ 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 };
832
1369
  }
833
1370
  /** Python `get_provider_cost_history` (OpenRouter only; daily snapshots under `<home>/cost-snapshots`). */
834
1371
  async costHistory(profileHome, providerRaw, days) {
@@ -843,56 +1380,46 @@ export class ProviderCatalog {
843
1380
  const apiKey = this.apiKeyFor('openrouter', profileHome, config);
844
1381
  if (!apiKey)
845
1382
  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.' };
846
- const file = join(profileHome, 'cost-snapshots', 'openrouter.json');
847
- const read = () => {
848
- try {
849
- const data = JSON.parse(readFileSync(file, 'utf8'));
850
- 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));
851
- }
852
- catch {
853
- return [];
854
- }
855
- };
856
- const deltas = (rows) => {
857
- const window = rows.slice(-days);
858
- return window.map((e, i) => {
859
- let delta = null;
860
- const prev = window[i - 1];
861
- if (i > 0 && e.used !== null && prev?.used !== null && prev?.used !== undefined) {
862
- delta = e.used - prev.used;
863
- if (delta < 0)
864
- delta = e.used;
865
- delta = Math.abs(delta) < 1e-9 ? 0 : Math.round(delta * 1e6) / 1e6;
866
- }
867
- return { date: e.date, used: e.used, delta };
868
- });
869
- };
870
- const info = await this.fetchOpenRouterKey(apiKey);
871
- if (info.kind !== 'ok') {
872
- 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.' };
1383
+ const info = await this.balance(profileHome, 'openrouter', { api_key: apiKey });
1384
+ if (info?.status !== 'ok' || !info.quota) {
1385
+ 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.' };
1386
+ }
1387
+ const snapshots = this.recordCostSnapshot(profileHome, info.quota, this.deps.now());
1388
+ 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.' };
1389
+ }
1390
+ readCostSnapshots(profileHome) {
1391
+ try {
1392
+ const data = JSON.parse(readFileSync(join(profileHome, 'cost-snapshots', 'openrouter.json'), 'utf8'));
1393
+ 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));
1394
+ }
1395
+ catch {
1396
+ return [];
873
1397
  }
874
- let snapshots = read();
1398
+ }
1399
+ /** Writes or updates the UTC day's OpenRouter usage snapshot; every successful key read (quota or cost history) records one. */
1400
+ recordCostSnapshot(profileHome, quota, at) {
1401
+ let snapshots = this.readCostSnapshots(profileHome);
875
1402
  try {
876
- const today = new Date().toISOString().slice(0, 10);
877
- const usage = info.quota.usage;
878
- const limit = info.quota.limit;
1403
+ const today = isoAt(at).slice(0, 10);
1404
+ const used = finite(quota.usage);
1405
+ const limit = finite(quota.limit);
879
1406
  const existing = snapshots.find((e) => e.date === today);
880
1407
  if (existing) {
881
- existing.used = usage;
1408
+ existing.used = used;
882
1409
  existing.limit = limit;
883
1410
  }
884
1411
  else
885
- snapshots.push({ date: today, used: usage, limit });
1412
+ snapshots.push({ date: today, used, limit });
886
1413
  snapshots.sort((a, b) => (a.date < b.date ? -1 : 1));
887
1414
  if (snapshots.length > COST_SNAPSHOT_MAX_DAYS)
888
1415
  snapshots = snapshots.slice(-COST_SNAPSHOT_MAX_DAYS);
889
1416
  mkdirSync(join(profileHome, 'cost-snapshots'), { recursive: true });
890
- atomicWriteText(file, JSON.stringify({ provider, snapshots }, null, 2));
1417
+ atomicWriteText(join(profileHome, 'cost-snapshots', 'openrouter.json'), JSON.stringify({ provider: 'openrouter', snapshots }, null, 2));
1418
+ return snapshots;
891
1419
  }
892
1420
  catch {
893
- snapshots = read();
1421
+ return this.readCostSnapshots(profileHome);
894
1422
  }
895
- 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.' };
896
1423
  }
897
1424
  }
898
1425
  export const envFileExists = (profileHome) => existsSync(join(profileHome, '.env'));