@maudecode/talaria-web 1.17.2 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (623) hide show
  1. package/_release.json +8 -8
  2. package/dist/api/auth-raw.d.ts +1 -1
  3. package/dist/api/auth-raw.js +20 -20
  4. package/dist/api/auth-raw.js.map +1 -1
  5. package/dist/api/automation-raw.d.ts +7 -1
  6. package/dist/api/automation-raw.js +74 -27
  7. package/dist/api/automation-raw.js.map +1 -1
  8. package/dist/api/automation-router.d.ts +128 -23
  9. package/dist/api/automation-router.js +29 -13
  10. package/dist/api/automation-router.js.map +1 -1
  11. package/dist/api/chat-router.d.ts +1845 -40
  12. package/dist/api/chat-router.js +196 -114
  13. package/dist/api/chat-router.js.map +1 -1
  14. package/dist/api/git-router.d.ts +37 -3
  15. package/dist/api/git-router.js +47 -48
  16. package/dist/api/git-router.js.map +1 -1
  17. package/dist/api/raw-routes.d.ts +9 -1
  18. package/dist/api/raw-routes.js +286 -73
  19. package/dist/api/raw-routes.js.map +1 -1
  20. package/dist/api/router.d.ts +16 -6
  21. package/dist/api/router.js +28 -26
  22. package/dist/api/router.js.map +1 -1
  23. package/dist/api/session-visibility.d.ts +4 -4
  24. package/dist/api/session-visibility.js +26 -16
  25. package/dist/api/session-visibility.js.map +1 -1
  26. package/dist/api/sessions-router.d.ts +3312 -131
  27. package/dist/api/sessions-router.js +287 -157
  28. package/dist/api/sessions-router.js.map +1 -1
  29. package/dist/api/settings-router.d.ts +457 -8
  30. package/dist/api/settings-router.js +54 -33
  31. package/dist/api/settings-router.js.map +1 -1
  32. package/dist/api/sse-routes.d.ts +7 -2
  33. package/dist/api/sse-routes.js +109 -62
  34. package/dist/api/sse-routes.js.map +1 -1
  35. package/dist/api/tools-raw.d.ts +5 -0
  36. package/dist/api/tools-raw.js +63 -55
  37. package/dist/api/tools-raw.js.map +1 -1
  38. package/dist/api/tools-router.d.ts +998 -85
  39. package/dist/api/tools-router.js +447 -61
  40. package/dist/api/tools-router.js.map +1 -1
  41. package/dist/api/transcribe-stream.d.ts +16 -0
  42. package/dist/api/transcribe-stream.js +172 -0
  43. package/dist/api/transcribe-stream.js.map +1 -0
  44. package/dist/app.d.ts +4 -1
  45. package/dist/app.js +98 -59
  46. package/dist/app.js.map +1 -1
  47. package/dist/auth/gate.js +15 -11
  48. package/dist/auth/gate.js.map +1 -1
  49. package/dist/auth/oidc.d.ts +5 -2
  50. package/dist/auth/oidc.js +8 -7
  51. package/dist/auth/oidc.js.map +1 -1
  52. package/dist/auth/passkeys.d.ts +15 -14
  53. package/dist/auth/passkeys.js +89 -91
  54. package/dist/auth/passkeys.js.map +1 -1
  55. package/dist/auth/store.d.ts +17 -7
  56. package/dist/auth/store.js +93 -52
  57. package/dist/auth/store.js.map +1 -1
  58. package/dist/bin/talaria-web.js +12 -81
  59. package/dist/bin/talaria-web.js.map +1 -1
  60. package/dist/cli/ctl.js +2 -1
  61. package/dist/cli/ctl.js.map +1 -1
  62. package/dist/cli/dotenv.d.ts +5 -0
  63. package/dist/cli/dotenv.js +15 -2
  64. package/dist/cli/dotenv.js.map +1 -1
  65. package/dist/cli/launcher.js +5 -5
  66. package/dist/cli/launcher.js.map +1 -1
  67. package/dist/cli/main.d.ts +1 -0
  68. package/dist/cli/main.js +85 -0
  69. package/dist/cli/main.js.map +1 -0
  70. package/dist/cli/supervise.d.ts +2 -0
  71. package/dist/cli/supervise.js +67 -4
  72. package/dist/cli/supervise.js.map +1 -1
  73. package/dist/config/agent-config.d.ts +34 -11
  74. package/dist/config/agent-config.js +116 -60
  75. package/dist/config/agent-config.js.map +1 -1
  76. package/dist/config.d.ts +4 -0
  77. package/dist/config.js +19 -6
  78. package/dist/config.js.map +1 -1
  79. package/dist/fs/atomic.d.ts +1 -0
  80. package/dist/fs/atomic.js +4 -1
  81. package/dist/fs/atomic.js.map +1 -1
  82. package/dist/fs/stat.d.ts +1 -0
  83. package/dist/fs/stat.js +11 -0
  84. package/dist/fs/stat.js.map +1 -0
  85. package/dist/http/context.d.ts +31 -12
  86. package/dist/http/context.js +60 -24
  87. package/dist/http/context.js.map +1 -1
  88. package/dist/http/csp.d.ts +2 -1
  89. package/dist/http/csp.js +3 -2
  90. package/dist/http/csp.js.map +1 -1
  91. package/dist/onboarding.d.ts +2 -2
  92. package/dist/onboarding.js +10 -8
  93. package/dist/onboarding.js.map +1 -1
  94. package/dist/profiles/profiles.d.ts +20 -4
  95. package/dist/profiles/profiles.js +115 -30
  96. package/dist/profiles/profiles.js.map +1 -1
  97. package/dist/projects.d.ts +15 -6
  98. package/dist/projects.js +55 -47
  99. package/dist/projects.js.map +1 -1
  100. package/dist/providers/catalog.d.ts +84 -14
  101. package/dist/providers/catalog.js +474 -151
  102. package/dist/providers/catalog.js.map +1 -1
  103. package/dist/providers/env-file.d.ts +10 -3
  104. package/dist/providers/env-file.js +24 -75
  105. package/dist/providers/env-file.js.map +1 -1
  106. package/dist/providers/runtime-env.d.ts +4 -2
  107. package/dist/providers/runtime-env.js +9 -6
  108. package/dist/providers/runtime-env.js.map +1 -1
  109. package/dist/providers/tables.d.ts +3 -1
  110. package/dist/providers/tables.js +4 -2
  111. package/dist/providers/tables.js.map +1 -1
  112. package/dist/raster-image.d.ts +7 -0
  113. package/dist/raster-image.js +267 -0
  114. package/dist/raster-image.js.map +1 -0
  115. package/dist/redact.js +342 -46
  116. package/dist/redact.js.map +1 -1
  117. package/dist/release.d.ts +7 -0
  118. package/dist/release.js +14 -0
  119. package/dist/release.js.map +1 -1
  120. package/dist/runtime.d.ts +5 -1
  121. package/dist/runtime.js +305 -96
  122. package/dist/runtime.js.map +1 -1
  123. package/dist/server.js +15 -0
  124. package/dist/server.js.map +1 -1
  125. package/dist/sessions/anchor.d.ts +9 -3
  126. package/dist/sessions/anchor.js +28 -36
  127. package/dist/sessions/anchor.js.map +1 -1
  128. package/dist/sessions/background-events.d.ts +2 -0
  129. package/dist/sessions/background-events.js +10 -0
  130. package/dist/sessions/background-events.js.map +1 -0
  131. package/dist/sessions/background-tasks.d.ts +26 -27
  132. package/dist/sessions/background-tasks.js +89 -97
  133. package/dist/sessions/background-tasks.js.map +1 -1
  134. package/dist/sessions/background-updates.d.ts +15 -8
  135. package/dist/sessions/background-updates.js +101 -25
  136. package/dist/sessions/background-updates.js.map +1 -1
  137. package/dist/sessions/cli-sessions.d.ts +27 -6
  138. package/dist/sessions/cli-sessions.js +152 -42
  139. package/dist/sessions/cli-sessions.js.map +1 -1
  140. package/dist/sessions/completions.d.ts +77 -9
  141. package/dist/sessions/completions.js +482 -45
  142. package/dist/sessions/completions.js.map +1 -1
  143. package/dist/sessions/compress.d.ts +8 -0
  144. package/dist/sessions/compress.js +2 -2
  145. package/dist/sessions/compress.js.map +1 -1
  146. package/dist/sessions/drafts.d.ts +14 -10
  147. package/dist/sessions/drafts.js +38 -43
  148. package/dist/sessions/drafts.js.map +1 -1
  149. package/dist/sessions/export.d.ts +5 -0
  150. package/dist/sessions/export.js +30 -0
  151. package/dist/sessions/export.js.map +1 -1
  152. package/dist/sessions/gateway-sessions.d.ts +46 -0
  153. package/dist/sessions/gateway-sessions.js +378 -0
  154. package/dist/sessions/gateway-sessions.js.map +1 -0
  155. package/dist/sessions/gateway-watcher.d.ts +7 -3
  156. package/dist/sessions/gateway-watcher.js +31 -19
  157. package/dist/sessions/gateway-watcher.js.map +1 -1
  158. package/dist/sessions/goals.d.ts +31 -0
  159. package/dist/sessions/goals.js +75 -0
  160. package/dist/sessions/goals.js.map +1 -0
  161. package/dist/sessions/handoff.d.ts +32 -0
  162. package/dist/sessions/handoff.js +114 -0
  163. package/dist/sessions/handoff.js.map +1 -0
  164. package/dist/sessions/journal.d.ts +5 -79
  165. package/dist/sessions/journal.js +15 -606
  166. package/dist/sessions/journal.js.map +1 -1
  167. package/dist/sessions/list.d.ts +31 -16
  168. package/dist/sessions/list.js +139 -129
  169. package/dist/sessions/list.js.map +1 -1
  170. package/dist/sessions/merge.d.ts +69 -13
  171. package/dist/sessions/merge.js +265 -64
  172. package/dist/sessions/merge.js.map +1 -1
  173. package/dist/sessions/notices.d.ts +22 -0
  174. package/dist/sessions/notices.js +72 -0
  175. package/dist/sessions/notices.js.map +1 -0
  176. package/dist/sessions/pending.d.ts +10 -4
  177. package/dist/sessions/pending.js +83 -7
  178. package/dist/sessions/pending.js.map +1 -1
  179. package/dist/sessions/prefix-index.d.ts +82 -0
  180. package/dist/sessions/prefix-index.js +232 -0
  181. package/dist/sessions/prefix-index.js.map +1 -0
  182. package/dist/sessions/recovery.d.ts +19 -0
  183. package/dist/sessions/recovery.js +322 -0
  184. package/dist/sessions/recovery.js.map +1 -0
  185. package/dist/sessions/relay.d.ts +38 -19
  186. package/dist/sessions/relay.js +302 -79
  187. package/dist/sessions/relay.js.map +1 -1
  188. package/dist/sessions/service.d.ts +260 -55
  189. package/dist/sessions/service.js +1448 -462
  190. package/dist/sessions/service.js.map +1 -1
  191. package/dist/sessions/session.d.ts +24 -2
  192. package/dist/sessions/session.js +69 -7
  193. package/dist/sessions/session.js.map +1 -1
  194. package/dist/sessions/shares.d.ts +7 -6
  195. package/dist/sessions/shares.js +22 -45
  196. package/dist/sessions/shares.js.map +1 -1
  197. package/dist/sessions/source-kind.js +1 -0
  198. package/dist/sessions/source-kind.js.map +1 -1
  199. package/dist/sessions/state-db-identity.d.ts +18 -0
  200. package/dist/sessions/state-db-identity.js +67 -0
  201. package/dist/sessions/state-db-identity.js.map +1 -0
  202. package/dist/sessions/state-db-worker.d.ts +1 -0
  203. package/dist/sessions/state-db-worker.js +35 -0
  204. package/dist/sessions/state-db-worker.js.map +1 -0
  205. package/dist/sessions/state-db.d.ts +113 -7
  206. package/dist/sessions/state-db.js +861 -44
  207. package/dist/sessions/state-db.js.map +1 -1
  208. package/dist/sessions/storage/backend.d.ts +162 -0
  209. package/dist/sessions/storage/backend.js +19 -0
  210. package/dist/sessions/storage/backend.js.map +1 -0
  211. package/dist/sessions/storage/conformance.d.ts +20 -0
  212. package/dist/sessions/storage/conformance.js +237 -0
  213. package/dist/sessions/storage/conformance.js.map +1 -0
  214. package/dist/sessions/storage/document.d.ts +16 -0
  215. package/dist/sessions/storage/document.js +44 -0
  216. package/dist/sessions/storage/document.js.map +1 -0
  217. package/dist/sessions/storage/json-documents.d.ts +23 -0
  218. package/dist/sessions/storage/json-documents.js +233 -0
  219. package/dist/sessions/storage/json-documents.js.map +1 -0
  220. package/dist/sessions/storage/json-files.d.ts +12 -0
  221. package/dist/sessions/storage/json-files.js +124 -0
  222. package/dist/sessions/storage/json-files.js.map +1 -0
  223. package/dist/sessions/storage/json-installation.d.ts +3 -0
  224. package/dist/sessions/storage/json-installation.js +31 -0
  225. package/dist/sessions/storage/json-installation.js.map +1 -0
  226. package/dist/sessions/storage/json-journal.d.ts +114 -0
  227. package/dist/sessions/storage/json-journal.js +686 -0
  228. package/dist/sessions/storage/json-journal.js.map +1 -0
  229. package/dist/sessions/storage/json-recovery.d.ts +4 -0
  230. package/dist/sessions/storage/json-recovery.js +195 -0
  231. package/dist/sessions/storage/json-recovery.js.map +1 -0
  232. package/dist/sessions/storage/json.d.ts +68 -0
  233. package/dist/sessions/storage/json.js +258 -0
  234. package/dist/sessions/storage/json.js.map +1 -0
  235. package/dist/sessions/storage/row.d.ts +6 -0
  236. package/dist/sessions/storage/row.js +49 -0
  237. package/dist/sessions/storage/row.js.map +1 -0
  238. package/dist/sessions/storage/search-text.d.ts +4 -0
  239. package/dist/sessions/storage/search-text.js +16 -0
  240. package/dist/sessions/storage/search-text.js.map +1 -0
  241. package/dist/sessions/store.d.ts +65 -68
  242. package/dist/sessions/store.js +305 -602
  243. package/dist/sessions/store.js.map +1 -1
  244. package/dist/sessions/streams.d.ts +4 -0
  245. package/dist/sessions/streams.js +9 -1
  246. package/dist/sessions/streams.js.map +1 -1
  247. package/dist/sessions/titles.d.ts +2 -0
  248. package/dist/sessions/titles.js +5 -1
  249. package/dist/sessions/titles.js.map +1 -1
  250. package/dist/sessions/todo.d.ts +5 -0
  251. package/dist/sessions/todo.js +28 -21
  252. package/dist/sessions/todo.js.map +1 -1
  253. package/dist/sessions/tool-display.d.ts +5 -4
  254. package/dist/sessions/tool-display.js +30 -50
  255. package/dist/sessions/tool-display.js.map +1 -1
  256. package/dist/sessions/turn-context.d.ts +11 -2
  257. package/dist/sessions/turn-context.js +18 -11
  258. package/dist/sessions/turn-context.js.map +1 -1
  259. package/dist/sessions/turn.d.ts +122 -17
  260. package/dist/sessions/turn.js +939 -358
  261. package/dist/sessions/turn.js.map +1 -1
  262. package/dist/sessions/wakeup-pause.d.ts +54 -0
  263. package/dist/sessions/wakeup-pause.js +163 -0
  264. package/dist/sessions/wakeup-pause.js.map +1 -0
  265. package/dist/sessions/window.js +13 -12
  266. package/dist/sessions/window.js.map +1 -1
  267. package/dist/settings.d.ts +19 -7
  268. package/dist/settings.js +178 -184
  269. package/dist/settings.js.map +1 -1
  270. package/dist/sidecar/client.d.ts +17 -3
  271. package/dist/sidecar/client.js +78 -8
  272. package/dist/sidecar/client.js.map +1 -1
  273. package/dist/sidecar/discover.d.ts +2 -1
  274. package/dist/sidecar/discover.js +93 -10
  275. package/dist/sidecar/discover.js.map +1 -1
  276. package/dist/sidecar/env.d.ts +4 -0
  277. package/dist/sidecar/env.js +3 -0
  278. package/dist/sidecar/env.js.map +1 -0
  279. package/dist/sidecar/fake.d.ts +2 -2
  280. package/dist/sidecar/fake.js +19 -2
  281. package/dist/sidecar/fake.js.map +1 -1
  282. package/dist/startup.d.ts +13 -5
  283. package/dist/startup.js +48 -0
  284. package/dist/startup.js.map +1 -1
  285. package/dist/test/env-file.d.ts +5 -0
  286. package/dist/test/env-file.js +88 -0
  287. package/dist/test/env-file.js.map +1 -0
  288. package/dist/test/harness.d.ts +1 -0
  289. package/dist/test/harness.js +1 -1
  290. package/dist/test/harness.js.map +1 -1
  291. package/dist/test/passkeys.d.ts +22 -0
  292. package/dist/test/passkeys.js +62 -0
  293. package/dist/test/passkeys.js.map +1 -0
  294. package/dist/test/storage.d.ts +15 -0
  295. package/dist/test/storage.js +26 -0
  296. package/dist/test/storage.js.map +1 -0
  297. package/dist/test/ts-worker-hooks.d.ts +1 -0
  298. package/dist/test/ts-worker-hooks.js +24 -0
  299. package/dist/test/ts-worker-hooks.js.map +1 -0
  300. package/dist/text/diff.d.ts +9 -14
  301. package/dist/text/diff.js +74 -108
  302. package/dist/text/diff.js.map +1 -1
  303. package/dist/tools/commands.d.ts +6 -1
  304. package/dist/tools/commands.js +8 -2
  305. package/dist/tools/commands.js.map +1 -1
  306. package/dist/tools/connectors.d.ts +33 -0
  307. package/dist/tools/connectors.js +121 -0
  308. package/dist/tools/connectors.js.map +1 -0
  309. package/dist/tools/crons.d.ts +38 -1
  310. package/dist/tools/crons.js +122 -18
  311. package/dist/tools/crons.js.map +1 -1
  312. package/dist/tools/dashboard-plugins.d.ts +53 -0
  313. package/dist/tools/dashboard-plugins.js +188 -0
  314. package/dist/tools/dashboard-plugins.js.map +1 -0
  315. package/dist/tools/extensions.d.ts +18 -5
  316. package/dist/tools/extensions.js +344 -266
  317. package/dist/tools/extensions.js.map +1 -1
  318. package/dist/tools/gateway-restart.d.ts +62 -0
  319. package/dist/tools/gateway-restart.js +142 -0
  320. package/dist/tools/gateway-restart.js.map +1 -0
  321. package/dist/tools/health.d.ts +44 -0
  322. package/dist/tools/health.js +201 -46
  323. package/dist/tools/health.js.map +1 -1
  324. package/dist/tools/hygiene.d.ts +4 -3
  325. package/dist/tools/hygiene.js +23 -9
  326. package/dist/tools/hygiene.js.map +1 -1
  327. package/dist/tools/kanban.d.ts +4 -2
  328. package/dist/tools/kanban.js +28 -19
  329. package/dist/tools/kanban.js.map +1 -1
  330. package/dist/tools/mcp.d.ts +13 -0
  331. package/dist/tools/mcp.js +88 -0
  332. package/dist/tools/mcp.js.map +1 -1
  333. package/dist/tools/memory-masks.d.ts +5 -0
  334. package/dist/tools/memory-masks.js +256 -0
  335. package/dist/tools/memory-masks.js.map +1 -0
  336. package/dist/tools/memory.d.ts +29 -3
  337. package/dist/tools/memory.js +69 -27
  338. package/dist/tools/memory.js.map +1 -1
  339. package/dist/tools/plugin-settings.d.ts +15 -0
  340. package/dist/tools/plugin-settings.js +90 -0
  341. package/dist/tools/plugin-settings.js.map +1 -0
  342. package/dist/tools/project-os.d.ts +11 -0
  343. package/dist/tools/project-os.js +267 -0
  344. package/dist/tools/project-os.js.map +1 -0
  345. package/dist/tools/skills.d.ts +14 -5
  346. package/dist/tools/skills.js +49 -16
  347. package/dist/tools/skills.js.map +1 -1
  348. package/dist/tools/update-notifications.d.ts +34 -15
  349. package/dist/tools/update-notifications.js +141 -57
  350. package/dist/tools/update-notifications.js.map +1 -1
  351. package/dist/tools/update-rollback.d.ts +11 -0
  352. package/dist/tools/update-rollback.js +28 -0
  353. package/dist/tools/update-rollback.js.map +1 -0
  354. package/dist/tools/updates.d.ts +110 -32
  355. package/dist/tools/updates.js +1112 -294
  356. package/dist/tools/updates.js.map +1 -1
  357. package/dist/tools/wiki.d.ts +68 -0
  358. package/dist/tools/wiki.js +413 -0
  359. package/dist/tools/wiki.js.map +1 -0
  360. package/dist/util.d.ts +2 -0
  361. package/dist/util.js +5 -0
  362. package/dist/util.js.map +1 -1
  363. package/dist/workspace/escape.d.ts +61 -0
  364. package/dist/workspace/escape.js +149 -0
  365. package/dist/workspace/escape.js.map +1 -0
  366. package/dist/workspace/extract.d.ts +22 -0
  367. package/dist/workspace/extract.js +308 -0
  368. package/dist/workspace/extract.js.map +1 -0
  369. package/dist/workspace/fs.d.ts +12 -0
  370. package/dist/workspace/fs.js +92 -23
  371. package/dist/workspace/fs.js.map +1 -1
  372. package/dist/workspace/git.d.ts +34 -1
  373. package/dist/workspace/git.js +328 -35
  374. package/dist/workspace/git.js.map +1 -1
  375. package/dist/workspace/media.d.ts +16 -13
  376. package/dist/workspace/media.js +43 -61
  377. package/dist/workspace/media.js.map +1 -1
  378. package/dist/workspace/rollback.d.ts +36 -22
  379. package/dist/workspace/rollback.js +178 -272
  380. package/dist/workspace/rollback.js.map +1 -1
  381. package/dist/workspace/unzip.js +4 -1
  382. package/dist/workspace/unzip.js.map +1 -1
  383. package/dist/workspace/upload.d.ts +7 -4
  384. package/dist/workspace/upload.js +5 -2
  385. package/dist/workspace/upload.js.map +1 -1
  386. package/dist/workspace/workspaces.d.ts +15 -19
  387. package/dist/workspace/workspaces.js +55 -127
  388. package/dist/workspace/workspaces.js.map +1 -1
  389. package/dist/workspace/worktrees.d.ts +2 -1
  390. package/dist/workspace/worktrees.js +179 -42
  391. package/dist/workspace/worktrees.js.map +1 -1
  392. package/package.json +7 -3
  393. package/sidecar/agent_dependency.json +4 -4
  394. package/sidecar/agent_support.json +26 -0
  395. package/sidecar/scripts/record-fixtures.py +12 -3
  396. package/sidecar/scripts/replay_sidecar.py +32 -0
  397. package/sidecar/scripts/suite_runner.py +302 -0
  398. package/sidecar/scripts/test.sh +12 -4
  399. package/sidecar/talaria_sidecar/__init__.py +6 -1
  400. package/sidecar/talaria_sidecar/__main__.py +4 -2
  401. package/sidecar/talaria_sidecar/agent_mutation.py +975 -0
  402. package/sidecar/talaria_sidecar/agent_services.py +1053 -0
  403. package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
  404. package/sidecar/talaria_sidecar/home.py +56 -3
  405. package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
  406. package/sidecar/talaria_sidecar/methods/aux.py +166 -24
  407. package/sidecar/talaria_sidecar/methods/chat.py +852 -135
  408. package/sidecar/talaria_sidecar/methods/checkpoints.py +89 -0
  409. package/sidecar/talaria_sidecar/methods/commands.py +294 -4
  410. package/sidecar/talaria_sidecar/methods/config.py +123 -4
  411. package/sidecar/talaria_sidecar/methods/connectors.py +343 -0
  412. package/sidecar/talaria_sidecar/methods/cron.py +395 -36
  413. package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
  414. package/sidecar/talaria_sidecar/methods/gateway.py +212 -55
  415. package/sidecar/talaria_sidecar/methods/gateway_sessions.py +234 -0
  416. package/sidecar/talaria_sidecar/methods/goals.py +81 -41
  417. package/sidecar/talaria_sidecar/methods/kanban.py +108 -34
  418. package/sidecar/talaria_sidecar/methods/mcp.py +1 -1
  419. package/sidecar/talaria_sidecar/methods/memory.py +84 -0
  420. package/sidecar/talaria_sidecar/methods/oauth.py +5 -2
  421. package/sidecar/talaria_sidecar/methods/process.py +218 -15
  422. package/sidecar/talaria_sidecar/methods/profiles.py +121 -13
  423. package/sidecar/talaria_sidecar/methods/providers.py +217 -28
  424. package/sidecar/talaria_sidecar/methods/runtime.py +69 -1
  425. package/sidecar/talaria_sidecar/methods/skills.py +67 -4
  426. package/sidecar/talaria_sidecar/methods/state_db.py +316 -75
  427. package/sidecar/talaria_sidecar/methods/stt.py +159 -78
  428. package/sidecar/talaria_sidecar/methods/text.py +14 -1
  429. package/sidecar/talaria_sidecar/methods/toolsets.py +49 -0
  430. package/sidecar/talaria_sidecar/methods/usage.py +113 -14
  431. package/sidecar/talaria_sidecar/methods/worktree.py +26 -19
  432. package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
  433. package/sidecar/talaria_sidecar/rpc.py +45 -4
  434. package/sidecar/talaria_sidecar/runtime.py +98 -13
  435. package/sidecar/talaria_sidecar/speed.py +35 -0
  436. package/static/dist/FILES.txt +126 -126
  437. package/static/dist/assets/AppShell-cey33v9s.js +8 -0
  438. package/static/dist/assets/ChatPage-i8wnugr7.js +18 -0
  439. package/static/dist/assets/ConnectTargets-hg19553x.js +1 -0
  440. package/static/dist/assets/ExtensionRoute-cly4apm5.js +1 -0
  441. package/static/dist/assets/Field-j8uqoigz.js +9 -0
  442. package/static/dist/assets/HubRoute-e4bgz41j.js +2 -0
  443. package/static/dist/assets/Markdown-lahuichm.js +598 -0
  444. package/static/dist/assets/RightPanel-lbo4i0fn.js +1 -0
  445. package/static/dist/assets/Select-gqatm2e0.js +1 -0
  446. package/static/dist/assets/SessionListPanel-mfqayfjh.js +1 -0
  447. package/static/dist/assets/SettingsLayout-b8980u7k.js +1 -0
  448. package/static/dist/assets/States-e7dkgtxf.js +1 -0
  449. package/static/dist/assets/Toaster-bfqqy786.js +1 -0
  450. package/static/dist/assets/_app-n1n6pfx1.js +1 -0
  451. package/static/dist/assets/{_app.ext._extensionId-opyh9y0v.js → _app.ext._extensionId-buxjxav3.js} +1 -1
  452. package/static/dist/assets/_app.index-jt8zwiu2.js +1 -0
  453. package/static/dist/assets/_app.insights-cv76ml71.js +1 -0
  454. package/static/dist/assets/_app.kanban-h6ts53j7.js +1 -0
  455. package/static/dist/assets/_app.logs-dm873z90.js +1 -0
  456. package/static/dist/assets/_app.memory-glk5y6m8.js +1 -0
  457. package/static/dist/assets/_app.profiles-ka6zpstq.js +1 -0
  458. package/static/dist/assets/{_app.session._sessionId-bfd4h3id.js → _app.session._sessionId-g2vzghvc.js} +1 -1
  459. package/static/dist/assets/_app.settings-hgjdhzcd.js +1 -0
  460. package/static/dist/assets/_app.settings._section-jf2kmhae.js +3 -0
  461. package/static/dist/assets/_app.skills-cii852yq.js +1 -0
  462. package/static/dist/assets/_app.tasks-fp9q7atm.js +1 -0
  463. package/static/dist/assets/_app.todos-dw94qzue.js +1 -0
  464. package/static/dist/assets/_app.workspaces-inyygzze.js +1 -0
  465. package/static/dist/assets/{abnfDiagram-VCTEODGH-b2x1lqot.js → abnfDiagram-VCTEODGH-gjayq3ra.js} +1 -1
  466. package/static/dist/assets/architecture-7GRP2DOG-lp56972r.js +1 -0
  467. package/static/dist/assets/{architectureDiagram-5GKGNRK7-h38o5l7b.js → architectureDiagram-5GKGNRK7-nwb3xovn.js} +1 -1
  468. package/static/dist/assets/archive-b1j004vn.js +1 -0
  469. package/static/dist/assets/back-g74gkozu.js +1 -0
  470. package/static/dist/assets/{blockDiagram-I7D4REHJ-bqquxv2u.js → blockDiagram-I7D4REHJ-joedghi3.js} +1 -1
  471. package/static/dist/assets/{c4Diagram-7LVT6UL2-f3fb8w6z.js → c4Diagram-7LVT6UL2-hyrjrohe.js} +1 -1
  472. package/static/dist/assets/channel-es95o4y8.js +1 -0
  473. package/static/dist/assets/{chunk-4HAMMTFA-bzfyyzp3.js → chunk-4HAMMTFA-koouqbut.js} +1 -1
  474. package/static/dist/assets/{chunk-75Z2AOVW-ly99rfx5.js → chunk-75Z2AOVW-bl4bhdnt.js} +1 -1
  475. package/static/dist/assets/{chunk-DU6HZSFF-bg6ecfqf.js → chunk-DU6HZSFF-gpedttsp.js} +3 -3
  476. package/static/dist/assets/{chunk-F27PBJKO-e8k7mmqw.js → chunk-F27PBJKO-d8xxi0zi.js} +1 -1
  477. package/static/dist/assets/{chunk-GMAD6QVW-cmalnoaj.js → chunk-GMAD6QVW-jro0hl73.js} +1 -1
  478. package/static/dist/assets/{chunk-GVQU2GXP-gmif5o01.js → chunk-GVQU2GXP-djn6gbyu.js} +1 -1
  479. package/static/dist/assets/{chunk-IMKFNOWR-f1nwwh6u.js → chunk-IMKFNOWR-vw4p5p7o.js} +1 -1
  480. package/static/dist/assets/{chunk-L3NEJ4N5-oktfh5v7.js → chunk-L3NEJ4N5-n1hj77q8.js} +1 -1
  481. package/static/dist/assets/{chunk-OSK3NFVY-b1sdx96z.js → chunk-OSK3NFVY-oy0gwn2n.js} +1 -1
  482. package/static/dist/assets/{chunk-P2QGCYS3-fhu4nzxt.js → chunk-P2QGCYS3-onf1hz6c.js} +1 -1
  483. package/static/dist/assets/{chunk-POPQ4Y6H-em31rnef.js → chunk-POPQ4Y6H-m8sqn7cs.js} +1 -1
  484. package/static/dist/assets/{chunk-PWAF6VOD-bxhhyftw.js → chunk-PWAF6VOD-ek4smc8x.js} +1 -1
  485. package/static/dist/assets/{chunk-SHT3W25Y-fmrvwcav.js → chunk-SHT3W25Y-g9gfvefo.js} +3 -3
  486. package/static/dist/assets/{chunk-SVP7TREG-lymukl2j.js → chunk-SVP7TREG-f9nfv2cu.js} +1 -1
  487. package/static/dist/assets/{chunk-TICWLB2K-lmza6k23.js → chunk-TICWLB2K-bnk3m1mq.js} +1 -1
  488. package/static/dist/assets/classDiagram-ZZMXUADV-kmihoqxh.js +1 -0
  489. package/static/dist/assets/classDiagram-v2-VYDZK3BY-kmihoqxh.js +1 -0
  490. package/static/dist/assets/copied-bli9tbcx.js +1 -0
  491. package/static/dist/assets/{copy-fw5b1pc6.js → copy-jamtd1pf.js} +1 -1
  492. package/static/dist/assets/{cynefin-OW5HDTMX-czlhbyhs.js → cynefin-OW5HDTMX-f8xfdd3f.js} +1 -1
  493. package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-gq7mvswb.js → cynefinDiagram-5FMLGOSQ-n9lb99h9.js} +1 -1
  494. package/static/dist/assets/{dagre-GXQ25YYZ-cqdbf7dr.js → dagre-GXQ25YYZ-dgailddi.js} +4 -4
  495. package/static/dist/assets/{diagram-S7CK7UJ4-o6arxa58.js → diagram-S7CK7UJ4-m6aeoe7c.js} +1 -1
  496. package/static/dist/assets/{diagram-UQ7AKVKN-nhnd68tz.js → diagram-UQ7AKVKN-jall3j0x.js} +1 -1
  497. package/static/dist/assets/{diagram-VSXAHHWV-dk2sclzn.js → diagram-VSXAHHWV-hbawi7hn.js} +1 -1
  498. package/static/dist/assets/{diagram-VX7I27RA-n3k5nyx3.js → diagram-VX7I27RA-hendl2tt.js} +1 -1
  499. package/static/dist/assets/{diagram-Z3DM3KII-m6q9cwdh.js → diagram-Z3DM3KII-igwc35p3.js} +1 -1
  500. package/static/dist/assets/{ebnfDiagram-PWID7BFC-lsa5641p.js → ebnfDiagram-PWID7BFC-c64knz08.js} +1 -1
  501. package/static/dist/assets/{erDiagram-RLTQ6QDP-fo2ckbe6.js → erDiagram-RLTQ6QDP-brdu9lcm.js} +1 -1
  502. package/static/dist/assets/eventmodeling-NTZA5JFV-njk1j8wl.js +1 -0
  503. package/static/dist/assets/flowDiagram-HODETNUW-nma6f0ga.js +1 -0
  504. package/static/dist/assets/folder-plus-lvt9kudb.js +1 -0
  505. package/static/dist/assets/{ganttDiagram-EL5Y4UJY-nzqz6x9g.js → ganttDiagram-EL5Y4UJY-ma6jxj5i.js} +1 -1
  506. package/static/dist/assets/{gitGraph-4MIJSDKK-gukvcxe6.js → gitGraph-4MIJSDKK-naa9w6xa.js} +1 -1
  507. package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-lqhdp8f0.js → gitGraphDiagram-WWUBYQGX-hzltvsod.js} +1 -1
  508. package/static/dist/assets/{highlighted-body-KPVGNVTW-igw6cp39.js → highlighted-body-KPVGNVTW-ghoavj8d.js} +1 -1
  509. package/static/dist/assets/host-hzm8n85n.js +1 -0
  510. package/static/dist/assets/index-drdcaqeq.css +2 -0
  511. package/static/dist/assets/index-jzm844he.js +11 -0
  512. package/static/dist/assets/{info-A6RAGUB7-gvu7a93l.js → info-A6RAGUB7-mn2ut5ko.js} +1 -1
  513. package/static/dist/assets/{infoDiagram-27XIBGKW-m8t8g6w1.js → infoDiagram-27XIBGKW-h6kuqvsb.js} +1 -1
  514. package/static/dist/assets/{ishikawaDiagram-5VMMS53U-l07vkbgj.js → ishikawaDiagram-5VMMS53U-e7bnwq7h.js} +1 -1
  515. package/static/dist/assets/{journeyDiagram-3NMN7TZE-hybkgj8l.js → journeyDiagram-3NMN7TZE-etd1bi6p.js} +1 -1
  516. package/static/dist/assets/{kanban-definition-UXKFOSKX-by13uvtn.js → kanban-definition-UXKFOSKX-emb1g9dz.js} +4 -4
  517. package/static/dist/assets/lazyRouteComponent-o816ytzl.js +1 -0
  518. package/static/dist/assets/{line-osxq6fq7.js → line-h0ojzsvs.js} +1 -1
  519. package/static/dist/assets/link-k01ryaf9.js +1 -0
  520. package/static/dist/assets/login-bsae49ew.js +1 -0
  521. package/static/dist/assets/mermaid-HWGCJPDP-ihl34s74.js +1 -0
  522. package/static/dist/assets/{mermaid-parser.core-i91oxmou.js → mermaid-parser.core-bnre27u8.js} +3 -3
  523. package/static/dist/assets/{mindmap-definition-YA3MSWOX-m82mx8ww.js → mindmap-definition-YA3MSWOX-o1vathns.js} +1 -1
  524. package/static/dist/assets/onboarding-c65tysya.js +1 -0
  525. package/static/dist/assets/onboarding_model_label-gpn28uix.js +1 -0
  526. package/static/dist/assets/{packet-AYTQ26CC-nbtgge2o.js → packet-AYTQ26CC-hatq7m2e.js} +1 -1
  527. package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
  528. package/static/dist/assets/{pegDiagram-XKGWAZYB-f1zxzga1.js → pegDiagram-XKGWAZYB-gwisz8jv.js} +1 -1
  529. package/static/dist/assets/{pie-WAS4IAKB-of8vuhxs.js → pie-WAS4IAKB-fpftf0mk.js} +1 -1
  530. package/static/dist/assets/{pieDiagram-E7YTZNPT-o7y8ftio.js → pieDiagram-E7YTZNPT-khm1o9a6.js} +1 -1
  531. package/static/dist/assets/{quadrantDiagram-AXDQQJYC-iqiwia1t.js → quadrantDiagram-AXDQQJYC-owqc4uqn.js} +1 -1
  532. package/static/dist/assets/{radar-RG4KPBEZ-delnsy2v.js → radar-RG4KPBEZ-cntfwjpq.js} +1 -1
  533. package/static/dist/assets/{railroad-74A4TZTK-nspnyq9u.js → railroad-74A4TZTK-a7t5eibp.js} +1 -1
  534. package/static/dist/assets/railroad-abnf-HS5TGJTU-ol8cdktf.js +1 -0
  535. package/static/dist/assets/railroad-ebnf-LZEXJU2U-i8vzd429.js +1 -0
  536. package/static/dist/assets/railroad-peg-WCYAUIDC-nw3ulewz.js +1 -0
  537. package/static/dist/assets/{railroadDiagram-O6MQD6OU-z41tqx5z.js → railroadDiagram-O6MQD6OU-etuh0qa0.js} +1 -1
  538. package/static/dist/assets/{refresh-cw-nt1dtsx4.js → refresh-cw-mqbkv2a3.js} +1 -1
  539. package/static/dist/assets/refresh-l29ljp76.js +1 -0
  540. package/static/dist/assets/{requirementDiagram-BXWQKSXE-bdvif194.js → requirementDiagram-BXWQKSXE-oq2ulk22.js} +1 -1
  541. package/static/dist/assets/{sankeyDiagram-P5KCCOFB-h73e0rol.js → sankeyDiagram-P5KCCOFB-iqc02emv.js} +1 -1
  542. package/static/dist/assets/saved-ndd4t2xb.js +1 -0
  543. package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-iv95mb1z.js → sequenceDiagram-WJ2MYXX4-i0v116q0.js} +2 -2
  544. package/static/dist/assets/settings_section_extensions_title-jhfo6o8z.js +1 -0
  545. package/static/dist/assets/share._token-c1ri0hy1.js +2 -0
  546. package/static/dist/assets/sliders-horizontal-j3jjt9tv.js +1 -0
  547. package/static/dist/assets/{stateDiagram-D77RDMKH-k417ixmf.js → stateDiagram-D77RDMKH-dnpt1ato.js} +1 -1
  548. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-bxjacpk6.js +1 -0
  549. package/static/dist/assets/{swimlanes-42K2YHIH-esola70w.js → swimlanes-42K2YHIH-loqbdazb.js} +1 -1
  550. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-id62p9hz.js +8 -0
  551. package/static/dist/assets/{timeline-definition-24CTP7MA-getrz2y8.js → timeline-definition-24CTP7MA-ol2hnvma.js} +1 -1
  552. package/static/dist/assets/toast-i3h34v6o.js +64 -0
  553. package/static/dist/assets/{treeView-Q6P3EWNA-hc7ap1q7.js → treeView-Q6P3EWNA-idrj6iox.js} +1 -1
  554. package/static/dist/assets/{treemap-WGGIJYW6-b7k1syy5.js → treemap-WGGIJYW6-lzr49vv5.js} +1 -1
  555. package/static/dist/assets/untitled-oaoya4qj.js +1 -0
  556. package/static/dist/assets/useForm-nfaxgmsk.js +1 -0
  557. package/static/dist/assets/{useParams-lhg566ew.js → useParams-gjc70qru.js} +1 -1
  558. package/static/dist/assets/{useSearch-lbrh6ejn.js → useSearch-e6ae539u.js} +1 -1
  559. package/static/dist/assets/{vennDiagram-4TSXK5OY-n6pcy5qb.js → vennDiagram-4TSXK5OY-bvwr9o2z.js} +1 -1
  560. package/static/dist/assets/{wardley-WFR3VGLG-lpa18vz1.js → wardley-WFR3VGLG-31ui8tma.js} +1 -1
  561. package/static/dist/assets/{wardleyDiagram-VM6X3IG4-kpclwwg7.js → wardleyDiagram-VM6X3IG4-k5wqtx4b.js} +1 -1
  562. package/static/dist/assets/{xychartDiagram-S5SC5T6Z-paa2n8y0.js → xychartDiagram-S5SC5T6Z-aqbgvujg.js} +1 -1
  563. package/static/dist/index.html +1 -1
  564. package/static/dist/sw.js +1 -1
  565. package/static/dist/assets/AppShell-fb2td9zi.js +0 -1
  566. package/static/dist/assets/ChatPage-cj96kiy9.js +0 -25
  567. package/static/dist/assets/Dialog-kxcg7ard.js +0 -1
  568. package/static/dist/assets/ExtensionRoute-jf7h0754.js +0 -1
  569. package/static/dist/assets/HubRoute-gmanpd5y.js +0 -2
  570. package/static/dist/assets/Markdown-mdwlvj3w.js +0 -598
  571. package/static/dist/assets/RightPanel-ftvrjlcp.js +0 -1
  572. package/static/dist/assets/Select-bilaypej.js +0 -1
  573. package/static/dist/assets/SessionListPanel-og8n36qs.js +0 -1
  574. package/static/dist/assets/SettingsLayout-jonjefmj.js +0 -1
  575. package/static/dist/assets/States-nmejrwrm.js +0 -1
  576. package/static/dist/assets/Toaster-nnl8zkt9.js +0 -1
  577. package/static/dist/assets/_app-gdidzd94.js +0 -1
  578. package/static/dist/assets/_app.index-hbarzbjx.js +0 -1
  579. package/static/dist/assets/_app.insights-luywlarz.js +0 -1
  580. package/static/dist/assets/_app.kanban-kincnbr8.js +0 -1
  581. package/static/dist/assets/_app.logs-oi2gr3gs.js +0 -1
  582. package/static/dist/assets/_app.memory-b6jfivt6.js +0 -1
  583. package/static/dist/assets/_app.profiles-lpvixmqp.js +0 -1
  584. package/static/dist/assets/_app.settings-vowx3iq0.js +0 -1
  585. package/static/dist/assets/_app.settings._section-cnntg1tu.js +0 -3
  586. package/static/dist/assets/_app.skills-fb57lpau.js +0 -1
  587. package/static/dist/assets/_app.tasks-eszpx1q7.js +0 -2
  588. package/static/dist/assets/_app.todos-nr05ac6w.js +0 -1
  589. package/static/dist/assets/_app.workspaces-d2luomu6.js +0 -1
  590. package/static/dist/assets/architecture-7GRP2DOG-hd0o7bde.js +0 -1
  591. package/static/dist/assets/back-bemaer09.js +0 -1
  592. package/static/dist/assets/cancel-drd0g6d9.js +0 -1
  593. package/static/dist/assets/channel-cpdgf867.js +0 -1
  594. package/static/dist/assets/classDiagram-ZZMXUADV-ko09sguk.js +0 -1
  595. package/static/dist/assets/classDiagram-v2-VYDZK3BY-ko09sguk.js +0 -1
  596. package/static/dist/assets/copied-ima3pysb.js +0 -1
  597. package/static/dist/assets/endpoints-d33siokx.js +0 -64
  598. package/static/dist/assets/eventmodeling-NTZA5JFV-emctlo64.js +0 -1
  599. package/static/dist/assets/flowDiagram-HODETNUW-bfi3moqa.js +0 -1
  600. package/static/dist/assets/index-cxq58xb1.js +0 -11
  601. package/static/dist/assets/index-hgtx08n5.css +0 -2
  602. package/static/dist/assets/lazyRouteComponent-af0lvgjx.js +0 -1
  603. package/static/dist/assets/link-jyxicdhg.js +0 -1
  604. package/static/dist/assets/login-ec21t39x.js +0 -1
  605. package/static/dist/assets/mermaid-HWGCJPDP-gfl8z3ep.js +0 -1
  606. package/static/dist/assets/onboarding-btnltvyz.js +0 -1
  607. package/static/dist/assets/onboarding_model_label-dp7xffoh.js +0 -1
  608. package/static/dist/assets/railroad-abnf-HS5TGJTU-nst1qxyn.js +0 -1
  609. package/static/dist/assets/railroad-ebnf-LZEXJU2U-ci37x8qy.js +0 -1
  610. package/static/dist/assets/railroad-peg-WCYAUIDC-aimkk07z.js +0 -1
  611. package/static/dist/assets/refresh-b4uams1i.js +0 -1
  612. package/static/dist/assets/saved-kg52csy7.js +0 -1
  613. package/static/dist/assets/settings_section_extensions_title-ej0iapxe.js +0 -1
  614. package/static/dist/assets/share._token-f97e94r0.js +0 -2
  615. package/static/dist/assets/sliders-horizontal-fj18it47.js +0 -1
  616. package/static/dist/assets/sse-fwpqtzbg.js +0 -1
  617. package/static/dist/assets/stateDiagram-v2-MP3YSRHH-hwn4apct.js +0 -1
  618. package/static/dist/assets/swimlanesDiagram-VR7AAH4N-d1h97oyg.js +0 -8
  619. package/static/dist/assets/todoStore-eginf9h4.js +0 -1
  620. package/static/dist/assets/tts-lxdxnmjd.js +0 -1
  621. package/static/dist/assets/untitled-ovpfthg4.js +0 -1
  622. package/static/dist/assets/useForm-bcblv1rx.js +0 -1
  623. package/static/dist/assets/useLocale-fi5jb8ma.js +0 -1
