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
@@ -9,7 +9,8 @@
9
9
  // attach) through the per-launch buildPrompt/wrapAdapter overrides, so the
10
10
  // existing router shows it as an automation tab in the matching sidepanel.
11
11
  //
12
- // This module owns: validation + rails (concurrency, budgets, quarantine),
12
+ // This module owns: validation + rails (concurrency, the session budget and
13
+ // its provider shares through the budget planner, quarantine),
13
14
  // the task adapter wrapper (turn loop), result capture, the permission broker
14
15
  // for 'ask' dispatches, waits, transcripts, the dispatch registry, the
15
16
  // auto-remember hook, and the runs' usage meters (assistant-usage.js: exact
@@ -18,6 +19,7 @@
18
19
  // injected.
19
20
 
20
21
  import { randomBytes } from 'node:crypto';
22
+ import { chooseUsageAccount } from './account-usage.js';
21
23
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
22
24
  import { dirname, isAbsolute, resolve } from 'node:path';
23
25
  import {
@@ -32,7 +34,9 @@ import { loadStyleGuideForRun } from './style-guide/store.js';
32
34
  import { claimCheckWaived, normalizeTestDensity, testingApplies } from './assistant-test-density.js';
33
35
  import { normalizeEffort } from './effort-levels.js';
34
36
  import { createTurnEvidence, recordProviderEvent, evidenceSnapshot, claimEvidenceLine } from './assistant-evidence.js';
35
- import { codexUsageCostUsd } from './assistant-budget.js';
37
+ import { BUDGET_MAX_USD, codexUsageCostUsd, floorCents } from './assistant-budget.js';
38
+ import { MIN_SPEND_USD, PROVIDER_LABELS, isFreePrice } from './assistant-budget-split.js';
39
+ import { createBudgetPlanner } from './assistant-budget-planner.js';
36
40
  import { CLAUDE_ESTIMATE_SOURCE, CODEX_USAGE_KEYS, TOKEN_KEYS, addTokens, codexUsageTokens, createClaudeMeter, createCodexMeter, createOpenCodeMeter, totalTokens, withTotal, zeroTokens } from './assistant-usage.js';
37
41
  import { claudeTokensCostUsd } from './assistant-pricing.js';
38
42
  import { RemotePolicyError, clampDispatchSpec, getSessionPolicy, readRegisteredProjects } from './remote-policy.js';
@@ -58,6 +62,12 @@ export function runStatusFromState(state) {
58
62
  return ACTIVE_STATES.has(value) ? 'running' : null;
59
63
  }
60
64
 
65
+ /**
66
+ * The least work time a run gets: a max_minutes below it is raised to it, never refused. A run's
67
+ * max_minutes counts work only (the work clock below), so a short cap would only cut a turn off.
68
+ */
69
+ export const MIN_MAX_MINUTES = 20;
70
+
61
71
  export const DEFAULT_LIMITS = Object.freeze({
62
72
  perProvider: Object.freeze({ 'claude-code': 3, codex: 4, opencode: 3 }),
63
73
  perCodexAccount: 2,
@@ -65,16 +75,13 @@ export const DEFAULT_LIMITS = Object.freeze({
65
75
  queuedPerSession: 20,
66
76
  heldPerSession: 10,
67
77
  maxFanOut: 6,
78
+ // max_minutes: the work time a run gets (MIN_MAX_MINUTES at least), not the wall clock.
68
79
  defaultMaxMinutes: 45,
69
80
  maxMaxMinutes: 240,
70
- // Budget caps (USD). The live values come from assistant-config.json through
71
- // `readLimits` (assistant-budget.js); these are the defaults.
72
- defaultBudgetUsd: 5,
73
- maxBudgetUsd: 25,
74
- sessionSoftBudgetUsd: 10,
75
- sessionWarnUsd: 8,
76
- sessionHardBudgetUsd: 25,
77
- brainBudgetUsd: 10,
81
+ // The money is the session budget (assistant-budget.js, read live by the budget planner): a
82
+ // dispatch's budget_usd is only an optional ceiling for that run, inside its provider's share.
83
+ minRunBudgetUsd: MIN_SPEND_USD,
84
+ maxRunBudgetUsd: BUDGET_MAX_USD,
78
85
  permissionTimeoutMs: 10 * 60_000,
79
86
  questionTimeoutMs: 30 * 60_000,
80
87
  idleTimeoutMs: 20 * 60_000,
@@ -275,15 +282,15 @@ export function summarizeProviderEvent(payload = {}) {
275
282
  return null;
276
283
  }
277
284
 
285
+ // The old five-cap keys a config file may still hold: never limits (the budget planner owns the money).
286
+ const LEGACY_BUDGET_LIMITS = new Set(['defaultBudgetUsd', 'maxBudgetUsd', 'sessionWarnUsd', 'sessionSoftBudgetUsd', 'sessionHardBudgetUsd', 'brainBudgetUsd', 'budgetVersion', 'budgetUpdatedAt']);
278
287
  function mergeLimits(overrides = {}) {
279
288
  const limits = { ...DEFAULT_LIMITS, perProvider: { ...DEFAULT_LIMITS.perProvider } };
280
289
  for (const [key, value] of Object.entries(overrides || {})) {
290
+ if (LEGACY_BUDGET_LIMITS.has(key)) continue;
281
291
  if (key === 'perProvider' && value && typeof value === 'object') Object.assign(limits.perProvider, value);
282
292
  else if (value !== undefined && value !== null) limits[key] = value;
283
293
  }
284
- // Older configs name a soft budget and warned at 80 % of it.
285
- const soft = Number(overrides?.sessionSoftBudgetUsd);
286
- if ((overrides?.sessionWarnUsd === undefined || overrides?.sessionWarnUsd === null) && Number.isFinite(soft) && soft > 0) limits.sessionWarnUsd = Math.round(soft * 80) / 100;
287
294
  return limits;
288
295
  }
289
296
 
@@ -332,6 +339,12 @@ export function createAssistantDispatcher({
332
339
  getCodexAccount = () => null,
333
340
  CODEX_DEFAULT_HOME = null,
334
341
  claudeAccounts = null,
342
+ // The Claude account a Claude Code worker uses when its dispatch names none (the user's choice, read live).
343
+ claudeWorkerAccount = () => null,
344
+ // lib/codex-accounts.js (unusableReason) and the Codex account a worker gets when a dispatch names none.
345
+ codexAccounts = null,
346
+ accountUsage = null,
347
+ codexWorkerAccount = () => null,
335
348
  isValidMcpProfileValue = () => true,
336
349
  normalizeMcpProfileName = (value) => value,
337
350
  readActiveMcpProfile = () => 'full',
@@ -342,6 +355,8 @@ export function createAssistantDispatcher({
342
355
  detectProject = () => 'global',
343
356
  limits: limitOverrides = {},
344
357
  now = Date.now,
358
+ // The timer that stops a turn when its run's work time reaches max_minutes (tests fire it by hand).
359
+ workTimers = { set: (fn, ms) => { const timer = setTimeout(fn, ms); timer.unref?.(); return timer; }, clear: (timer) => clearTimeout(timer) },
345
360
  randomId = () => randomBytes(16).toString('hex'),
346
361
  strictResult = true,
347
362
  // Routing (assistant-router.js) and the model catalog (assistant-catalog.js);
@@ -357,12 +372,17 @@ export function createAssistantDispatcher({
357
372
  // Clarification (assistant-clarify.js): { forDispatch(sessionId, { briefId, independent }) }.
358
373
  // A brain's dispatch waits while its questions are open and carries the user's brief.
359
374
  clarifier = null,
360
- // Budget: live limit overrides (assistant-config.json, re-read on every
361
- // check), the brain's own spend per assistant session (the runtime) and list
375
+ // Live limit overrides (assistant-config.json `limits`, re-read on every check), the brain's own
376
+ // spend per assistant session (the runtime: its total, and by the provider it ran on) and list
362
377
  // prices for Codex usage (assistant-budget.js createModelPricing).
363
378
  readLimits = null,
364
379
  brainSpend = () => 0,
380
+ brainSpendByProvider = null,
365
381
  pricing = null,
382
+ // The session budget (assistant-budget-planner.js createBudgetPlanner): the setting, each
383
+ // session's provider shares, the checkpoints that divide what is left again. Without one the
384
+ // dispatcher keeps a planner of its own on the default budget ($25, no Jev).
385
+ budget: budgetPlanner = null,
366
386
  // Exact token accounting (assistant-usage.js): the ledger (createUsageLedger) and the task of
367
387
  // the brain turn in progress, (assistantSessionId) => taskId | null. Both optional: without
368
388
  // them the meters still run for cost and the run's own numbers, and nothing is written.
@@ -411,6 +431,10 @@ export function createAssistantDispatcher({
411
431
  // Usage meters (assistant-usage.js), apart from the live state: a Claude result can arrive
412
432
  // after its turn was aborted or its run left the list.
413
433
  const meters = new Map(); // runId → { claude, codex, opencode }
434
+ // The session budget: the injected planner, else one of the dispatcher's own on the default budget.
435
+ const budget = budgetPlanner || createBudgetPlanner({ spendOf: (id) => providerSpend(id) });
436
+ // `${sessionId}:${provider}` → a share check in flight (a re-split may take Jev's timeout).
437
+ const shareChecks = new Map();
414
438
  let persistTimer = null;
415
439
  let stalledTimer = null;
416
440
  let unsubscribeRuntime = null;
@@ -432,6 +456,11 @@ export function createAssistantDispatcher({
432
456
  entry.turnState = 'terminal';
433
457
  closeUsageState(entry);
434
458
  }
459
+ // A turn cut off by the restart worked until the run was last updated (as far as anything shows).
460
+ if (entry.workingSince) {
461
+ entry.workMs = workUsedMs(entry, Math.max(Date.parse(entry.workingSince) || 0, Date.parse(entry.updatedAt || '') || 0));
462
+ entry.workingSince = null;
463
+ }
435
464
  entries.set(entry.runId, entry);
436
465
  }
437
466
  }
@@ -465,16 +494,19 @@ export function createAssistantDispatcher({
465
494
  function peek(runId) {
466
495
  const entry = entries.get(String(runId || '')) || null;
467
496
  if (!entry) return null;
497
+ const at = now();
468
498
  return {
469
499
  runId: entry.runId, title: entry.title || null, state: entry.state, turnState: entry.turnState || null,
470
500
  taskId: entry.taskId || null, turnTaskId: turnTaskOf(entry), provider: entry.provider || null, model: entry.model || null,
501
+ maxMinutes: entry.maxMinutes ?? null, workMs: workUsedMs(entry, at), workLeftMs: workLeftMs(entry, at),
471
502
  };
472
503
  }
473
504
  function view(entry) {
474
505
  if (!entry) return null;
475
506
  const runtimeRun = safeRuntime()?.get?.(entry.runId) || null;
507
+ const at = now();
476
508
  const startedAt = entry.startedAt || entry.createdAt;
477
- const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() : now();
509
+ const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() : at;
478
510
  const elapsedMs = startedAt ? Math.max(0, end - new Date(startedAt).getTime()) : 0;
479
511
  // The brief goes to the worker's prompt; views name it only. The meters' state stays inside.
480
512
  const { rawSpec: _rawSpec, brief: _brief, usageState: _usageState, ...rest } = entry;
@@ -498,7 +530,13 @@ export function createAssistantDispatcher({
498
530
  active: ACTIVE_STATES.has(entry.state),
499
531
  terminal: TERMINAL_STATES.has(entry.state),
500
532
  outcome: entry.lastResult?.status || null,
533
+ // The wall clock since the run started (reports use it); max_minutes counts work time only.
501
534
  elapsedMs,
535
+ // Work time used and left of max_minutes; workingSince is when the turn in progress started
536
+ // working (null while the run is queued, idle between turns or waiting on a permission).
537
+ workMs: workUsedMs(entry, at),
538
+ workLeftMs: workLeftMs(entry, at),
539
+ workingSince: entry.workingSince || null,
502
540
  turnCount: Array.isArray(entry.turns) ? entry.turns.length : 0,
503
541
  // The turn whose result this view carries: what a brain has read once it sees it.
504
542
  resultTurn: (Array.isArray(entry.turns) ? entry.turns.at(-1)?.n : null) ?? 0,
@@ -513,6 +551,44 @@ export function createAssistantDispatcher({
513
551
  };
514
552
  }
515
553
  function safeRuntime() { try { return getRuntime(); } catch { return null; } }
554
+
555
+ // ── work clock (max_minutes) ───────────────────────────────────────────────
556
+ // A run's max_minutes counts the time it works: a turn, from its start to its end (result,
557
+ // failure, stop, cap), the result-retry turn included. Time queued before the start, idle between
558
+ // turns and blocked on a relayed permission or question (those waits have their own timeouts)
559
+ // does not count. entry.workMs holds the closed stretches; entry.workingSince the start of the
560
+ // open one. While a turn works, a timer on the work time left stops a turn that hangs.
561
+ function workUsedMs(entry, at = now()) {
562
+ const since = entry.workingSince ? Date.parse(entry.workingSince) : NaN;
563
+ return (Number(entry.workMs) || 0) + (Number.isFinite(since) ? Math.max(0, at - since) : 0);
564
+ }
565
+ function workLeftMs(entry, at = now()) {
566
+ const capMs = (Number(entry.maxMinutes) || limits.defaultMaxMinutes) * 60_000;
567
+ return Math.max(0, capMs - workUsedMs(entry, at));
568
+ }
569
+ function startWork(entry, liveState) {
570
+ if (!liveState || liveState.dead || entry.workingSince) return;
571
+ entry.workingSince = iso(now());
572
+ armWorkCap(entry, liveState);
573
+ }
574
+ function pauseWork(entry, liveState = live.get(entry.runId)) {
575
+ if (liveState?.workTimer) { workTimers.clear(liveState.workTimer); liveState.workTimer = null; }
576
+ if (!entry.workingSince) return;
577
+ entry.workMs = workUsedMs(entry);
578
+ entry.workingSince = null;
579
+ }
580
+ function armWorkCap(entry, liveState) {
581
+ if (liveState.workTimer) workTimers.clear(liveState.workTimer);
582
+ liveState.workTimer = workTimers.set(() => {
583
+ liveState.workTimer = null;
584
+ if (!entry.workingSince || liveState.dead || TERMINAL_STATES.has(entry.state)) return;
585
+ if (workLeftMs(entry) > 0) { armWorkCap(entry, liveState); return; }
586
+ pauseWork(entry, liveState);
587
+ entry.notes.push(`stopped: its ${entry.maxMinutes} minutes of work time were used up`);
588
+ stop(entry.runId, 'time_cap').catch(() => {});
589
+ }, workLeftMs(entry));
590
+ }
591
+
516
592
  function touch(entry) { entry.version = (Number(entry.version) || 0) + 1; entry.updatedAt = iso(now()); }
517
593
  function emit(entry, reason, extra = {}) {
518
594
  touch(entry);
@@ -685,15 +761,44 @@ export function createAssistantDispatcher({
685
761
  let claudeAccount = null;
686
762
  const accountId = spec.accountId || spec.codexAccountId || spec.claudeAccountId || null;
687
763
  if (provider === 'codex') {
688
- codexAccount = accountId ? findCodexAccount(accountId) : getCodexAccount(null);
689
- if (!codexAccount) throw new DispatchError('ACCOUNT_NOT_FOUND', `Codex account not found: ${accountId || 'default'}`);
690
- }
691
- if (provider === 'claude-code' && accountId && accountId !== 'default') {
692
- claudeAccount = claudeAccounts?.find?.(accountId) || null;
693
- if (!claudeAccount) throw new DispatchError('ACCOUNT_NOT_FOUND', `Claude account not found: ${accountId}`);
694
- }
695
- const maxMinutes = Math.min(Math.max(Number(spec.maxMinutes) || limits.defaultMaxMinutes, 1), limits.maxMaxMinutes);
696
- const budgetUsd = Math.min(Math.max(Number(spec.budgetUsd) || limits.defaultBudgetUsd, 0.1), limits.maxBudgetUsd);
764
+ // The account the dispatch names, else the user's Codex worker account, else Default. A signed-out
765
+ // account (or one whose key cannot be read) is refused here, with why: the worker would only fail at
766
+ // its first turn. Its credential is added when the worker starts (lib/codex-accounts.js launchFor()).
767
+ let wanted = accountId;
768
+ if (!wanted) { try { wanted = codexWorkerAccount?.() || null; } catch { wanted = null; } }
769
+ wanted = wanted || 'default';
770
+ codexAccount = wanted === 'default' ? (findCodexAccount('default') || getCodexAccount(null)) : findCodexAccount(wanted);
771
+ if (!codexAccount || (wanted !== 'default' && codexAccount.id !== wanted)) throw new DispatchError('ACCOUNT_NOT_FOUND', `Codex account not found: ${wanted}. Pick one from agent_catalog's Codex accounts.`);
772
+ const unusable = typeof codexAccounts?.unusableReason === 'function' ? codexAccounts.unusableReason(wanted) : '';
773
+ if (unusable) throw new DispatchError('ACCOUNT_SIGNED_OUT', unusable);
774
+ }
775
+ if (provider === 'claude-code') {
776
+ // The account the dispatch names, else the user's worker account, else Default. A signed-out
777
+ // account (or one whose key cannot be read) is refused here, with why: the worker would only
778
+ // fail at its first turn. Its credential is added when the worker starts (launchFor()).
779
+ let wanted = accountId;
780
+ if (!wanted) { try { wanted = claudeWorkerAccount?.() || null; } catch { wanted = null; } }
781
+ wanted = wanted || 'default';
782
+ claudeAccount = typeof claudeAccounts?.describe === 'function'
783
+ ? claudeAccounts.describe(wanted)
784
+ : (wanted === 'default' ? { id: 'default' } : claudeAccounts?.find?.(wanted) || null);
785
+ if (!claudeAccount || claudeAccount.id !== wanted) throw new DispatchError('ACCOUNT_NOT_FOUND', `Claude account not found: ${wanted}. Pick one from agent_catalog's Claude accounts.`);
786
+ const unusable = typeof claudeAccounts?.unusableReason === 'function' ? claudeAccounts.unusableReason(wanted) : '';
787
+ if (unusable) throw new DispatchError('ACCOUNT_SIGNED_OUT', unusable);
788
+ if (claudeAccount.id === 'default') claudeAccount = null;
789
+ }
790
+ const askedMinutes = Number(spec.maxMinutes) || limits.defaultMaxMinutes;
791
+ const maxMinutes = Math.max(MIN_MAX_MINUTES, Math.min(askedMinutes, limits.maxMaxMinutes));
792
+ // A configured default (assistant-config.json limits.defaultMaxMinutes) under the minimum is raised the same way, with its own note.
793
+ if (askedMinutes < MIN_MAX_MINUTES) notes.push(`${Number(spec.maxMinutes) ? 'max_minutes' : 'the default max_minutes'} ${askedMinutes} raised to the ${MIN_MAX_MINUTES}-minute minimum`);
794
+ // budget_usd: an optional ceiling for this one run, inside its provider's share of the session budget.
795
+ // None: the run may use what its share has left.
796
+ const askedBudget = Number(spec.budgetUsd);
797
+ const budgetUsd = Number.isFinite(askedBudget) && askedBudget > 0
798
+ ? Math.min(Math.max(askedBudget, Number(limits.minRunBudgetUsd) || MIN_SPEND_USD), Number(limits.maxRunBudgetUsd) || BUDGET_MAX_USD)
799
+ : null;
800
+ // A free model (the catalog says so) needs no share of the budget.
801
+ const freeModel = provider === 'opencode' && isFreePrice(modelInfo?.price || null);
697
802
  const idleTimeoutMs = Math.min(Math.max(Number(spec.idleTimeoutMs) || limits.idleTimeoutMs, limits.minIdleTimeoutMs), limits.maxIdleTimeoutMs);
698
803
  const maxTurns = Math.min(Math.max(Number(spec.maxTurns) || limits.maxTurns, 1), limits.maxMaxTurns);
699
804
  const outputSchema = spec.outputSchema && typeof spec.outputSchema === 'object' ? spec.outputSchema : null;
@@ -710,7 +815,7 @@ export function createAssistantDispatcher({
710
815
  claudeAccountId: claudeAccount?.id || (provider === 'claude-code' ? 'default' : null),
711
816
  claudeConfigDir: claudeAccount ? (claudeAccounts?.homeFor?.(claudeAccount.id) || null) : null,
712
817
  accountId: provider === 'codex' ? (codexAccount?.id || 'default') : provider === 'claude-code' ? (claudeAccount?.id || 'default') : null,
713
- maxMinutes, budgetUsd, idleTimeoutMs, maxTurns, outputSchema, title, tags,
818
+ maxMinutes, budgetUsd, freeModel, idleTimeoutMs, maxTurns, outputSchema, title, tags,
714
819
  focus: spec.focus === true,
715
820
  workflowId: spec.workflowId ? String(spec.workflowId) : null,
716
821
  parentRunId: spec.parentRunId ? String(spec.parentRunId) : null,
@@ -751,49 +856,68 @@ export function createAssistantDispatcher({
751
856
  if (!assistantSessionId) return 0;
752
857
  try { return Math.max(0, Number(brainSpend(assistantSessionId)) || 0); } catch { return 0; }
753
858
  }
754
- /** The session's whole spend: the central brain plus every run. The hard cap is checked against this. */
859
+ /** The session's whole spend: the central brain plus every run. */
755
860
  function sessionTotal(assistantSessionId) { return sessionSpend(assistantSessionId) + sessionBrainSpend(assistantSessionId); }
756
- function runRemaining(entry) { return Math.max(0, (Number(entry.budgetUsd) || 0) - (Number(entry.costUsd) || 0)); }
861
+ /** The brain's spend by the provider it ran on; an older runtime (one total) books it all on Claude Code. */
862
+ function brainByProvider(assistantSessionId) {
863
+ if (typeof brainSpendByProvider === 'function') {
864
+ try {
865
+ const rows = brainSpendByProvider(assistantSessionId);
866
+ if (rows && typeof rows === 'object') return rows;
867
+ } catch { /* fall through */ }
868
+ }
869
+ const total = sessionBrainSpend(assistantSessionId);
870
+ return total > 0 ? { 'claude-code': total } : {};
871
+ }
872
+ /** What a Claude turn in progress has spent so far (its calls at list price), not booked yet. */
873
+ function pendingRunUsd(entry) {
874
+ const liveState = live.get(entry.runId);
875
+ return liveState && liveState.turnOpen ? Math.max(0, Number(liveState.claudePendingUsd) || 0) : 0;
876
+ }
757
877
  /**
758
- * What the session's started runs may still spend: each live run's cap minus
759
- * its spend. A new run only gets what the hard cap leaves after this, so the
760
- * runs together cannot pass the cap (up to one provider step; see docs).
878
+ * The session's spend by provider, for the budget planner: { providers: { [p]: { brainUsd, runsUsd,
879
+ * liveRuns, runningRuns } }, unpricedRuns, liveRuns: [{ provider, model, taskClass }] }. A run's
880
+ * spend counts on its provider (removed runs included), with what a Claude turn in progress has
881
+ * spent so far; the brain's on the provider it ran on.
761
882
  */
762
- function sessionReserved(assistantSessionId, { except = null } = {}) {
763
- if (!assistantSessionId) return 0;
764
- let total = 0;
883
+ function providerSpend(assistantSessionId) {
884
+ const providers = {};
885
+ for (const provider of NATIVE_PROVIDERS) providers[provider] = { brainUsd: 0, runsUsd: 0, liveRuns: 0, runningRuns: 0 };
886
+ const liveRuns = [];
887
+ let unpricedRuns = 0;
888
+ if (!assistantSessionId) return { providers, unpricedRuns, liveRuns };
765
889
  for (const entry of entries.values()) {
766
- if (entry.assistantSessionId !== assistantSessionId || entry.runId === except) continue;
767
- if (!ACTIVE_STATES.has(entry.state) || WAITING_STATES.has(entry.state)) continue;
768
- total += runRemaining(entry);
890
+ if (entry.assistantSessionId !== assistantSessionId) continue;
891
+ const row = providers[entry.provider];
892
+ if (Number(entry.unpricedTurns) > 0) unpricedRuns += 1;
893
+ if (!row) continue;
894
+ row.runsUsd += (Number(entry.costUsd) || 0) + pendingRunUsd(entry);
895
+ if (ACTIVE_STATES.has(entry.state) && !WAITING_STATES.has(entry.state)) {
896
+ row.liveRuns += 1;
897
+ if (entry.state === 'running' || entry.state === 'starting' || entry.state === 'awaiting_permission') row.runningRuns += 1;
898
+ liveRuns.push({ provider: entry.provider, model: entry.model || null, taskClass: entry.taskClass || null });
899
+ }
769
900
  }
770
- return total;
901
+ for (const [provider, usd] of Object.entries(brainByProvider(assistantSessionId))) {
902
+ if (providers[provider]) providers[provider].brainUsd += Math.max(0, Number(usd) || 0);
903
+ }
904
+ for (const row of Object.values(providers)) { row.runsUsd = r4(row.runsUsd); row.brainUsd = r4(row.brainUsd); }
905
+ return { providers, unpricedRuns, liveRuns };
771
906
  }
772
- /** The session's money view (Budget tab, cost chip, agent_list totals). */
907
+ /** The session's money view (the Budget modal, the spend chip, agent_list / agent_usage): budget-planner's view. */
773
908
  function sessionBudget(assistantSessionId) {
774
909
  refreshLimits();
775
- const dispatchUsd = sessionSpend(assistantSessionId);
776
- const brainUsd = sessionBrainSpend(assistantSessionId);
777
- const totalUsd = dispatchUsd + brainUsd;
778
- const reservedUsd = sessionReserved(assistantSessionId);
779
- let unpricedRuns = 0;
780
- for (const entry of entries.values()) if (entry.assistantSessionId === assistantSessionId && Number(entry.unpricedTurns) > 0) unpricedRuns += 1;
781
- return {
782
- brainUsd: r4(brainUsd), dispatchUsd: r4(dispatchUsd), totalUsd: r4(totalUsd), reservedUsd: r4(reservedUsd),
783
- availableUsd: r4(Math.max(0, limits.sessionHardBudgetUsd - totalUsd - reservedUsd)),
784
- warnUsd: limits.sessionWarnUsd, hardUsd: limits.sessionHardBudgetUsd,
785
- defaultRunUsd: limits.defaultBudgetUsd, maxRunUsd: limits.maxBudgetUsd, brainCapUsd: limits.brainBudgetUsd,
786
- warned: totalUsd >= limits.sessionWarnUsd, exceeded: totalUsd >= limits.sessionHardBudgetUsd, unpricedRuns,
787
- };
910
+ try { return budget.view(assistantSessionId); } catch { return null; }
788
911
  }
912
+ /** Whether the session runs under a budget now (No limit: false). */
913
+ function limitedNow() { try { return budget.limited(); } catch { return true; } }
789
914
  /**
790
- * Why a run's spend could not be held to the dollar caps (no price for its
791
- * model), else null. Enforced only with a price source (`pricing`): Claude
792
- * reports cost, Codex needs a list price, OpenCode a catalog price (a free
793
- * model is fine).
915
+ * Why a run's spend could not be held to the budget (no price for its model), else null. Enforced
916
+ * only with a budget and a price source (`pricing`): Claude reports cost, Codex needs a list price,
917
+ * OpenCode a catalog price (a free model is fine).
794
918
  */
795
919
  function unpricedReason(entry) {
796
- if (!pricing || entry.provider === 'claude-code') return null;
920
+ if (!pricing || entry.provider === 'claude-code' || !limitedNow()) return null;
797
921
  const model = entry.pricingModel || entry.model || null;
798
922
  if (entry.provider === 'codex') {
799
923
  let price = null;
@@ -805,65 +929,93 @@ export function createAssistantDispatcher({
805
929
  function refuseUnpriced(entry) {
806
930
  const why = unpricedReason(entry);
807
931
  if (!why) return;
808
- throw new DispatchError('BUDGET_UNPRICED', `Not dispatched: ${why}, so the budget caps could not hold its spend. Pick a priced model or a free one (agent_catalog shows prices).`, { status: 409, provider: entry.provider, model: entry.pricingModel || entry.model || null });
932
+ throw new DispatchError('BUDGET_UNPRICED', `Not dispatched: ${why}, so the session budget could not hold its spend. Pick a priced model or a free one (agent_catalog shows prices).`, { status: 409, provider: entry.provider, model: entry.pricingModel || entry.model || null });
933
+ }
934
+ const providerName = (provider) => PROVIDER_LABELS[provider] || provider;
935
+ /** The session budget's words for a provider whose share is used up ("codex share used up: $4.00 of $4.00 spent"). */
936
+ function shareText(sessionId, provider) {
937
+ const cov = budget.coverage(sessionId);
938
+ const row = cov?.providers?.[provider];
939
+ if (!row) return `the ${provider} share is used up`;
940
+ return `the ${provider} share of the session budget is used up (${usd(row.spentUsd)} spent of its ${usd(row.shareUsd ?? row.spentUsd)} share; ${usd(cov.leftUsd)} of the ${usd(cov.amountUsd)} budget left in the session)`;
941
+ }
942
+ /** The providers whose share is used up, named for an error ("codex, opencode"). */
943
+ function dryText(sessionId) {
944
+ let dry = [];
945
+ try { dry = budget.dry(sessionId) || []; } catch { dry = []; }
946
+ return dry.length ? dry.join(', ') : 'none';
809
947
  }
810
- /** Why this run may not spend more (unpriced usage, its own cap, or the session's hard cap), else null. */
948
+ /**
949
+ * Why this run may not spend more, else null:
950
+ * BUDGET_UNPRICED its usage could not be priced (the budget cannot hold it)
951
+ * RUN_BUDGET_EXCEEDED it reached its own ceiling (budget_usd)
952
+ * BUDGET_EXCEEDED the session reached its budget
953
+ * BUDGET_SHARE_EXHAUSTED its provider's share is used up (a re-split may still move money to it)
954
+ * Under No limit: never anything.
955
+ */
811
956
  function budgetBlock(entry) {
957
+ if (!limitedNow()) return null;
812
958
  if (pricing && Number(entry.unpricedTurns) > 0) {
813
- return { code: 'BUDGET_UNPRICED', reason: 'budget_unpriced', message: `its usage could not be priced (${entry.unpricedReason || 'no price'}), so the budget caps cannot hold it`, detail: { unpricedTurns: entry.unpricedTurns } };
959
+ return { code: 'BUDGET_UNPRICED', reason: 'budget_unpriced', message: `its usage could not be priced (${entry.unpricedReason || 'no price'}), so the session budget cannot hold it`, detail: { unpricedTurns: entry.unpricedTurns } };
814
960
  }
815
961
  const cap = Number(entry.budgetUsd) || 0;
816
962
  const cost = Number(entry.costUsd) || 0;
817
- if (cap > 0 && cost >= cap) {
818
- return { code: 'RUN_BUDGET_EXCEEDED', reason: 'budget_cap', message: `run spent ${usd(cost)} of its ${usd(cap)} cap`, detail: { runSpent: r4(cost), runCap: cap } };
963
+ if (cap > 0 && cost + pendingRunUsd(entry) >= cap) {
964
+ return { code: 'RUN_BUDGET_EXCEEDED', reason: 'budget_cap', message: `run spent ${usd(cost + pendingRunUsd(entry))} of its ${usd(cap)} ceiling`, detail: { runSpent: r4(cost), runCap: cap } };
819
965
  }
820
966
  const sessionId = entry.assistantSessionId;
821
- if (sessionId) {
822
- const total = sessionTotal(sessionId);
823
- if (total >= limits.sessionHardBudgetUsd) {
824
- return { code: 'BUDGET_EXCEEDED', reason: 'session_budget_cap', message: `assistant session spent ${usd(total)} (brain + agents), at or above its hard cap of ${usd(limits.sessionHardBudgetUsd)}`, detail: { sessionSpent: r4(total), sessionCap: limits.sessionHardBudgetUsd } };
825
- }
967
+ if (!sessionId) return null;
968
+ const cov = budget.coverage(sessionId);
969
+ if (!cov) return null;
970
+ if (cov.leftUsd < MIN_SPEND_USD) {
971
+ return { code: 'BUDGET_EXCEEDED', reason: 'session_budget_cap', message: `assistant session spent ${usd(cov.spentUsd)} (brain + agents), its whole budget of ${usd(cov.amountUsd)}`, detail: { sessionSpent: r4(cov.spentUsd), sessionBudget: cov.amountUsd } };
972
+ }
973
+ if (!entry.freeModel && (cov.providers[entry.provider]?.workerLeftUsd ?? 0) < MIN_SPEND_USD) {
974
+ return { code: 'BUDGET_SHARE_EXHAUSTED', reason: 'budget_share', provider: entry.provider, message: shareText(sessionId, entry.provider), detail: { provider: entry.provider, sessionLeft: r4(cov.leftUsd), dry: budget.dry(sessionId) } };
826
975
  }
827
976
  return null;
828
977
  }
829
978
  /**
830
- * Fit a run's cap into what the session has left (hard cap − spend − the
831
- * live runs' reservations). Throws BUDGET_EXCEEDED when not a cent is left.
979
+ * Can the run's provider pay for its next step? When its share is used up the session's unspent
980
+ * budget is divided again first (budget planner: Jev or the fallback). → true, or false when no
981
+ * money could move to it.
832
982
  */
833
- function fitBudget(entry) {
983
+ async function coverShare(entry, { reason = 'share_used_up' } = {}) {
834
984
  const sessionId = entry.assistantSessionId;
835
- if (!sessionId) return;
836
- const spent = sessionTotal(sessionId);
837
- const reserved = sessionReserved(sessionId, { except: entry.runId });
838
- const hard = limits.sessionHardBudgetUsd;
839
- // Whole cents: a run never gets a fraction of a cent the session does not have.
840
- const available = Math.floor((hard - spent - reserved) * 100 + 1e-6) / 100;
841
- if (available <= 0) {
842
- throw new DispatchError('BUDGET_EXCEEDED', `No session budget left for another run: ${usd(spent)} spent (brain + agents) and ${usd(reserved)} reserved by live runs, against the hard cap of ${usd(hard)}. Complete finished runs (agent_complete) to release their reservation, or ask the user to raise the cap (Assistant → Budget).`, { status: 409, sessionSpent: r4(spent), sessionReserved: r4(reserved), sessionCap: hard });
843
- }
844
- if ((Number(entry.budgetUsd) || 0) > available) {
845
- const from = Number(entry.budgetUsd);
846
- entry.budgetUsd = available;
847
- entry.budgetClamped = { from, to: entry.budgetUsd, at: iso(now()) };
848
- entry.notes.push(`budget lowered from ${usd(from)} to ${usd(entry.budgetUsd)}: the session hard cap ${usd(hard)} minus ${usd(spent)} spent and ${usd(reserved)} reserved by live runs`);
849
- }
985
+ if (!sessionId || !limitedNow() || entry.freeModel) return true;
986
+ try { return await budget.ensureCovered(sessionId, { provider: entry.provider, reason, task: { taskClass: entry.taskClass || null, summary: entry.title || null, provider: entry.provider, model: entry.model || null } }); }
987
+ catch { return false; }
850
988
  }
851
989
  /**
852
- * After money moved in a session: one warning at the warning threshold, and
853
- * at the hard cap every live, queued and held run of the session stops.
990
+ * The Claude CLI's own stop (--max-budget-usd) for a Claude worker under a budget: the run's ceiling,
991
+ * never more than the session has left when it starts. A backstop: the dispatcher stops the run at its
992
+ * provider's share between model calls. null under No limit (no CLI cap at all).
993
+ */
994
+ function claudeBackstop(entry) {
995
+ if (!limitedNow()) return null;
996
+ const cap = Number(entry.budgetUsd) || 0;
997
+ const left = entry.assistantSessionId ? (budget.coverage(entry.assistantSessionId)?.leftUsd ?? null) : null;
998
+ const value = Math.min(cap > 0 ? cap : Infinity, left === null ? Infinity : left);
999
+ if (!Number.isFinite(value)) return cap > 0 ? cap : null;
1000
+ return Math.max(MIN_SPEND_USD, floorCents(value) / 100);
1001
+ }
1002
+ /**
1003
+ * After money moved in a session: one warning at 80 % of the budget (per budget amount), and at
1004
+ * the budget every live, queued and held run of the session stops. Nothing under No limit.
854
1005
  * Returns the session's budget view.
855
1006
  */
856
1007
  function checkSession(assistantSessionId, { source = null } = {}) {
857
1008
  if (!assistantSessionId) return null;
858
1009
  const money = sessionBudget(assistantSessionId);
1010
+ if (!money || money.unlimited) return money;
859
1011
  const anchor = source || [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId)
860
1012
  .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))[0] || null;
861
- if (money.warned && !money.exceeded && sessionBudgetWarned.get(assistantSessionId) !== money.warnUsd) {
862
- sessionBudgetWarned.set(assistantSessionId, money.warnUsd);
1013
+ if (money.warned && !money.exceeded && sessionBudgetWarned.get(assistantSessionId) !== money.amountUsd) {
1014
+ sessionBudgetWarned.set(assistantSessionId, money.amountUsd);
863
1015
  if (anchor) {
864
1016
  emit(anchor, 'budget_warning', {
865
- sessionSpent: money.totalUsd, warnAt: money.warnUsd, softCap: money.warnUsd, hardCap: money.hardUsd,
866
- text: `session budget warning: ${usd(money.totalUsd)} spent (brain ${usd(money.brainUsd)} + agents ${usd(money.dispatchUsd)}), warning at ${usd(money.warnUsd)}, hard cap ${usd(money.hardUsd)}. Ask the user before spending more.`,
1017
+ sessionSpent: money.totalUsd, warnAt: money.warnAtUsd, budgetUsd: money.amountUsd, leftUsd: money.leftUsd,
1018
+ text: `session budget warning: ${usd(money.totalUsd)} spent of the ${usd(money.amountUsd)} budget (brain ${usd(money.brainUsd)} + agents ${usd(money.dispatchUsd)}), ${usd(money.leftUsd)} left. Ask the user before spending more.`,
867
1019
  });
868
1020
  }
869
1021
  }
@@ -871,19 +1023,34 @@ export function createAssistantDispatcher({
871
1023
  const victims = [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId && ACTIVE_STATES.has(e.state));
872
1024
  for (const victim of victims) {
873
1025
  victim.completionReason = victim.completionReason || 'session_budget_cap';
874
- if (!(victim.notes || []).some((note) => note.startsWith('stopped: session hard cap'))) victim.notes.push(`stopped: session hard cap ${usd(money.hardUsd)} reached (${usd(money.totalUsd)} spent)`);
1026
+ if (!(victim.notes || []).some((note) => note.startsWith('stopped: session budget'))) victim.notes.push(`stopped: session budget ${usd(money.amountUsd)} reached (${usd(money.totalUsd)} spent)`);
875
1027
  stop(victim.runId, 'session_budget_cap').catch(() => {});
876
1028
  }
877
- if (anchor && sessionBudgetExceeded.get(assistantSessionId) !== money.hardUsd) {
878
- sessionBudgetExceeded.set(assistantSessionId, money.hardUsd);
1029
+ if (anchor && sessionBudgetExceeded.get(assistantSessionId) !== money.amountUsd) {
1030
+ sessionBudgetExceeded.set(assistantSessionId, money.amountUsd);
879
1031
  emit(anchor, 'budget_exceeded', {
880
- sessionSpent: money.totalUsd, hardCap: money.hardUsd, stopped: victims.map((e) => e.runId),
881
- text: `session hard cap reached: ${usd(money.totalUsd)} spent of ${usd(money.hardUsd)}; ${victims.length} run${victims.length === 1 ? '' : 's'} stopped and new dispatches are refused until the user raises the cap (Assistant → Budget).`,
1032
+ sessionSpent: money.totalUsd, budgetUsd: money.amountUsd, stopped: victims.map((e) => e.runId),
1033
+ text: `session budget reached: ${usd(money.totalUsd)} spent of ${usd(money.amountUsd)}; ${victims.length} run${victims.length === 1 ? '' : 's'} stopped, and dispatches, follow-ups and brain turns are refused until the user raises the budget or switches to No limit (Assistant → Budget).`,
882
1034
  });
883
1035
  }
884
1036
  }
885
1037
  return money;
886
1038
  }
1039
+ /**
1040
+ * Stop the working runs of `provider` in the session: its share is used up and no money could move
1041
+ * to it (budget planner). Idle runs hold no money and are left alone (a follow-up re-splits first and
1042
+ * is refused when nothing moves); queued and held ones are checked when they start.
1043
+ */
1044
+ function stopProviderRuns(assistantSessionId, provider, text) {
1045
+ const working = new Set(['starting', 'running', 'awaiting_permission']);
1046
+ const victims = [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId && e.provider === provider && working.has(e.state) && !e.freeModel);
1047
+ for (const victim of victims) {
1048
+ victim.completionReason = victim.completionReason || 'budget_share';
1049
+ if (!(victim.notes || []).some((note) => note.startsWith('stopped: the'))) victim.notes.push(`stopped: ${text}; no unspent budget could be moved to it`);
1050
+ stop(victim.runId, 'budget_share').catch(() => {});
1051
+ }
1052
+ return victims.map((e) => e.runId);
1053
+ }
887
1054
 
888
1055
  // ── dispatch ───────────────────────────────────────────────────────────────
889
1056
  /** Entry fields that come straight from a normalized spec. */
@@ -894,7 +1061,7 @@ export function createAssistantDispatcher({
894
1061
  cwd: spec.cwd, project: spec.project, mcpProfile: spec.mcpProfile,
895
1062
  permissionPolicy: spec.permissionPolicy, capability: spec.capability, usesBrowser: spec.usesBrowser, usesComputer: !!spec.usesComputer,
896
1063
  title: spec.title, task: spec.task, context: spec.context, tags: spec.tags,
897
- maxMinutes: spec.maxMinutes, budgetUsd: spec.budgetUsd, idleTimeoutMs: spec.idleTimeoutMs, maxTurns: spec.maxTurns,
1064
+ maxMinutes: spec.maxMinutes, budgetUsd: spec.budgetUsd ?? null, freeModel: spec.freeModel === true, idleTimeoutMs: spec.idleTimeoutMs, maxTurns: spec.maxTurns,
898
1065
  outputSchema: spec.outputSchema, focus: spec.focus, workflowId: spec.workflowId, parentRunId: spec.parentRunId,
899
1066
  idempotencyKey: spec.idempotencyKey, requestedBrowserSessionId: spec.requestedBrowserSessionId,
900
1067
  modelInfo: spec.modelInfo || null, modelContextWindow: spec.modelContextWindow || null, pricingModel: spec.pricingModel || spec.model || null,
@@ -905,6 +1072,7 @@ export function createAssistantDispatcher({
905
1072
  runId, assistantSessionId: sessionId, origin,
906
1073
  state: 'queued', turnState: null, turns: [], lastResult: null, costUsd: 0, usage: null, costBasis: 'none',
907
1074
  pending: [], notes: [], createdAt: iso(now()), startedAt: null, finishedAt: null, completionReason: null,
1075
+ workMs: 0, workingSince: null,
908
1076
  memoryId: null, memoryStored: false, browserSessionId: null, browserTabId: null, version: 0, updatedAt: iso(now()),
909
1077
  runtime: null, providerSessionId: null, providerThreadId: null, degraded: null,
910
1078
  route: null, escalation: null, escalationDepth: 0,
@@ -974,10 +1142,7 @@ export function createAssistantDispatcher({
974
1142
  }
975
1143
  }
976
1144
  refreshLimits();
977
- const spent = sessionTotal(sessionId);
978
- if (sessionId && spent >= limits.sessionHardBudgetUsd) {
979
- throw new DispatchError('BUDGET_EXCEEDED', `Assistant session spent $${spent.toFixed(2)} (brain + agents), at or above the hard cap of $${limits.sessionHardBudgetUsd}. Ask the user to raise it (Assistant → Budget) before dispatching more.`, { status: 409, sessionSpent: r4(spent), sessionCap: limits.sessionHardBudgetUsd });
980
- }
1145
+ refuseSessionOver(sessionId);
981
1146
  // "true" (a string) is computer use too: normalizeSpec reads it so, and so must the gate.
982
1147
  rawSpec = { ...rawSpec, usesComputer: rawSpec.usesComputer === true || rawSpec.usesComputer === 'true' };
983
1148
  // A design dispatch's defaults fill what it leaves out, before any clamp; any dispatch's own browser
@@ -1016,7 +1181,21 @@ export function createAssistantDispatcher({
1016
1181
  try {
1017
1182
  // An approved route may have moved it to another provider: clamp for that one
1018
1183
  // (a route approved for design brings design's defaults).
1019
- const input = routed?.spec ? clampRemote(withClassDefaults(routed.spec, knownClass(routed.route?.taskClass)), sessionId, remoteNotes) : rawSpec;
1184
+ let input = routed?.spec ? clampRemote(withClassDefaults(routed.spec, knownClass(routed.route?.taskClass)), sessionId, remoteNotes) : rawSpec;
1185
+ // UI / callers without an Assistant session bypass the router, but may
1186
+ // still ask for Auto. A routed concrete account is never picked again here.
1187
+ if (!routed?.route && accountUsage && ['codex', 'claude-code'].includes(input.provider)) {
1188
+ await accountUsage.refresh(input.provider);
1189
+ const worker = input.provider === 'codex' ? codexWorkerAccount() : claudeWorkerAccount();
1190
+ const choice = input.accountId || input.codexAccountId || input.claudeAccountId || worker || 'default';
1191
+ const { row, reason } = chooseUsageAccount(accountUsage.list()[input.provider] || [], choice, worker || 'default');
1192
+ if (choice === 'auto' && !row) throw new DispatchError('ACCOUNT_NO_SUBSCRIPTION', 'Auto requires a signed-in subscription account.');
1193
+ if (row && reason) {
1194
+ input = { ...input, accountId: row.id, codexAccountId: null, claudeAccountId: null };
1195
+ const message = `${reason === 'accounts_all_limited' ? 'All subscription accounts limited; soonest reset' : reason === 'account_limited' ? `Account ${choice} out of usage` : 'Auto · most usage left'} → ${row.label || row.id}, ${row.usage?.remainingPercent == null ? 'usage unknown' : `${Math.round(row.usage.remainingPercent)}% left`}${row.usage?.resetsAt ? ` (resets ${row.usage.resetsAt})` : ''}.`;
1196
+ routed = { ...routed, route: { status: 'auto', target: { kind: 'dispatch', provider: input.provider, model: input.model || null, accountId: row.id, account: { id: row.id, label: row.label || row.id, mode: row.mode, usage: { remainingPercent: row.usage?.remainingPercent ?? null, limited: row.usage?.limited ?? null, resetsAt: row.usage?.resetsAt || null } } }, corrections: [{ reason, from: choice, to: row.id, message }] } };
1197
+ }
1198
+ }
1020
1199
  // A route approved for other work cannot carry image / video creation or design, nor the other way round:
1021
1200
  // the class decides the model's capability check, the worker's media block or rules and what is collected.
1022
1201
  const declaredClass = knownClass(rawSpec.taskClass);
@@ -1039,14 +1218,17 @@ export function createAssistantDispatcher({
1039
1218
  if (brief) { entry.brief = brief; entry.notes.push(`carries the user's brief ${brief.briefId || ''}`.trim()); }
1040
1219
  if (routed?.route) entry.route = routed.route;
1041
1220
  entries.set(runId, entry);
1042
- try { refuseUnpriced(entry); fitBudget(entry); } catch (error) { entries.delete(runId); throw error; }
1043
- entry.taskId = taskAtDispatch(sessionId);
1221
+ // Before the budget check awaits (it may re-split): a retry with the same key replays this run.
1044
1222
  if (spec.idempotencyKey && sessionId) idempotency.set(`${sessionId}:${spec.idempotencyKey}`, { runId, at: now() });
1223
+ let budgetCorrections = [];
1224
+ try { budgetCorrections = await fitBudget(entry, { routed: !!routed?.route }); } catch (error) { entries.delete(runId); throw error; }
1225
+ entry.taskId = taskAtDispatch(sessionId);
1045
1226
  const launched = await launchOrQueue(entry, spec, { queuedCount, strict: true });
1227
+ const withCorrections = budgetCorrections.length ? { ...launched, corrections: budgetCorrections } : launched;
1046
1228
  // The route's own "decided" event (it had no run attached) still tells the brain to dispatch.
1047
1229
  return routed?.approvedMeanwhile
1048
- ? { ...launched, next: 'The user picked this route while the dispatch was on its way, so this run started on it. Do not dispatch it again when the route_decided event for this route arrives.' }
1049
- : launched;
1230
+ ? { ...withCorrections, next: 'The user picked this route while the dispatch was on its way, so this run started on it. Do not dispatch it again when the route_decided event for this route arrives.' }
1231
+ : withCorrections;
1050
1232
  } catch (error) {
1051
1233
  // A run that exists (it failed or stopped at start) used the route; a refusal before one did not.
1052
1234
  if (routed?.claimed && !(runId && entries.has(runId))) router.releaseRoute?.(routed.route?.routeId);
@@ -1054,6 +1236,47 @@ export function createAssistantDispatcher({
1054
1236
  }
1055
1237
  }
1056
1238
 
1239
+ /** BUDGET_EXCEEDED (409) when the session has spent its whole budget (No limit: never). */
1240
+ function refuseSessionOver(sessionId) {
1241
+ if (!sessionId || !limitedNow()) return;
1242
+ const cov = budget.coverage(sessionId);
1243
+ if (cov && cov.leftUsd < MIN_SPEND_USD) {
1244
+ throw new DispatchError('BUDGET_EXCEEDED', `Assistant session spent ${usd(cov.spentUsd)} (brain + agents), its whole budget of ${usd(cov.amountUsd)}. Ask the user to raise the budget or switch to No limit (Assistant → Budget) before dispatching more.`, { status: 409, sessionSpent: r4(cov.spentUsd), sessionBudget: cov.amountUsd, reason: 'session_budget' });
1245
+ }
1246
+ }
1247
+ /**
1248
+ * A new run under the session budget. No limit: its budget_usd is dropped, with a note and a
1249
+ * correction. With a budget: an unpriced model is refused (BUDGET_UNPRICED), the session's split is
1250
+ * made when it has none (its first dispatch), and the run's provider must have share left, a re-split
1251
+ * first, unless its model is free; else BUDGET_SHARE_EXHAUSTED (409), which names the dry providers
1252
+ * and tells the brain to route again. Nothing is reserved: every run draws on its provider's share
1253
+ * as it spends. `routed`: the router already noted the task. → the corrections for the answer.
1254
+ */
1255
+ async function fitBudget(entry, { routed = false } = {}) {
1256
+ const corrections = [];
1257
+ if (!limitedNow()) {
1258
+ if (entry.budgetUsd) {
1259
+ const text = `budget_usd ${usd(entry.budgetUsd)} ignored: this session has no budget (No limit)`;
1260
+ entry.notes.push(text);
1261
+ corrections.push({ field: 'budget_usd', from: entry.budgetUsd, to: null, reason: 'no_limit', text });
1262
+ entry.budgetIgnoredUsd = entry.budgetUsd;
1263
+ entry.budgetUsd = null;
1264
+ }
1265
+ return corrections;
1266
+ }
1267
+ refuseUnpriced(entry);
1268
+ const sessionId = entry.assistantSessionId;
1269
+ if (!sessionId) return corrections;
1270
+ const task = { taskClass: entry.taskClass || null, summary: entry.title || null, provider: entry.provider, model: entry.model || null };
1271
+ await budget.ensureSplit(sessionId, { task: routed ? null : task });
1272
+ refuseSessionOver(sessionId);
1273
+ if (entry.freeModel) return corrections;
1274
+ if (!(await budget.ensureCovered(sessionId, { provider: entry.provider, task: routed ? null : task }))) {
1275
+ throw new DispatchError('BUDGET_SHARE_EXHAUSTED', `Not dispatched: ${shareText(sessionId, entry.provider)}, and no unspent budget could be moved to it. Route the task again with agent_route (it moves the task to a provider with money left; providers out of share now: ${dryText(sessionId)}), pick a free model, or ask the user to raise the budget (Assistant → Budget).`, { status: 409, provider: entry.provider, dry: budget.dry(sessionId), reason: 'budget_share' });
1276
+ }
1277
+ return corrections;
1278
+ }
1279
+
1057
1280
  /** The router decided this route as approved, for this session (after a hold was returned on its card). */
1058
1281
  function routeApprovedFor(routeId, sessionId) {
1059
1282
  let decided = null;
@@ -1151,7 +1374,7 @@ export function createAssistantDispatcher({
1151
1374
  const entry = entries.get(runId);
1152
1375
  if (!entry || entry.state !== 'awaiting_route' || !target?.provider) return { ok: false, code: 'NOT_HELD' };
1153
1376
  const catalogValue = await catalogSnapshot();
1154
- let input = { ...(entry.rawSpec || {}), provider: target.provider, model: target.model || null, effort: target.effort ?? entry.rawSpec?.effort ?? null };
1377
+ let input = { ...(entry.rawSpec || {}), provider: target.provider, model: target.model || null, effort: target.effort ?? entry.rawSpec?.effort ?? null, ...(target.accountId ? { accountId: target.accountId, codexAccountId: null, claudeAccountId: null } : {}) };
1155
1378
  let spec;
1156
1379
  // The picked provider may need another clamp (Codex under strict worker approvals), or the level fell meanwhile.
1157
1380
  const remoteNotes = [];
@@ -1201,6 +1424,7 @@ export function createAssistantDispatcher({
1201
1424
  const context = [entry.context, note].filter(Boolean).join('\n\n');
1202
1425
  const result = await dispatch({
1203
1426
  provider: to.provider, model: to.model || null, effort: to.effort || null, task: entry.task, cwd: entry.cwd, context,
1427
+ accountId: to.accountId || (to.provider === entry.provider ? entry.accountId : null),
1204
1428
  title: clip(`${entry.title} ${retry ? '↻' : '↑'}`, 80), tags: entry.tags, workflowId: entry.workflowId, parentRunId: runId,
1205
1429
  mcpProfile: entry.provider === to.provider ? entry.mcpProfile : undefined, capability: entry.capability,
1206
1430
  permissionPolicy: to.provider === 'codex' && entry.permissionPolicy === 'ask' ? 'restricted' : entry.permissionPolicy,
@@ -1295,7 +1519,7 @@ export function createAssistantDispatcher({
1295
1519
  */
1296
1520
  function emitFailed(entry, evidenceAcc = null) {
1297
1521
  const done = () => { delete entry.judging; entry.escalation = computeEscalation(entry); emit(entry, 'failed'); };
1298
- if (!judge?.workerOutcome || entry.completionReason === 'budget_cap') { done(); return Promise.resolve(); }
1522
+ if (!judge?.workerOutcome || budgetStopReason(entry.completionReason)) { done(); return Promise.resolve(); }
1299
1523
  entry.judging = true;
1300
1524
  const evidence = evidenceSnapshot(evidenceAcc);
1301
1525
  const error = [entry.error, ...(entry.notes || []).slice(-3)].filter(Boolean).map((line) => String(line)).join('\n');
@@ -1328,10 +1552,12 @@ export function createAssistantDispatcher({
1328
1552
  async function start(entry) {
1329
1553
  const runtime = safeRuntime();
1330
1554
  if (!runtime) throw new DispatchError('RUNTIME_UNAVAILABLE', 'Native loop runtime is not available', { status: 503 });
1331
- // A queued or held run starts under the caps that apply now.
1555
+ // A queued or held run starts under the budget as it is now (its provider's share included).
1332
1556
  refreshLimits();
1333
- try { refuseUnpriced(entry); fitBudget(entry); } catch (error) {
1334
- entry.state = 'stopped'; entry.completionReason = error?.code === 'BUDGET_UNPRICED' ? 'budget_unpriced' : 'session_budget_cap'; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
1557
+ try { await fitBudget(entry, { routed: true }); } catch (error) {
1558
+ entry.state = 'stopped';
1559
+ entry.completionReason = error?.code === 'BUDGET_UNPRICED' ? 'budget_unpriced' : error?.code === 'BUDGET_SHARE_EXHAUSTED' ? 'budget_share' : 'session_budget_cap';
1560
+ entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
1335
1561
  entry.notes.push(`not started: ${error?.message || error}`);
1336
1562
  emit(entry, 'stopped');
1337
1563
  throw error;
@@ -1378,7 +1604,8 @@ export function createAssistantDispatcher({
1378
1604
  claudeAccountId: entry.claudeAccountId, claudeConfigDir: entry.claudeConfigDir,
1379
1605
  driverType: 'native', runtimeType: 'native', surface: 'sidepanel', runId: entry.runId, cwd: entry.cwd,
1380
1606
  runMode: 'task', assistantSessionId: entry.assistantSessionId, permissionPolicy: entry.permissionPolicy,
1381
- capability: entry.capability, budgetUsd: entry.budgetUsd, maxBudgetUsd: entry.provider === 'claude-code' ? entry.budgetUsd : null,
1607
+ // A Claude worker's CLI stop (--max-budget-usd): a backstop under a budget, none under No limit.
1608
+ capability: entry.capability, budgetUsd: entry.budgetUsd, maxBudgetUsd: entry.provider === 'claude-code' ? claudeBackstop(entry) : null,
1382
1609
  idleTimeoutMs: entry.idleTimeoutMs, maxTurns: entry.maxTurns, outputSchema: entry.outputSchema, project: entry.project,
1383
1610
  title: entry.title, tags: entry.tags, workflowId: entry.workflowId, parentRunId: entry.parentRunId,
1384
1611
  // The user's brief after a clarification (assistant-task-prompt.js briefBlock).
@@ -1499,6 +1726,8 @@ export function createAssistantDispatcher({
1499
1726
  entry.pending = [...(entry.pending || []), pending];
1500
1727
  entry.state = 'awaiting_permission';
1501
1728
  entry.turnState = 'awaiting_permission';
1729
+ // Waiting on the answer is not work: the request's own timeout bounds it.
1730
+ pauseWork(entry, liveState);
1502
1731
  liveState.ctx?.setTurnState?.('awaiting_permission');
1503
1732
  liveState.ctx?.emit?.({ type: 'synabun.permission_request', requestId, kind, toolName: pending.toolName, input: pending.input, questions: pending.questions });
1504
1733
  const timer = setTimeout(() => finishPending(runId, requestId, { behavior: 'deny', message: `No decision within ${Math.round(timeoutMs / 60000)} minutes.`, resolvedBy: 'timeout' }), timeoutMs);
@@ -1526,6 +1755,7 @@ export function createAssistantDispatcher({
1526
1755
  if (entry.state === 'awaiting_permission' && entry.pending.length === 0) {
1527
1756
  entry.state = 'running';
1528
1757
  entry.turnState = 'running';
1758
+ startWork(entry, liveState);
1529
1759
  liveState.ctx?.setTurnState?.('running');
1530
1760
  }
1531
1761
  const reply = { behavior: response?.behavior === 'allow' ? 'allow' : 'deny', ...response };
@@ -1581,6 +1811,8 @@ export function createAssistantDispatcher({
1581
1811
  startUsagePolling(entry, liveState);
1582
1812
  entry.state = 'running';
1583
1813
  entry.turnState = 'running';
1814
+ // The work clock runs from here to the turn's end (its result-retry turn included).
1815
+ startWork(entry, liveState);
1584
1816
  ctx.setTurnState('running');
1585
1817
  emit(entry, 'turn_started', { turn: turnNumber });
1586
1818
  // What this turn runs (commands, tool results, edited files): the evidence behind a "done".
@@ -1607,7 +1839,8 @@ export function createAssistantDispatcher({
1607
1839
  parsed.judged = { status: judged.status, confidence: judged.statusConfidence };
1608
1840
  if (judged.status === 'needs_input') parsed.question = clip(lastParagraph(text), 500);
1609
1841
  if (evidence.files.length) { parsed.files = [...evidence.files]; parsed.changes = evidence.files.map((path) => ({ path, note: '' })); }
1610
- } else if (noBlock && !liveState.dead) {
1842
+ } else if (noBlock && !liveState.dead && !liveState.abortReason) {
1843
+ // Never after a stop (user, cap): the turn ends as aborted instead (handleTurnError).
1611
1844
  retried = true;
1612
1845
  try {
1613
1846
  const retry = await inner.runTurn(buildResultRetryPrompt(), { ...meta, turn: turnNumber, retry: true });
@@ -1617,6 +1850,9 @@ export function createAssistantDispatcher({
1617
1850
  retryCost = accumulateCost(entry, retry);
1618
1851
  if (entry.provider === 'codex') settleCodexTurn(entry, liveState, retry);
1619
1852
  } catch (error) {
1853
+ // Stopped during the retry: the run ends here (handleTurnError books the retry), with no
1854
+ // turn_completed or follow-up after it. The first provider turn was paid for: book it first.
1855
+ if (liveState.abortReason) { accumulateCost(entry, raw); throw error; }
1620
1856
  retryCost = accumulateCost(entry, error);
1621
1857
  if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
1622
1858
  entry.notes.push(`result retry failed: ${error?.message || error}`);
@@ -1659,6 +1895,8 @@ export function createAssistantDispatcher({
1659
1895
  entry.turns = [...(entry.turns || []), turn].slice(-50);
1660
1896
  entry.lastResult = parsed;
1661
1897
  entry.lastText = text.slice(0, 20_000);
1898
+ // The turn has ended: its result is in. Idle time from here on is not work.
1899
+ pauseWork(entry, liveState);
1662
1900
  // Blocked or contract-less turns offer a one-tier escalation (the run
1663
1901
  // itself stays warm for follow-ups; the brain or the user decides). A
1664
1902
  // status Jev read from the message is a usable result.
@@ -1689,13 +1927,17 @@ export function createAssistantDispatcher({
1689
1927
  try {
1690
1928
  await runInnerTurn(prompt, meta, { origin: 'dispatch' });
1691
1929
  } catch (error) {
1930
+ pauseWork(entry, liveState);
1692
1931
  return handleTurnError(error, liveState, entry, ctx);
1693
1932
  }
1694
1933
  for (;;) {
1695
- // Out of money (the run's cap or the session's): stop instead of idling warm.
1934
+ // Stopped while the turn ran (a stop that did not fail it): never idle, never take a queued follow-up.
1935
+ if (liveState.abortReason) { entry.completionReason = entry.completionReason || liveState.abortReason; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1936
+ // Out of money (its own ceiling, the session's budget, unpriced usage): stop instead of idling
1937
+ // warm. A provider share that is used up holds nothing from an idle run: it waits like any other.
1696
1938
  refreshLimits();
1697
1939
  const spentOut = budgetBlock(entry);
1698
- if (spentOut) return budgetStop(entry, spentOut);
1940
+ if (spentOut && spentOut.code !== 'BUDGET_SHARE_EXHAUSTED') return budgetStop(entry, spentOut);
1699
1941
  entry.state = 'idle';
1700
1942
  entry.turnState = 'idle';
1701
1943
  ctx.setTurnState('idle');
@@ -1710,15 +1952,24 @@ export function createAssistantDispatcher({
1710
1952
  return { providerSessionId: inner.identity?.()?.providerSessionId };
1711
1953
  }
1712
1954
  refreshLimits();
1713
- const blocked = budgetBlock(entry);
1955
+ let blocked = budgetBlock(entry);
1956
+ // Its provider's share is used up: the unspent budget is divided again first.
1957
+ if (blocked?.code === 'BUDGET_SHARE_EXHAUSTED' && await coverShare(entry)) blocked = budgetBlock(entry);
1714
1958
  if (blocked) {
1715
- entry.notes.push(`follow-up dropped: ${blocked.message}`);
1959
+ entry.notes.push(`follow-up dropped: ${blocked.message}${blocked.code === 'BUDGET_SHARE_EXHAUSTED' ? '; no unspent budget could be moved to it' : ''}`);
1716
1960
  return budgetStop(entry, blocked);
1717
1961
  }
1962
+ // No work time left: a turn started now would only be cut off.
1963
+ if (workLeftMs(entry) <= 0) {
1964
+ entry.completionReason = 'time_cap';
1965
+ entry.notes.push(`follow-up dropped: its ${entry.maxMinutes} minutes of work time are used up`);
1966
+ return { providerSessionId: inner.identity?.()?.providerSessionId };
1967
+ }
1718
1968
  try {
1719
1969
  const followPrompt = buildFollowUpPrompt(next.text, state, { turn: liveState.turnCounter + 1, maxTurns: entry.maxTurns, origin: next.origin, testDensity: changedTestDensity(entry) });
1720
1970
  await runInnerTurn(followPrompt, meta, { origin: next.origin, label: clip(next.text, 80), followUp: next.text, taskId: next.taskId || entry.taskId || null });
1721
1971
  } catch (error) {
1972
+ pauseWork(entry, liveState);
1722
1973
  return handleTurnError(error, liveState, entry, ctx);
1723
1974
  }
1724
1975
  }
@@ -1817,7 +2068,7 @@ export function createAssistantDispatcher({
1817
2068
  if (budgetStopReason(entry.completionReason)) return { providerSessionId: null };
1818
2069
  if (error?.code === 'BUDGET_CAP') {
1819
2070
  entry.completionReason = 'budget_cap';
1820
- entry.notes.push('stopped: per-run budget cap reached');
2071
+ entry.notes.push('stopped: the Claude CLI reached the budget it started with (the run\'s ceiling, or what the session had left when it started)');
1821
2072
  const runtime = safeRuntime();
1822
2073
  runtime?.stop?.(entry.runId, 'budget_cap').catch(() => {});
1823
2074
  return { providerSessionId: null };
@@ -1832,7 +2083,7 @@ export function createAssistantDispatcher({
1832
2083
  entry.notes.push(`provider error: ${error?.message || error}`);
1833
2084
  throw error;
1834
2085
  }
1835
- function budgetStopReason(reason) { return reason === 'budget_cap' || reason === 'session_budget_cap' || reason === 'budget_unpriced'; }
2086
+ function budgetStopReason(reason) { return reason === 'budget_cap' || reason === 'session_budget_cap' || reason === 'budget_unpriced' || reason === 'budget_share'; }
1836
2087
  /** Stop a run that is out of money; the runtime ends it as stopped with this reason. */
1837
2088
  function budgetStop(entry, block) {
1838
2089
  entry.completionReason = block.reason;
@@ -1863,7 +2114,7 @@ export function createAssistantDispatcher({
1863
2114
  function announceUnpriced(entry) {
1864
2115
  if (!(Number(entry.unpricedTurns) > 0) || entry.unpricedAnnounced) return;
1865
2116
  entry.unpricedAnnounced = true;
1866
- emit(entry, 'budget_unpriced', { text: `this run's usage could not be priced (${entry.unpricedReason}); its tokens are recorded but the dollar caps cannot meter it.` });
2117
+ emit(entry, 'budget_unpriced', { text: `this run's usage could not be priced (${entry.unpricedReason}); its tokens are recorded but the session budget cannot meter it.` });
1867
2118
  }
1868
2119
  function codexThreadId(entry) {
1869
2120
  try { return entry.providerThreadId || live.get(entry.runId)?.inner?.identity?.()?.providerThreadId || null; } catch { return entry.providerThreadId || null; }
@@ -2046,6 +2297,13 @@ export function createAssistantDispatcher({
2046
2297
  if (TERMINAL_STATES.has(entry.state)) meter.clearPending();
2047
2298
  const pending = meter.pending();
2048
2299
  setPendingUsage(entry, pending.tokens, pending.byModel[0]?.model || null);
2300
+ // The calls of the turn in progress at list price: the budget sees them between model calls (the
2301
+ // turn's reported cost replaces them at its result).
2302
+ const liveState = live.get(entry.runId);
2303
+ if (liveState) {
2304
+ liveState.claudePendingUsd = pending.byModel.reduce((sum, row) => sum + (Number(claudeTokensCostUsd(row.model, row.tokens)) || 0), 0);
2305
+ if (pendingChanged && !settled && liveState.turnOpen) enforceMidTurn(entry, liveState);
2306
+ }
2049
2307
  }
2050
2308
 
2051
2309
  // Codex: the rollout files of the run's thread and its sub-agent threads.
@@ -2368,12 +2626,17 @@ export function createAssistantDispatcher({
2368
2626
  const held = meters.get(entry.runId);
2369
2627
  if (held) { held.codex = null; held.opencode = null; }
2370
2628
  }
2371
- /** Caps checked between provider steps: the run stops as soon as it is out of money. */
2629
+ /**
2630
+ * The budget checked between provider steps (Claude model calls, OpenCode steps, Codex rollout
2631
+ * records): the run stops as soon as it reaches its ceiling or the session its budget. Its
2632
+ * provider's share running dry divides the unspent budget again first (shareRanDry).
2633
+ */
2372
2634
  function enforceMidTurn(entry, liveState) {
2373
2635
  if (TERMINAL_STATES.has(entry.state) || liveState.budgetStopping) return;
2374
2636
  refreshLimits();
2375
2637
  const block = budgetBlock(entry);
2376
- if (block) {
2638
+ if (block?.code === 'BUDGET_SHARE_EXHAUSTED') shareRanDry(entry);
2639
+ else if (block) {
2377
2640
  liveState.budgetStopping = true;
2378
2641
  entry.completionReason = block.reason;
2379
2642
  entry.notes.push(`stopped mid-turn: ${block.message}`);
@@ -2381,6 +2644,25 @@ export function createAssistantDispatcher({
2381
2644
  }
2382
2645
  checkSession(entry.assistantSessionId, { source: entry });
2383
2646
  }
2647
+ /**
2648
+ * A provider's share ran dry while a run of it works: the session's unspent budget is divided again
2649
+ * (budget planner). Only when no money could move to it are that provider's runs of the session
2650
+ * stopped (completion reason budget_share). One check per session and provider at a time.
2651
+ */
2652
+ function shareRanDry(entry) {
2653
+ const sessionId = entry.assistantSessionId;
2654
+ if (!sessionId) return;
2655
+ const key = `${sessionId}:${entry.provider}`;
2656
+ if (shareChecks.has(key)) return;
2657
+ const check = coverShare(entry).then((covered) => {
2658
+ if (covered || TERMINAL_STATES.has(entry.state)) return;
2659
+ // Spent again meanwhile: the share as it is now decides.
2660
+ const still = budgetBlock(entry);
2661
+ if (still?.code !== 'BUDGET_SHARE_EXHAUSTED') return;
2662
+ stopProviderRuns(sessionId, entry.provider, still.message);
2663
+ }).catch(() => {}).finally(() => { if (shareChecks.get(key) === check) shareChecks.delete(key); });
2664
+ shareChecks.set(key, check);
2665
+ }
2384
2666
 
2385
2667
  // ── run ↔ memory ───────────────────────────────────────────────────────────
2386
2668
  /**
@@ -2421,7 +2703,16 @@ export function createAssistantDispatcher({
2421
2703
  refreshLimits();
2422
2704
  const blocked = budgetBlock(entry);
2423
2705
  if (blocked) {
2424
- throw new DispatchError(blocked.code, `Run ${runId} cannot take a follow-up: ${blocked.message}. ${blocked.reason === 'budget_cap' ? 'Dispatch a new run with its result as context.' : 'Ask the user to raise the cap (Assistant → Budget).'}`, { status: 409, ...blocked.detail });
2706
+ const next = blocked.reason === 'budget_cap' ? 'Dispatch a new run with its result as context.'
2707
+ : blocked.reason === 'budget_share' ? 'No unspent budget could be moved to it. Route the follow-up as a new task with agent_route (it moves the task to a provider with money left), or ask the user to raise the budget (Assistant → Budget).'
2708
+ : blocked.reason === 'budget_unpriced' ? 'Dispatch a new run on a priced or free model with its result as context.'
2709
+ : 'Ask the user to raise the budget or switch to No limit (Assistant → Budget).';
2710
+ throw new DispatchError(blocked.code, `Run ${runId} cannot take a follow-up: ${blocked.message}. ${next}`, { status: 409, reason: blocked.reason, ...blocked.detail });
2711
+ }
2712
+ // No work time left: the turn loop would drop the follow-up, so refuse it here (a follow-up
2713
+ // queued while a turn still works is checked again when that turn ends).
2714
+ if (workLeftMs(entry) <= 0) {
2715
+ throw new DispatchError('TIME_CAP', `Run ${runId} cannot take a follow-up: its ${entry.maxMinutes} minutes of work time are used up. Dispatch a new run with its result as context.`, { status: 409, reason: 'time_cap', maxMinutes: entry.maxMinutes ?? null, workMs: workUsedMs(entry) });
2425
2716
  }
2426
2717
  // A follow-up works for the task in progress when it is sent, which may be a later one than the run's.
2427
2718
  const item = { kind: 'follow-up', text: message, origin, at: iso(now()), taskId: taskNow(entry.assistantSessionId) || entry.taskId || null };
@@ -2432,6 +2723,18 @@ export function createAssistantDispatcher({
2432
2723
  emit(entry, 'follow_up_queued', { queued: liveState.followUps.length });
2433
2724
  return { ok: true, queued: true, position: liveState.followUps.length, run: view(entry) };
2434
2725
  }
2726
+ /**
2727
+ * Before a follow-up: when the run's provider share is used up, the session's unspent budget is
2728
+ * divided again (Jev or the fallback), so sendTurn decides on the share as it is after that.
2729
+ * Never throws; resolves to whether the provider can pay now.
2730
+ */
2731
+ async function coverFollowUp(runId) {
2732
+ const entry = entries.get(String(runId || ''));
2733
+ if (!entry || TERMINAL_STATES.has(entry.state)) return true;
2734
+ const blocked = budgetBlock(entry);
2735
+ if (blocked?.code !== 'BUDGET_SHARE_EXHAUSTED') return !blocked;
2736
+ return coverShare(entry);
2737
+ }
2435
2738
  function complete(runId, { reason = 'complete' } = {}) {
2436
2739
  const entry = requireEntry(runId);
2437
2740
  const liveState = live.get(runId);
@@ -2489,6 +2792,7 @@ export function createAssistantDispatcher({
2489
2792
  function finalize(entry, runtimeRun) {
2490
2793
  if (TERMINAL_STATES.has(entry.state)) return;
2491
2794
  const liveState = live.get(entry.runId);
2795
+ pauseWork(entry, liveState);
2492
2796
  let status = runtimeRun?.status || 'completed';
2493
2797
  // A completed worker whose loop file was swept meanwhile (a stale-file cleanup) reaches the
2494
2798
  // runtime's next check as "stopped": its turn loop had already ended on the completion.
@@ -2507,7 +2811,9 @@ export function createAssistantDispatcher({
2507
2811
  }
2508
2812
  entry.pending = [];
2509
2813
  releaseDesktop(entry);
2510
- if (entry.state === 'failed' && entry.completionReason !== 'budget_cap' && judge?.workerOutcome) {
2814
+ // A run that ended gives back what it did not spend: the session's split is made again (debounced).
2815
+ if (entry.assistantSessionId) { try { budget.onRunEnded(entry.assistantSessionId); } catch { /* the split stays as it is */ } }
2816
+ if (entry.state === 'failed' && !budgetStopReason(entry.completionReason) && judge?.workerOutcome) {
2511
2817
  // State, queue and desktop move on now; the 'failed' event waits for its cause.
2512
2818
  emitFailed(entry, liveState?.evidence || null);
2513
2819
  processQueue().catch(() => {});
@@ -2764,6 +3070,7 @@ export function createAssistantDispatcher({
2764
3070
  if (unsubscribeRuntime) { try { unsubscribeRuntime(); } catch {} unsubscribeRuntime = null; }
2765
3071
  for (const entry of entries.values()) {
2766
3072
  if (ACTIVE_STATES.has(entry.state)) {
3073
+ pauseWork(entry);
2767
3074
  entry.state = 'interrupted'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
2768
3075
  delete entry.rawSpec;
2769
3076
  // What the run's meter can still read is booked now; nothing provisional outlives the process.
@@ -2782,7 +3089,7 @@ export function createAssistantDispatcher({
2782
3089
  return {
2783
3090
  dispatch, sendTurn, complete, completeClosedTab, stop, killAll, get, peek, list, totals, wait, transcript, pendingPermissions,
2784
3091
  respondPermission, subscribe, shutdown, limits,
2785
- sessionBudget, checkSession, refreshLimits,
3092
+ sessionBudget, checkSession, refreshLimits, providerSpend, coverFollowUp, budget,
2786
3093
  resolveRoute, declineRoute, heldRouteRefusal, escalate,
2787
3094
  markDelivered, deliveryState,
2788
3095
  remove, removeFinished,