synabun 2.0.5 → 2.0.9

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 (527) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/post-plan.mjs +6 -30
  6. package/hooks/claude-code/session-start.mjs +1 -4
  7. package/hooks/claude-code/shared.mjs +112 -75
  8. package/lib/child-path.js +303 -0
  9. package/lib/claude-hooks.js +2 -1
  10. package/lib/data-home-migration.js +2 -0
  11. package/lib/desktop-app.js +265 -0
  12. package/lib/external-tools.js +7 -3
  13. package/lib/log-redact.js +102 -0
  14. package/lib/package-processes.js +670 -0
  15. package/lib/packaged-runtime.js +138 -11
  16. package/lib/runtime-dependencies.js +225 -0
  17. package/mcp-server/dist/http.d.ts +3 -1
  18. package/mcp-server/dist/http.d.ts.map +1 -1
  19. package/mcp-server/dist/http.js +15 -4
  20. package/mcp-server/dist/http.js.map +1 -1
  21. package/mcp-server/dist/index.d.ts.map +1 -1
  22. package/mcp-server/dist/index.js +9 -6
  23. package/mcp-server/dist/index.js.map +1 -1
  24. package/mcp-server/dist/services/browser-bench.d.ts +1 -1
  25. package/mcp-server/dist/services/browser-bench.d.ts.map +1 -1
  26. package/mcp-server/dist/services/browser-bench.js +1 -1
  27. package/mcp-server/dist/services/browser-bench.js.map +1 -1
  28. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  29. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  30. package/mcp-server/dist/services/budget-judgments.js +153 -0
  31. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  32. package/mcp-server/dist/services/categories.d.ts +35 -2
  33. package/mcp-server/dist/services/categories.d.ts.map +1 -1
  34. package/mcp-server/dist/services/categories.js +77 -33
  35. package/mcp-server/dist/services/categories.js.map +1 -1
  36. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  37. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  38. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  39. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  40. package/mcp-server/dist/services/desktop-bench.d.ts +7 -7
  41. package/mcp-server/dist/services/desktop-bench.d.ts.map +1 -1
  42. package/mcp-server/dist/services/desktop-bench.js +1 -1
  43. package/mcp-server/dist/services/desktop-bench.js.map +1 -1
  44. package/mcp-server/dist/services/identity.d.ts +11 -0
  45. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  46. package/mcp-server/dist/services/identity.js +24 -0
  47. package/mcp-server/dist/services/identity.js.map +1 -1
  48. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  49. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  50. package/mcp-server/dist/services/neural-interface.js +34 -2
  51. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  52. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  53. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  54. package/mcp-server/dist/services/typesafe-config.js +4 -1
  55. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  56. package/mcp-server/dist/tools/agents.d.ts +11 -8
  57. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  58. package/mcp-server/dist/tools/agents.js +35 -14
  59. package/mcp-server/dist/tools/agents.js.map +1 -1
  60. package/mcp-server/dist/tools/bluesky.js +1 -1
  61. package/mcp-server/dist/tools/bluesky.js.map +1 -1
  62. package/mcp-server/dist/tools/browser-advanced.d.ts +1 -1
  63. package/mcp-server/dist/tools/browser-advanced.d.ts.map +1 -1
  64. package/mcp-server/dist/tools/browser-advanced.js +1 -1
  65. package/mcp-server/dist/tools/browser-advanced.js.map +1 -1
  66. package/mcp-server/dist/tools/browser-batch.d.ts +1 -1
  67. package/mcp-server/dist/tools/browser-batch.d.ts.map +1 -1
  68. package/mcp-server/dist/tools/browser-batch.js +1 -1
  69. package/mcp-server/dist/tools/browser-batch.js.map +1 -1
  70. package/mcp-server/dist/tools/browser-cheatsheet.d.ts +1 -1
  71. package/mcp-server/dist/tools/browser-cheatsheet.d.ts.map +1 -1
  72. package/mcp-server/dist/tools/browser-cheatsheet.js +1 -1
  73. package/mcp-server/dist/tools/browser-cheatsheet.js.map +1 -1
  74. package/mcp-server/dist/tools/browser-interact.d.ts +1 -1
  75. package/mcp-server/dist/tools/browser-interact.d.ts.map +1 -1
  76. package/mcp-server/dist/tools/browser-interact.js +1 -1
  77. package/mcp-server/dist/tools/browser-interact.js.map +1 -1
  78. package/mcp-server/dist/tools/browser-navigate.d.ts +1 -1
  79. package/mcp-server/dist/tools/browser-navigate.d.ts.map +1 -1
  80. package/mcp-server/dist/tools/browser-navigate.js +1 -1
  81. package/mcp-server/dist/tools/browser-navigate.js.map +1 -1
  82. package/mcp-server/dist/tools/browser-observe.d.ts +1 -1
  83. package/mcp-server/dist/tools/browser-observe.d.ts.map +1 -1
  84. package/mcp-server/dist/tools/browser-observe.js +1 -1
  85. package/mcp-server/dist/tools/browser-observe.js.map +1 -1
  86. package/mcp-server/dist/tools/card-tools.d.ts +1 -1
  87. package/mcp-server/dist/tools/card-tools.d.ts.map +1 -1
  88. package/mcp-server/dist/tools/card-tools.js +1 -1
  89. package/mcp-server/dist/tools/card-tools.js.map +1 -1
  90. package/mcp-server/dist/tools/category.d.ts +1 -1
  91. package/mcp-server/dist/tools/category.d.ts.map +1 -1
  92. package/mcp-server/dist/tools/category.js +5 -1
  93. package/mcp-server/dist/tools/category.js.map +1 -1
  94. package/mcp-server/dist/tools/choice.d.ts +1 -1
  95. package/mcp-server/dist/tools/choice.d.ts.map +1 -1
  96. package/mcp-server/dist/tools/choice.js +1 -1
  97. package/mcp-server/dist/tools/choice.js.map +1 -1
  98. package/mcp-server/dist/tools/computer.d.ts +1 -1
  99. package/mcp-server/dist/tools/computer.d.ts.map +1 -1
  100. package/mcp-server/dist/tools/computer.js +1 -1
  101. package/mcp-server/dist/tools/computer.js.map +1 -1
  102. package/mcp-server/dist/tools/discord-channel.d.ts +1 -1
  103. package/mcp-server/dist/tools/discord-channel.d.ts.map +1 -1
  104. package/mcp-server/dist/tools/discord-channel.js +1 -1
  105. package/mcp-server/dist/tools/discord-channel.js.map +1 -1
  106. package/mcp-server/dist/tools/discord-guild.d.ts +1 -1
  107. package/mcp-server/dist/tools/discord-guild.d.ts.map +1 -1
  108. package/mcp-server/dist/tools/discord-guild.js +1 -1
  109. package/mcp-server/dist/tools/discord-guild.js.map +1 -1
  110. package/mcp-server/dist/tools/discord-member.d.ts +1 -1
  111. package/mcp-server/dist/tools/discord-member.d.ts.map +1 -1
  112. package/mcp-server/dist/tools/discord-member.js +1 -1
  113. package/mcp-server/dist/tools/discord-member.js.map +1 -1
  114. package/mcp-server/dist/tools/discord-message.d.ts +1 -1
  115. package/mcp-server/dist/tools/discord-message.d.ts.map +1 -1
  116. package/mcp-server/dist/tools/discord-message.js +1 -1
  117. package/mcp-server/dist/tools/discord-message.js.map +1 -1
  118. package/mcp-server/dist/tools/discord-onboarding.d.ts +1 -1
  119. package/mcp-server/dist/tools/discord-onboarding.d.ts.map +1 -1
  120. package/mcp-server/dist/tools/discord-onboarding.js +1 -1
  121. package/mcp-server/dist/tools/discord-onboarding.js.map +1 -1
  122. package/mcp-server/dist/tools/discord-role.d.ts +1 -1
  123. package/mcp-server/dist/tools/discord-role.d.ts.map +1 -1
  124. package/mcp-server/dist/tools/discord-role.js +1 -1
  125. package/mcp-server/dist/tools/discord-role.js.map +1 -1
  126. package/mcp-server/dist/tools/discord-thread.d.ts +1 -1
  127. package/mcp-server/dist/tools/discord-thread.d.ts.map +1 -1
  128. package/mcp-server/dist/tools/discord-thread.js +1 -1
  129. package/mcp-server/dist/tools/discord-thread.js.map +1 -1
  130. package/mcp-server/dist/tools/discord-webhook.d.ts +1 -1
  131. package/mcp-server/dist/tools/discord-webhook.d.ts.map +1 -1
  132. package/mcp-server/dist/tools/discord-webhook.js +1 -1
  133. package/mcp-server/dist/tools/discord-webhook.js.map +1 -1
  134. package/mcp-server/dist/tools/fb-groups.d.ts +1 -1
  135. package/mcp-server/dist/tools/fb-groups.d.ts.map +1 -1
  136. package/mcp-server/dist/tools/fb-groups.js +1 -1
  137. package/mcp-server/dist/tools/fb-groups.js.map +1 -1
  138. package/mcp-server/dist/tools/forget.d.ts +1 -1
  139. package/mcp-server/dist/tools/forget.d.ts.map +1 -1
  140. package/mcp-server/dist/tools/forget.js +1 -1
  141. package/mcp-server/dist/tools/forget.js.map +1 -1
  142. package/mcp-server/dist/tools/git-tools.d.ts +1 -1
  143. package/mcp-server/dist/tools/git-tools.d.ts.map +1 -1
  144. package/mcp-server/dist/tools/git-tools.js +1 -1
  145. package/mcp-server/dist/tools/git-tools.js.map +1 -1
  146. package/mcp-server/dist/tools/gsc/experience.d.ts +1 -1
  147. package/mcp-server/dist/tools/gsc/experience.d.ts.map +1 -1
  148. package/mcp-server/dist/tools/gsc/experience.js +1 -1
  149. package/mcp-server/dist/tools/gsc/experience.js.map +1 -1
  150. package/mcp-server/dist/tools/gsc/indexing.d.ts +1 -1
  151. package/mcp-server/dist/tools/gsc/indexing.d.ts.map +1 -1
  152. package/mcp-server/dist/tools/gsc/indexing.js +1 -1
  153. package/mcp-server/dist/tools/gsc/indexing.js.map +1 -1
  154. package/mcp-server/dist/tools/gsc/inspect.d.ts +1 -1
  155. package/mcp-server/dist/tools/gsc/inspect.d.ts.map +1 -1
  156. package/mcp-server/dist/tools/gsc/inspect.js +1 -1
  157. package/mcp-server/dist/tools/gsc/inspect.js.map +1 -1
  158. package/mcp-server/dist/tools/gsc/navigate.d.ts +1 -1
  159. package/mcp-server/dist/tools/gsc/navigate.d.ts.map +1 -1
  160. package/mcp-server/dist/tools/gsc/navigate.js +1 -1
  161. package/mcp-server/dist/tools/gsc/navigate.js.map +1 -1
  162. package/mcp-server/dist/tools/gsc/performance.d.ts +1 -1
  163. package/mcp-server/dist/tools/gsc/performance.d.ts.map +1 -1
  164. package/mcp-server/dist/tools/gsc/performance.js +1 -1
  165. package/mcp-server/dist/tools/gsc/performance.js.map +1 -1
  166. package/mcp-server/dist/tools/gsc/settings.d.ts +1 -1
  167. package/mcp-server/dist/tools/gsc/settings.d.ts.map +1 -1
  168. package/mcp-server/dist/tools/gsc/settings.js +1 -1
  169. package/mcp-server/dist/tools/gsc/settings.js.map +1 -1
  170. package/mcp-server/dist/tools/image-tools.d.ts +1 -1
  171. package/mcp-server/dist/tools/image-tools.d.ts.map +1 -1
  172. package/mcp-server/dist/tools/image-tools.js +1 -1
  173. package/mcp-server/dist/tools/image-tools.js.map +1 -1
  174. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +1 -1
  175. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +1 -1
  176. package/mcp-server/dist/tools/leonardo-browser-tools.js +1 -1
  177. package/mcp-server/dist/tools/leonardo-browser-tools.js.map +1 -1
  178. package/mcp-server/dist/tools/loop.d.ts +1 -1
  179. package/mcp-server/dist/tools/loop.d.ts.map +1 -1
  180. package/mcp-server/dist/tools/loop.js +1 -1
  181. package/mcp-server/dist/tools/loop.js.map +1 -1
  182. package/mcp-server/dist/tools/memories.d.ts +1 -1
  183. package/mcp-server/dist/tools/memories.d.ts.map +1 -1
  184. package/mcp-server/dist/tools/memories.js +1 -1
  185. package/mcp-server/dist/tools/memories.js.map +1 -1
  186. package/mcp-server/dist/tools/morelogin.d.ts +1 -1
  187. package/mcp-server/dist/tools/morelogin.d.ts.map +1 -1
  188. package/mcp-server/dist/tools/morelogin.js +1 -1
  189. package/mcp-server/dist/tools/morelogin.js.map +1 -1
  190. package/mcp-server/dist/tools/profile.d.ts +1 -1
  191. package/mcp-server/dist/tools/profile.d.ts.map +1 -1
  192. package/mcp-server/dist/tools/profile.js +1 -1
  193. package/mcp-server/dist/tools/profile.js.map +1 -1
  194. package/mcp-server/dist/tools/recall.d.ts +1 -1
  195. package/mcp-server/dist/tools/recall.d.ts.map +1 -1
  196. package/mcp-server/dist/tools/recall.js +1 -1
  197. package/mcp-server/dist/tools/recall.js.map +1 -1
  198. package/mcp-server/dist/tools/reflect.d.ts +1 -1
  199. package/mcp-server/dist/tools/reflect.d.ts.map +1 -1
  200. package/mcp-server/dist/tools/reflect.js +1 -1
  201. package/mcp-server/dist/tools/reflect.js.map +1 -1
  202. package/mcp-server/dist/tools/remember.d.ts +1 -1
  203. package/mcp-server/dist/tools/remember.d.ts.map +1 -1
  204. package/mcp-server/dist/tools/remember.js +13 -11
  205. package/mcp-server/dist/tools/remember.js.map +1 -1
  206. package/mcp-server/dist/tools/restore.d.ts +1 -1
  207. package/mcp-server/dist/tools/restore.d.ts.map +1 -1
  208. package/mcp-server/dist/tools/restore.js +1 -1
  209. package/mcp-server/dist/tools/restore.js.map +1 -1
  210. package/mcp-server/dist/tools/style-guide-tools.d.ts +1 -1
  211. package/mcp-server/dist/tools/style-guide-tools.d.ts.map +1 -1
  212. package/mcp-server/dist/tools/style-guide-tools.js +1 -1
  213. package/mcp-server/dist/tools/style-guide-tools.js.map +1 -1
  214. package/mcp-server/dist/tools/sync.d.ts +1 -1
  215. package/mcp-server/dist/tools/sync.d.ts.map +1 -1
  216. package/mcp-server/dist/tools/sync.js +1 -1
  217. package/mcp-server/dist/tools/sync.js.map +1 -1
  218. package/mcp-server/dist/tools/tictactoe-tools.d.ts +1 -1
  219. package/mcp-server/dist/tools/tictactoe-tools.d.ts.map +1 -1
  220. package/mcp-server/dist/tools/tictactoe-tools.js +1 -1
  221. package/mcp-server/dist/tools/tictactoe-tools.js.map +1 -1
  222. package/mcp-server/dist/tools/utils.d.ts +1 -1
  223. package/mcp-server/dist/tools/utils.d.ts.map +1 -1
  224. package/mcp-server/dist/tools/utils.js +1 -1
  225. package/mcp-server/dist/tools/utils.js.map +1 -1
  226. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  227. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  228. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  229. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  230. package/mcp-server/dist/tools/whiteboard-tools.d.ts +3 -3
  231. package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -1
  232. package/mcp-server/dist/tools/whiteboard-tools.js +1 -1
  233. package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -1
  234. package/mcp-server/dist/tools/youtube/analytics.d.ts +1 -1
  235. package/mcp-server/dist/tools/youtube/analytics.d.ts.map +1 -1
  236. package/mcp-server/dist/tools/youtube/analytics.js +1 -1
  237. package/mcp-server/dist/tools/youtube/analytics.js.map +1 -1
  238. package/mcp-server/dist/tools/youtube/download.d.ts +1 -1
  239. package/mcp-server/dist/tools/youtube/download.d.ts.map +1 -1
  240. package/mcp-server/dist/tools/youtube/download.js +1 -1
  241. package/mcp-server/dist/tools/youtube/download.js.map +1 -1
  242. package/mcp-server/dist/tools/youtube/media.d.ts +1 -1
  243. package/mcp-server/dist/tools/youtube/media.d.ts.map +1 -1
  244. package/mcp-server/dist/tools/youtube/media.js +1 -1
  245. package/mcp-server/dist/tools/youtube/media.js.map +1 -1
  246. package/mcp-server/dist/tools/youtube/metadata.d.ts +1 -1
  247. package/mcp-server/dist/tools/youtube/metadata.d.ts.map +1 -1
  248. package/mcp-server/dist/tools/youtube/metadata.js +1 -1
  249. package/mcp-server/dist/tools/youtube/metadata.js.map +1 -1
  250. package/mcp-server/dist/tools/youtube/navigate.d.ts +1 -1
  251. package/mcp-server/dist/tools/youtube/navigate.d.ts.map +1 -1
  252. package/mcp-server/dist/tools/youtube/navigate.js +1 -1
  253. package/mcp-server/dist/tools/youtube/navigate.js.map +1 -1
  254. package/mcp-server/dist/tools/youtube/playlist.d.ts +1 -1
  255. package/mcp-server/dist/tools/youtube/playlist.d.ts.map +1 -1
  256. package/mcp-server/dist/tools/youtube/playlist.js +1 -1
  257. package/mcp-server/dist/tools/youtube/playlist.js.map +1 -1
  258. package/mcp-server/dist/tools/youtube/schedule.d.ts +1 -1
  259. package/mcp-server/dist/tools/youtube/schedule.d.ts.map +1 -1
  260. package/mcp-server/dist/tools/youtube/schedule.js +1 -1
  261. package/mcp-server/dist/tools/youtube/schedule.js.map +1 -1
  262. package/mcp-server/dist/tools/youtube/source.d.ts +1 -1
  263. package/mcp-server/dist/tools/youtube/source.d.ts.map +1 -1
  264. package/mcp-server/dist/tools/youtube/source.js +1 -1
  265. package/mcp-server/dist/tools/youtube/source.js.map +1 -1
  266. package/mcp-server/dist/tools/youtube/upload.d.ts +1 -1
  267. package/mcp-server/dist/tools/youtube/upload.d.ts.map +1 -1
  268. package/mcp-server/dist/tools/youtube/upload.js +4 -9
  269. package/mcp-server/dist/tools/youtube/upload.js.map +1 -1
  270. package/mcp-server/dist/tui.js +18 -5
  271. package/mcp-server/dist/tui.js.map +1 -1
  272. package/mcp-server/dist/version.d.ts +5 -0
  273. package/mcp-server/dist/version.d.ts.map +1 -0
  274. package/mcp-server/dist/version.js +21 -0
  275. package/mcp-server/dist/version.js.map +1 -0
  276. package/mcp-server/package-lock.json +3 -3
  277. package/mcp-server/package.json +3 -3
  278. package/mcp-server/run.mjs +15 -9
  279. package/mcp-server/src/http.ts +18 -3
  280. package/mcp-server/src/index.ts +9 -6
  281. package/mcp-server/src/services/browser-bench.ts +1 -1
  282. package/mcp-server/src/services/budget-judgments.ts +183 -0
  283. package/mcp-server/src/services/categories.ts +77 -32
  284. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  285. package/mcp-server/src/services/desktop-bench.ts +1 -1
  286. package/mcp-server/src/services/identity.ts +27 -0
  287. package/mcp-server/src/services/neural-interface.ts +34 -2
  288. package/mcp-server/src/services/typesafe-config.ts +4 -1
  289. package/mcp-server/src/tools/agents.ts +34 -15
  290. package/mcp-server/src/tools/bluesky.ts +1 -1
  291. package/mcp-server/src/tools/browser-advanced.ts +1 -1
  292. package/mcp-server/src/tools/browser-batch.ts +1 -1
  293. package/mcp-server/src/tools/browser-cheatsheet.ts +1 -1
  294. package/mcp-server/src/tools/browser-interact.ts +1 -1
  295. package/mcp-server/src/tools/browser-navigate.ts +1 -1
  296. package/mcp-server/src/tools/browser-observe.ts +1 -1
  297. package/mcp-server/src/tools/card-tools.ts +1 -1
  298. package/mcp-server/src/tools/category.ts +6 -1
  299. package/mcp-server/src/tools/choice.ts +1 -1
  300. package/mcp-server/src/tools/computer.ts +1 -1
  301. package/mcp-server/src/tools/discord-channel.ts +1 -1
  302. package/mcp-server/src/tools/discord-guild.ts +1 -1
  303. package/mcp-server/src/tools/discord-member.ts +1 -1
  304. package/mcp-server/src/tools/discord-message.ts +1 -1
  305. package/mcp-server/src/tools/discord-onboarding.ts +1 -1
  306. package/mcp-server/src/tools/discord-role.ts +1 -1
  307. package/mcp-server/src/tools/discord-thread.ts +1 -1
  308. package/mcp-server/src/tools/discord-webhook.ts +1 -1
  309. package/mcp-server/src/tools/fb-groups.ts +1 -1
  310. package/mcp-server/src/tools/forget.ts +1 -1
  311. package/mcp-server/src/tools/git-tools.ts +1 -1
  312. package/mcp-server/src/tools/gsc/experience.ts +1 -1
  313. package/mcp-server/src/tools/gsc/indexing.ts +1 -1
  314. package/mcp-server/src/tools/gsc/inspect.ts +1 -1
  315. package/mcp-server/src/tools/gsc/navigate.ts +1 -1
  316. package/mcp-server/src/tools/gsc/performance.ts +1 -1
  317. package/mcp-server/src/tools/gsc/settings.ts +1 -1
  318. package/mcp-server/src/tools/image-tools.ts +1 -1
  319. package/mcp-server/src/tools/leonardo-browser-tools.ts +1 -1
  320. package/mcp-server/src/tools/loop.ts +1 -1
  321. package/mcp-server/src/tools/memories.ts +1 -1
  322. package/mcp-server/src/tools/morelogin.ts +1 -1
  323. package/mcp-server/src/tools/profile.ts +1 -1
  324. package/mcp-server/src/tools/recall.ts +1 -1
  325. package/mcp-server/src/tools/reflect.ts +1 -1
  326. package/mcp-server/src/tools/remember.ts +13 -12
  327. package/mcp-server/src/tools/restore.ts +1 -1
  328. package/mcp-server/src/tools/style-guide-tools.ts +1 -1
  329. package/mcp-server/src/tools/sync.ts +1 -1
  330. package/mcp-server/src/tools/tictactoe-tools.ts +1 -1
  331. package/mcp-server/src/tools/utils.ts +1 -1
  332. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  333. package/mcp-server/src/tools/whiteboard-tools.ts +1 -1
  334. package/mcp-server/src/tools/youtube/analytics.ts +1 -1
  335. package/mcp-server/src/tools/youtube/download.ts +1 -1
  336. package/mcp-server/src/tools/youtube/media.ts +1 -1
  337. package/mcp-server/src/tools/youtube/metadata.ts +1 -1
  338. package/mcp-server/src/tools/youtube/navigate.ts +1 -1
  339. package/mcp-server/src/tools/youtube/playlist.ts +1 -1
  340. package/mcp-server/src/tools/youtube/schedule.ts +1 -1
  341. package/mcp-server/src/tools/youtube/source.ts +1 -1
  342. package/mcp-server/src/tools/youtube/upload.ts +4 -12
  343. package/mcp-server/src/tui.ts +24 -10
  344. package/mcp-server/src/version.ts +22 -0
  345. package/neural-interface/i18n/en.json +645 -57
  346. package/neural-interface/i18n/pt-BR.json +644 -56
  347. package/neural-interface/lib/account-usage.js +163 -0
  348. package/neural-interface/lib/agent-sandbox-elevation.js +102 -0
  349. package/neural-interface/lib/agent-sandbox-setting.js +57 -0
  350. package/neural-interface/lib/assistant-api.js +274 -52
  351. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  352. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  353. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  354. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  355. package/neural-interface/lib/assistant-budget-split.js +408 -0
  356. package/neural-interface/lib/assistant-budget.js +94 -95
  357. package/neural-interface/lib/assistant-config.js +62 -14
  358. package/neural-interface/lib/assistant-dispatch.js +432 -125
  359. package/neural-interface/lib/assistant-jev.js +26 -3
  360. package/neural-interface/lib/assistant-persona.js +68 -11
  361. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  362. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  363. package/neural-interface/lib/assistant-route-gate-codex.js +3 -1
  364. package/neural-interface/lib/assistant-route-gate.js +19 -3
  365. package/neural-interface/lib/assistant-router.js +373 -42
  366. package/neural-interface/lib/assistant-runtime.js +312 -48
  367. package/neural-interface/lib/assistant-user-writer.js +4 -2
  368. package/neural-interface/lib/augmented-path.js +317 -53
  369. package/neural-interface/lib/claude-accounts.js +342 -31
  370. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  371. package/neural-interface/lib/claude-executable.js +6 -1
  372. package/neural-interface/lib/claude-model-catalog.js +3 -3
  373. package/neural-interface/lib/claude-panel-auth.js +221 -0
  374. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  375. package/neural-interface/lib/cli-install-api.js +137 -0
  376. package/neural-interface/lib/cli-install.js +797 -0
  377. package/neural-interface/lib/cli-path.js +130 -0
  378. package/neural-interface/lib/cli-resolver.js +744 -0
  379. package/neural-interface/lib/cli-update-planner.js +143 -7
  380. package/neural-interface/lib/codex-account-auth.js +180 -0
  381. package/neural-interface/lib/codex-accounts.js +502 -0
  382. package/neural-interface/lib/codex-capabilities.js +2 -1
  383. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  384. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  385. package/neural-interface/lib/codex-panel-auth.js +62 -0
  386. package/neural-interface/lib/codex-session-settings.js +1 -1
  387. package/neural-interface/lib/codex-settings-schema.js +122 -0
  388. package/neural-interface/lib/desktop/config.js +3 -0
  389. package/neural-interface/lib/exec-loop-command.js +3 -1
  390. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  391. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  392. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  393. package/neural-interface/lib/git-bash/install.js +529 -0
  394. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  395. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  396. package/neural-interface/lib/git-bash/manager.js +268 -0
  397. package/neural-interface/lib/git-bash/pins.js +206 -0
  398. package/neural-interface/lib/git-bash/pins.json +34 -0
  399. package/neural-interface/lib/git-bash/resolver.js +194 -0
  400. package/neural-interface/lib/git-read.js +34 -2
  401. package/neural-interface/lib/install-details.js +163 -0
  402. package/neural-interface/lib/mcp-installer.js +14 -2
  403. package/neural-interface/lib/native-loop-providers.js +22 -4
  404. package/neural-interface/lib/native-loop-runtime.js +9 -4
  405. package/neural-interface/lib/os-path.js +319 -0
  406. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  407. package/neural-interface/lib/provider-panel-terminal-auth.js +96 -0
  408. package/neural-interface/lib/pty-host/core.js +2 -1
  409. package/neural-interface/lib/pty-host/manager.js +27 -2
  410. package/neural-interface/lib/remote-policy.js +20 -11
  411. package/neural-interface/lib/session-indexer.js +3 -1
  412. package/neural-interface/lib/session-title-generator.js +6 -3
  413. package/neural-interface/lib/system-about.js +340 -0
  414. package/neural-interface/lib/system-activity.js +66 -0
  415. package/neural-interface/lib/terminal-env.js +11 -3
  416. package/neural-interface/lib/terminal-path.js +55 -0
  417. package/neural-interface/lib/virtual-socket.js +12 -1
  418. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  419. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  420. package/neural-interface/lib/whatsapp/commands.js +13 -14
  421. package/neural-interface/lib/whatsapp/config.js +134 -20
  422. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  423. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  424. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  425. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  426. package/neural-interface/lib/whatsapp/manager.js +92 -32
  427. package/neural-interface/lib/whatsapp/paths.js +11 -1
  428. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  429. package/neural-interface/lib/whatsapp/redact.js +63 -0
  430. package/neural-interface/lib/whatsapp/service.js +343 -91
  431. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  432. package/neural-interface/package-lock.json +2 -2
  433. package/neural-interface/package.json +1 -1
  434. package/neural-interface/public/index.html +2 -0
  435. package/neural-interface/public/index2d.html +2 -0
  436. package/neural-interface/public/offline.html +16 -1
  437. package/neural-interface/public/onboarding.html +96 -2
  438. package/neural-interface/public/shared/account-i18n.js +7 -0
  439. package/neural-interface/public/shared/account-menu.js +542 -0
  440. package/neural-interface/public/shared/account-usage.js +79 -0
  441. package/neural-interface/public/shared/account-view.js +336 -0
  442. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  443. package/neural-interface/public/shared/assistant/asst-account-menu.js +298 -0
  444. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  445. package/neural-interface/public/shared/assistant/asst-api.js +39 -2
  446. package/neural-interface/public/shared/assistant/asst-brain-picker.js +288 -32
  447. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  448. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  449. package/neural-interface/public/shared/assistant/asst-menu.js +219 -80
  450. package/neural-interface/public/shared/assistant/asst-model-chip.js +135 -0
  451. package/neural-interface/public/shared/assistant/asst-panel.js +87 -16
  452. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  453. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  454. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  455. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  456. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  457. package/neural-interface/public/shared/assistant/asst-styles.js +160 -29
  458. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  459. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  460. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  461. package/neural-interface/public/shared/cdx/cdx-panel.js +49 -108
  462. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  463. package/neural-interface/public/shared/cdx/cdx-styles.js +172 -111
  464. package/neural-interface/public/shared/cdx/cdx-tabs.js +264 -664
  465. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  466. package/neural-interface/public/shared/claude-account-view.js +3 -0
  467. package/neural-interface/public/shared/cli-install-view.js +392 -0
  468. package/neural-interface/public/shared/cli-status.js +282 -22
  469. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  470. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  471. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  472. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  473. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  474. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  475. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  476. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  477. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  478. package/neural-interface/public/shared/git-bash-view.js +145 -0
  479. package/neural-interface/public/shared/html-shell.js +1 -0
  480. package/neural-interface/public/shared/i18n.js +19 -1
  481. package/neural-interface/public/shared/install-details-view.js +86 -0
  482. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -1
  483. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +36 -1
  484. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  485. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +169 -2
  486. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  487. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  488. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  489. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  490. package/neural-interface/public/shared/showcase-privacy.js +490 -0
  491. package/neural-interface/public/shared/storage.js +30 -3
  492. package/neural-interface/public/shared/styles.css +281 -32
  493. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  494. package/neural-interface/public/shared/ui-claude-panel.js +262 -217
  495. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  496. package/neural-interface/public/shared/ui-explorer.js +2 -0
  497. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  498. package/neural-interface/public/shared/ui-judgments.js +10 -1
  499. package/neural-interface/public/shared/ui-loading.js +30 -6
  500. package/neural-interface/public/shared/ui-menubar.js +13 -3
  501. package/neural-interface/public/shared/ui-navbar.js +3 -1
  502. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  503. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  504. package/neural-interface/public/shared/ui-sessions.js +9 -3
  505. package/neural-interface/public/shared/ui-settings.js +55 -3
  506. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  507. package/neural-interface/public/shared/ui-sync.js +8 -0
  508. package/neural-interface/public/shared/ui-terminal.js +17 -0
  509. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  510. package/neural-interface/public/shared/ui-update.js +598 -7
  511. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  512. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  513. package/neural-interface/public/shared/worker-end.js +2 -0
  514. package/neural-interface/public/sw.js +1 -1
  515. package/neural-interface/public/variant/2d/main.js +10 -1
  516. package/neural-interface/public/variant/3d/main.js +10 -1
  517. package/neural-interface/scripts/rebuild-pty.js +14 -2
  518. package/neural-interface/server.js +1384 -679
  519. package/npm-shrinkwrap.json +4424 -0
  520. package/package.json +57 -2
  521. package/postinstall.js +61 -47
  522. package/preuninstall.js +8 -35
  523. package/setup.js +80 -47
  524. package/skills/synabun/modules/idea.md +1 -1
  525. package/skills/synabun/modules/schedule.md +1 -1
  526. package/updater.cmd +2 -2
  527. package/updater.mjs +97 -3