@@ -1,15 +1,6 @@
1
- /**
2
- * Provider cards, the model picker catalog, live model lists, quotas and cost
3
- * history (Python `api/providers.py`, the `/api/models` parts of
4
- * `api/config.py`, `_handle_live_models`).
5
- *
6
- * The Agent's own primitives (registry, auth status, live model ids, account
7
- * usage) come through the sidecar; the static tables and key detection live
8
- * here. ponytail: one live-id cache with a 24h TTL replaces the Python
9
- * publisher/provenance machinery; `refresh()` evicts it.
10
- */
1
+ import { updateDocument } from '../sessions/storage/document.js';
11
2
  import { homeDotenvKeys } from '../cli/dotenv.js';
12
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'node:fs';
13
4
  import { createHash, randomUUID } from 'node:crypto';
14
5
  import { join } from 'node:path';
15
6
  import { atomicWriteText } from '../fs/atomic.js';
@@ -17,7 +8,7 @@ import { str } from '../util.js';
17
8
  import { QUOTA_THRESHOLD_DEFAULTS } from '../settings.js';
18
9
  import { loadEnvFile } from './env-file.js';
19
10
  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
- import { activeProviderFromConfig, canonicaliseProviderId, configuredModelIds, configuredModelOptions, customProviderEntries, customProviderSlug, dict, effectiveDefaultModel, isDict, isOpenAiFamilyProvider, mainModelSupportsServiceTier, modelSection, parseProviderQualifiedModel, providerIdentity, resolveProviderAlias, } from '../config/agent-config.js';
11
+ import { activeProviderFromConfig, canonicaliseProviderId, configuredModelIds, configuredModelOptions, customProviderEntries, customProviderSlug, dict, effectiveDefaultModel, isDict, modelSection, parseProviderQualifiedModel, providerIdentity, resolveProviderAlias, } from '../config/agent-config.js';
21
12
  /** Python `_model_matches_picker_selection`: same bare id, and the routing hints agree when the selection names one. */
22
13
  function modelMatchesPickerSelection(modelId, selected, providerId) {
23
14
  const candidate = modelId.trim();
@@ -133,16 +124,19 @@ export function stampAuxiliarySelections(aux, catalog) {
133
124
  const match = provider === 'auto' ? undefined : options.find((o) => o.provider === key && o.bare === model);
134
125
  if (match)
135
126
  return { value_label: match.label || match.bare, provider_label: match.group, option_id: match.id };
136
- const group = catalog.groups.find((g) => canonicaliseProviderId(g.provider_id) === key);
137
- return { value_label: model || null, provider_label: provider === 'auto' ? null : group?.provider ?? displayName(provider), option_id: null };
127
+ return { value_label: model || null, provider_label: provider === 'auto' ? null : providerLabel(catalog, provider), option_id: null };
138
128
  };
139
129
  // Auto falls back to the main chat model; name the effective one the catalog resolved (a legacy string `model` or a
140
130
  // `HERMES_MODEL`-style override never reaches `aux.main`).
141
131
  const mainParsed = parseProviderQualifiedModel(catalog.default_model);
142
132
  const main = describe(mainParsed?.[1] ?? catalog.active_provider ?? aux.main.provider, mainParsed?.[0] ?? (catalog.default_model || aux.main.model));
143
- const tasks = aux.tasks.map((t) => {
144
- if ((str(t.provider).trim() || 'auto') === 'auto' && !str(t.model).trim())
145
- return { ...t, is_auto: true, value_label: main.value_label, provider_label: main.provider_label, selected_option_id: null, in_catalog: true };
133
+ const isAuto = (route) => (str(route.provider).trim() || 'auto') === 'auto' && !str(route.model).trim();
134
+ const tasks = aux.tasks.map(({ effective, ...t }) => {
135
+ if (isAuto(t)) {
136
+ // TAL-800: an Auto task that inherits another slot runs on that slot's route, the main model only when it is Auto too.
137
+ const base = isDict(effective) && !isAuto(effective) ? describe(effective.provider, effective.model) : main;
138
+ return { ...t, is_auto: true, value_label: base.value_label, provider_label: base.provider_label, selected_option_id: null, in_catalog: true };
139
+ }
146
140
  const pinned = describe(t.provider, t.model);
147
141
  return { ...t, is_auto: false, value_label: pinned.value_label, provider_label: pinned.provider_label, selected_option_id: pinned.option_id, in_catalog: pinned.option_id !== null };
148
142
  });
@@ -156,6 +150,9 @@ const configuredBaseUrl = (pid, config) => {
156
150
  const cfg = dict(providers[pid] ?? providers[Object.keys(providers).find((k) => providerIdentity(k) === providerIdentity(pid)) ?? '']);
157
151
  return str(cfg.base_url).trim() || (activeProviderFromConfig(config) === pid ? str(modelSection(config).base_url).trim() : '');
158
152
  };
153
+ /** TAL-603: the credential family a card's badge names; `null` when the card holds no credential. */
154
+ const KEY_SOURCE_KINDS = { env_file: 'env', env_var: 'env', env: 'env', oauth: 'oauth', token: 'oauth', config_yaml: 'config', config: 'config', credential_pool: 'pool', plugin: 'plugin' };
155
+ const keySourceKind = (row) => row.has_key === true && row.key_source !== 'none' ? KEY_SOURCE_KINDS[str(row.key_source)] ?? 'other' : null;
159
156
  /** TAL-570: Ollama and LM Studio run keyless, so a configured endpoint makes them ready the way a key does. */
160
157
  const selfHostedEndpoint = (pid, config) => SELF_HOSTED_PROVIDER_IDS.has(pid) && Boolean(configuredBaseUrl(pid, config));
161
158
  /** Python `_unqualified_model_id`: strip a picker routing hint (`@provider:`). */
@@ -164,7 +161,13 @@ const unqualifiedModelId = (id) => parseProviderQualifiedModel(id)?.[0] ?? id.tr
164
161
  const MODEL_PICKER_OVERFLOW_THRESHOLD = 25;
165
162
  const MODEL_PICKER_VISIBLE_TARGET = 15;
166
163
  const ACCOUNT_USAGE_CACHE_TTL_S = 45;
164
+ /** TAL-807: the oldest account-usage read the picker still shows a quota level from. */
165
+ const PICKER_USAGE_MAX_AGE_S = 600;
166
+ /** TAL-807: how long a picker read waits for a provider's first account-usage read. */
167
+ const PICKER_USAGE_WAIT_MS = 2_000;
167
168
  const LIVE_TTL_S = 86_400;
169
+ /** TAL-799: the Agent's `_PROVIDER_MODELS_FALLBACK_TTL`; its placeholder for an unreachable catalog is re-probed soon. */
170
+ const FALLBACK_TTL_S = 60;
168
171
  const PROVIDERS_TTL_S = 30;
169
172
  /** The sidecar reads a balance endpoint with a 15 s timeout of its own. */
170
173
  const BALANCE_TIMEOUT_MS = 20_000;
@@ -340,6 +343,11 @@ export function classifyQuotaSource(source, t) {
340
343
  urgency: { remaining: urgency(source.windows[0]).remaining, pace: urgency(source.windows[source.pace_window_index ?? 0]).pace },
341
344
  };
342
345
  }
346
+ /** A provider's name as the catalog lists it (a named custom endpoint's configured name, a plugin's display name), else its static display name. */
347
+ export function providerLabel(catalog, provider) {
348
+ const key = canonicaliseProviderId(provider);
349
+ return catalog?.groups.find((g) => canonicaliseProviderId(g.provider_id) === key)?.provider ?? displayName(provider);
350
+ }
343
351
  export function displayName(pid) {
344
352
  return PROVIDER_DISPLAY[pid] ?? pid.split('-').map((w) => (w ? w[0].toUpperCase() + w.slice(1) : w)).join(' ');
345
353
  }
@@ -388,6 +396,10 @@ function looksLikeCodexOauthToken(value) {
388
396
  return false;
389
397
  }
390
398
  }
399
+ const BALANCE_PROVIDERS = ['openrouter', 'deepseek', 'opencode-go'];
400
+ function isBalanceProvider(pid) {
401
+ return BALANCE_PROVIDERS.some((provider) => provider === pid);
402
+ }
391
403
  function valueCountsAsApiKey(pid, value) {
392
404
  const text = str(value).trim();
393
405
  if (!text)
@@ -455,9 +467,12 @@ export function uniqueQuotaSources(sources) {
455
467
  }
456
468
  export class ProviderCatalog {
457
469
  deps;
470
+ scopedKeyRevisions = new Map();
471
+ scopedKeyAuthority = new Map();
472
+ credentialReads = new Map();
458
473
  /** Failed live lookups by home, provider, and the `sourceFingerprint` they ran under (TAL-542). */
459
474
  liveFailed = new Set();
460
- /** `source`: the `sourceFingerprint` the ids were fetched under; another one makes them stale (TAL-542). */
475
+ /** `source`: the `sourceFingerprint` the ids were fetched under; another one makes them stale (TAL-542). `ttl`: seconds they stay fresh. */
461
476
  liveIds = new Map();
462
477
  liveInflight = new Map();
463
478
  /** `pooled`: the fill's `poolProviders` answer, null when unknown (TAL-638). */
@@ -479,15 +494,23 @@ export class ProviderCatalog {
479
494
  }
480
495
  return { groups: this.liveIds.size, models, age_seconds: oldest === null ? null : Math.max(0, Math.round(this.deps.now() - oldest)) };
481
496
  }
482
- invalidate(profileHome, provider) {
497
+ invalidate(profileHome, provider, opts = {}) {
483
498
  for (const key of [...this.liveIds.keys()]) {
484
499
  if (profileHome && !key.startsWith(`${profileHome}\0`))
485
500
  continue;
486
501
  if (provider && !key.endsWith(`\0${provider}`))
487
502
  continue;
488
- this.liveIds.delete(key);
503
+ if (opts.preserveConfirmed)
504
+ this.liveIds.get(key).ttl = 0;
505
+ else
506
+ this.liveIds.delete(key);
489
507
  }
490
508
  this.providersCache.clear();
509
+ if (opts.preserveConfirmed)
510
+ return;
511
+ for (const key of [...this.lastQuotaSources.keys()])
512
+ if (!profileHome || key.startsWith(`${profileHome}\0`))
513
+ this.lastQuotaSources.delete(key);
491
514
  // Credentials changed: the last pool answers no longer stand in for a failed lookup.
492
515
  for (const key of [...this.lastPoolAnswers.keys()])
493
516
  if (!profileHome || key.startsWith(`${profileHome}\0`))
@@ -511,27 +534,44 @@ export class ProviderCatalog {
511
534
  }
512
535
  return catalogOptionId(catalog, model, provider);
513
536
  }
537
+ /** TAL-821: `providerLabel` against the last catalog built for this home; a cold home starts building one. */
538
+ providerLabel(profileHome, provider) {
539
+ const catalog = this.lastModels.get(profileHome);
540
+ if (!catalog)
541
+ void this.warmModelOptions(profileHome);
542
+ return providerLabel(catalog, provider);
543
+ }
514
544
  /**
515
545
  * TAL-542: the identity of the files a catalog and its live ids derive from: config.yaml, `.env`, the Agent's sign-in
516
546
  * store (`auth.json`, rewritten by an account switch), and the installed plugins directory, so a change outside Web
517
- * makes both stale.
547
+ * makes both stale. Opaque scoped-key revisions cover external secret sources without exposing their keys.
518
548
  */
519
- sourceFingerprint(profileHome) {
549
+ sourceFingerprint(profileHome, includeScoped = true) {
520
550
  const stamp = (name) => {
521
551
  try {
522
552
  const st = statSync(join(profileHome, name), { bigint: true });
523
- return `${String(st.mtimeNs)}:${String(st.size)}:${String(st.ino)}`;
553
+ return `${String(st.mtimeNs)}:${String(st.ctimeNs)}:${String(st.size)}:${String(st.ino)}`;
524
554
  }
525
555
  catch {
526
556
  return 'missing';
527
557
  }
528
558
  };
529
- return [this.deps.config.fingerprint(profileHome), ...['.env', 'auth.json', 'plugins'].map(stamp)].join('|');
559
+ const names = new Set([...Object.values(PROVIDER_ENV_VAR), ...Object.values(PROVIDER_ENV_VAR_ALIASES).flat()]);
560
+ const config = this.deps.config.peek(profileHome, { refresh: false }) ?? {};
561
+ for (const cp of [modelSection(config), ...Object.values(dict(config.providers)).filter(isDict), ...customProviderEntries(config)]) {
562
+ for (const name of [str(cp.key_env).trim(), /^\$\{([^}]+)\}$/.exec(str(cp.api_key).trim())?.[1]])
563
+ if (name)
564
+ names.add(name);
565
+ }
566
+ const credentials = [...names].sort().map((name) => [name, this.processEnv(name, profileHome)]);
567
+ const models = ['HERMES_MODEL', 'OPENAI_MODEL', 'LLM_MODEL'].map((name) => [name, this.deps.env[name]]);
568
+ const environment = createHash('sha256').update(JSON.stringify([credentials, models, includeScoped ? this.scopedKeyRevisions.get(profileHome) ?? '[]' : '[]'])).digest('hex');
569
+ return [profileHome, environment, this.deps.config.fingerprint(profileHome), ...['.env', 'auth.json', 'plugins'].map(stamp)].join('|');
530
570
  }
531
571
  /** The last catalog built for this home, younger than `maxAgeS`, while its source files are unchanged since (TAL-542). */
532
572
  currentModels(profileHome, maxAgeS = Infinity) {
533
573
  const meta = this.lastModelsMeta.get(profileHome);
534
- return meta?.source === this.sourceFingerprint(profileHome) && this.deps.now() - meta.at < maxAgeS ? this.lastModels.get(profileHome) : undefined;
574
+ return this.scopedKeyAuthority.get(profileHome) !== false && meta?.source === this.sourceFingerprint(profileHome) && this.deps.now() - meta.at < maxAgeS ? this.lastModels.get(profileHome) : undefined;
535
575
  }
536
576
  /**
537
577
  * TAL-542: `repairSessionModel` against this home's catalog. Agent-owned sign-in and plugin state has no file to
@@ -551,6 +591,7 @@ export class ProviderCatalog {
551
591
  * await. A source that changed while the build awaited makes that build stale, so it is rebuilt once.
552
592
  */
553
593
  async warmSessionModelRepair(profileHome) {
594
+ await this.poolProviders(profileHome);
554
595
  for (let attempt = 0; attempt < 2 && !this.currentModels(profileHome, PROVIDERS_TTL_S); attempt += 1) {
555
596
  try {
556
597
  await this.models(profileHome);
@@ -562,6 +603,7 @@ export class ProviderCatalog {
562
603
  }
563
604
  /** Builds the catalog `modelOptionFor` reads once per home, again after config.yaml or `.env` changes; an unavailable catalog leaves every option id null. */
564
605
  async warmModelOptions(profileHome) {
606
+ await this.poolProviders(profileHome);
565
607
  if (this.currentModels(profileHome))
566
608
  return;
567
609
  try {
@@ -573,16 +615,20 @@ export class ProviderCatalog {
573
615
  * The process-environment value of `name` as a named profile may see it: values `loadStartupEnv` copied from the
574
616
  * default profile's `.env` belong to that profile only, so they never count for another profile.
575
617
  */
576
- processEnv(name, profileHome) {
577
- if (!this.deps.isRootProfileHome(profileHome) && homeDotenvKeys(this.deps.env).has(name))
618
+ /** `cleared`: the variable a key delete clears, which also retires its `.env`-owned startup copy (TAL-641). */
619
+ processEnv(name, profileHome, cleared) {
620
+ if ((name === cleared || !this.deps.isRootProfileHome(profileHome)) && homeDotenvKeys(this.deps.env).has(name))
578
621
  return undefined;
579
622
  return this.deps.env[name];
580
623
  }
581
- /** Python `_provider_has_key` minus the credential pool, which `probeKey` reads from the sidecar. */
582
- providerHasKey(pid, config, envValues, profileHome) {
624
+ /**
625
+ * Python `_provider_has_key` minus the credential pool, which `probeKey` reads from the sidecar.
626
+ * `cleared` (TAL-641) answers for after `setProviderKey(…, null)`: that variable's `.env` entry and startup copy are gone.
627
+ */
628
+ providerHasKey(pid, config, envValues, profileHome, cleared) {
583
629
  const envVar = providerEnvVar(pid);
584
630
  if (envVar) {
585
- if (valueCountsAsApiKey(pid, envValues[envVar]) || valueCountsAsApiKey(pid, this.processEnv(envVar, profileHome)))
631
+ if ((envVar !== cleared && valueCountsAsApiKey(pid, envValues[envVar])) || valueCountsAsApiKey(pid, this.processEnv(envVar, profileHome, cleared)))
586
632
  return true;
587
633
  for (const alias of PROVIDER_ENV_VAR_ALIASES[pid] ?? [])
588
634
  if (valueCountsAsApiKey(pid, envValues[alias]) || valueCountsAsApiKey(pid, this.processEnv(alias, profileHome)))
@@ -606,25 +652,24 @@ export class ProviderCatalog {
606
652
  return false;
607
653
  }
608
654
  async authStatus(profileHome, pid) {
609
- const sidecar = this.deps.sidecar();
610
- if (!sidecar)
611
- return null;
612
- try {
613
- return (await sidecar.call('providers.auth_status', { profile_home: profileHome, provider: pid })).status;
614
- }
615
- catch {
616
- return null;
617
- }
655
+ return this.poolLookup(profileHome, `auth\0${pid}`, async (sidecar) => {
656
+ const { status } = await sidecar.call('providers.auth_status', { profile_home: profileHome, provider: pid });
657
+ return { logged_in: status.logged_in, key_source: str(status.key_source), error: str(status.error) };
658
+ });
618
659
  }
619
660
  /** `pooled`: the providers whose persisted credential pool holds an added account (TAL-638). */
620
- async probeKey(profileHome, pid, config, envValues, pooled) {
661
+ async probeKey(profileHome, pid, config, envValues, pooled, scopedKeys) {
662
+ let authoritative = true;
621
663
  let hasKey = this.providerHasKey(pid, config, envValues, profileHome);
622
664
  let isOauth = OAUTH_PROVIDERS.has(pid);
623
665
  let keySource = 'none';
624
666
  let authError = null;
667
+ let removable = false;
625
668
  if (isOauth) {
626
669
  keySource = 'oauth';
627
- const status = await this.authStatus(profileHome, pid);
670
+ const lookup = await this.authStatus(profileHome, pid);
671
+ authoritative = lookup.authoritative;
672
+ const status = lookup.value;
628
673
  if (status?.logged_in) {
629
674
  hasKey = true;
630
675
  keySource = str(status.key_source) || 'oauth';
@@ -641,8 +686,10 @@ export class ProviderCatalog {
641
686
  else if (hasKey) {
642
687
  const envVar = providerEnvVar(pid);
643
688
  if (envVar) {
644
- if (valueCountsAsApiKey(pid, envValues[envVar]))
689
+ if (valueCountsAsApiKey(pid, envValues[envVar])) {
645
690
  keySource = 'env_file';
691
+ removable = !pooled.has(pid) && !this.providerHasKey(pid, config, envValues, profileHome, envVar);
692
+ }
646
693
  else if (valueCountsAsApiKey(pid, this.processEnv(envVar, profileHome)))
647
694
  keySource = 'env_var';
648
695
  else {
@@ -667,8 +714,16 @@ export class ProviderCatalog {
667
714
  hasKey = true;
668
715
  keySource = 'credential_pool';
669
716
  }
717
+ else if (isBalanceProvider(pid)) {
718
+ if (scopedKeys[pid]?.present) {
719
+ hasKey = true;
720
+ keySource = 'env';
721
+ }
722
+ }
670
723
  else if (!providerEnvVar(pid) && /^[a-z][a-z0-9_-]{0,63}$/.test(pid)) {
671
- const status = await this.authStatus(profileHome, pid);
724
+ const lookup = await this.authStatus(profileHome, pid);
725
+ authoritative = lookup.authoritative;
726
+ const status = lookup.value;
672
727
  if (status?.logged_in) {
673
728
  hasKey = true;
674
729
  const raw = str(status.key_source);
@@ -676,14 +731,21 @@ export class ProviderCatalog {
676
731
  isOauth = true;
677
732
  }
678
733
  }
679
- return { hasKey, keySource, authError, isOauth };
734
+ return { authoritative, hasKey, keySource, authError, isOauth, removable };
680
735
  }
681
736
  /** Live model ids from the Agent for one provider, cached per profile home. */
682
737
  async liveModelIds(profileHome, pid, opts = {}) {
738
+ const inputSource = this.sourceFingerprint(profileHome, false);
739
+ if (!opts.source || opts.source !== this.sourceFingerprint(profileHome))
740
+ await this.poolProviders(profileHome);
683
741
  const key = `${profileHome}\0${pid}`;
684
- const source = this.sourceFingerprint(profileHome);
742
+ const source = inputSource === this.sourceFingerprint(profileHome, false) ? this.sourceFingerprint(profileHome) : inputSource;
685
743
  const hit = this.liveIds.get(key);
686
- if (hit?.source === source && !opts.force && this.deps.now() - hit.at < LIVE_TTL_S)
744
+ if (this.scopedKeyAuthority.get(profileHome) === false) {
745
+ this.liveFailed.add(`${key}\0${source}`);
746
+ return hit?.source === source ? hit.ids : [];
747
+ }
748
+ if (hit?.source === source && !opts.force && this.deps.now() - hit.at < hit.ttl)
687
749
  return hit.ids;
688
750
  // A lookup started under another source answers for the old endpoint or credential; this one starts its own.
689
751
  const flightKey = `${key}\0${source}`;
@@ -694,51 +756,75 @@ export class ProviderCatalog {
694
756
  // No sidecar is a failed lookup for this source: the ids it keeps showing never confirm a stale-model repair (TAL-542).
695
757
  if (!sidecar) {
696
758
  this.liveFailed.add(flightKey);
697
- return hit?.ids ?? [];
759
+ return hit?.source === source && source === this.sourceFingerprint(profileHome) ? hit.ids : [];
698
760
  }
699
761
  const run = sidecar.call('providers.model_ids', { profile_home: profileHome, provider: pid, ...(opts.force ? { force_refresh: true } : {}) }, { timeoutMs: 30_000 })
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 ?? []; })
762
+ .then((r) => {
763
+ if (source !== this.sourceFingerprint(profileHome)) {
764
+ this.liveFailed.add(flightKey);
765
+ return [];
766
+ }
767
+ if (r.fallback && hit?.source === source && hit.confirmed) {
768
+ this.liveFailed.add(flightKey);
769
+ return hit.ids;
770
+ }
771
+ this.liveIds.set(key, { at: this.deps.now(), ids: r.model_ids, source, ttl: r.fallback ? FALLBACK_TTL_S : LIVE_TTL_S, confirmed: !r.fallback });
772
+ // TAL-799: the Agent's curated placeholder for an unreachable catalog is a failed lookup that still lists models.
773
+ if (r.fallback)
774
+ this.liveFailed.add(flightKey);
775
+ else
776
+ this.liveFailed.delete(flightKey);
777
+ return r.model_ids;
778
+ })
779
+ .catch((error) => { this.deps.log(`[catalog] live model ids for ${pid} failed: ${str(error.message)}`); this.liveFailed.add(flightKey); return hit?.source === source && source === this.sourceFingerprint(profileHome) ? hit.ids : []; })
702
780
  .finally(() => { this.liveInflight.delete(flightKey); });
703
781
  this.liveInflight.set(flightKey, run);
704
782
  return run;
705
783
  }
784
+ /** Whether the last live lookup for this provider under `source` failed or answered with the Agent's placeholder list. */
785
+ liveLookupFailed(profileHome, pid, source = this.sourceFingerprint(profileHome)) {
786
+ return this.liveFailed.has(`${profileHome}\0${pid}\0${source}`);
787
+ }
706
788
  /**
707
789
  * TAL-288: the model-provider plugins installed and enabled in this profile, as the sidecar reads them from the Agent.
708
790
  * A failed lookup lists none, so every other provider stays usable; an id that cannot be a provider routing hint is dropped.
709
791
  */
710
792
  async pluginProviders(profileHome) {
711
- const sidecar = this.deps.sidecar();
712
- if (!sidecar)
713
- return [];
714
- try {
793
+ return this.poolLookup(profileHome, 'plugins', async (sidecar) => {
715
794
  const { providers } = await sidecar.call('plugins.providers', { profile_home: profileHome }, { timeoutMs: PLUGIN_LIST_TIMEOUT_MS });
716
795
  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
- }
796
+ });
797
+ }
798
+ /**
799
+ * TAL-798: the GGUFs staged for the Agent's managed llama.cpp provider, selectable before its server runs (a turn boots
800
+ * it). Null when none are staged or the listing failed, so every other provider stays usable.
801
+ */
802
+ async localModels(profileHome) {
803
+ return this.poolLookup(profileHome, 'local-models', async (sidecar) => sidecar.call('providers.local_models', { profile_home: profileHome }, { timeoutMs: PLUGIN_LIST_TIMEOUT_MS }));
722
804
  }
723
805
  /** Python `get_providers`. */
724
806
  async providers(profileHome) {
725
807
  return structuredClone((await this.providerRows(profileHome)).payload);
726
808
  }
727
809
  /** The cached `providers` answer, shared with `quotas`; callers never mutate it. */
728
- async providerRows(profileHome) {
810
+ async providerRows(profileHome, pool) {
729
811
  const config = await this.deps.config.read(profileHome);
730
812
  const envValues = loadEnvFile(join(profileHome, '.env'));
731
- const cacheKey = JSON.stringify([config, envValues]);
813
+ pool ??= await this.poolProviders(profileHome);
814
+ const source = this.sourceFingerprint(profileHome);
815
+ const cacheKey = source;
732
816
  const hit = this.providersCache.get(profileHome);
733
- if (hit?.key === cacheKey && this.deps.now() - hit.at < PROVIDERS_TTL_S)
817
+ if (pool.authoritative && hit?.key === cacheKey && this.deps.now() - hit.at < PROVIDERS_TTL_S)
734
818
  return hit;
735
- const pooledIds = await this.poolProviders(profileHome);
819
+ const pooledIds = pool.value?.providers ?? null;
820
+ const pluginLookup = await this.pluginProviders(profileHome);
821
+ let authoritative = pool.authoritative && pluginLookup.authoritative;
736
822
  const pooled = new Set(pooledIds ?? []);
737
823
  const active = activeProviderFromConfig(config);
738
824
  const known = new Set([...Object.keys(PROVIDER_DISPLAY), ...Object.keys(PROVIDER_MODELS), ...OAUTH_PROVIDERS]);
739
825
  // 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
826
  // 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)));
827
+ const plugins = (pluginLookup.value ?? []).filter((p) => !known.has(p.name) && !known.has(providerIdentity(p.name)));
742
828
  const pluginIds = new Set(plugins.map((p) => p.name));
743
829
  const providersCfg = dict(config.providers);
744
830
  for (const key of Object.keys(providersCfg)) {
@@ -749,7 +835,8 @@ export class ProviderCatalog {
749
835
  }
750
836
  const rows = [];
751
837
  for (const pid of [...known].sort()) {
752
- const probe = await this.probeKey(profileHome, pid, config, envValues, pooled);
838
+ const probe = await this.probeKey(profileHome, pid, config, envValues, pooled, pool.value?.keys ?? {});
839
+ authoritative &&= probe.authoritative;
753
840
  if (pid === 'openai' && !probe.hasKey && looksLikeCodexOauthToken(str(envValues[providerEnvVar('openai') ?? ''] ?? this.deps.env.OPENAI_API_KEY)))
754
841
  continue;
755
842
  const configured = probe.hasKey || selfHostedEndpoint(pid, config);
@@ -758,7 +845,7 @@ export class ProviderCatalog {
758
845
  // Python: the card prefers the live catalog for every keyed provider (exactly like the picker), keeping the
759
846
  // static list as the cold/failed-probe fallback; Nous renders a featured subset with the full count.
760
847
  if (configured && (probe.isOauth || pid === 'lmstudio' || pid === 'nous' || !BESPOKE_CATALOG_PROVIDERS.has(pid))) {
761
- const live = await this.liveModelIds(profileHome, pid);
848
+ const live = await this.liveModelIds(profileHome, pid, { source });
762
849
  if (live.length) {
763
850
  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, []) }));
764
851
  modelsTotal = live.length;
@@ -785,6 +872,7 @@ export class ProviderCatalog {
785
872
  is_self_hosted: SELF_HOSTED_PROVIDER_IDS.has(pid),
786
873
  is_custom: false,
787
874
  key_source: probe.keySource,
875
+ removable: probe.removable && !SELF_HOSTED_PROVIDER_IDS.has(pid),
788
876
  base_url: baseUrl,
789
877
  auth_error: probe.authError,
790
878
  env_var: providerEnvVar(pid),
@@ -798,7 +886,7 @@ export class ProviderCatalog {
798
886
  const live = ready ? await this.liveModelIds(profileHome, plugin.name) : [];
799
887
  rows.push({
800
888
  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',
889
+ configurable: false, is_oauth: false, is_plugin_provider: true, is_self_hosted: false, is_custom: false, key_source: ready ? 'plugin' : 'none', removable: false,
802
890
  base_url: null, auth_error: plugin.setup === 'ready' ? (live.length ? null : PLUGIN_NO_MODELS) : PLUGIN_SETUP_ERRORS[plugin.setup], env_var: null,
803
891
  models: live.map((id) => ({ id, label: labelForModel(id, []) })), models_total: live.length,
804
892
  });
@@ -812,32 +900,50 @@ export class ProviderCatalog {
812
900
  // Python: an `api_key: ${VAR}` reference counts when the variable resolves.
813
901
  const cpKey = str(cp.api_key).trim();
814
902
  const envRef = /^\$\{([^}]+)\}$/.exec(cpKey)?.[1] ?? '';
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));
903
+ const yamlKey = !envRef && valueCountsAsApiKey(slug, cp.api_key);
904
+ const hasKey = envRef ? Boolean((this.processEnv(envRef, profileHome) ?? '').trim()) : yamlKey || Boolean(str(cp.key_env).trim() && this.processEnv(str(cp.key_env).trim(), profileHome));
905
+ // TAL-603: a `${VAR}` reference or `key_env` credential comes from the environment.
906
+ const keySource = yamlKey ? 'config_yaml' : hasKey ? 'env_var' : cpKey ? 'config_yaml' : 'none';
816
907
  rows.push({
817
908
  id: slug, display_name: name, has_key: hasKey, configured: hasKey,
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',
909
+ configurable: false, is_oauth: false, is_plugin_provider: false, is_self_hosted: false, is_custom: true, key_source: keySource, removable: false,
819
910
  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,
820
911
  });
821
912
  }
822
913
  // Python `_provider_sort_key`: active first, then `custom:*`, then keyed providers, then the rest (alphabetical within).
823
914
  const rank = (p) => (str(p.id) === active ? 0 : str(p.id).startsWith('custom:') ? 1 : p.has_key ? 2 : 3);
824
915
  rows.sort((a, b) => rank(a) - rank(b) || (str(a.id) < str(b.id) ? -1 : str(a.id) > str(b.id) ? 1 : 0));
825
- for (const row of rows)
916
+ for (const row of rows) {
826
917
  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);
918
+ // TAL-603: the server decides the Active badge, the key badge and the count; a featured list never outnumbers its total.
919
+ row.is_active = str(row.id) === active;
920
+ row.key_source_kind = keySourceKind(row);
921
+ row.models_total = Math.max(Number(row.models_total), row.models.length);
922
+ }
923
+ authoritative &&= source === this.sourceFingerprint(profileHome);
924
+ const availability = this.availability(source, authoritative, Boolean(pool.value));
925
+ const entry = { at: this.deps.now(), key: cacheKey, payload: { providers: rows, active_provider: active, availability }, pooled: pooledIds };
926
+ if (authoritative)
927
+ this.providersCache.set(profileHome, entry);
829
928
  return entry;
830
929
  }
831
930
  /** Python `get_available_models` (static catalog + live ids for keyed providers). */
832
- async models(profileHome) {
931
+ async models(profileHome, attempt = 0) {
932
+ const pool = await this.poolProviders(profileHome);
833
933
  const source = this.sourceFingerprint(profileHome);
934
+ const providerState = (await this.providerRows(profileHome, pool)).payload;
834
935
  const unconfirmed = new Set();
835
936
  const config = await this.deps.config.read(profileHome);
836
937
  const envValues = loadEnvFile(join(profileHome, '.env'));
837
- const active = activeProviderFromConfig(config);
838
938
  const model = modelSection(config);
839
939
  const defaultModel = effectiveDefaultModel(config, this.deps.env);
840
940
  const cfgBaseUrl = str(model.base_url).trim();
941
+ const localLookup = await this.localModels(profileHome);
942
+ const local = localLookup.value?.models.length ? localLookup.value : null;
943
+ const configured = activeProviderFromConfig(config);
944
+ // A `custom` main model at the running local server's endpoint is that server: its Local group owns the selection.
945
+ const atLocalServer = configured === 'custom' && Boolean(cfgBaseUrl) && cfgBaseUrl.replace(/\/+$/, '') === str(local?.current_endpoint).replace(/\/+$/, '');
946
+ const active = local && atLocalServer ? local.provider_id : configured;
841
947
  const detected = new Set();
842
948
  const configuredIds = new Map();
843
949
  const append = (pid, id) => {
@@ -851,6 +957,8 @@ export class ProviderCatalog {
851
957
  };
852
958
  if (active)
853
959
  detected.add(active);
960
+ if (local)
961
+ detected.add(local.provider_id);
854
962
  const providersCfg = dict(config.providers);
855
963
  const rawKeyFor = new Map();
856
964
  for (const [key, value] of Object.entries(providersCfg)) {
@@ -878,8 +986,8 @@ export class ProviderCatalog {
878
986
  // ready plugin providers (TAL-288), under their own name, and providers keyed only through the credential pool (TAL-638).
879
987
  const signedIn = new Set();
880
988
  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'))
989
+ for (const row of providerState.providers) {
990
+ if (row.has_key !== true || (row.is_oauth !== true && row.is_plugin_provider !== true && row.key_source !== 'credential_pool' && row.key_source !== 'env'))
883
991
  continue;
884
992
  signedIn.add(str(row.id));
885
993
  if (row.is_plugin_provider === true)
@@ -917,8 +1025,15 @@ export class ProviderCatalog {
917
1025
  const named = [...namedCustom.entries()].find(([, v]) => customProviderEntries(config).some((e) => customProviderSlug(e.name) === customProviderSlug(v.name) && str(e.base_url).trim().replace(/\/+$/, '') === cfgBaseUrl.replace(/\/+$/, '')));
918
1026
  detected.add(named?.[0] ?? active ?? 'custom');
919
1027
  }
1028
+ // TAL-911: Agent pickers drop `model_catalog.excluded_providers` under any of their aliases, the active provider included.
1029
+ const excludedCfg = dict(config.model_catalog).excluded_providers;
1030
+ const excluded = new Set(Array.isArray(excludedCfg) ? excludedCfg.map(providerIdentity).filter(Boolean) : []);
1031
+ const isExcluded = (pid) => excluded.has(providerIdentity(pid));
1032
+ const pickerDefault = isExcluded(active) || isExcluded(parseProviderQualifiedModel(defaultModel)?.[1]) ? '' : defaultModel;
920
1033
  const groups = [];
921
1034
  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()) {
1035
+ if (isExcluded(pid))
1036
+ continue;
922
1037
  if (pid.startsWith('custom:')) {
923
1038
  const g = namedCustom.get(pid);
924
1039
  const models = [...(g?.models ?? [])];
@@ -926,6 +1041,11 @@ export class ProviderCatalog {
926
1041
  groups.push({ provider: g?.name ?? pid.replace('custom:', ''), provider_id: pid, models: applyProviderPrefix(models, pid, active) });
927
1042
  continue;
928
1043
  }
1044
+ if (pid === local?.provider_id) {
1045
+ // A GGUF's id is its file name, quant tag included (`Q4_K_M`); prettifying would mangle it.
1046
+ groups.push({ provider: 'Local', provider_id: pid, models: applyProviderPrefix(local.models.map((id) => ({ id, label: id })), pid, active) });
1047
+ continue;
1048
+ }
929
1049
  if (pid === 'custom') {
930
1050
  const models = [...customModels];
931
1051
  for (const id of configuredIds.get(pid) ?? [])
@@ -937,20 +1057,25 @@ export class ProviderCatalog {
937
1057
  }
938
1058
  const providerCfg = dict(providersCfg[rawKeyFor.get(pid) ?? pid]);
939
1059
  let raw = [];
1060
+ let allowFallback = false;
940
1061
  if ('models' in providerCfg && providerCfg.models_discovered !== true)
941
1062
  raw = configuredModelOptions(providerCfg.models);
942
- if (!raw.length && (this.providerHasKey(pid, config, envValues, profileHome) || selfHostedEndpoint(pid, config) || signedIn.has(pid))) {
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}`))
1063
+ if (!raw.length) {
1064
+ // TAL-799: a provider without credentials lists the Agent's curated catalog too; the static tables only cover a missing sidecar.
1065
+ const keyed = this.providerHasKey(pid, config, envValues, profileHome) || selfHostedEndpoint(pid, config) || signedIn.has(pid);
1066
+ const live = await this.liveModelIds(profileHome, pid, { source });
1067
+ const failed = this.liveLookupFailed(profileHome, pid, source);
1068
+ // TAL-542: a failed lookup, or a curated list no account confirmed, leaves this group unconfirmed, so the stale-model repair never trusts it.
1069
+ allowFallback = failed || !keyed;
1070
+ if (allowFallback)
946
1071
  unconfirmed.add(pid);
947
1072
  if (live.length)
948
1073
  raw = live.map((id) => ({ id, label: pid === 'nous' ? `${formatOllamaLabel(id.includes('/') ? id.slice(id.indexOf('/') + 1) : id)} (via Nous)` : labelForModel(id, []) }));
949
1074
  // Python (#1567): an authenticated Nous account with an empty live catalog shows no group; only a failed lookup falls back to the curated list.
950
- else if (pid === 'nous' && !this.liveFailed.has(`${profileHome}\0${pid}\0${source}`))
1075
+ else if (keyed && pid === 'nous' && !failed)
951
1076
  continue;
952
1077
  }
953
- if (!raw.length)
1078
+ if (!raw.length && allowFallback)
954
1079
  raw = pid === 'openrouter' ? FALLBACK_MODELS.map((m) => ({ id: m.id, label: m.label })) : [...(PROVIDER_MODELS[pid] ?? [])];
955
1080
  for (const id of configuredIds.get(pid) ?? [])
956
1081
  if (!raw.some((m) => m.id === id))
@@ -958,7 +1083,7 @@ export class ProviderCatalog {
958
1083
  if (raw.length)
959
1084
  groups.push({ provider: pluginNames.get(pid) ?? displayName(pid), provider_id: pid, models: applyProviderPrefix(raw, pid, active) });
960
1085
  }
961
- if (defaultModel) {
1086
+ if (pickerDefault) {
962
1087
  const all = new Set(groups.flatMap((g) => g.models.map((m) => m.id)));
963
1088
  if (!all.has(defaultModel) && !all.has(`@${active ?? ''}:${defaultModel}`)) {
964
1089
  const label = labelForModel(defaultModel, groups);
@@ -991,11 +1116,6 @@ export class ProviderCatalog {
991
1116
  }
992
1117
  const rank = (g) => (g.provider_id === active ? 0 : g.provider_id.startsWith('custom:') ? 1 : withKeys.has(g.provider_id) ? 2 : 3);
993
1118
  kept.sort((a, b) => rank(a) - rank(b) || (a.provider_id < b.provider_id ? -1 : a.provider_id > b.provider_id ? 1 : 0));
994
- for (const g of kept)
995
- if (isOpenAiFamilyProvider(g.provider_id))
996
- for (const m of g.models)
997
- if (/^(@[^:]+:)?(openai\/)?(gpt-5|o[0-9])/.test(m.id))
998
- m.supports_fast_tier = true;
999
1119
  const aliases = {};
1000
1120
  const rawAliases = model.aliases;
1001
1121
  if (isDict(rawAliases))
@@ -1003,19 +1123,118 @@ export class ProviderCatalog {
1003
1123
  if (k && v)
1004
1124
  aliases[k.trim()] = str(v).trim();
1005
1125
  const [defaultBare, defaultProvider] = parseProviderQualifiedModel(defaultModel) ?? [defaultModel, active];
1006
- const defaults = { active_provider: active, default_model: defaultModel, default_provider_id: defaultProvider, default_bare_id: defaultBare };
1007
- if (!kept.length && defaultModel) {
1126
+ if (source !== this.sourceFingerprint(profileHome)) {
1127
+ if (attempt === 0)
1128
+ return this.models(profileHome, 1);
1129
+ throw new Error('Provider configuration changed during catalog lookup. Try again.');
1130
+ }
1131
+ if (!providerState.availability.authoritative || !localLookup.authoritative)
1132
+ for (const g of kept)
1133
+ unconfirmed.add(g.provider_id);
1134
+ const previous = this.currentModels(profileHome);
1135
+ const availability = this.availability(source, providerState.availability.authoritative && localLookup.authoritative && !unconfirmed.size && source === this.sourceFingerprint(profileHome), previous?.availability?.status === 'available' || previous?.availability?.status === 'stale');
1136
+ for (const g of kept)
1137
+ g.availability = this.availability(source, !unconfirmed.has(g.provider_id) && providerState.availability.authoritative, Boolean(previous?.groups.some((old) => old.provider_id === g.provider_id && old.availability?.status !== 'unavailable')));
1138
+ const defaults = { availability, active_provider: active, default_model: defaultModel, default_provider_id: defaultProvider, default_bare_id: defaultBare };
1139
+ if (!kept.length && pickerDefault) {
1008
1140
  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: {} });
1141
+ return this.remember(profileHome, { source, unconfirmed, attempt }, { ...defaults, groups: [{ provider: 'Default', provider_id: providerId, models: stampModelEntries([{ id: defaultModel, label: labelForModel(defaultModel, []) }], providerId) }], aliases: {}, configured_model_badges: {} });
1010
1142
  }
1011
1143
  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) });
1144
+ return this.remember(profileHome, { source, unconfirmed, attempt }, { ...defaults, groups: stamped, aliases, configured_model_badges: this.badges(kept, active, defaultModel, fallbackCfg) });
1145
+ }
1146
+ /**
1147
+ * TAL-807: the picker catalog (`/api/models`) with each group's live pool rate limit, read per request so an expired one
1148
+ * is gone, and its quota level. The cached catalog holds neither; a failed read leaves them out.
1149
+ */
1150
+ async pickerModels(profileHome, profile) {
1151
+ const catalog = await this.models(profileHome);
1152
+ const entries = (g) => [...g.models, ...(g.extra_models ?? [])];
1153
+ let limits = {};
1154
+ try {
1155
+ const sidecar = this.deps.sidecar();
1156
+ const providers = catalog.groups.map((g) => ({ slug: g.provider_id, models: entries(g).map((e) => e.bare_id ?? e.id) }));
1157
+ if (sidecar)
1158
+ limits = (await sidecar.call('usage.limits', { profile_home: profileHome, providers })).limits;
1159
+ }
1160
+ catch (error) {
1161
+ this.deps.log(`[catalog] pool limits failed: ${str(error.message)}`);
1162
+ }
1163
+ for (const g of catalog.groups) {
1164
+ const limit = limits[g.provider_id];
1165
+ if (limit?.scope === 'account')
1166
+ g.limit = limit;
1167
+ if (limit?.scope === 'models') {
1168
+ // The sidecar answers by bare id; the picker names entries by id.
1169
+ const cooled = {};
1170
+ for (const e of entries(g)) {
1171
+ const resetsAt = limit.models[e.bare_id ?? e.id];
1172
+ if (resetsAt) {
1173
+ e.limit = { scope: 'model', resets_at: resetsAt };
1174
+ cooled[e.id] = resetsAt;
1175
+ }
1176
+ }
1177
+ if (Object.keys(cooled).length)
1178
+ g.limit = { scope: 'models', models: cooled };
1179
+ }
1180
+ }
1181
+ const thresholds = (await this.deps.quotaThresholds?.(profile)) ?? QUOTA_THRESHOLD_DEFAULTS;
1182
+ // An account limit already says the login is out; the quota level would only repeat it.
1183
+ const levels = await Promise.all(catalog.groups.map((g) => (g.limit?.scope === 'account' ? Promise.resolve(null) : this.pickerQuotaLevel(profileHome, g.provider_id, thresholds))));
1184
+ catalog.groups.forEach((g, i) => { const level = levels[i]; if (level)
1185
+ g.quota_level = level; });
1186
+ return catalog;
1187
+ }
1188
+ /** TAL-807: account-usage reads the picker started, by `accountUsageCache` key. */
1189
+ pickerUsageReads = new Map();
1190
+ /**
1191
+ * TAL-807: the TAL-411 `remaining` level of the tightest account-scope window in the provider's cached account-usage
1192
+ * read; a model-family window (Opus or Sonnet weekly) never counts. A read past the cache TTL starts a new one, which
1193
+ * the picker waits for only when it has no read at all, and then for at most `PICKER_USAGE_WAIT_MS`; one past
1194
+ * `PICKER_USAGE_MAX_AGE_S` shows nothing.
1195
+ */
1196
+ async pickerQuotaLevel(profileHome, pid, t) {
1197
+ if (!ACCOUNT_USAGE_PROVIDERS.has(pid))
1198
+ return null;
1199
+ const key = `${profileHome}\0${pid}\0`;
1200
+ let cached = this.accountUsageCache.get(key);
1201
+ if (cached?.source !== this.sourceFingerprint(profileHome))
1202
+ cached = undefined;
1203
+ if (!cached || this.deps.now() - cached.at > ACCOUNT_USAGE_CACHE_TTL_S) {
1204
+ let read = this.pickerUsageReads.get(key);
1205
+ if (!read) {
1206
+ read = this.quota(profileHome, pid).then(() => undefined, () => undefined).finally(() => this.pickerUsageReads.delete(key));
1207
+ this.pickerUsageReads.set(key, read);
1208
+ }
1209
+ if (!cached) {
1210
+ let timer;
1211
+ await Promise.race([read, new Promise((resolve) => { timer = setTimeout(resolve, PICKER_USAGE_WAIT_MS); })]);
1212
+ clearTimeout(timer);
1213
+ cached = this.accountUsageCache.get(key);
1214
+ }
1215
+ }
1216
+ const limits = cached?.limits;
1217
+ if (cached?.source !== this.sourceFingerprint(profileHome) || !limits?.available || this.deps.now() - cached.at > PICKER_USAGE_MAX_AGE_S)
1218
+ return null;
1219
+ const raw = (Array.isArray(limits.windows) ? limits.windows : []).filter((w) => isDict(w) && w.scope !== 'model');
1220
+ const tightest = normalizeQuotaWindows(raw, this.deps.now()).windows
1221
+ .reduce((a, w) => (w.remaining_percent !== null && w.remaining_percent < (a?.remaining_percent ?? Infinity) ? w : a), undefined);
1222
+ return tightest ? quotaLevel(tightest, limits.stale ? 'stale' : 'available', 'remaining', t) : null;
1013
1223
  }
1014
1224
  /** Stamps the default's entry id and keeps the catalog for `modelOptionFor`. */
1015
- remember(profileHome, meta, catalog) {
1225
+ async remember(profileHome, meta, catalog) {
1226
+ if (catalog.availability)
1227
+ for (const group of catalog.groups)
1228
+ group.availability ??= catalog.availability;
1229
+ await this.stampSpeedTiers(profileHome, catalog.groups.flatMap((g) => [...g.models, ...(g.extra_models ?? [])]));
1230
+ if (meta.source !== this.sourceFingerprint(profileHome)) {
1231
+ if (meta.attempt === 0)
1232
+ return this.models(profileHome, 1);
1233
+ throw new Error('Provider configuration changed during catalog lookup. Try again.');
1234
+ }
1016
1235
  catalog.default_option_id = catalogOptionId(catalog, catalog.default_bare_id ?? null, catalog.default_provider_id ?? null);
1017
1236
  this.lastModels.set(profileHome, catalog);
1018
- this.lastModelsMeta.set(profileHome, { ...meta, at: this.deps.now() });
1237
+ this.lastModelsMeta.set(profileHome, { source: meta.source, unconfirmed: meta.unconfirmed, at: this.deps.now() });
1019
1238
  return structuredClone(catalog);
1020
1239
  }
1021
1240
  /** Python `_configured_model_badges_from_static_catalog`: which picker rows are the main/fallback selections. */
@@ -1047,14 +1266,17 @@ export class ProviderCatalog {
1047
1266
  return badges;
1048
1267
  }
1049
1268
  /** Python `_handle_live_models`. */
1050
- async liveModels(profileHome, providerRaw) {
1269
+ async liveModels(profileHome, providerRaw, attempt = 0) {
1270
+ const fingerprint = this.sourceFingerprint(profileHome);
1051
1271
  const config = await this.deps.config.read(profileHome);
1052
1272
  let provider = providerRaw.trim().toLowerCase() || str(modelSection(config).provider).trim().toLowerCase();
1053
1273
  if (!provider)
1054
1274
  return { error: 'no_provider', models: [] };
1055
1275
  provider = resolveProviderAlias(provider);
1056
1276
  let ids = await this.liveModelIds(profileHome, provider);
1057
- let source = 'live';
1277
+ if (fingerprint !== this.sourceFingerprint(profileHome))
1278
+ return attempt === 0 ? this.liveModels(profileHome, providerRaw, 1) : { availability: this.availability(this.sourceFingerprint(profileHome), false, false), provider, source: 'unavailable', models: [], count: 0 };
1279
+ let source = this.liveLookupFailed(profileHome, provider, fingerprint) ? 'fallback' : 'live';
1058
1280
  if (!ids.length) {
1059
1281
  const fromConfig = [];
1060
1282
  if (provider === 'custom' || provider.startsWith('custom:')) {
@@ -1064,16 +1286,33 @@ export class ProviderCatalog {
1064
1286
  fromConfig.push(...[str(cp.model).trim(), ...configuredModelIds(cp.models)].filter(Boolean));
1065
1287
  }
1066
1288
  }
1067
- ids = fromConfig.length ? [...new Set(fromConfig)] : (PROVIDER_MODELS[provider] ?? []).map((m) => m.id);
1068
- source = fromConfig.length ? 'config' : 'static';
1289
+ if (fromConfig.length) {
1290
+ ids = [...new Set(fromConfig)];
1291
+ source = 'config';
1292
+ }
1293
+ else if (source === 'fallback') {
1294
+ ids = (PROVIDER_MODELS[provider] ?? []).map((m) => m.id);
1295
+ source = 'static';
1296
+ }
1069
1297
  }
1070
- // Python: the dropdown-enrichment surface keeps the picker's visibility budget (15 rows past 25) and annotates
1071
- // OpenAI-family rows with `supports_fast_tier`; the answer carries `count`.
1072
1298
  if (provider !== 'nous' && ids.length > MODEL_PICKER_OVERFLOW_THRESHOLD)
1073
1299
  ids = ids.slice(0, MODEL_PICKER_VISIBLE_TARGET);
1074
- const annotateFastTier = isOpenAiFamilyProvider(provider);
1075
- const models = stampModelEntries(ids.filter(Boolean).map((id) => ({ id, label: labelForModel(id, []), ...(annotateFastTier ? { supports_fast_tier: mainModelSupportsServiceTier(id, provider) } : {}) })), provider);
1076
- return { provider, source, models, count: models.length };
1300
+ const models = stampModelEntries(ids.filter(Boolean).map((id) => ({ id, label: labelForModel(id, []) })), provider);
1301
+ await this.stampSpeedTiers(profileHome, models);
1302
+ if (fingerprint !== this.sourceFingerprint(profileHome))
1303
+ return attempt === 0 ? this.liveModels(profileHome, providerRaw, 1) : { availability: this.availability(this.sourceFingerprint(profileHome), false, false), provider, source: 'unavailable', models: [], count: 0 };
1304
+ return { availability: this.availability(fingerprint, source === 'live' || source === 'config', source === 'fallback' && models.length > 0), provider, source, models, count: models.length };
1305
+ }
1306
+ async stampSpeedTiers(profileHome, entries) {
1307
+ if (!entries.length)
1308
+ return;
1309
+ const config = await this.deps.config.read(profileHome);
1310
+ // An unavailable Agent offers no paid tier; writes still require a successful lookup.
1311
+ const tiers = await this.deps.config.speedTiers(profileHome, config, entries.map((e) => ({ model: e.bare_id ?? e.id, provider: e.provider_id ?? '' }))).catch(() => []);
1312
+ entries.forEach((entry, i) => {
1313
+ entry.supported_speed_tiers = tiers[i] ?? [];
1314
+ entry.supports_fast_tier = entry.supported_speed_tiers.some((t) => t.service_tier === 'priority');
1315
+ });
1077
1316
  }
1078
1317
  /**
1079
1318
  * Python `_resolve_model_context_length` inputs: the effective base URL, key, and any configured window for this
@@ -1136,27 +1375,35 @@ export class ProviderCatalog {
1136
1375
  if (envVar) {
1137
1376
  for (const name of [envVar, ...(PROVIDER_ENV_VAR_ALIASES[pid] ?? [])]) {
1138
1377
  const v = str(envValues[name] ?? this.processEnv(name, profileHome)).trim();
1378
+ // A reference owns this slot; only the Agent can resolve it before lower-priority keys.
1139
1379
  if (v)
1140
- return v;
1380
+ return v.startsWith('op://') ? null : v;
1141
1381
  }
1142
1382
  }
1143
1383
  // The same config sources `providerHasKey` counts, so a provider shown as keyed resolves its key.
1144
1384
  const model = modelSection(config);
1145
1385
  if (canonicaliseProviderId(model.provider) === canonicaliseProviderId(pid) && valueCountsAsApiKey(pid, model.api_key))
1146
- return str(model.api_key).trim();
1386
+ return str(model.api_key).trim().startsWith('op://') ? null : str(model.api_key).trim();
1147
1387
  for (const [key, value] of Object.entries(dict(config.providers))) {
1148
1388
  if (providerIdentity(key) === providerIdentity(pid) && isDict(value) && valueCountsAsApiKey(pid, value.api_key))
1149
- return str(value.api_key).trim();
1389
+ return str(value.api_key).trim().startsWith('op://') ? null : str(value.api_key).trim();
1150
1390
  }
1151
1391
  return null;
1152
1392
  }
1393
+ /** A raw key or the names the Agent resolves inside the profile's secret scope. */
1394
+ usageKey(pid, apiKey) {
1395
+ const envVar = providerEnvVar(pid);
1396
+ return apiKey ? { api_key: apiKey } : { env_vars: envVar ? [envVar, ...(PROVIDER_ENV_VAR_ALIASES[pid] ?? [])] : [] };
1397
+ }
1153
1398
  /** Account-usage snapshots by profile home, provider, and pool account. */
1154
1399
  accountUsageCache = new Map();
1400
+ quotaReadSequence = 0;
1155
1401
  /**
1156
1402
  * Python `get_provider_quota`. `credential` scopes the read to one credential-pool account (TAL-548): a benched account
1157
1403
  * answers with its local pool state, and every other read uses that account's key.
1158
1404
  */
1159
1405
  async quota(profileHome, providerRaw, opts = {}) {
1406
+ const sequence = opts.sequence ?? ++this.quotaReadSequence;
1160
1407
  const at = opts.at ?? this.deps.now();
1161
1408
  const computed_at = isoAt(at);
1162
1409
  const config = await this.deps.config.read(profileHome);
@@ -1174,7 +1421,9 @@ export class ProviderCatalog {
1174
1421
  let limits = null;
1175
1422
  // Python `_ACCOUNT_USAGE_CACHE_TTL_SECONDS`: a snapshot answers repeat polls for 45 s unless `refresh` is set.
1176
1423
  const cacheKey = `${profileHome}\0${provider}\0${credential?.credential_id ?? ''}`;
1177
- const cached = this.accountUsageCache.get(cacheKey);
1424
+ const hit = this.accountUsageCache.get(cacheKey);
1425
+ const source = this.sourceFingerprint(profileHome);
1426
+ const cached = hit?.source === source ? hit : undefined;
1178
1427
  if (cached && !opts.refresh && this.deps.now() - cached.at <= ACCOUNT_USAGE_CACHE_TTL_S)
1179
1428
  limits = cached.limits;
1180
1429
  else if (sidecar) {
@@ -1187,7 +1436,12 @@ export class ProviderCatalog {
1187
1436
  catch (error) {
1188
1437
  limits = { available: false, unavailable_reason: str(error.message), windows: [], details: [] };
1189
1438
  }
1190
- this.accountUsageCache.set(cacheKey, { at: this.deps.now(), limits });
1439
+ if (source === this.sourceFingerprint(profileHome)) {
1440
+ if ((this.accountUsageCache.get(cacheKey)?.sequence ?? -1) <= sequence)
1441
+ this.accountUsageCache.set(cacheKey, { at: this.deps.now(), limits, source, sequence });
1442
+ }
1443
+ else
1444
+ limits = null;
1191
1445
  }
1192
1446
  // The cache keeps the Agent's raw windows; pace is recomputed as of every response.
1193
1447
  if (limits)
@@ -1197,11 +1451,11 @@ export class ProviderCatalog {
1197
1451
  const reason = str(limits?.unavailable_reason).trim();
1198
1452
  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
1453
  }
1200
- if (provider === 'openrouter' || provider === 'deepseek' || provider === 'opencode-go') {
1454
+ if (isBalanceProvider(provider)) {
1201
1455
  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;
1456
+ const key = credential ? { credential_id: credential.credential_id } : this.usageKey(provider, apiKey);
1457
+ const read = await this.balance(profileHome, provider, key);
1458
+ const status = read?.status === 'no_key' ? 'no_key' : read?.status === 'ok' ? 'ok' : read?.status === 'http_error' ? read.http_status : null;
1205
1459
  if (provider === 'openrouter') {
1206
1460
  if (status === 'no_key')
1207
1461
  return answer(false, 'no_key', 'OpenRouter quota status needs an OPENROUTER_API_KEY configured on the server.');
@@ -1235,7 +1489,7 @@ export class ProviderCatalog {
1235
1489
  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
1490
  }
1237
1491
  // 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)) ?? [];
1492
+ const entries = credential ? [credential] : (await this.poolEntries(profileHome, provider, null)).value?.entries ?? [];
1239
1493
  if (entries.length) {
1240
1494
  const limits = credential ? poolEntryLimits(credential, at) : poolLimits(entries, at);
1241
1495
  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 });
@@ -1247,7 +1501,7 @@ export class ProviderCatalog {
1247
1501
  * `apiKey`, the provider's configured key. Null when the pool is unknown (see `poolLookup`).
1248
1502
  */
1249
1503
  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);
1504
+ return this.poolLookup(profileHome, `pool\0${pid}`, (sidecar) => sidecar.call('usage.pool', { profile_home: profileHome, provider: pid, ...this.usageKey(pid, apiKey) }));
1251
1505
  }
1252
1506
  /** The last answer of each credential-pool lookup, by profile home and lookup. */
1253
1507
  lastPoolAnswers = new Map();
@@ -1256,23 +1510,48 @@ export class ProviderCatalog {
1256
1510
  * lookup, else null: an unknown pool, never an empty one, so no pool account is ever reported as removed.
1257
1511
  */
1258
1512
  async poolLookup(profileHome, lookup, call) {
1513
+ const source = this.sourceFingerprint(profileHome);
1259
1514
  const key = `${profileHome}\0${lookup}`;
1260
1515
  try {
1261
1516
  const sidecar = this.deps.sidecar();
1262
1517
  if (!sidecar)
1263
1518
  throw new Error('the sidecar is not running');
1264
1519
  const answer = await call(sidecar);
1265
- this.lastPoolAnswers.set(key, answer);
1266
- return answer;
1520
+ if (source !== this.sourceFingerprint(profileHome))
1521
+ return { value: null, authoritative: false };
1522
+ this.lastPoolAnswers.set(key, { source, value: answer });
1523
+ return { value: answer, authoritative: true };
1267
1524
  }
1268
1525
  catch (error) {
1269
1526
  this.deps.log(`[catalog] credential pool lookup ${lookup.replace('\0', ' ')} failed: ${str(error.message)}`);
1270
- return this.lastPoolAnswers.get(key) ?? null;
1527
+ const hit = this.lastPoolAnswers.get(key);
1528
+ return { value: hit?.source === source && source === this.sourceFingerprint(profileHome) ? hit.value : null, authoritative: false };
1271
1529
  }
1272
1530
  }
1273
- /** TAL-548: the providers whose persisted credential pool holds an added account; none when the sidecar cannot say. */
1531
+ /** One shared read of pool membership and scoped key revisions before reusing catalog data. */
1274
1532
  poolProviders(profileHome) {
1275
- return this.poolLookup(profileHome, 'providers', async (sidecar) => (await sidecar.call('usage.pool_providers', { profile_home: profileHome })).providers);
1533
+ const inputSource = this.sourceFingerprint(profileHome, false);
1534
+ const readKey = this.sourceFingerprint(profileHome);
1535
+ const pending = this.credentialReads.get(readKey);
1536
+ if (pending)
1537
+ return pending;
1538
+ const read = (async () => {
1539
+ const config = await this.deps.config.read(profileHome);
1540
+ const key_envs = Object.fromEntries(BALANCE_PROVIDERS.filter((pid) => this.apiKeyFor(pid, profileHome, config) === null).map((pid) => [pid, this.usageKey(pid, null).env_vars ?? []]));
1541
+ const result = await this.poolLookup(profileHome, 'providers', (sidecar) => sidecar.call('usage.pool_providers', { profile_home: profileHome, key_envs }));
1542
+ if (inputSource !== this.sourceFingerprint(profileHome, false))
1543
+ return { value: null, authoritative: false };
1544
+ this.scopedKeyAuthority.set(profileHome, result.authoritative);
1545
+ if (result.authoritative)
1546
+ this.scopedKeyRevisions.set(profileHome, JSON.stringify(Object.entries(result.value?.keys ?? {}).sort(([a], [b]) => a.localeCompare(b)).map(([pid, state]) => [pid, state.revision])));
1547
+ return result;
1548
+ })().catch(() => {
1549
+ if (inputSource === this.sourceFingerprint(profileHome, false))
1550
+ this.scopedKeyAuthority.set(profileHome, false);
1551
+ return { value: null, authoritative: false };
1552
+ }).finally(() => { this.credentialReads.delete(readKey); });
1553
+ this.credentialReads.set(readKey, read);
1554
+ return read;
1276
1555
  }
1277
1556
  /** TAL-548: one key-based balance read through the sidecar, which resolves a pool account's key itself; null when it fails. */
1278
1557
  async balance(profileHome, provider, key) {
@@ -1289,52 +1568,46 @@ export class ProviderCatalog {
1289
1568
  }
1290
1569
  quotaScopeCache = null;
1291
1570
  /** Python `_quota_server_scope_id`: a 32-hex id persisted at `<state>/.quota_scope_id` (created atomically once). */
1292
- quotaServerScopeId() {
1571
+ async quotaServerScopeId() {
1293
1572
  if (this.quotaScopeCache)
1294
1573
  return this.quotaScopeCache;
1295
- const dir = this.deps.stateDir;
1296
- if (!dir) {
1574
+ const documents = this.deps.documents;
1575
+ if (!documents) {
1297
1576
  this.quotaScopeCache = randomUUID().replaceAll('-', '');
1298
1577
  return this.quotaScopeCache;
1299
1578
  }
1300
- const path = join(dir, '.quota_scope_id');
1301
- let stored = '';
1302
- try {
1303
- stored = readFileSync(path, 'utf8').trim().toLowerCase();
1304
- }
1305
- catch {
1306
- stored = '';
1307
- }
1308
- if (!/^[0-9a-f]{32}$/.test(stored)) {
1309
- stored = randomUUID().replaceAll('-', '');
1310
- mkdirSync(dir, { recursive: true });
1311
- const temporary = `${path}.tmp.${String(process.pid)}`;
1312
- writeFileSync(temporary, `${stored}\n`, 'utf8');
1313
- renameSync(temporary, path);
1314
- }
1579
+ const stored = await updateDocument(documents, 'catalog', 'quota-scope', (value) => {
1580
+ const previous = typeof value === 'string' ? value.trim().toLowerCase() : '';
1581
+ if (/^[0-9a-f]{32}$/.test(previous))
1582
+ return { value, result: previous };
1583
+ const id = randomUUID().replaceAll('-', '');
1584
+ return { value: `${id}\n`, result: id };
1585
+ });
1315
1586
  this.quotaScopeCache = stored;
1316
1587
  return stored;
1317
1588
  }
1318
1589
  /** Python `_quota_profile_scope_id`. */
1319
- quotaProfileScopeId(profile) {
1320
- return `qscope_${createHash('sha256').update(`${this.quotaServerScopeId()}\0${profile}`).digest('hex').slice(0, 32)}`;
1590
+ async quotaProfileScopeId(profile) {
1591
+ return `qscope_${createHash('sha256').update(`${(await this.quotaServerScopeId())}\0${profile}`).digest('hex').slice(0, 32)}`;
1321
1592
  }
1322
1593
  /**
1323
1594
  * Python `get_provider_quotas`: the stable-identity envelope the iOS widget persists. A keyed provider is one source per
1324
1595
  * credential-pool account, else one for its own key (Python `_quota_source_descriptors`, TAL-548). Each source id appears
1325
1596
  * once (two custom providers can share a slug), ordered by provider, account label, then source id.
1326
1597
  */
1327
- async quotas(profileHome, profile, opts = {}) {
1598
+ async quotas(profileHome, profile, opts = {}, attempt = 0) {
1599
+ const sequence = ++this.quotaReadSequence;
1600
+ const source = this.sourceFingerprint(profileHome);
1328
1601
  const at = this.deps.now();
1329
1602
  const { payload: status, pooled: pooledIds } = await this.providerRows(profileHome);
1330
1603
  const active = status.active_provider;
1331
- const scopeId = this.quotaProfileScopeId(profile);
1604
+ const scopeId = (await this.quotaProfileScopeId(profile));
1332
1605
  // Python `_quota_source_id(profile, provider, credential_id)`: a pool account's id, or "provider" for the provider's own key.
1333
1606
  const sourceId = (pid, credential) => `qsrc_${createHash('sha256').update(`${scopeId}\0${pid}\0${credential}`).digest('hex').slice(0, 32)}`;
1334
1607
  // A provider configured only through `hermes auth add` has no key in .env or config.yaml, yet its pool accounts are
1335
1608
  // sources, including a signed-out OAuth provider's. An unknown pool (a failed lookup with no earlier answer) never
1336
1609
  // reports a requested source as missing.
1337
- let poolUnknown = pooledIds === null;
1610
+ let poolUnknown = !status.availability.authoritative;
1338
1611
  const pooled = new Set(pooledIds ?? []);
1339
1612
  const config = await this.deps.config.read(profileHome);
1340
1613
  const perProvider = await Promise.all(status.providers.filter((p) => p.has_key || p.is_custom || pooled.has(str(p.id))).map(async (p) => {
@@ -1342,30 +1615,80 @@ export class ProviderCatalog {
1342
1615
  const label = str(p.display_name) || pid;
1343
1616
  const apiKey = this.apiKeyFor(pid, profileHome, config);
1344
1617
  const found = await this.poolEntries(profileHome, pid, apiKey);
1345
- if (!found)
1618
+ if (!found.authoritative)
1346
1619
  poolUnknown = true;
1347
- const entries = found ?? [];
1620
+ const entries = found.value?.entries ?? [];
1348
1621
  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
1622
  // 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));
1623
+ const ownKey = found.authoritative && ((!entries.length && (p.has_key === true || p.is_custom === true) && !pooled.has(pid)) || ((apiKey !== null || found.value?.has_api_key === true) && !entries.some((e) => e.matches_api_key)));
1351
1624
  return ownKey ? [{ source_id: sourceId(pid, 'provider'), provider_id: pid, provider_label: label, account_label: label, credential: null }, ...accounts] : accounts;
1352
1625
  }));
1353
1626
  let descriptors = uniqueQuotaSources(perProvider.flat());
1627
+ const membership = new Set(descriptors.map((d) => d.source_id));
1354
1628
  const requested = str(opts.sourceId).trim() || null;
1355
1629
  if (requested)
1356
1630
  descriptors = descriptors.filter((d) => d.source_id === requested);
1357
- const thresholds = this.deps.quotaThresholds?.(profile) ?? QUOTA_THRESHOLD_DEFAULTS;
1358
- const sources = await Promise.all(descriptors.map(async (d) => {
1359
- const q = await this.quota(profileHome, d.provider_id, { refresh: opts.refresh ?? false, at, credential: d.credential });
1631
+ const thresholds = (await this.deps.quotaThresholds?.(profile)) ?? QUOTA_THRESHOLD_DEFAULTS;
1632
+ let sources = await Promise.all(descriptors.map(async (d) => {
1633
+ const q = await this.quota(profileHome, d.provider_id, { refresh: opts.refresh ?? false, at, credential: d.credential, sequence });
1360
1634
  const limits = dict(q.account_limits);
1361
1635
  return classifyQuotaSource({
1362
- source_id: d.source_id, provider_id: d.provider_id, provider_label: d.provider_label, account_label: d.account_label,
1636
+ computed_at: isoAt(at), source_id: d.source_id, provider_id: d.provider_id, provider_label: d.provider_label, account_label: d.account_label,
1363
1637
  is_active_provider: d.provider_id === active, supported: q.supported === true, status: str(limits.status) || str(q.status) || 'unavailable',
1364
1638
  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 ?? [],
1365
1639
  unavailable_reason: limits.unavailable_reason ?? null, retry_after: limits.retry_after ?? null, fetched_at: limits.fetched_at ?? null, message: q.message ?? null,
1366
1640
  }, thresholds);
1367
1641
  }));
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 };
1642
+ if (source !== this.sourceFingerprint(profileHome) && attempt === 0)
1643
+ return this.quotas(profileHome, profile, opts, 1);
1644
+ poolUnknown ||= source !== this.sourceFingerprint(profileHome);
1645
+ const cacheKey = `${profileHome}\0${scopeId}`;
1646
+ const cached = source === this.sourceFingerprint(profileHome) ? this.lastQuotaSources.get(cacheKey) : undefined;
1647
+ const prior = cached?.source === source ? cached : undefined;
1648
+ const previous = prior?.sources ?? [];
1649
+ if (!poolUnknown) {
1650
+ sources = sources.map((s) => {
1651
+ const old = previous.find((p) => p.source_id === s.source_id);
1652
+ return s.status === 'unavailable' && old ? this.staleQuotaSource(old, str(s.unavailable_reason || s.message)) : s;
1653
+ });
1654
+ const latestMembership = prior && prior.sequence > sequence ? prior.membership : membership;
1655
+ const sourceSequences = new Map(prior?.sourceSequences);
1656
+ const merged = new Map(previous.filter((s) => latestMembership.has(str(s.source_id))).map((s) => [str(s.source_id), s]));
1657
+ for (const value of sources) {
1658
+ const id = str(value.source_id);
1659
+ if (!latestMembership.has(id) || (sourceSequences.get(id) ?? -1) > sequence)
1660
+ continue;
1661
+ merged.set(id, value);
1662
+ sourceSequences.set(id, sequence);
1663
+ }
1664
+ for (const id of sourceSequences.keys())
1665
+ if (!latestMembership.has(id))
1666
+ sourceSequences.delete(id);
1667
+ const snapshot = uniqueQuotaSources([...merged.values()]);
1668
+ this.lastQuotaSources.set(cacheKey, { source, sequence: Math.max(sequence, prior?.sequence ?? -1), membership: latestMembership, sources: snapshot, sourceSequences });
1669
+ // A delayed read answers with the newest confirmed projection, including confirmed removals.
1670
+ sources = snapshot.filter((s) => !requested || s.source_id === requested);
1671
+ }
1672
+ else {
1673
+ sources = previous.filter((s) => !requested || s.source_id === requested).map((s) => this.staleQuotaSource(s));
1674
+ }
1675
+ const currentSource = this.sourceFingerprint(profileHome);
1676
+ if (currentSource !== source) {
1677
+ poolUnknown = true;
1678
+ sources = [];
1679
+ }
1680
+ const latestMembership = this.lastQuotaSources.get(cacheKey)?.membership;
1681
+ const availability = this.availability(currentSource, !poolUnknown, sources.length > 0);
1682
+ return { availability, version: 1, computed_at: isoAt(at), scope_id: scopeId, profile_id: profile, active_provider: active, requested_source_id: requested, missing_source: Boolean(requested && !poolUnknown && !latestMembership?.has(requested)), sources };
1683
+ }
1684
+ lastQuotaSources = new Map();
1685
+ staleQuotaSource(source, reason = 'Provider data is temporarily unavailable. Showing last-confirmed data.') {
1686
+ return { ...source, status: 'stale', unavailable_reason: reason, message: reason, urgency: { remaining: 'stale', pace: 'stale' } };
1687
+ }
1688
+ availability(source, authoritative, cached) {
1689
+ return { authoritative, status: authoritative ? 'available' : cached ? 'stale' : 'unavailable',
1690
+ unavailable_reason: authoritative ? null : cached ? 'Provider data is temporarily unavailable. Showing last-confirmed data.' : 'Provider data is temporarily unavailable. Try again.',
1691
+ generation: createHash('sha256').update(source).digest('hex') };
1369
1692
  }
1370
1693
  /** Python `get_provider_cost_history` (OpenRouter only; daily snapshots under `<home>/cost-snapshots`). */
1371
1694
  async costHistory(profileHome, providerRaw, days) {
@@ -1378,9 +1701,9 @@ export class ProviderCatalog {
1378
1701
  const budget = this.deps.costBudget();
1379
1702
  const config = await this.deps.config.read(profileHome);
1380
1703
  const apiKey = this.apiKeyFor('openrouter', profileHome, config);
1381
- if (!apiKey)
1704
+ const info = await this.balance(profileHome, 'openrouter', this.usageKey('openrouter', apiKey));
1705
+ if (info?.status === 'no_key')
1382
1706
  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.' };
1383
- const info = await this.balance(profileHome, 'openrouter', { api_key: apiKey });
1384
1707
  if (info?.status !== 'ok' || !info.quota) {
1385
1708
  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
1709
  }