@@ -1,333 +1,289 @@
1
- // ═══════════════════════════════════════════
2
- // SynaBun Assistant — Budget (⋯ → Budget…)
3
- // ═══════════════════════════════════════════
4
- // The money caps of the assistant: default and largest per-run cap, the
5
- // session warning, the session hard cap (brain + every run) and the brain cap.
6
- // Saved to assistant-config.json through PUT /api/assistant/budget; the
7
- // dispatcher and the runtime read them on their next check, so a change
8
- // applies to running sessions without a restart. The pure helpers
9
- // (validateBudgetDraft, budgetTone, budgetChipText) are covered by node:test;
10
- // nothing here touches `document` at import time.
11
-
1
+ // One saved per-session budget. Both entry points use this controller and form;
2
+ // chips display confirmed server values, while an unsaved edit stays in the form.
12
3
  import { getBudget, putBudget } from './asst-api.js';
4
+ import { openMenu, closeMenu, isMenuOpen } from './asst-menu.js';
5
+ import { createMascotControl } from './asst-mascot.js';
13
6
 
14
- export const BUDGET_FIELDS = Object.freeze([
15
- { id: 'defaultRunUsd', key: 'assistant.budget.defaultRun', label: 'Default per-run cap', desc: 'Each dispatched run stops at this spend unless the brain asks for another cap.' },
16
- { id: 'maxRunUsd', key: 'assistant.budget.maxRun', label: 'Largest per-run cap', desc: 'The most a brain may give one run (budget_usd).' },
17
- { id: 'sessionWarnUsd', key: 'assistant.budget.sessionWarn', label: 'Session warning', desc: 'The brain and this panel are warned once when the session spends this much.' },
18
- { id: 'sessionHardUsd', key: 'assistant.budget.sessionHard', label: 'Session hard cap', desc: 'Brain + every run of the session. At this spend runs stop, and dispatches, follow-ups and brain turns are refused.' },
19
- { id: 'brainUsd', key: 'assistant.budget.brain', label: 'Brain cap', desc: 'The central brain\'s own spend in one session, checked before every brain turn.' },
20
- ]);
21
7
  export const BUDGET_BOUNDS = Object.freeze({ minUsd: 0.1, maxUsd: 1000 });
22
-
23
- function tf(t) {
24
- return (key, fallback, vars) => {
25
- let text = typeof t === 'function' ? t(key, vars) : null;
26
- if (!text || text === key) text = fallback;
27
- if (vars) for (const [name, value] of Object.entries(vars)) text = String(text).replaceAll(`{${name}}`, String(value));
28
- return text;
29
- };
30
- }
31
- const money = (value) => `$${(Number(value) || 0).toFixed(2)}`;
32
- function esc(value) {
33
- return String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
34
- }
35
- function el(tag, className, text) {
8
+ const money = value => `$${(Number(value) || 0).toFixed(2)}`;
9
+ // The saved amount: whole dollars as typed ("$1000"), cents always with two digits ("$12.50").
10
+ const amountText = value => (Number.isInteger(Number(value)) ? `$${Number(value)}` : money(value));
11
+ const tf = t => (key, fallback, vars = {}) => {
12
+ const value = t?.(key, vars);
13
+ return String(!value || value === key ? fallback : value).replace(/\{(\w+)\}/g, (all, key) => vars[key] ?? all);
14
+ };
15
+ const el = (tag, cls, text) => {
36
16
  const node = document.createElement(tag);
37
- if (className) node.className = className;
17
+ if (cls) node.className = cls;
38
18
  if (text != null) node.textContent = text;
39
19
  return node;
40
- }
20
+ };
41
21
 
42
- /**
43
- * Check a draft ({ field: string|number }) the way the server does: each value
44
- * an amount between the bounds, then default ≤ largest ≤ hard, warning < hard,
45
- * brain ≤ hard. → { values, errors: { field: message } }.
46
- */
47
- export function validateBudgetDraft(draft = {}, { bounds = BUDGET_BOUNDS, t = null } = {}) {
48
- const tr = tf(t);
49
- const values = {};
50
- const errors = {};
51
- for (const field of BUDGET_FIELDS) {
52
- const raw = draft[field.id];
53
- const text = typeof raw === 'string' ? raw.trim().replace(/^\$/, '') : raw;
54
- const n = text === '' || text === null || text === undefined ? NaN : Number(text);
55
- if (!Number.isFinite(n)) { errors[field.id] = tr('assistant.budget.errNumber', 'Enter an amount in US dollars.'); continue; }
56
- if (n < bounds.minUsd || n > bounds.maxUsd) { errors[field.id] = tr('assistant.budget.errRange', 'Between {min} and {max}.', { min: money(bounds.minUsd), max: `$${bounds.maxUsd}` }); continue; }
57
- values[field.id] = Math.round(n * 100) / 100;
22
+ export function validateBudgetDraft(draft = {}, { bounds = BUDGET_BOUNDS, t } = {}) {
23
+ const tr = tf(t), errors = {}, values = { unlimited: draft.unlimited === true };
24
+ const raw = typeof draft.amountUsd === 'string' ? draft.amountUsd.trim() : draft.amountUsd;
25
+ const amount = raw === '' || raw == null ? NaN : Number(raw);
26
+ if (!values.unlimited) {
27
+ if (!Number.isFinite(amount)) errors.amountUsd = tr('assistant.budget.errNumber', 'Enter an amount in US dollars.');
28
+ else if (amount < bounds.minUsd || amount > bounds.maxUsd) errors.amountUsd = tr('assistant.budget.errRange', 'Between {min} and {max}.', { min: money(bounds.minUsd), max: money(bounds.maxUsd) });
58
29
  }
59
- const v = values;
60
- const has = (...ids) => ids.every((id) => Number.isFinite(v[id]));
61
- if (has('maxRunUsd', 'sessionHardUsd') && v.maxRunUsd > v.sessionHardUsd && !errors.maxRunUsd) errors.maxRunUsd = tr('assistant.budget.errMaxOverHard', 'Cannot exceed the session hard cap ({hard}).', { hard: money(v.sessionHardUsd) });
62
- if (has('defaultRunUsd', 'maxRunUsd') && v.defaultRunUsd > v.maxRunUsd && !errors.defaultRunUsd) errors.defaultRunUsd = tr('assistant.budget.errDefaultOverMax', 'Cannot exceed the largest per-run cap ({max}).', { max: money(v.maxRunUsd) });
63
- if (has('sessionWarnUsd', 'sessionHardUsd') && v.sessionWarnUsd >= v.sessionHardUsd && !errors.sessionWarnUsd) errors.sessionWarnUsd = tr('assistant.budget.errWarnOverHard', 'Must be below the session hard cap ({hard}).', { hard: money(v.sessionHardUsd) });
64
- if (has('brainUsd', 'sessionHardUsd') && v.brainUsd > v.sessionHardUsd && !errors.brainUsd) errors.brainUsd = tr('assistant.budget.errBrainOverHard', 'Cannot exceed the session hard cap ({hard}).', { hard: money(v.sessionHardUsd) });
30
+ // No limit must work even if the unfinished amount field is invalid.
31
+ if (Number.isFinite(amount) && amount >= bounds.minUsd && amount <= bounds.maxUsd) values.amountUsd = Math.round(amount * 100) / 100;
65
32
  return { values, errors };
66
33
  }
67
-
68
- /** 'over' at the hard cap, 'warn' at the warning, else 'ok' (null without a budget view). */
69
34
  export function budgetTone(view) {
70
- if (!view || !Number.isFinite(Number(view.totalUsd))) return null;
71
- if (Number(view.totalUsd) >= Number(view.hardUsd)) return 'over';
72
- if (Number(view.totalUsd) >= Number(view.warnUsd)) return 'warn';
35
+ if (!view) return null;
36
+ if (view.unlimited) return 'ok';
37
+ if (view.exceeded || Number(view.spentUsd) >= Number(view.amountUsd)) return 'over';
38
+ if (Number(view.spentUsd) >= Number(view.warnAtUsd ?? Number(view.amountUsd) * 0.8)) return 'warn';
73
39
  return 'ok';
74
40
  }
75
-
76
- /** Cost chip tooltip: "Brain $x · agents $y · $z of $cap cap". */
77
- export function budgetChipText(view, { t = null, brainMetered = true } = {}) {
78
- const tr = tf(t);
41
+ export function budgetSpendText(view, { t } = {}) {
79
42
  if (!view) return '';
80
- const brain = brainMetered ? money(view.brainUsd) : tr('assistant.budget.notMetered', 'not metered');
81
- const parts = [
82
- tr('assistant.budget.chipSplit', 'Brain {brain} · agents {agents}', { brain, agents: money(view.dispatchUsd) }),
83
- tr('assistant.budget.chipCap', '{total} of the {hard} session cap', { total: money(view.totalUsd), hard: money(view.hardUsd) }),
84
- ];
85
- if (Number(view.unpricedRuns) > 0) parts.push(tr('assistant.budget.chipUnpriced', '{n} run(s) with unpriced usage', { n: view.unpricedRuns }));
86
- return parts.join(' · ');
43
+ return view.unlimited
44
+ ? tf(t)('assistant.budget.spendUnlimited', '{spent} · no limit', { spent: money(view.spentUsd) })
45
+ : `${money(view.spentUsd)} / ${money(view.amountUsd)}`;
46
+ }
47
+ export function budgetSettingText(budget, { t } = {}) {
48
+ if (!budget) return '…';
49
+ return budget.unlimited ? tf(t)('assistant.budget.noLimit', 'No limit')
50
+ : tf(t)('assistant.budget.perSession', '{amount} / session', { amount: amountText(budget.amountUsd) });
51
+ }
52
+ export function budgetChipText(view, { t } = {}) {
53
+ if (!view) return '';
54
+ const tr = tf(t);
55
+ return [budgetSpendText(view, { t }), tr('assistant.budget.chipSplit', 'Brain {brain} · agents {agents}', { brain: money(view.brainUsd), agents: money(view.dispatchUsd) }),
56
+ Number(view.unpricedRuns) > 0 ? tr('assistant.budget.chipUnpriced', '{n} run(s) with unpriced usage', { n: view.unpricedRuns }) : ''].filter(Boolean).join(' · ');
87
57
  }
88
58
 
89
- let _editor = null;
90
-
91
- /**
92
- * Open the Budget modal. hooks: { t, sessionId, brainMetered, onToast, onSaved(payload) }.
93
- */
94
- export async function openBudgetEditor(hooks = {}) {
95
- if (_editor) _editor.close();
96
- const tr = tf(hooks.t);
97
- const overlay = el('div', 'asst-modal-overlay');
98
- overlay.setAttribute('role', 'dialog');
99
- overlay.setAttribute('aria-modal', 'true');
100
- overlay.setAttribute('aria-label', tr('assistant.budget.title', 'Budget'));
101
- const modal = el('div', 'asst-modal asst-routes-modal asst-budget-modal');
102
- modal.innerHTML = `
103
- <div class="asst-modal-head"><span class="asst-icon" aria-hidden="true">$</span><span>${esc(tr('assistant.budget.title', 'Budget'))}</span><button type="button" class="asst-iconbtn asst-modal-close" aria-label="${esc(tr('common.close', 'Close'))}">✕</button></div>
104
- <div class="asst-modal-body"><div class="asst-modal-note">${esc(tr('common.loading', 'Loading...'))}</div></div>
105
- <div class="asst-modal-foot">
106
- <button type="button" class="asst-btn asst-btn-secondary asst-budget-reset-all">${esc(tr('assistant.budget.resetAll', 'Defaults'))}</button>
107
- <span class="asst-modal-spacer"></span>
108
- <button type="button" class="asst-btn asst-btn-secondary asst-budget-cancel">${esc(tr('common.cancel', 'Cancel'))}</button>
109
- <button type="button" class="asst-btn asst-btn-primary asst-budget-save">${esc(tr('common.save', 'Save'))}</button>
110
- </div>`;
111
- overlay.appendChild(modal);
112
- const body = modal.querySelector('.asst-modal-body');
113
- const saveBtn = modal.querySelector('.asst-budget-save');
114
- const returnTo = document.activeElement;
115
- const state = { data: null, draft: {}, error: '', conflict: false, serverField: null, saving: false };
116
-
117
- function close() {
118
- if (!overlay.isConnected) return;
119
- document.removeEventListener('keydown', onKey, true);
120
- overlay.remove();
121
- _editor = null;
122
- if (returnTo?.isConnected) { try { returnTo.focus({ preventScroll: true }); } catch { /* ignore */ } }
59
+ /** Serializable writer with one conflict retry, no polling and no local persistence. */
60
+ export function createBudgetController({ sessionId = null, t, get = getBudget, put = putBudget, onSaved, debounceMs = 450 } = {}) {
61
+ const tr = tf(t), listeners = new Set(), commits = new Set(), edits = new Map();
62
+ const state = { data: null, draft: { unlimited: false, amountUsd: '' }, dirty: false, saving: false, loading: false, error: '', fieldError: '', saved: false };
63
+ let timer, revision = 0, loading, writing, disposed = false;
64
+ const notify = () => { if (!disposed) for (const fn of listeners) fn(state); };
65
+ const committed = event => { if (!disposed) for (const fn of commits) { try { fn(event); } catch { /* an observer cannot fail a save */ } } };
66
+ const adopt = data => {
67
+ if (!data?.budget || (state.data && Number(data.version) < Number(state.data.version))) return;
68
+ // A PUT returns before the split finishes: don't overwrite a newer live split
69
+ // that arrived during this request with the old split from its response.
70
+ const live = state.data?.session;
71
+ if (live && data.session && live.version >= data.session.version &&
72
+ Date.parse(live.split?.updatedAt || 0) > Date.parse(data.session.split?.updatedAt || 0)) data = { ...data, session: live };
73
+ state.data = data;
74
+ if (!edits.has('unlimited')) state.draft.unlimited = data.budget.unlimited;
75
+ if (!edits.has('amountUsd')) state.draft.amountUsd = String(data.budget.amountUsd ?? '');
76
+ if (!disposed) onSaved?.(data);
77
+ };
78
+ async function load() {
79
+ if (loading) return loading;
80
+ state.loading = true; state.error = ''; notify();
81
+ loading = (async () => {
82
+ try { adopt(await get(sessionId)); }
83
+ catch { state.error = tr('assistant.budget.loadFailed', 'Could not load the budget. Try again.'); }
84
+ finally { state.loading = false; loading = null; notify(); }
85
+ return state.data;
86
+ })();
87
+ return loading;
123
88
  }
124
- function onKey(e) {
125
- if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return; }
126
- if (e.key === 'Enter' && e.target?.matches?.('.asst-budget-input')) { e.preventDefault(); save(); return; }
127
- e.stopPropagation();
89
+ function edit(patch, { immediate = false } = {}) {
90
+ revision++; state.draft = { ...state.draft, ...patch }; state.dirty = true;
91
+ for (const field of Object.keys(patch)) edits.set(field, revision);
92
+ state.error = ''; state.fieldError = ''; state.saved = false;
93
+ clearTimeout(timer); notify();
94
+ if (immediate) return flush();
95
+ timer = setTimeout(flush, debounceMs);
128
96
  }
129
- document.addEventListener('keydown', onKey, true);
130
- overlay.addEventListener('mousedown', (e) => { if (e.target === overlay) close(); });
131
- modal.querySelector('.asst-modal-close').addEventListener('click', close);
132
- modal.querySelector('.asst-budget-cancel').addEventListener('click', close);
133
- modal.querySelector('.asst-budget-reset-all').addEventListener('click', () => {
134
- if (!state.data) return;
135
- for (const field of BUDGET_FIELDS) state.draft[field.id] = String(state.data.defaults?.[field.id] ?? '');
136
- render();
137
- });
138
- document.body.appendChild(overlay);
139
- _editor = { close };
140
-
141
- function sourceLabel(source) {
142
- if (source === 'saved') return tr('assistant.budget.sourceSaved', 'saved');
143
- if (source === 'legacy') return tr('assistant.budget.sourceLegacy', 'from soft budget');
144
- return tr('assistant.budget.sourceDefault', 'default');
97
+ async function flush() {
98
+ clearTimeout(timer);
99
+ if (writing) return writing;
100
+ if (!state.data || !state.dirty) return;
101
+ writing = (async () => {
102
+ while (state.dirty) {
103
+ const changed = new Map(edits);
104
+ const { values, errors } = validateBudgetDraft(state.draft, { bounds: state.data.bounds, t });
105
+ state.fieldError = errors.amountUsd || '';
106
+ if (state.fieldError) { notify(); break; }
107
+ const patch = Object.fromEntries([...changed.keys()].filter(field => Object.hasOwn(values, field)).map(field => [field, values[field]]));
108
+ state.saving = true; state.error = ''; notify();
109
+ try {
110
+ let response;
111
+ try { response = await put(patch, state.data.version, sessionId); }
112
+ catch (err) {
113
+ if (err.code !== 'VERSION_CONFLICT' && err.status !== 409) throw err;
114
+ adopt(await get(sessionId));
115
+ response = await put(patch, state.data.version, sessionId); // retry the edit once
116
+ }
117
+ for (const [field, at] of changed) if (edits.get(field) === at) edits.delete(field);
118
+ state.dirty = edits.size > 0;
119
+ adopt(response); state.saved = !state.dirty;
120
+ committed({ ok: true, patch, budget: response.budget });
121
+ } catch (err) {
122
+ if (err.code === 'BUDGET_INVALID') state.fieldError = tr('assistant.budget.invalid', 'The server rejected this budget. Check the amount and try again.');
123
+ else if (err.code === 'VERSION_CONFLICT' || err.status === 409) state.error = tr('assistant.budget.conflict', 'Changed elsewhere again. Your edit is kept; try again.');
124
+ else state.error = tr('assistant.budget.saveFailed', 'Could not save the budget. Your edit is kept; try again.');
125
+ committed({ ok: false, patch, error: err });
126
+ break;
127
+ } finally { state.saving = false; notify(); }
128
+ }
129
+ })();
130
+ try { await writing; } finally { writing = null; }
145
131
  }
146
-
147
- function sessionSection(view) {
148
- const section = el('section', 'asst-routes-section asst-budget-session');
149
- section.appendChild(el('div', 'asst-routes-section-title', tr('assistant.budget.thisSession', 'This session')));
150
- const tone = budgetTone(view) || 'ok';
151
- const hard = Math.max(0.01, Number(view.hardUsd) || 0);
152
- const meter = el('div', 'asst-budget-meter');
153
- meter.dataset.tone = tone;
154
- meter.setAttribute('role', 'meter');
155
- meter.setAttribute('aria-valuemin', '0');
156
- meter.setAttribute('aria-valuemax', String(hard));
157
- meter.setAttribute('aria-valuenow', String(Number(view.totalUsd) || 0));
158
- meter.setAttribute('aria-label', tr('assistant.budget.meterLabel', 'Session spend against the hard cap'));
159
- const fill = el('span', 'asst-budget-meter-fill');
160
- fill.style.width = `${Math.min(100, (Number(view.totalUsd) / hard) * 100)}%`;
161
- const tick = el('span', 'asst-budget-meter-warn');
162
- tick.style.left = `${Math.min(100, (Number(view.warnUsd) / hard) * 100)}%`;
163
- meter.append(fill, tick);
164
- section.appendChild(meter);
165
- const brain = hooks.brainMetered === false ? tr('assistant.budget.notMeteredCodex', 'not metered (Codex brain)') : money(view.brainUsd);
166
- const line = tr('assistant.budget.sessionLine', '{total} spent of {hard} — brain {brain}, agents {agents}; {reserved} reserved by live runs, {available} left for new runs.', {
167
- total: money(view.totalUsd), hard: money(view.hardUsd), brain, agents: money(view.dispatchUsd), reserved: money(view.reservedUsd), available: money(view.availableUsd),
168
- });
169
- section.appendChild(el('div', 'asst-modal-note', line));
170
- if (Number(view.unpricedRuns) > 0) {
171
- const note = el('div', 'asst-modal-note asst-budget-unpriced', tr('assistant.budget.unpriced', '{n} run(s) used a model with no known price: their tokens are recorded but the dollar caps cannot meter them.', { n: view.unpricedRuns }));
172
- section.appendChild(note);
173
- }
174
- return section;
132
+ function receiveSession(view) {
133
+ if (!view || (sessionId && view.sessionId !== sessionId)) return;
134
+ if (state.data && Number(view.version) < Number(state.data.version)) return;
135
+ const saved = state.data?.budget || {};
136
+ adopt({ ...(state.data || {}), version: view.version, budget: { ...saved, unlimited: view.unlimited, amountUsd: view.amountUsd ?? saved.amountUsd, version: view.version }, session: view });
137
+ notify();
175
138
  }
139
+ return { state, load, edit, flush, receiveSession,
140
+ subscribe(fn) { listeners.add(fn); fn(state); return () => listeners.delete(fn); },
141
+ onCommit(fn) { commits.add(fn); return () => commits.delete(fn); },
142
+ destroy() { flush(); disposed = true; clearTimeout(timer); listeners.clear(); commits.clear(); },
143
+ };
144
+ }
176
145
 
177
- function capRow(field, errors) {
178
- const data = state.data;
179
- const row = el('div', 'asst-routes-row asst-budget-row');
180
- row.dataset.field = field.id;
181
- const main = el('div', 'asst-routes-row-main');
182
- const inputId = `asst-budget-${field.id}`;
183
- const label = el('label', 'asst-routes-row-label', tr(field.key, field.label));
184
- label.htmlFor = inputId;
185
- main.appendChild(label);
186
- main.appendChild(el('div', 'asst-budget-desc', tr(`${field.key}Desc`, field.desc)));
187
- const effective = data.budget?.[field.id];
188
- const def = data.defaults?.[field.id];
189
- main.appendChild(el('div', 'asst-budget-meta', tr('assistant.budget.effective', 'In effect: {value} · default {def}', { value: money(effective), def: money(def) })));
190
- const error = errors[field.id] || (state.serverField === field.id ? state.error : '');
191
- if (error) {
192
- const msg = el('div', 'asst-budget-field-error', error);
193
- msg.id = `${inputId}-error`;
194
- main.appendChild(msg);
195
- }
196
- row.appendChild(main);
197
- const wrap = el('div', 'asst-budget-amount');
198
- wrap.appendChild(el('span', 'asst-budget-unit', 'USD $'));
199
- const input = el('input', 'asst-dd-input asst-budget-input');
200
- input.id = inputId;
201
- input.type = 'number';
202
- input.inputMode = 'decimal';
203
- input.step = '0.01';
204
- input.min = String(data.bounds?.minUsd ?? BUDGET_BOUNDS.minUsd);
205
- input.max = String(data.bounds?.maxUsd ?? BUDGET_BOUNDS.maxUsd);
206
- input.value = state.draft[field.id] ?? '';
207
- if (error) { input.setAttribute('aria-invalid', 'true'); input.setAttribute('aria-describedby', `${inputId}-error`); }
208
- input.addEventListener('input', () => {
209
- state.draft[field.id] = input.value;
210
- if (state.serverField === field.id) { state.serverField = null; state.error = ''; }
211
- const pos = input.selectionStart;
212
- render();
213
- const again = modal.querySelector(`#${CSS.escape(inputId)}`);
214
- again?.focus({ preventScroll: true });
215
- try { again?.setSelectionRange?.(pos, pos); } catch { /* number inputs have no selection */ }
216
- });
217
- wrap.appendChild(input);
218
- row.appendChild(wrap);
219
- const source = String(state.draft[field.id]) === String(effective) ? (data.sources?.[field.id] || 'default') : 'edited';
220
- const badge = el('span', 'asst-routes-source', source === 'edited' ? tr('assistant.budget.sourceEdited', 'edited') : sourceLabel(source));
221
- badge.dataset.source = source === 'saved' || source === 'edited' ? 'yours' : 'default';
222
- row.appendChild(badge);
223
- return row;
224
- }
146
+ let uid = 0;
147
+ /** The identical autosave control in the empty-state popover and the session modal. */
148
+ export function createBudgetControls(controller, { t } = {}) {
149
+ const tr = tf(t), id = `asst-budget-${++uid}`;
150
+ const root = el('section', 'asst-budget-controls'); root.tabIndex = -1;
151
+ const label = el('label', 'asst-routes-row-label', tr('assistant.budget.amount', 'Budget per session (USD)'));
152
+ label.htmlFor = id;
153
+ const input = el('input', 'asst-dd-input asst-budget-input');
154
+ input.id = id; input.type = 'number'; input.inputMode = 'decimal'; input.step = '0.01';
155
+ const switchLabel = el('label', 'asst-budget-switch-label');
156
+ const toggle = el('input', 'asst-budget-switch');
157
+ toggle.type = 'checkbox'; toggle.setAttribute('role', 'switch');
158
+ switchLabel.append(toggle, el('span', '', tr('assistant.budget.noLimit', 'No limit')));
159
+ const note = el('div', 'asst-modal-note', tr('assistant.budget.applies', 'Applies to each new session and all open sessions. Jev splits it across providers.'));
160
+ const error = el('div', 'asst-budget-field-error'); error.id = `${id}-error`; error.setAttribute('role', 'alert');
161
+ const status = el('div', 'asst-modal-note'); status.setAttribute('role', 'status');
162
+ note.id = `${id}-note`; input.setAttribute('aria-describedby', `${note.id} ${error.id}`);
163
+ const retry = el('button', 'asst-btn asst-btn-secondary', tr('assistant.budget.retry', 'Try again')); retry.type = 'button';
164
+ retry.addEventListener('click', () => controller.state.dirty ? controller.flush() : controller.load());
165
+ input.addEventListener('input', () => controller.edit({ amountUsd: input.value }));
166
+ input.addEventListener('blur', () => controller.flush());
167
+ input.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); controller.flush(); } });
168
+ toggle.addEventListener('change', () => controller.edit({ unlimited: toggle.checked }, { immediate: true }));
169
+ root.append(label, input, switchLabel, note, error, status, retry);
170
+ root.addEventListener('keydown', e => { if (e.key !== 'Escape' && e.key !== 'Tab') e.stopPropagation(); });
171
+ const unsubscribe = controller.subscribe(state => {
172
+ if (input.value !== String(state.draft.amountUsd)) input.value = state.draft.amountUsd;
173
+ input.min = String(state.data?.bounds?.minUsd ?? BUDGET_BOUNDS.minUsd); input.max = String(state.data?.bounds?.maxUsd ?? BUDGET_BOUNDS.maxUsd);
174
+ input.disabled = !state.data || state.draft.unlimited;
175
+ toggle.disabled = !state.data; toggle.checked = state.draft.unlimited;
176
+ input.setAttribute('aria-invalid', state.fieldError ? 'true' : 'false');
177
+ error.textContent = state.fieldError || state.error; error.hidden = !error.textContent;
178
+ status.textContent = state.loading ? tr('common.loading', 'Loading...') : state.saving ? tr('assistant.budget.saving', 'Saving…') : state.saved ? tr('assistant.budget.saved', 'Budget saved — applies now.') : '';
179
+ retry.hidden = !(state.error || state.fieldError);
180
+ });
181
+ return { el: root, focus: () => (!input.disabled ? input : !toggle.disabled ? toggle : !retry.hidden ? retry : root).focus({ preventScroll: true }), destroy: unsubscribe };
182
+ }
225
183
 
226
- function enforcementSection(data) {
227
- const section = el('section', 'asst-routes-section');
228
- section.appendChild(el('div', 'asst-routes-section-title', tr('assistant.budget.howEnforced', 'How caps are enforced')));
229
- const names = { 'claude-code': 'Claude', codex: 'Codex', opencode: 'OpenCode', brain: tr('assistant.budget.brainRow', 'Brain') };
230
- const list = el('ul', 'asst-budget-enforcement');
231
- for (const [id, info] of Object.entries(data.enforcement || {})) {
232
- const item = el('li');
233
- item.appendChild(el('strong', null, `${names[id] || id}: `));
234
- item.appendChild(document.createTextNode(`${info.perRun} ${info.session}`));
235
- list.appendChild(item);
236
- }
237
- section.appendChild(list);
238
- const pricing = data.pricing;
239
- if (pricing) {
240
- // One table prices the catalog and the usage ledger; models.dev only fills in Codex models it lacks.
241
- const fromTable = pricing.source === 'table' && pricing.table;
242
- section.appendChild(el('div', 'asst-modal-note', fromTable
243
- ? tr('assistant.budget.pricingTable', "List prices: SynaBun's table, checked against the Anthropic and OpenAI pricing pages on {date}. A Codex model it does not list is priced from models.dev.", { date: pricing.table.verified || '' })
244
- : pricing.available
245
- ? tr('assistant.budget.pricingOk', 'Codex list prices: models.dev (OpenCode cache).')
246
- : tr('assistant.budget.pricingMissing', 'Codex list prices unavailable (no OpenCode models.dev cache): Codex runs are flagged unpriced.')));
247
- const drift = Array.isArray(pricing.drift) ? [...new Set(pricing.drift.map((row) => row.model))] : [];
248
- if (drift.length) section.appendChild(el('div', 'asst-modal-note', tr('assistant.budget.pricingDrift', 'models.dev lists another price for {models}: the table may be out of date.', { models: drift.join(', ') })));
249
- }
250
- if (Array.isArray(data.repairs) && data.repairs.length) {
251
- section.appendChild(el('div', 'asst-modal-note asst-budget-unpriced', tr('assistant.budget.repaired', 'The saved file broke a rule ({fields}); the values in effect were corrected. Save to store them.', { fields: data.repairs.join(', ') })));
252
- }
253
- return section;
254
- }
184
+ export function mountBudgetChip(slot, controller, { t, mascot } = {}) {
185
+ const tr = tf(t), btn = el('button', 'asst-dd asst-empty-control-btn asst-empty-budget-btn');
186
+ btn.type = 'button'; btn.setAttribute('aria-haspopup', 'dialog'); btn.setAttribute('aria-expanded', 'false');
187
+ const icon = el('span', 'asst-icon'); icon.setAttribute('aria-hidden', 'true');
188
+ icon.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M16 10h5v5h-5zM7 9h4"/></svg>';
189
+ const value = el('span', 'asst-dd-label'), caret = el('span', 'asst-dd-caret'); caret.setAttribute('aria-hidden', 'true');
190
+ caret.innerHTML = '<svg viewBox="0 0 12 12"><path d="m3 4.5 3 3 3-3"/></svg>';
191
+ btn.append(icon, el('span', 'asst-empty-project-key', tr('assistant.budget.title', 'Budget')), value, caret);
192
+ const actor = createMascotControl(btn, { director: mascot, pose: 'budget', options: () => ({ unlimited: !!controller.state.data?.budget.unlimited }) });
193
+ const offCommit = controller.onCommit(event => actor.commit(!event.ok ? 'wince' : Object.hasOwn(event.patch, 'unlimited') ? event.patch.unlimited ? 'pulse' : 'pop' : 'nod'));
194
+ let unlimited;
195
+ const off = controller.subscribe(state => {
196
+ value.textContent = budgetSettingText(state.data?.budget, { t });
197
+ btn.setAttribute('aria-label', `${tr('assistant.budget.title', 'Budget')}: ${value.textContent}`);
198
+ if (unlimited !== state.data?.budget.unlimited) { unlimited = state.data?.budget.unlimited; actor.refresh(); }
199
+ });
200
+ btn.addEventListener('click', () => {
201
+ if (isMenuOpen(btn)) { closeMenu({ restoreFocus: true }); return; }
202
+ const controls = createBudgetControls(controller, { t });
203
+ openMenu(btn, { title: tr('assistant.budget.title', 'Budget'), role: 'dialog', content: controls.el, width: 320, repositionOnResize: true,
204
+ className: 'asst-budget-popover', placement: 'prefer-below', onOpen: node => actor.open(node), onClose: () => { actor.close(); controller.flush(); controls.destroy(); } });
205
+ controls.focus();
206
+ if (!controller.state.data) controller.load().then(() => { if (isMenuOpen(btn)) controls.focus(); });
207
+ });
208
+ slot.appendChild(btn);
209
+ return { el: btn, destroy() { if (isMenuOpen(btn)) closeMenu(); actor.destroy(); offCommit(); off(); btn.remove(); } };
210
+ }
255
211
 
256
- function render() {
257
- body.innerHTML = '';
258
- const { errors } = state.data ? validateBudgetDraft(state.draft, { bounds: state.data.bounds || BUDGET_BOUNDS, t: hooks.t }) : { errors: {} };
259
- if (state.error && !state.serverField) {
260
- const banner = el('div', 'asst-routes-error');
261
- banner.setAttribute('role', 'alert');
262
- banner.appendChild(el('span', null, state.error));
263
- if (state.conflict) {
264
- const reload = el('button', 'asst-btn asst-btn-secondary', tr('assistant.budget.reload', 'Reload'));
265
- reload.type = 'button';
266
- reload.addEventListener('click', () => load());
267
- banner.appendChild(reload);
268
- }
269
- body.appendChild(banner);
270
- }
271
- if (!state.data) { saveBtn.disabled = true; return; }
272
- if (state.data.session) body.appendChild(sessionSection(state.data.session));
273
- const caps = el('section', 'asst-routes-section');
274
- caps.appendChild(el('div', 'asst-routes-section-title', tr('assistant.budget.caps', 'Caps (US dollars)')));
275
- const list = el('div', 'asst-routes-list');
276
- for (const field of BUDGET_FIELDS) list.appendChild(capRow(field, errors));
277
- caps.appendChild(list);
278
- caps.appendChild(el('div', 'asst-modal-note', tr('assistant.budget.applies', 'Saved caps apply at once to open sessions and their runs: the next dispatch, follow-up, turn end and metered step. A Claude run keeps the CLI cap it started with, and the dispatcher also stops it at the new cap after its turn.')));
279
- body.appendChild(caps);
280
- body.appendChild(enforcementSection(state.data));
281
- saveBtn.disabled = state.saving || Object.keys(errors).length > 0;
212
+ export function renderBudgetSession(view, { t } = {}) {
213
+ const tr = tf(t), section = el('section', 'asst-budget-session');
214
+ section.append(el('h3', 'asst-routes-section-title', tr('assistant.budget.thisSession', 'This session')), el('div', 'asst-budget-total', budgetSpendText(view, { t })));
215
+ section.dataset.tone = budgetTone(view);
216
+ if (!view.unlimited) {
217
+ const meter = el('meter', 'asst-budget-meter'); meter.min = 0; meter.max = view.amountUsd; meter.value = view.spentUsd;
218
+ meter.setAttribute('aria-label', tr('assistant.budget.meterLabel', 'Session spend against the budget')); section.append(meter);
282
219
  }
283
-
284
- async function load() {
285
- state.error = '';
286
- state.conflict = false;
287
- state.serverField = null;
288
- try {
289
- const data = await getBudget(hooks.sessionId || null);
290
- state.data = data || null;
291
- state.draft = Object.fromEntries(BUDGET_FIELDS.map((field) => [field.id, String(data?.budget?.[field.id] ?? '')]));
292
- } catch (err) {
293
- state.data = null;
294
- state.error = err?.status === 404
295
- ? tr('assistant.budget.unavailable', 'Budget settings are not available on this server yet (restart SynaBun to load them).')
296
- : `${tr('assistant.budget.loadFailed', 'Could not load the budget')}: ${err?.message || err}`;
220
+ const providers = el('div', 'asst-budget-providers');
221
+ for (const row of view.providers || []) {
222
+ const item = el('div', 'asst-budget-provider'); item.dataset.provider = row.provider;
223
+ item.dataset.eligible = String(row.eligible); item.dataset.dry = String((view.dry || []).includes(row.provider));
224
+ const heading = el('div', 'asst-budget-provider-name', row.label || row.provider);
225
+ if (row.isBrain) heading.append(el('span', 'asst-budget-brain', tr('assistant.budget.brainRow', 'Brain')));
226
+ item.append(heading);
227
+ item.append(el('div', 'asst-budget-provider-money', view.unlimited
228
+ ? tr('assistant.budget.providerSpend', '{spent} spent', { spent: money(row.spentUsd) })
229
+ : tr('assistant.budget.providerAmounts', 'Share {share} · spent {spent} · left for tasks {left}', { share: row.shareUsd == null ? '—' : money(row.shareUsd), spent: money(row.spentUsd), left: row.workerLeftUsd == null ? '—' : money(row.workerLeftUsd) })));
230
+ if (!view.unlimited && row.isBrain && view.split) item.append(el('div', 'asst-modal-note', tr('assistant.budget.reserve', '{reserve} reserved for the brain within this share.', { reserve: money(view.split.brainReserveLeftUsd) })));
231
+ if (!view.unlimited && (view.dry || []).includes(row.provider)) item.append(el('div', 'asst-budget-dry', tr('assistant.budget.dry', 'Share used up')));
232
+ if (!row.eligible && row.reason) {
233
+ const reason = String(row.reason).replace(/^brain:/, '');
234
+ item.append(el('div', 'asst-modal-note', tr(`assistant.budget.ineligible.${reason}`, { no_models: 'No available models', signed_out: 'Signed out', limited: 'Provider usage limit reached' }[reason] || reason)));
297
235
  }
298
- render();
299
- modal.querySelector('.asst-budget-input')?.focus({ preventScroll: true });
236
+ providers.append(item);
237
+ }
238
+ section.append(providers);
239
+ if (!view.unlimited) {
240
+ if (view.split) {
241
+ const split = view.split;
242
+ const source = split.source === 'jev' ? 'Jev' : tr('assistant.budget.fixedRule', 'Fixed rule');
243
+ const why = split.fallbackWhy ? tr(`assistant.budget.fallback.${split.fallbackWhy}`, { jev_off: 'Jev is off', jev_unavailable: 'Jev is unavailable', jev_incomplete: 'Jev returned an incomplete split', nothing_left: 'No budget left' }[split.fallbackWhy] || split.fallbackWhy) : '';
244
+ const time = el('time'); time.dateTime = split.updatedAt || '';
245
+ time.textContent = split.updatedAt && Number.isFinite(Date.parse(split.updatedAt)) ? new Date(split.updatedAt).toLocaleString() : '';
246
+ const meta = el('div', 'asst-modal-note', [source, why].filter(Boolean).join(' · ') + ' · '); meta.append(time); section.append(meta);
247
+ } else section.append(el('div', 'asst-modal-note', tr('assistant.budget.awaitingSplit', 'The split is prepared when work starts.')));
300
248
  }
249
+ if (view.unpricedRuns > 0) section.append(el('div', 'asst-budget-unpriced', tr('assistant.budget.chipUnpriced', '{n} run(s) with unpriced usage', { n: view.unpricedRuns })));
250
+ return section;
251
+ }
301
252
 
302
- async function save() {
303
- if (!state.data || state.saving) return;
304
- const { values, errors } = validateBudgetDraft(state.draft, { bounds: state.data.bounds || BUDGET_BOUNDS, t: hooks.t });
305
- if (Object.keys(errors).length) { render(); return; }
306
- state.saving = true;
307
- saveBtn.disabled = true;
308
- try {
309
- const res = await putBudget(values, state.data.version, hooks.sessionId || null);
310
- state.saving = false;
311
- state.data = res;
312
- try { hooks.onSaved?.(res); } catch { /* ignore */ }
313
- hooks.onToast?.(tr('assistant.budget.saved', 'Budget saved — applies now.'));
314
- close();
315
- } catch (err) {
316
- state.saving = false;
317
- if (err?.status === 409 || err?.code === 'VERSION_CONFLICT') {
318
- state.error = tr('assistant.budget.conflict', 'Changed elsewhere — reload to see the saved caps.');
319
- state.conflict = true;
320
- } else if (err?.code === 'BUDGET_INVALID' && err?.field && BUDGET_FIELDS.some((f) => f.id === err.field)) {
321
- state.error = err.message;
322
- state.serverField = err.field;
323
- } else {
324
- state.error = `${tr('assistant.budget.saveFailed', 'Could not save the budget')}: ${err?.message || err}`;
325
- }
326
- render();
253
+ let editor;
254
+ export async function openBudgetEditor(hooks = {}) {
255
+ editor?.close();
256
+ const controller = hooks.controller || createBudgetController(hooks), tr = tf(hooks.t);
257
+ const returnTo = document.activeElement, overlay = el('div', 'asst-modal-overlay asst-budget-overlay');
258
+ overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', tr('assistant.budget.title', 'Budget'));
259
+ const modal = el('div', 'asst-modal asst-budget-modal'), head = el('div', 'asst-modal-head');
260
+ const closeBtn = el('button', 'asst-iconbtn asst-modal-close', '✕'); closeBtn.type = 'button'; closeBtn.setAttribute('aria-label', tr('common.close', 'Close'));
261
+ head.append(el('span', '', tr('assistant.budget.title', 'Budget')), closeBtn);
262
+ const body = el('div', 'asst-modal-body'), controls = createBudgetControls(controller, hooks), live = el('div');
263
+ body.append(controls.el, live); modal.append(head, body); overlay.append(modal); document.body.append(overlay);
264
+ const off = controller.subscribe(state => { live.replaceChildren(...(state.data?.session ? [renderBudgetSession(state.data.session, hooks)] : [])); });
265
+ function close() {
266
+ if (!overlay.isConnected) return;
267
+ controller.flush(); off(); controls.destroy(); if (!hooks.controller) controller.destroy();
268
+ window.removeEventListener('keydown', onKey, true); overlay.remove(); editor = null;
269
+ if (returnTo?.isConnected) returnTo.focus({ preventScroll: true });
270
+ }
271
+ function onKey(e) {
272
+ if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); return; }
273
+ if (e.key === 'Tab') {
274
+ const nodes = [...modal.querySelectorAll('button, input')].filter(node => !node.disabled && !node.hidden);
275
+ const index = nodes.indexOf(document.activeElement);
276
+ if (e.shiftKey && index <= 0) { e.preventDefault(); nodes.at(-1)?.focus(); }
277
+ else if (!e.shiftKey && (index < 0 || index === nodes.length - 1)) { e.preventDefault(); nodes[0]?.focus(); }
327
278
  }
328
279
  }
329
- saveBtn.addEventListener('click', () => save());
330
-
331
- await load();
332
- return { el: overlay, close };
280
+ closeBtn.addEventListener('click', close); overlay.addEventListener('mousedown', e => { if (e.target === overlay) close(); });
281
+ window.addEventListener('keydown', onKey, true);
282
+ overlay.addEventListener('keydown', e => e.stopPropagation());
283
+ const handle = { el: overlay, close, controller }; editor = handle;
284
+ closeBtn.focus({ preventScroll: true });
285
+ await controller.load(); if (overlay.isConnected) controls.focus();
286
+ return handle;
333
287
  }
288
+
289
+ export function closeBudgetEditor(controller) { if (editor?.controller === controller) editor.close(); }