synabun 2.0.0 → 2.0.7

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 (507) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/NOTICE +5 -0
  3. package/README.md +2 -1
  4. package/THIRD-PARTY-LICENSES.md +59 -0
  5. package/benchmarks/fixtures/browser/README.md +1 -1
  6. package/benchmarks/fixtures/desktop/README.md +1 -1
  7. package/docs/whatsapp.md +153 -18
  8. package/hooks/claude-code/pre-compact.mjs +11 -0
  9. package/hooks/claude-code/prompt-submit.mjs +60 -184
  10. package/hooks/claude-code/session-start.mjs +73 -16
  11. package/hooks/claude-code/shared.mjs +26 -3
  12. package/hooks/claude-code/stop.mjs +5 -5
  13. package/lib/child-path.js +303 -0
  14. package/lib/claude-hooks.js +2 -1
  15. package/lib/data-home-migration.js +2 -0
  16. package/lib/desktop-app.js +265 -0
  17. package/lib/external-tools.js +7 -3
  18. package/lib/log-redact.js +102 -0
  19. package/lib/packaged-runtime.js +138 -11
  20. package/lib/start-launcher.js +88 -1
  21. package/mcp-server/dist/http.d.ts +3 -1
  22. package/mcp-server/dist/http.d.ts.map +1 -1
  23. package/mcp-server/dist/http.js +15 -4
  24. package/mcp-server/dist/http.js.map +1 -1
  25. package/mcp-server/dist/index.d.ts.map +1 -1
  26. package/mcp-server/dist/index.js +9 -6
  27. package/mcp-server/dist/index.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/codex-config-heal.d.ts +5 -1
  33. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  34. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  35. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  36. package/mcp-server/dist/services/identity.d.ts +11 -0
  37. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  38. package/mcp-server/dist/services/identity.js +24 -0
  39. package/mcp-server/dist/services/identity.js.map +1 -1
  40. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  41. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  42. package/mcp-server/dist/services/neural-interface.js +34 -2
  43. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  44. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  45. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  46. package/mcp-server/dist/services/typesafe-config.js +4 -1
  47. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  48. package/mcp-server/dist/tools/agents.d.ts +10 -7
  49. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  50. package/mcp-server/dist/tools/agents.js +34 -13
  51. package/mcp-server/dist/tools/agents.js.map +1 -1
  52. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  53. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  54. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  55. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  56. package/mcp-server/dist/tui.js +2 -1
  57. package/mcp-server/dist/tui.js.map +1 -1
  58. package/mcp-server/dist/version.d.ts +5 -0
  59. package/mcp-server/dist/version.d.ts.map +1 -0
  60. package/mcp-server/dist/version.js +21 -0
  61. package/mcp-server/dist/version.js.map +1 -0
  62. package/mcp-server/package-lock.json +2 -2
  63. package/mcp-server/package.json +1 -3
  64. package/mcp-server/run.mjs +15 -9
  65. package/mcp-server/src/http.ts +18 -3
  66. package/mcp-server/src/index.ts +9 -6
  67. package/mcp-server/src/services/budget-judgments.ts +183 -0
  68. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  69. package/mcp-server/src/services/identity.ts +27 -0
  70. package/mcp-server/src/services/neural-interface.ts +34 -2
  71. package/mcp-server/src/services/typesafe-config.ts +4 -1
  72. package/mcp-server/src/tools/agents.ts +33 -14
  73. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  74. package/mcp-server/src/tui.ts +2 -1
  75. package/mcp-server/src/version.ts +22 -0
  76. package/neural-interface/i18n/en.json +2126 -209
  77. package/neural-interface/i18n/pt-BR.json +2123 -207
  78. package/neural-interface/lib/account-usage.js +163 -0
  79. package/neural-interface/lib/assistant-api.js +316 -57
  80. package/neural-interface/lib/assistant-brains/claude.js +19 -2
  81. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  82. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  83. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  84. package/neural-interface/lib/assistant-budget-split.js +408 -0
  85. package/neural-interface/lib/assistant-budget.js +94 -95
  86. package/neural-interface/lib/assistant-catalog.js +179 -27
  87. package/neural-interface/lib/assistant-config.js +91 -14
  88. package/neural-interface/lib/assistant-dispatch.js +494 -138
  89. package/neural-interface/lib/assistant-hidden-models.js +393 -66
  90. package/neural-interface/lib/assistant-jev.js +26 -3
  91. package/neural-interface/lib/assistant-memory.js +24 -10
  92. package/neural-interface/lib/assistant-persona.js +73 -12
  93. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  94. package/neural-interface/lib/assistant-pricing.js +21 -6
  95. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  96. package/neural-interface/lib/assistant-route-gate.js +19 -3
  97. package/neural-interface/lib/assistant-router.js +373 -42
  98. package/neural-interface/lib/assistant-runtime.js +488 -59
  99. package/neural-interface/lib/assistant-task-prompt.js +13 -3
  100. package/neural-interface/lib/assistant-test-density.js +142 -0
  101. package/neural-interface/lib/assistant-user-context-budgets.js +123 -0
  102. package/neural-interface/lib/assistant-user-context.js +908 -0
  103. package/neural-interface/lib/assistant-user-knowledge.js +328 -0
  104. package/neural-interface/lib/assistant-user-learn.js +404 -0
  105. package/neural-interface/lib/assistant-user-threads.js +226 -0
  106. package/neural-interface/lib/assistant-user-writer.js +183 -0
  107. package/neural-interface/lib/augmented-path.js +317 -53
  108. package/neural-interface/lib/browser-background-tab.js +134 -0
  109. package/neural-interface/lib/browser-screenshot.js +678 -61
  110. package/neural-interface/lib/claude-accounts.js +342 -31
  111. package/neural-interface/lib/claude-agent-bridge.js +123 -9
  112. package/neural-interface/lib/claude-executable.js +6 -1
  113. package/neural-interface/lib/claude-model-catalog.js +33 -5
  114. package/neural-interface/lib/claude-panel-auth.js +221 -0
  115. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  116. package/neural-interface/lib/claude-temporary.js +46 -7
  117. package/neural-interface/lib/cli-install-api.js +137 -0
  118. package/neural-interface/lib/cli-install.js +797 -0
  119. package/neural-interface/lib/cli-path.js +130 -0
  120. package/neural-interface/lib/cli-resolver.js +744 -0
  121. package/neural-interface/lib/cli-update-planner.js +143 -7
  122. package/neural-interface/lib/cloudflared/install.js +533 -0
  123. package/neural-interface/lib/cloudflared/manager.js +545 -0
  124. package/neural-interface/lib/cloudflared/pins.js +115 -0
  125. package/neural-interface/lib/cloudflared/pins.json +63 -0
  126. package/neural-interface/lib/cloudflared/tunnel.js +265 -0
  127. package/neural-interface/lib/codex-account-auth.js +178 -0
  128. package/neural-interface/lib/codex-accounts.js +489 -0
  129. package/neural-interface/lib/codex-capabilities.js +2 -1
  130. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  131. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  132. package/neural-interface/lib/codex-panel-auth.js +62 -0
  133. package/neural-interface/lib/codex-session-settings.js +1 -1
  134. package/neural-interface/lib/codex-settings-schema.js +120 -0
  135. package/neural-interface/lib/desktop/config.js +3 -0
  136. package/neural-interface/lib/editor-completion-endpoint.js +264 -0
  137. package/neural-interface/lib/editor-completion-opencode.js +436 -0
  138. package/neural-interface/lib/editor-completion.js +714 -0
  139. package/neural-interface/lib/editor-config.js +149 -0
  140. package/neural-interface/lib/editor-files-api.js +268 -0
  141. package/neural-interface/lib/editor-git-api.js +134 -0
  142. package/neural-interface/lib/file-explorer-api.js +1039 -0
  143. package/neural-interface/lib/file-explorer-system.js +355 -0
  144. package/neural-interface/lib/file-explorer-trash.ps1 +85 -0
  145. package/neural-interface/lib/file-explorer-watch.js +239 -0
  146. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  147. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  148. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  149. package/neural-interface/lib/git-bash/install.js +529 -0
  150. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  151. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  152. package/neural-interface/lib/git-bash/manager.js +268 -0
  153. package/neural-interface/lib/git-bash/pins.js +206 -0
  154. package/neural-interface/lib/git-bash/pins.json +34 -0
  155. package/neural-interface/lib/git-bash/resolver.js +194 -0
  156. package/neural-interface/lib/git-diff-api.js +103 -0
  157. package/neural-interface/lib/git-read.js +234 -0
  158. package/neural-interface/lib/greeting/api.js +89 -0
  159. package/neural-interface/lib/greeting/budgets.js +90 -0
  160. package/neural-interface/lib/greeting/engine.js +461 -0
  161. package/neural-interface/lib/greeting/facts.js +320 -0
  162. package/neural-interface/lib/greeting/sources.js +252 -0
  163. package/neural-interface/lib/greeting/writer.js +139 -0
  164. package/neural-interface/lib/http-guards.js +189 -13
  165. package/neural-interface/lib/install-details.js +145 -0
  166. package/neural-interface/lib/machine-names.js +88 -0
  167. package/neural-interface/lib/mcp-installer.js +14 -2
  168. package/neural-interface/lib/memory-api.js +31 -7
  169. package/neural-interface/lib/native-loop-providers.js +26 -8
  170. package/neural-interface/lib/native-loop-runtime.js +9 -4
  171. package/neural-interface/lib/os-path.js +319 -0
  172. package/neural-interface/lib/plugin-installer.js +4 -2
  173. package/neural-interface/lib/project-storage.js +5 -7
  174. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  175. package/neural-interface/lib/provider-panel-terminal-auth.js +95 -0
  176. package/neural-interface/lib/pty-host/core.js +2 -1
  177. package/neural-interface/lib/pty-host/manager.js +27 -2
  178. package/neural-interface/lib/remote-policy.js +20 -11
  179. package/neural-interface/lib/request-gate.js +589 -0
  180. package/neural-interface/lib/session-title-generator.js +4 -3
  181. package/neural-interface/lib/system-about.js +340 -0
  182. package/neural-interface/lib/system-activity.js +66 -0
  183. package/neural-interface/lib/terminal-env.js +11 -3
  184. package/neural-interface/lib/terminal-path.js +55 -0
  185. package/neural-interface/lib/ui-blob-api.js +108 -0
  186. package/neural-interface/lib/ui-blob-store.js +68 -0
  187. package/neural-interface/lib/virtual-socket.js +12 -1
  188. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  189. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  190. package/neural-interface/lib/whatsapp/commands.js +13 -14
  191. package/neural-interface/lib/whatsapp/config.js +134 -20
  192. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  193. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  194. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  195. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  196. package/neural-interface/lib/whatsapp/manager.js +92 -32
  197. package/neural-interface/lib/whatsapp/paths.js +11 -1
  198. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  199. package/neural-interface/lib/whatsapp/redact.js +63 -0
  200. package/neural-interface/lib/whatsapp/service.js +343 -91
  201. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  202. package/neural-interface/package-lock.json +701 -2
  203. package/neural-interface/package.json +13 -2
  204. package/neural-interface/public/index.html +2 -0
  205. package/neural-interface/public/index2d.html +2 -0
  206. package/neural-interface/public/install-app.html +9 -6
  207. package/neural-interface/public/offline.html +19 -3
  208. package/neural-interface/public/onboarding.html +107 -2
  209. package/neural-interface/public/shared/account-i18n.js +7 -0
  210. package/neural-interface/public/shared/account-menu.js +539 -0
  211. package/neural-interface/public/shared/account-usage.js +19 -0
  212. package/neural-interface/public/shared/account-view.js +335 -0
  213. package/neural-interface/public/shared/agent-runtime-options.js +50 -14
  214. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  215. package/neural-interface/public/shared/assistant/asst-api.js +32 -2
  216. package/neural-interface/public/shared/assistant/asst-automation.js +286 -0
  217. package/neural-interface/public/shared/assistant/asst-brain-picker.js +430 -81
  218. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  219. package/neural-interface/public/shared/assistant/asst-composer.js +11 -1
  220. package/neural-interface/public/shared/assistant/asst-mascot.js +140 -61
  221. package/neural-interface/public/shared/assistant/asst-menu.js +27 -11
  222. package/neural-interface/public/shared/assistant/asst-panel.js +198 -42
  223. package/neural-interface/public/shared/assistant/asst-render.js +197 -23
  224. package/neural-interface/public/shared/assistant/asst-route-models.js +108 -0
  225. package/neural-interface/public/shared/assistant/asst-route.js +95 -343
  226. package/neural-interface/public/shared/assistant/asst-routes-editor.js +534 -0
  227. package/neural-interface/public/shared/assistant/asst-sanitize.js +8 -1
  228. package/neural-interface/public/shared/assistant/asst-sidepanel.js +31 -9
  229. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  230. package/neural-interface/public/shared/assistant/asst-state.js +186 -2
  231. package/neural-interface/public/shared/assistant/asst-styles.js +247 -11
  232. package/neural-interface/public/shared/blob-store.js +111 -22
  233. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  234. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  235. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  236. package/neural-interface/public/shared/cdx/cdx-panel.js +80 -118
  237. package/neural-interface/public/shared/cdx/cdx-render.js +25 -89
  238. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  239. package/neural-interface/public/shared/cdx/cdx-styles.js +164 -107
  240. package/neural-interface/public/shared/cdx/cdx-tabs.js +348 -668
  241. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  242. package/neural-interface/public/shared/claude-account-view.js +3 -0
  243. package/neural-interface/public/shared/cli-install-view.js +392 -0
  244. package/neural-interface/public/shared/cli-status.js +282 -22
  245. package/neural-interface/public/shared/constants.js +6 -1
  246. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  247. package/neural-interface/public/shared/cp/cp-rehydrate.js +2 -1
  248. package/neural-interface/public/shared/cp/cp-styles.js +61 -0
  249. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  250. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  251. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  252. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  253. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  254. package/neural-interface/public/shared/editor/editor-ai-logic.js +409 -0
  255. package/neural-interface/public/shared/editor/editor-ai.js +619 -0
  256. package/neural-interface/public/shared/editor/editor-commands.js +212 -0
  257. package/neural-interface/public/shared/editor/editor-compare.js +103 -0
  258. package/neural-interface/public/shared/editor/editor-completion-list.js +172 -0
  259. package/neural-interface/public/shared/editor/editor-doc.js +257 -0
  260. package/neural-interface/public/shared/editor/editor-git-logic.js +131 -0
  261. package/neural-interface/public/shared/editor/editor-git.js +95 -0
  262. package/neural-interface/public/shared/editor/editor-languages.js +156 -0
  263. package/neural-interface/public/shared/editor/editor-lint.js +229 -0
  264. package/neural-interface/public/shared/editor/editor-loader.js +51 -0
  265. package/neural-interface/public/shared/editor/editor-markdown.js +90 -0
  266. package/neural-interface/public/shared/editor/editor-menu.js +233 -0
  267. package/neural-interface/public/shared/editor/editor-minimap.js +371 -0
  268. package/neural-interface/public/shared/editor/editor-palette-logic.js +192 -0
  269. package/neural-interface/public/shared/editor/editor-palette.js +125 -0
  270. package/neural-interface/public/shared/editor/editor-panel-logic.js +288 -0
  271. package/neural-interface/public/shared/editor/editor-panel.js +2099 -0
  272. package/neural-interface/public/shared/editor/editor-prefs.js +224 -0
  273. package/neural-interface/public/shared/editor/editor-safety.js +500 -0
  274. package/neural-interface/public/shared/editor/editor-search.js +352 -0
  275. package/neural-interface/public/shared/editor/editor-statusbar.js +144 -0
  276. package/neural-interface/public/shared/editor/editor-tabs-logic.js +331 -0
  277. package/neural-interface/public/shared/editor/editor-tabs.js +233 -0
  278. package/neural-interface/public/shared/editor/editor-theme.js +244 -0
  279. package/neural-interface/public/shared/editor/editor-view.js +332 -0
  280. package/neural-interface/public/shared/explorer/fx-api.js +216 -0
  281. package/neural-interface/public/shared/explorer/fx-links.js +204 -0
  282. package/neural-interface/public/shared/explorer/fx-logic.js +537 -0
  283. package/neural-interface/public/shared/explorer/fx-main.js +865 -0
  284. package/neural-interface/public/shared/explorer/fx-names.js +87 -0
  285. package/neural-interface/public/shared/explorer/fx-ops.js +370 -0
  286. package/neural-interface/public/shared/explorer/fx-popups.js +490 -0
  287. package/neural-interface/public/shared/explorer/fx-tree.js +790 -0
  288. package/neural-interface/public/shared/file-links.js +339 -0
  289. package/neural-interface/public/shared/git-bash-view.js +145 -0
  290. package/neural-interface/public/shared/greeting/greeting-card.js +178 -0
  291. package/neural-interface/public/shared/greeting/greeting-view.js +148 -0
  292. package/neural-interface/public/shared/html-shell.js +161 -165
  293. package/neural-interface/public/shared/i18n.js +19 -1
  294. package/neural-interface/public/shared/install-details-view.js +86 -0
  295. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -0
  296. package/neural-interface/public/shared/ocp-v2/ocp-v2-greeting.js +47 -0
  297. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +131 -17
  298. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -1
  299. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +22 -0
  300. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +158 -0
  301. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +3 -2
  302. package/neural-interface/public/shared/pwa-install-page.js +32 -2
  303. package/neural-interface/public/shared/pwa-install.css +3 -3
  304. package/neural-interface/public/shared/pwa-install.js +100 -1
  305. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  306. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  307. package/neural-interface/public/shared/settings/settings-code-editor-pane.js +412 -0
  308. package/neural-interface/public/shared/settings/settings-code-editor.js +328 -0
  309. package/neural-interface/public/shared/settings/settings-greeting.js +133 -0
  310. package/neural-interface/public/shared/settings/settings-ia.js +33 -4
  311. package/neural-interface/public/shared/settings/settings-user-context.js +154 -0
  312. package/neural-interface/public/shared/showcase-privacy.js +489 -0
  313. package/neural-interface/public/shared/start-bridge.js +238 -19
  314. package/neural-interface/public/shared/storage.js +30 -3
  315. package/neural-interface/public/shared/styles.css +1710 -770
  316. package/neural-interface/public/shared/synabun-mascot.js +68 -58
  317. package/neural-interface/public/shared/tunnel-view.js +167 -0
  318. package/neural-interface/public/shared/ui-claude-panel.js +369 -229
  319. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  320. package/neural-interface/public/shared/ui-detail.js +15 -0
  321. package/neural-interface/public/shared/ui-explorer.js +22 -0
  322. package/neural-interface/public/shared/ui-file-explorer.js +481 -2496
  323. package/neural-interface/public/shared/ui-file-links.js +103 -0
  324. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  325. package/neural-interface/public/shared/ui-help.js +26 -0
  326. package/neural-interface/public/shared/ui-invite.js +126 -116
  327. package/neural-interface/public/shared/ui-judgments.js +10 -1
  328. package/neural-interface/public/shared/ui-keybinds.js +4 -0
  329. package/neural-interface/public/shared/ui-loading.js +187 -19
  330. package/neural-interface/public/shared/ui-menubar.js +25 -33
  331. package/neural-interface/public/shared/ui-native-loop-router-state.js +11 -0
  332. package/neural-interface/public/shared/ui-native-loop-router.js +15 -2
  333. package/neural-interface/public/shared/ui-navbar.js +48 -22
  334. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  335. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  336. package/neural-interface/public/shared/ui-sessions.js +9 -3
  337. package/neural-interface/public/shared/ui-settings.js +509 -490
  338. package/neural-interface/public/shared/ui-sidepanel-runtime.js +2 -1
  339. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  340. package/neural-interface/public/shared/ui-sync.js +22 -0
  341. package/neural-interface/public/shared/ui-terminal.js +40 -5
  342. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  343. package/neural-interface/public/shared/ui-tunnel.js +130 -0
  344. package/neural-interface/public/shared/ui-tutorial-steps.js +6 -2
  345. package/neural-interface/public/shared/ui-update.js +594 -5
  346. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  347. package/neural-interface/public/shared/ui-whiteboard.js +12 -0
  348. package/neural-interface/public/shared/ui-worker-end.js +245 -0
  349. package/neural-interface/public/shared/ui-workspaces.js +41 -27
  350. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  351. package/neural-interface/public/shared/worker-end.js +253 -0
  352. package/neural-interface/public/sw.js +2 -2
  353. package/neural-interface/public/variant/2d/main.js +10 -1
  354. package/neural-interface/public/variant/3d/main.js +10 -1
  355. package/neural-interface/public/vendor/codemirror/chunks/apl-CSDKT72Z.js +1 -0
  356. package/neural-interface/public/vendor/codemirror/chunks/asciiarmor-JYYPU5BM.js +1 -0
  357. package/neural-interface/public/vendor/codemirror/chunks/asn1-NRWCZDA6.js +1 -0
  358. package/neural-interface/public/vendor/codemirror/chunks/asterisk-5OU6D4ZQ.js +1 -0
  359. package/neural-interface/public/vendor/codemirror/chunks/brainfuck-ID4N4SYC.js +1 -0
  360. package/neural-interface/public/vendor/codemirror/chunks/chunk-4RZWGCS2.js +1 -0
  361. package/neural-interface/public/vendor/codemirror/chunks/chunk-ACTJM3JR.js +11 -0
  362. package/neural-interface/public/vendor/codemirror/chunks/chunk-S6BMAXLK.js +1 -0
  363. package/neural-interface/public/vendor/codemirror/chunks/chunk-TO2RDOHQ.js +3 -0
  364. package/neural-interface/public/vendor/codemirror/chunks/chunk-US2GMZZQ.js +13 -0
  365. package/neural-interface/public/vendor/codemirror/chunks/chunk-WYYO5ZAJ.js +1 -0
  366. package/neural-interface/public/vendor/codemirror/chunks/chunk-XALZTDJC.js +1 -0
  367. package/neural-interface/public/vendor/codemirror/chunks/chunk-YRNUTRDV.js +1 -0
  368. package/neural-interface/public/vendor/codemirror/chunks/chunk-Z3QOKH62.js +1 -0
  369. package/neural-interface/public/vendor/codemirror/chunks/clike-OWQBSOGL.js +1 -0
  370. package/neural-interface/public/vendor/codemirror/chunks/clojure-KV75UQWC.js +1 -0
  371. package/neural-interface/public/vendor/codemirror/chunks/cmake-GTEP5Y3C.js +1 -0
  372. package/neural-interface/public/vendor/codemirror/chunks/cobol-WCZ7QEGJ.js +1 -0
  373. package/neural-interface/public/vendor/codemirror/chunks/coffeescript-TGLBOT6B.js +1 -0
  374. package/neural-interface/public/vendor/codemirror/chunks/commonlisp-WVVI432V.js +1 -0
  375. package/neural-interface/public/vendor/codemirror/chunks/crystal-S6R6HLKQ.js +1 -0
  376. package/neural-interface/public/vendor/codemirror/chunks/css-PDA2RINP.js +1 -0
  377. package/neural-interface/public/vendor/codemirror/chunks/cypher-HL5QPL4C.js +1 -0
  378. package/neural-interface/public/vendor/codemirror/chunks/d-HHJH2HAJ.js +1 -0
  379. package/neural-interface/public/vendor/codemirror/chunks/diff-MN3A35BY.js +1 -0
  380. package/neural-interface/public/vendor/codemirror/chunks/dist-25SQLBVS.js +3 -0
  381. package/neural-interface/public/vendor/codemirror/chunks/dist-27ZGGJJS.js +1 -0
  382. package/neural-interface/public/vendor/codemirror/chunks/dist-4E52OJIF.js +1 -0
  383. package/neural-interface/public/vendor/codemirror/chunks/dist-6FT73XDC.js +2 -0
  384. package/neural-interface/public/vendor/codemirror/chunks/dist-BE6YGRMW.js +1 -0
  385. package/neural-interface/public/vendor/codemirror/chunks/dist-CJSGKW2O.js +1 -0
  386. package/neural-interface/public/vendor/codemirror/chunks/dist-CRPH2WLI.js +1 -0
  387. package/neural-interface/public/vendor/codemirror/chunks/dist-CTG7JEQN.js +1 -0
  388. package/neural-interface/public/vendor/codemirror/chunks/dist-I3FEVELX.js +1 -0
  389. package/neural-interface/public/vendor/codemirror/chunks/dist-IM5BBSWZ.js +1 -0
  390. package/neural-interface/public/vendor/codemirror/chunks/dist-KERUKTIF.js +1 -0
  391. package/neural-interface/public/vendor/codemirror/chunks/dist-LXTMWZAM.js +1 -0
  392. package/neural-interface/public/vendor/codemirror/chunks/dist-MABASSET.js +1 -0
  393. package/neural-interface/public/vendor/codemirror/chunks/dist-MJVBUZLL.js +3 -0
  394. package/neural-interface/public/vendor/codemirror/chunks/dist-MNB3WZVU.js +1 -0
  395. package/neural-interface/public/vendor/codemirror/chunks/dist-QYWXOE3U.js +7 -0
  396. package/neural-interface/public/vendor/codemirror/chunks/dist-TEBOFWLZ.js +1 -0
  397. package/neural-interface/public/vendor/codemirror/chunks/dist-UNXFROM3.js +1 -0
  398. package/neural-interface/public/vendor/codemirror/chunks/dist-XL2SLQO6.js +1 -0
  399. package/neural-interface/public/vendor/codemirror/chunks/dist-XO42Y3AP.js +6 -0
  400. package/neural-interface/public/vendor/codemirror/chunks/dist-Y2WWDWJK.js +1 -0
  401. package/neural-interface/public/vendor/codemirror/chunks/dist-YFEZKWMU.js +1 -0
  402. package/neural-interface/public/vendor/codemirror/chunks/dist-ZY7SWQO6.js +1 -0
  403. package/neural-interface/public/vendor/codemirror/chunks/dockerfile-3Y4R4NCH.js +1 -0
  404. package/neural-interface/public/vendor/codemirror/chunks/dtd-5343GJ4P.js +1 -0
  405. package/neural-interface/public/vendor/codemirror/chunks/dylan-FI2CXSW2.js +1 -0
  406. package/neural-interface/public/vendor/codemirror/chunks/ebnf-GNQ77YWA.js +1 -0
  407. package/neural-interface/public/vendor/codemirror/chunks/ecl-MRPUAE2L.js +1 -0
  408. package/neural-interface/public/vendor/codemirror/chunks/eiffel-DZROES27.js +1 -0
  409. package/neural-interface/public/vendor/codemirror/chunks/elm-4KICRFKQ.js +1 -0
  410. package/neural-interface/public/vendor/codemirror/chunks/erlang-3IHYC263.js +1 -0
  411. package/neural-interface/public/vendor/codemirror/chunks/factor-C6D3E5UK.js +1 -0
  412. package/neural-interface/public/vendor/codemirror/chunks/fcl-VYTWUJMI.js +1 -0
  413. package/neural-interface/public/vendor/codemirror/chunks/forth-ZBO2EH5M.js +1 -0
  414. package/neural-interface/public/vendor/codemirror/chunks/fortran-5NDCRWIK.js +1 -0
  415. package/neural-interface/public/vendor/codemirror/chunks/gas-SKRMOXFP.js +1 -0
  416. package/neural-interface/public/vendor/codemirror/chunks/gherkin-L45YXZLW.js +1 -0
  417. package/neural-interface/public/vendor/codemirror/chunks/groovy-GLPTLN44.js +1 -0
  418. package/neural-interface/public/vendor/codemirror/chunks/haskell-E7NF7P4Y.js +1 -0
  419. package/neural-interface/public/vendor/codemirror/chunks/haxe-K2NVDOB6.js +1 -0
  420. package/neural-interface/public/vendor/codemirror/chunks/http-Q6DRQIJB.js +1 -0
  421. package/neural-interface/public/vendor/codemirror/chunks/idl-MZYNJVLN.js +1 -0
  422. package/neural-interface/public/vendor/codemirror/chunks/javascript-6YRYK2HR.js +1 -0
  423. package/neural-interface/public/vendor/codemirror/chunks/julia-HXEL2T5G.js +1 -0
  424. package/neural-interface/public/vendor/codemirror/chunks/livescript-MV7CI2JZ.js +1 -0
  425. package/neural-interface/public/vendor/codemirror/chunks/lua-E36NHOOD.js +1 -0
  426. package/neural-interface/public/vendor/codemirror/chunks/mathematica-3XOZZCMC.js +1 -0
  427. package/neural-interface/public/vendor/codemirror/chunks/mbox-ODYGFKS7.js +1 -0
  428. package/neural-interface/public/vendor/codemirror/chunks/mirc-WB43AWF5.js +1 -0
  429. package/neural-interface/public/vendor/codemirror/chunks/mllike-ZT6ET7ZH.js +1 -0
  430. package/neural-interface/public/vendor/codemirror/chunks/modelica-LLMZPFGA.js +1 -0
  431. package/neural-interface/public/vendor/codemirror/chunks/mscgen-2OPNNYFH.js +1 -0
  432. package/neural-interface/public/vendor/codemirror/chunks/mumps-6P3HWJH6.js +1 -0
  433. package/neural-interface/public/vendor/codemirror/chunks/nginx-TCGKQTRW.js +1 -0
  434. package/neural-interface/public/vendor/codemirror/chunks/nsis-ATIGIXTJ.js +1 -0
  435. package/neural-interface/public/vendor/codemirror/chunks/ntriples-5PLBI5OV.js +1 -0
  436. package/neural-interface/public/vendor/codemirror/chunks/octave-QF2S3NL6.js +1 -0
  437. package/neural-interface/public/vendor/codemirror/chunks/oz-JFIVFFH2.js +1 -0
  438. package/neural-interface/public/vendor/codemirror/chunks/pascal-JG6SW76V.js +1 -0
  439. package/neural-interface/public/vendor/codemirror/chunks/perl-4B3IWVVO.js +1 -0
  440. package/neural-interface/public/vendor/codemirror/chunks/pig-ABEBH4MI.js +1 -0
  441. package/neural-interface/public/vendor/codemirror/chunks/powershell-H32XE7TL.js +1 -0
  442. package/neural-interface/public/vendor/codemirror/chunks/properties-NN644MMA.js +1 -0
  443. package/neural-interface/public/vendor/codemirror/chunks/protobuf-YKWQK2J3.js +1 -0
  444. package/neural-interface/public/vendor/codemirror/chunks/pug-RIYXEKFW.js +1 -0
  445. package/neural-interface/public/vendor/codemirror/chunks/puppet-JZQ5WPEV.js +1 -0
  446. package/neural-interface/public/vendor/codemirror/chunks/python-EBCGGHAC.js +1 -0
  447. package/neural-interface/public/vendor/codemirror/chunks/q-5UMH2YQY.js +1 -0
  448. package/neural-interface/public/vendor/codemirror/chunks/r-7XMGG453.js +1 -0
  449. package/neural-interface/public/vendor/codemirror/chunks/rpm-IEV7TSLR.js +1 -0
  450. package/neural-interface/public/vendor/codemirror/chunks/ruby-T4SPFPNM.js +1 -0
  451. package/neural-interface/public/vendor/codemirror/chunks/sas-GEDX67ET.js +1 -0
  452. package/neural-interface/public/vendor/codemirror/chunks/scheme-FTLOWKPC.js +1 -0
  453. package/neural-interface/public/vendor/codemirror/chunks/shell-HUFRKFSJ.js +1 -0
  454. package/neural-interface/public/vendor/codemirror/chunks/sieve-P5QUDVYO.js +1 -0
  455. package/neural-interface/public/vendor/codemirror/chunks/smalltalk-2SORJNQI.js +1 -0
  456. package/neural-interface/public/vendor/codemirror/chunks/solr-M22TGAR5.js +1 -0
  457. package/neural-interface/public/vendor/codemirror/chunks/sparql-4M3KL752.js +1 -0
  458. package/neural-interface/public/vendor/codemirror/chunks/spreadsheet-3K23LA4N.js +1 -0
  459. package/neural-interface/public/vendor/codemirror/chunks/sql-VHRFMUBI.js +1 -0
  460. package/neural-interface/public/vendor/codemirror/chunks/stex-QZDAQUY2.js +1 -0
  461. package/neural-interface/public/vendor/codemirror/chunks/stylus-7ZDKAKBD.js +1 -0
  462. package/neural-interface/public/vendor/codemirror/chunks/swift-WCLCLGMR.js +1 -0
  463. package/neural-interface/public/vendor/codemirror/chunks/tcl-JLMU6WOK.js +1 -0
  464. package/neural-interface/public/vendor/codemirror/chunks/textile-4CHDKZ4T.js +1 -0
  465. package/neural-interface/public/vendor/codemirror/chunks/tiddlywiki-DKMCZZQ4.js +1 -0
  466. package/neural-interface/public/vendor/codemirror/chunks/tiki-F4JIG36Q.js +1 -0
  467. package/neural-interface/public/vendor/codemirror/chunks/toml-BHMHTIM2.js +1 -0
  468. package/neural-interface/public/vendor/codemirror/chunks/troff-YQ2KV5NT.js +1 -0
  469. package/neural-interface/public/vendor/codemirror/chunks/ttcn-ZHRCVFJY.js +1 -0
  470. package/neural-interface/public/vendor/codemirror/chunks/ttcn-cfg-KJOVTY4X.js +1 -0
  471. package/neural-interface/public/vendor/codemirror/chunks/turtle-535P5N6I.js +1 -0
  472. package/neural-interface/public/vendor/codemirror/chunks/vb-76UMQHOT.js +1 -0
  473. package/neural-interface/public/vendor/codemirror/chunks/vbscript-CGNDTK6Z.js +1 -0
  474. package/neural-interface/public/vendor/codemirror/chunks/velocity-VSJDO5T5.js +1 -0
  475. package/neural-interface/public/vendor/codemirror/chunks/verilog-4T6ETXIP.js +1 -0
  476. package/neural-interface/public/vendor/codemirror/chunks/vhdl-T7FNAXUN.js +1 -0
  477. package/neural-interface/public/vendor/codemirror/chunks/webidl-P6MJSJ6J.js +1 -0
  478. package/neural-interface/public/vendor/codemirror/chunks/xquery-UOVILRGN.js +1 -0
  479. package/neural-interface/public/vendor/codemirror/chunks/yacas-CV2APROL.js +1 -0
  480. package/neural-interface/public/vendor/codemirror/chunks/z80-3KFNVM4M.js +1 -0
  481. package/neural-interface/public/vendor/codemirror/codemirror-6.43.14-75f05e67.min.js +2 -0
  482. package/neural-interface/public/vendor/codemirror/manifest.json +60 -0
  483. package/neural-interface/scripts/build-vendor.mjs +78 -2
  484. package/neural-interface/scripts/vendor-codemirror-entry.js +121 -0
  485. package/neural-interface/server.js +2085 -1629
  486. package/neural-interface/templates/rulesets/README.md +1 -1
  487. package/package.json +5 -8
  488. package/setup.js +53 -15
  489. package/benchmarks/browser-live-results.json +0 -79
  490. package/benchmarks/browser-v2-results.json +0 -258
  491. package/neural-interface/lib/codex-capabilities.d.ts +0 -41
  492. package/neural-interface/public/category-logos/claude.svg +0 -1
  493. package/neural-interface/public/category-logos/gemini-color.svg +0 -1
  494. package/neural-interface/public/category-logos/openai.svg +0 -1
  495. package/neural-interface/public/category-logos/synabun.png +0 -0
  496. package/neural-interface/public/claude.svg +0 -1
  497. package/neural-interface/public/gallery.svg +0 -6
  498. package/neural-interface/public/leonardoai.svg +0 -1
  499. package/neural-interface/public/mcp.svg +0 -4
  500. package/neural-interface/public/opencode-logo-dark-square.svg +0 -1
  501. package/neural-interface/public/pin-svgrepo-com.svg +0 -25
  502. package/neural-interface/public/sessionicon.svg +0 -8
  503. package/neural-interface/public/toolbar-album.svg +0 -8
  504. package/neural-interface/public/toolbar-question.svg +0 -6
  505. package/neural-interface/public/toolbar-sessions.svg +0 -6
  506. package/neural-interface/scripts/codex-protocol-fixture.mjs +0 -83
  507. package/skills/synabun/synabunicon.png +0 -0
@@ -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 {
@@ -29,9 +31,12 @@ import { TASK_CLASS_META, classPlaybook, collectedMedia, describeTarget, designF
29
31
  import { MAX_MEDIA_BYTES, MAX_RUN_MEDIA_BYTES, copyRunMedia, mediaType, mediaUrl, servableRunMedia } from './assistant-media.js';
30
32
  import { loadPlaybook } from './assistant-playbooks.js';
31
33
  import { loadStyleGuideForRun } from './style-guide/store.js';
34
+ import { claimCheckWaived, normalizeTestDensity, testingApplies } from './assistant-test-density.js';
32
35
  import { normalizeEffort } from './effort-levels.js';
33
36
  import { createTurnEvidence, recordProviderEvent, evidenceSnapshot, claimEvidenceLine } from './assistant-evidence.js';
34
- 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';
35
40
  import { CLAUDE_ESTIMATE_SOURCE, CODEX_USAGE_KEYS, TOKEN_KEYS, addTokens, codexUsageTokens, createClaudeMeter, createCodexMeter, createOpenCodeMeter, totalTokens, withTotal, zeroTokens } from './assistant-usage.js';
36
41
  import { claudeTokensCostUsd } from './assistant-pricing.js';
37
42
  import { RemotePolicyError, clampDispatchSpec, getSessionPolicy, readRegisteredProjects } from './remote-policy.js';
@@ -57,6 +62,12 @@ export function runStatusFromState(state) {
57
62
  return ACTIVE_STATES.has(value) ? 'running' : null;
58
63
  }
59
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
+
60
71
  export const DEFAULT_LIMITS = Object.freeze({
61
72
  perProvider: Object.freeze({ 'claude-code': 3, codex: 4, opencode: 3 }),
62
73
  perCodexAccount: 2,
@@ -64,16 +75,13 @@ export const DEFAULT_LIMITS = Object.freeze({
64
75
  queuedPerSession: 20,
65
76
  heldPerSession: 10,
66
77
  maxFanOut: 6,
78
+ // max_minutes: the work time a run gets (MIN_MAX_MINUTES at least), not the wall clock.
67
79
  defaultMaxMinutes: 45,
68
80
  maxMaxMinutes: 240,
69
- // Budget caps (USD). The live values come from assistant-config.json through
70
- // `readLimits` (assistant-budget.js); these are the defaults.
71
- defaultBudgetUsd: 5,
72
- maxBudgetUsd: 25,
73
- sessionSoftBudgetUsd: 10,
74
- sessionWarnUsd: 8,
75
- sessionHardBudgetUsd: 25,
76
- 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,
77
85
  permissionTimeoutMs: 10 * 60_000,
78
86
  questionTimeoutMs: 30 * 60_000,
79
87
  idleTimeoutMs: 20 * 60_000,
@@ -274,15 +282,15 @@ export function summarizeProviderEvent(payload = {}) {
274
282
  return null;
275
283
  }
276
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']);
277
287
  function mergeLimits(overrides = {}) {
278
288
  const limits = { ...DEFAULT_LIMITS, perProvider: { ...DEFAULT_LIMITS.perProvider } };
279
289
  for (const [key, value] of Object.entries(overrides || {})) {
290
+ if (LEGACY_BUDGET_LIMITS.has(key)) continue;
280
291
  if (key === 'perProvider' && value && typeof value === 'object') Object.assign(limits.perProvider, value);
281
292
  else if (value !== undefined && value !== null) limits[key] = value;
282
293
  }
283
- // Older configs name a soft budget and warned at 80 % of it.
284
- const soft = Number(overrides?.sessionSoftBudgetUsd);
285
- if ((overrides?.sessionWarnUsd === undefined || overrides?.sessionWarnUsd === null) && Number.isFinite(soft) && soft > 0) limits.sessionWarnUsd = Math.round(soft * 80) / 100;
286
294
  return limits;
287
295
  }
288
296
 
@@ -331,6 +339,12 @@ export function createAssistantDispatcher({
331
339
  getCodexAccount = () => null,
332
340
  CODEX_DEFAULT_HOME = null,
333
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,
334
348
  isValidMcpProfileValue = () => true,
335
349
  normalizeMcpProfileName = (value) => value,
336
350
  readActiveMcpProfile = () => 'full',
@@ -341,6 +355,8 @@ export function createAssistantDispatcher({
341
355
  detectProject = () => 'global',
342
356
  limits: limitOverrides = {},
343
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) },
344
360
  randomId = () => randomBytes(16).toString('hex'),
345
361
  strictResult = true,
346
362
  // Routing (assistant-router.js) and the model catalog (assistant-catalog.js);
@@ -356,12 +372,17 @@ export function createAssistantDispatcher({
356
372
  // Clarification (assistant-clarify.js): { forDispatch(sessionId, { briefId, independent }) }.
357
373
  // A brain's dispatch waits while its questions are open and carries the user's brief.
358
374
  clarifier = null,
359
- // Budget: live limit overrides (assistant-config.json, re-read on every
360
- // 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
361
377
  // prices for Codex usage (assistant-budget.js createModelPricing).
362
378
  readLimits = null,
363
379
  brainSpend = () => 0,
380
+ brainSpendByProvider = null,
364
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,
365
386
  // Exact token accounting (assistant-usage.js): the ledger (createUsageLedger) and the task of
366
387
  // the brain turn in progress, (assistantSessionId) => taskId | null. Both optional: without
367
388
  // them the meters still run for cost and the run's own numbers, and nothing is written.
@@ -374,6 +395,9 @@ export function createAssistantDispatcher({
374
395
  // The project's Style Guide for a run's prompt (lib/style-guide/store.js): (cwd, taskClass) =>
375
396
  // { summary, designPath, tokenFiles, proposals } | null. Reads <dataDir>/style-guides.
376
397
  loadStyleGuide = (cwd, taskClass) => loadStyleGuideForRun(cwd, taskClass, { dataDir }),
398
+ // The session's Test density (assistant-test-density.js): (assistantSessionId) => level | null.
399
+ // Read when a prompt is composed; null or an unknown value is the default level.
400
+ testDensity = () => null,
377
401
  } = {}) {
378
402
  if (typeof getRuntime !== 'function') throw new Error('createAssistantDispatcher requires getRuntime()');
379
403
  if (!dataDir) throw new Error('createAssistantDispatcher requires dataDir');
@@ -407,6 +431,10 @@ export function createAssistantDispatcher({
407
431
  // Usage meters (assistant-usage.js), apart from the live state: a Claude result can arrive
408
432
  // after its turn was aborted or its run left the list.
409
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();
410
438
  let persistTimer = null;
411
439
  let stalledTimer = null;
412
440
  let unsubscribeRuntime = null;
@@ -428,6 +456,11 @@ export function createAssistantDispatcher({
428
456
  entry.turnState = 'terminal';
429
457
  closeUsageState(entry);
430
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
+ }
431
464
  entries.set(entry.runId, entry);
432
465
  }
433
466
  }
@@ -461,16 +494,19 @@ export function createAssistantDispatcher({
461
494
  function peek(runId) {
462
495
  const entry = entries.get(String(runId || '')) || null;
463
496
  if (!entry) return null;
497
+ const at = now();
464
498
  return {
465
499
  runId: entry.runId, title: entry.title || null, state: entry.state, turnState: entry.turnState || null,
466
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),
467
502
  };
468
503
  }
469
504
  function view(entry) {
470
505
  if (!entry) return null;
471
506
  const runtimeRun = safeRuntime()?.get?.(entry.runId) || null;
507
+ const at = now();
472
508
  const startedAt = entry.startedAt || entry.createdAt;
473
- const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() : now();
509
+ const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() : at;
474
510
  const elapsedMs = startedAt ? Math.max(0, end - new Date(startedAt).getTime()) : 0;
475
511
  // The brief goes to the worker's prompt; views name it only. The meters' state stays inside.
476
512
  const { rawSpec: _rawSpec, brief: _brief, usageState: _usageState, ...rest } = entry;
@@ -494,7 +530,13 @@ export function createAssistantDispatcher({
494
530
  active: ACTIVE_STATES.has(entry.state),
495
531
  terminal: TERMINAL_STATES.has(entry.state),
496
532
  outcome: entry.lastResult?.status || null,
533
+ // The wall clock since the run started (reports use it); max_minutes counts work time only.
497
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,
498
540
  turnCount: Array.isArray(entry.turns) ? entry.turns.length : 0,
499
541
  // The turn whose result this view carries: what a brain has read once it sees it.
500
542
  resultTurn: (Array.isArray(entry.turns) ? entry.turns.at(-1)?.n : null) ?? 0,
@@ -509,6 +551,44 @@ export function createAssistantDispatcher({
509
551
  };
510
552
  }
511
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
+
512
592
  function touch(entry) { entry.version = (Number(entry.version) || 0) + 1; entry.updatedAt = iso(now()); }
513
593
  function emit(entry, reason, extra = {}) {
514
594
  touch(entry);
@@ -681,15 +761,44 @@ export function createAssistantDispatcher({
681
761
  let claudeAccount = null;
682
762
  const accountId = spec.accountId || spec.codexAccountId || spec.claudeAccountId || null;
683
763
  if (provider === 'codex') {
684
- codexAccount = accountId ? findCodexAccount(accountId) : getCodexAccount(null);
685
- if (!codexAccount) throw new DispatchError('ACCOUNT_NOT_FOUND', `Codex account not found: ${accountId || 'default'}`);
686
- }
687
- if (provider === 'claude-code' && accountId && accountId !== 'default') {
688
- claudeAccount = claudeAccounts?.find?.(accountId) || null;
689
- if (!claudeAccount) throw new DispatchError('ACCOUNT_NOT_FOUND', `Claude account not found: ${accountId}`);
690
- }
691
- const maxMinutes = Math.min(Math.max(Number(spec.maxMinutes) || limits.defaultMaxMinutes, 1), limits.maxMaxMinutes);
692
- 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);
693
802
  const idleTimeoutMs = Math.min(Math.max(Number(spec.idleTimeoutMs) || limits.idleTimeoutMs, limits.minIdleTimeoutMs), limits.maxIdleTimeoutMs);
694
803
  const maxTurns = Math.min(Math.max(Number(spec.maxTurns) || limits.maxTurns, 1), limits.maxMaxTurns);
695
804
  const outputSchema = spec.outputSchema && typeof spec.outputSchema === 'object' ? spec.outputSchema : null;
@@ -706,7 +815,7 @@ export function createAssistantDispatcher({
706
815
  claudeAccountId: claudeAccount?.id || (provider === 'claude-code' ? 'default' : null),
707
816
  claudeConfigDir: claudeAccount ? (claudeAccounts?.homeFor?.(claudeAccount.id) || null) : null,
708
817
  accountId: provider === 'codex' ? (codexAccount?.id || 'default') : provider === 'claude-code' ? (claudeAccount?.id || 'default') : null,
709
- maxMinutes, budgetUsd, idleTimeoutMs, maxTurns, outputSchema, title, tags,
818
+ maxMinutes, budgetUsd, freeModel, idleTimeoutMs, maxTurns, outputSchema, title, tags,
710
819
  focus: spec.focus === true,
711
820
  workflowId: spec.workflowId ? String(spec.workflowId) : null,
712
821
  parentRunId: spec.parentRunId ? String(spec.parentRunId) : null,
@@ -747,49 +856,68 @@ export function createAssistantDispatcher({
747
856
  if (!assistantSessionId) return 0;
748
857
  try { return Math.max(0, Number(brainSpend(assistantSessionId)) || 0); } catch { return 0; }
749
858
  }
750
- /** 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. */
751
860
  function sessionTotal(assistantSessionId) { return sessionSpend(assistantSessionId) + sessionBrainSpend(assistantSessionId); }
752
- 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
+ }
753
877
  /**
754
- * What the session's started runs may still spend: each live run's cap minus
755
- * its spend. A new run only gets what the hard cap leaves after this, so the
756
- * 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.
757
882
  */
758
- function sessionReserved(assistantSessionId, { except = null } = {}) {
759
- if (!assistantSessionId) return 0;
760
- 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 };
761
889
  for (const entry of entries.values()) {
762
- if (entry.assistantSessionId !== assistantSessionId || entry.runId === except) continue;
763
- if (!ACTIVE_STATES.has(entry.state) || WAITING_STATES.has(entry.state)) continue;
764
- 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
+ }
765
900
  }
766
- 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 };
767
906
  }
768
- /** 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. */
769
908
  function sessionBudget(assistantSessionId) {
770
909
  refreshLimits();
771
- const dispatchUsd = sessionSpend(assistantSessionId);
772
- const brainUsd = sessionBrainSpend(assistantSessionId);
773
- const totalUsd = dispatchUsd + brainUsd;
774
- const reservedUsd = sessionReserved(assistantSessionId);
775
- let unpricedRuns = 0;
776
- for (const entry of entries.values()) if (entry.assistantSessionId === assistantSessionId && Number(entry.unpricedTurns) > 0) unpricedRuns += 1;
777
- return {
778
- brainUsd: r4(brainUsd), dispatchUsd: r4(dispatchUsd), totalUsd: r4(totalUsd), reservedUsd: r4(reservedUsd),
779
- availableUsd: r4(Math.max(0, limits.sessionHardBudgetUsd - totalUsd - reservedUsd)),
780
- warnUsd: limits.sessionWarnUsd, hardUsd: limits.sessionHardBudgetUsd,
781
- defaultRunUsd: limits.defaultBudgetUsd, maxRunUsd: limits.maxBudgetUsd, brainCapUsd: limits.brainBudgetUsd,
782
- warned: totalUsd >= limits.sessionWarnUsd, exceeded: totalUsd >= limits.sessionHardBudgetUsd, unpricedRuns,
783
- };
910
+ try { return budget.view(assistantSessionId); } catch { return null; }
784
911
  }
912
+ /** Whether the session runs under a budget now (No limit: false). */
913
+ function limitedNow() { try { return budget.limited(); } catch { return true; } }
785
914
  /**
786
- * Why a run's spend could not be held to the dollar caps (no price for its
787
- * model), else null. Enforced only with a price source (`pricing`): Claude
788
- * reports cost, Codex needs a list price, OpenCode a catalog price (a free
789
- * 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).
790
918
  */
791
919
  function unpricedReason(entry) {
792
- if (!pricing || entry.provider === 'claude-code') return null;
920
+ if (!pricing || entry.provider === 'claude-code' || !limitedNow()) return null;
793
921
  const model = entry.pricingModel || entry.model || null;
794
922
  if (entry.provider === 'codex') {
795
923
  let price = null;
@@ -801,65 +929,93 @@ export function createAssistantDispatcher({
801
929
  function refuseUnpriced(entry) {
802
930
  const why = unpricedReason(entry);
803
931
  if (!why) return;
804
- 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';
805
947
  }
806
- /** 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
+ */
807
956
  function budgetBlock(entry) {
957
+ if (!limitedNow()) return null;
808
958
  if (pricing && Number(entry.unpricedTurns) > 0) {
809
- 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 } };
810
960
  }
811
961
  const cap = Number(entry.budgetUsd) || 0;
812
962
  const cost = Number(entry.costUsd) || 0;
813
- if (cap > 0 && cost >= cap) {
814
- 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 } };
815
965
  }
816
966
  const sessionId = entry.assistantSessionId;
817
- if (sessionId) {
818
- const total = sessionTotal(sessionId);
819
- if (total >= limits.sessionHardBudgetUsd) {
820
- 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 } };
821
- }
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) } };
822
975
  }
823
976
  return null;
824
977
  }
825
978
  /**
826
- * Fit a run's cap into what the session has left (hard cap − spend − the
827
- * 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.
828
982
  */
829
- function fitBudget(entry) {
983
+ async function coverShare(entry, { reason = 'share_used_up' } = {}) {
830
984
  const sessionId = entry.assistantSessionId;
831
- if (!sessionId) return;
832
- const spent = sessionTotal(sessionId);
833
- const reserved = sessionReserved(sessionId, { except: entry.runId });
834
- const hard = limits.sessionHardBudgetUsd;
835
- // Whole cents: a run never gets a fraction of a cent the session does not have.
836
- const available = Math.floor((hard - spent - reserved) * 100 + 1e-6) / 100;
837
- if (available <= 0) {
838
- 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 });
839
- }
840
- if ((Number(entry.budgetUsd) || 0) > available) {
841
- const from = Number(entry.budgetUsd);
842
- entry.budgetUsd = available;
843
- entry.budgetClamped = { from, to: entry.budgetUsd, at: iso(now()) };
844
- 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`);
845
- }
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; }
846
988
  }
847
989
  /**
848
- * After money moved in a session: one warning at the warning threshold, and
849
- * 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.
850
1005
  * Returns the session's budget view.
851
1006
  */
852
1007
  function checkSession(assistantSessionId, { source = null } = {}) {
853
1008
  if (!assistantSessionId) return null;
854
1009
  const money = sessionBudget(assistantSessionId);
1010
+ if (!money || money.unlimited) return money;
855
1011
  const anchor = source || [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId)
856
1012
  .sort((a, b) => String(b.updatedAt || '').localeCompare(String(a.updatedAt || '')))[0] || null;
857
- if (money.warned && !money.exceeded && sessionBudgetWarned.get(assistantSessionId) !== money.warnUsd) {
858
- sessionBudgetWarned.set(assistantSessionId, money.warnUsd);
1013
+ if (money.warned && !money.exceeded && sessionBudgetWarned.get(assistantSessionId) !== money.amountUsd) {
1014
+ sessionBudgetWarned.set(assistantSessionId, money.amountUsd);
859
1015
  if (anchor) {
860
1016
  emit(anchor, 'budget_warning', {
861
- sessionSpent: money.totalUsd, warnAt: money.warnUsd, softCap: money.warnUsd, hardCap: money.hardUsd,
862
- 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.`,
863
1019
  });
864
1020
  }
865
1021
  }
@@ -867,19 +1023,34 @@ export function createAssistantDispatcher({
867
1023
  const victims = [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId && ACTIVE_STATES.has(e.state));
868
1024
  for (const victim of victims) {
869
1025
  victim.completionReason = victim.completionReason || 'session_budget_cap';
870
- 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)`);
871
1027
  stop(victim.runId, 'session_budget_cap').catch(() => {});
872
1028
  }
873
- if (anchor && sessionBudgetExceeded.get(assistantSessionId) !== money.hardUsd) {
874
- sessionBudgetExceeded.set(assistantSessionId, money.hardUsd);
1029
+ if (anchor && sessionBudgetExceeded.get(assistantSessionId) !== money.amountUsd) {
1030
+ sessionBudgetExceeded.set(assistantSessionId, money.amountUsd);
875
1031
  emit(anchor, 'budget_exceeded', {
876
- sessionSpent: money.totalUsd, hardCap: money.hardUsd, stopped: victims.map((e) => e.runId),
877
- 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).`,
878
1034
  });
879
1035
  }
880
1036
  }
881
1037
  return money;
882
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
+ }
883
1054
 
884
1055
  // ── dispatch ───────────────────────────────────────────────────────────────
885
1056
  /** Entry fields that come straight from a normalized spec. */
@@ -890,7 +1061,7 @@ export function createAssistantDispatcher({
890
1061
  cwd: spec.cwd, project: spec.project, mcpProfile: spec.mcpProfile,
891
1062
  permissionPolicy: spec.permissionPolicy, capability: spec.capability, usesBrowser: spec.usesBrowser, usesComputer: !!spec.usesComputer,
892
1063
  title: spec.title, task: spec.task, context: spec.context, tags: spec.tags,
893
- 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,
894
1065
  outputSchema: spec.outputSchema, focus: spec.focus, workflowId: spec.workflowId, parentRunId: spec.parentRunId,
895
1066
  idempotencyKey: spec.idempotencyKey, requestedBrowserSessionId: spec.requestedBrowserSessionId,
896
1067
  modelInfo: spec.modelInfo || null, modelContextWindow: spec.modelContextWindow || null, pricingModel: spec.pricingModel || spec.model || null,
@@ -901,6 +1072,7 @@ export function createAssistantDispatcher({
901
1072
  runId, assistantSessionId: sessionId, origin,
902
1073
  state: 'queued', turnState: null, turns: [], lastResult: null, costUsd: 0, usage: null, costBasis: 'none',
903
1074
  pending: [], notes: [], createdAt: iso(now()), startedAt: null, finishedAt: null, completionReason: null,
1075
+ workMs: 0, workingSince: null,
904
1076
  memoryId: null, memoryStored: false, browserSessionId: null, browserTabId: null, version: 0, updatedAt: iso(now()),
905
1077
  runtime: null, providerSessionId: null, providerThreadId: null, degraded: null,
906
1078
  route: null, escalation: null, escalationDepth: 0,
@@ -970,10 +1142,7 @@ export function createAssistantDispatcher({
970
1142
  }
971
1143
  }
972
1144
  refreshLimits();
973
- const spent = sessionTotal(sessionId);
974
- if (sessionId && spent >= limits.sessionHardBudgetUsd) {
975
- 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 });
976
- }
1145
+ refuseSessionOver(sessionId);
977
1146
  // "true" (a string) is computer use too: normalizeSpec reads it so, and so must the gate.
978
1147
  rawSpec = { ...rawSpec, usesComputer: rawSpec.usesComputer === true || rawSpec.usesComputer === 'true' };
979
1148
  // A design dispatch's defaults fill what it leaves out, before any clamp; any dispatch's own browser
@@ -1012,7 +1181,21 @@ export function createAssistantDispatcher({
1012
1181
  try {
1013
1182
  // An approved route may have moved it to another provider: clamp for that one
1014
1183
  // (a route approved for design brings design's defaults).
1015
- 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
+ }
1016
1199
  // A route approved for other work cannot carry image / video creation or design, nor the other way round:
1017
1200
  // the class decides the model's capability check, the worker's media block or rules and what is collected.
1018
1201
  const declaredClass = knownClass(rawSpec.taskClass);
@@ -1035,14 +1218,17 @@ export function createAssistantDispatcher({
1035
1218
  if (brief) { entry.brief = brief; entry.notes.push(`carries the user's brief ${brief.briefId || ''}`.trim()); }
1036
1219
  if (routed?.route) entry.route = routed.route;
1037
1220
  entries.set(runId, entry);
1038
- try { refuseUnpriced(entry); fitBudget(entry); } catch (error) { entries.delete(runId); throw error; }
1039
- entry.taskId = taskAtDispatch(sessionId);
1221
+ // Before the budget check awaits (it may re-split): a retry with the same key replays this run.
1040
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);
1041
1226
  const launched = await launchOrQueue(entry, spec, { queuedCount, strict: true });
1227
+ const withCorrections = budgetCorrections.length ? { ...launched, corrections: budgetCorrections } : launched;
1042
1228
  // The route's own "decided" event (it had no run attached) still tells the brain to dispatch.
1043
1229
  return routed?.approvedMeanwhile
1044
- ? { ...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.' }
1045
- : 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;
1046
1232
  } catch (error) {
1047
1233
  // A run that exists (it failed or stopped at start) used the route; a refusal before one did not.
1048
1234
  if (routed?.claimed && !(runId && entries.has(runId))) router.releaseRoute?.(routed.route?.routeId);
@@ -1050,6 +1236,47 @@ export function createAssistantDispatcher({
1050
1236
  }
1051
1237
  }
1052
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
+
1053
1280
  /** The router decided this route as approved, for this session (after a hold was returned on its card). */
1054
1281
  function routeApprovedFor(routeId, sessionId) {
1055
1282
  let decided = null;
@@ -1147,7 +1374,7 @@ export function createAssistantDispatcher({
1147
1374
  const entry = entries.get(runId);
1148
1375
  if (!entry || entry.state !== 'awaiting_route' || !target?.provider) return { ok: false, code: 'NOT_HELD' };
1149
1376
  const catalogValue = await catalogSnapshot();
1150
- 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 } : {}) };
1151
1378
  let spec;
1152
1379
  // The picked provider may need another clamp (Codex under strict worker approvals), or the level fell meanwhile.
1153
1380
  const remoteNotes = [];
@@ -1197,6 +1424,7 @@ export function createAssistantDispatcher({
1197
1424
  const context = [entry.context, note].filter(Boolean).join('\n\n');
1198
1425
  const result = await dispatch({
1199
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),
1200
1428
  title: clip(`${entry.title} ${retry ? '↻' : '↑'}`, 80), tags: entry.tags, workflowId: entry.workflowId, parentRunId: runId,
1201
1429
  mcpProfile: entry.provider === to.provider ? entry.mcpProfile : undefined, capability: entry.capability,
1202
1430
  permissionPolicy: to.provider === 'codex' && entry.permissionPolicy === 'ask' ? 'restricted' : entry.permissionPolicy,
@@ -1262,7 +1490,9 @@ export function createAssistantDispatcher({
1262
1490
  const ask = {
1263
1491
  status: noBlock && !entry.outputSchema,
1264
1492
  cause: noBlock || parsed.status === 'blocked',
1265
- claim: (noBlock || parsed.status === 'done') && evidence.hasEvidence,
1493
+ // At Test density None a "Not tested" result is what the brief asked for, not a claim to check.
1494
+ claim: (noBlock || parsed.status === 'done') && evidence.hasEvidence
1495
+ && !claimCheckWaived({ level: entry.testDensity || null, taskClass: entry.taskClass || null, text, evidence }),
1266
1496
  };
1267
1497
  if (!ask.status && !ask.cause && !ask.claim) return null;
1268
1498
  const judged = await bounded(judge.workerOutcome({
@@ -1289,7 +1519,7 @@ export function createAssistantDispatcher({
1289
1519
  */
1290
1520
  function emitFailed(entry, evidenceAcc = null) {
1291
1521
  const done = () => { delete entry.judging; entry.escalation = computeEscalation(entry); emit(entry, 'failed'); };
1292
- if (!judge?.workerOutcome || entry.completionReason === 'budget_cap') { done(); return Promise.resolve(); }
1522
+ if (!judge?.workerOutcome || budgetStopReason(entry.completionReason)) { done(); return Promise.resolve(); }
1293
1523
  entry.judging = true;
1294
1524
  const evidence = evidenceSnapshot(evidenceAcc);
1295
1525
  const error = [entry.error, ...(entry.notes || []).slice(-3)].filter(Boolean).map((line) => String(line)).join('\n');
@@ -1322,10 +1552,12 @@ export function createAssistantDispatcher({
1322
1552
  async function start(entry) {
1323
1553
  const runtime = safeRuntime();
1324
1554
  if (!runtime) throw new DispatchError('RUNTIME_UNAVAILABLE', 'Native loop runtime is not available', { status: 503 });
1325
- // 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).
1326
1556
  refreshLimits();
1327
- try { refuseUnpriced(entry); fitBudget(entry); } catch (error) {
1328
- 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 = [];
1329
1561
  entry.notes.push(`not started: ${error?.message || error}`);
1330
1562
  emit(entry, 'stopped');
1331
1563
  throw error;
@@ -1349,6 +1581,7 @@ export function createAssistantDispatcher({
1349
1581
  entry, ctx: null, ring: [], ringChars: 0, followUps: [], waitResolve: null, idleTimer: null,
1350
1582
  pendingResolvers: new Map(), dead: false, lastEventAt: now(), stalledFlagged: false, turnCounter: 0, abortReason: null,
1351
1583
  evidence: null, // the current turn's commands / tool results / edited files (assistant-evidence.js)
1584
+ completed: false, // its turn loop ended on a completion (agent_complete, a closed finished tab, a removal)
1352
1585
  };
1353
1586
  live.set(entry.runId, liveState);
1354
1587
  const extraEnv = {};
@@ -1371,7 +1604,8 @@ export function createAssistantDispatcher({
1371
1604
  claudeAccountId: entry.claudeAccountId, claudeConfigDir: entry.claudeConfigDir,
1372
1605
  driverType: 'native', runtimeType: 'native', surface: 'sidepanel', runId: entry.runId, cwd: entry.cwd,
1373
1606
  runMode: 'task', assistantSessionId: entry.assistantSessionId, permissionPolicy: entry.permissionPolicy,
1374
- 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,
1375
1609
  idleTimeoutMs: entry.idleTimeoutMs, maxTurns: entry.maxTurns, outputSchema: entry.outputSchema, project: entry.project,
1376
1610
  title: entry.title, tags: entry.tags, workflowId: entry.workflowId, parentRunId: entry.parentRunId,
1377
1611
  // The user's brief after a clarification (assistant-task-prompt.js briefBlock).
@@ -1403,7 +1637,7 @@ export function createAssistantDispatcher({
1403
1637
  source: 'assistant',
1404
1638
  focus: entry.focus === true,
1405
1639
  title: entry.title,
1406
- buildPrompt: () => buildTaskPrompt(playbook || styleGuide ? { ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}) } : state),
1640
+ buildPrompt: () => buildTaskPrompt({ ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}), testDensity: briefTestDensity(entry) }),
1407
1641
  wrapAdapter: (adapter, ctx) => createTaskAdapter(adapter, ctx, liveState, state),
1408
1642
  });
1409
1643
  entry.runtime = { status: descriptor.status };
@@ -1428,6 +1662,27 @@ export function createAssistantDispatcher({
1428
1662
  function styleGuideFor(entry) {
1429
1663
  try { return loadStyleGuide(entry.cwd, entry.taskClass || null) || null; } catch { return null; }
1430
1664
  }
1665
+ /** The session's Test density right now (the user's setting; the default when it cannot be read). */
1666
+ function testDensityFor(entry) {
1667
+ try { return normalizeTestDensity(testDensity(entry.assistantSessionId || null)); } catch { return normalizeTestDensity(null); }
1668
+ }
1669
+ /**
1670
+ * The level a run's brief carries, read when the prompt is composed and kept on the entry: the
1671
+ * claim check and a later follow-up go by what the worker was told. null for a class with no
1672
+ * TESTING block.
1673
+ */
1674
+ function briefTestDensity(entry) {
1675
+ entry.testDensity = testingApplies(entry.taskClass || null) ? testDensityFor(entry) : null;
1676
+ return entry.testDensity;
1677
+ }
1678
+ /** A follow-up turn names the level only when the user changed it since the run was last told. */
1679
+ function changedTestDensity(entry) {
1680
+ if (!testingApplies(entry.taskClass || null)) return null;
1681
+ const level = testDensityFor(entry);
1682
+ if (level === (entry.testDensity || null)) return null;
1683
+ entry.testDensity = level;
1684
+ return level;
1685
+ }
1431
1686
  /**
1432
1687
  * The rules a run of the entry's class follows (assistant-playbooks.js: the user's override
1433
1688
  * at <dataDir>/playbooks/<name>.md, else the shipped copy), or null. The run's notes say
@@ -1471,6 +1726,8 @@ export function createAssistantDispatcher({
1471
1726
  entry.pending = [...(entry.pending || []), pending];
1472
1727
  entry.state = 'awaiting_permission';
1473
1728
  entry.turnState = 'awaiting_permission';
1729
+ // Waiting on the answer is not work: the request's own timeout bounds it.
1730
+ pauseWork(entry, liveState);
1474
1731
  liveState.ctx?.setTurnState?.('awaiting_permission');
1475
1732
  liveState.ctx?.emit?.({ type: 'synabun.permission_request', requestId, kind, toolName: pending.toolName, input: pending.input, questions: pending.questions });
1476
1733
  const timer = setTimeout(() => finishPending(runId, requestId, { behavior: 'deny', message: `No decision within ${Math.round(timeoutMs / 60000)} minutes.`, resolvedBy: 'timeout' }), timeoutMs);
@@ -1498,6 +1755,7 @@ export function createAssistantDispatcher({
1498
1755
  if (entry.state === 'awaiting_permission' && entry.pending.length === 0) {
1499
1756
  entry.state = 'running';
1500
1757
  entry.turnState = 'running';
1758
+ startWork(entry, liveState);
1501
1759
  liveState.ctx?.setTurnState?.('running');
1502
1760
  }
1503
1761
  const reply = { behavior: response?.behavior === 'allow' ? 'allow' : 'deny', ...response };
@@ -1553,6 +1811,8 @@ export function createAssistantDispatcher({
1553
1811
  startUsagePolling(entry, liveState);
1554
1812
  entry.state = 'running';
1555
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);
1556
1816
  ctx.setTurnState('running');
1557
1817
  emit(entry, 'turn_started', { turn: turnNumber });
1558
1818
  // What this turn runs (commands, tool results, edited files): the evidence behind a "done".
@@ -1579,7 +1839,8 @@ export function createAssistantDispatcher({
1579
1839
  parsed.judged = { status: judged.status, confidence: judged.statusConfidence };
1580
1840
  if (judged.status === 'needs_input') parsed.question = clip(lastParagraph(text), 500);
1581
1841
  if (evidence.files.length) { parsed.files = [...evidence.files]; parsed.changes = evidence.files.map((path) => ({ path, note: '' })); }
1582
- } 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).
1583
1844
  retried = true;
1584
1845
  try {
1585
1846
  const retry = await inner.runTurn(buildResultRetryPrompt(), { ...meta, turn: turnNumber, retry: true });
@@ -1589,6 +1850,9 @@ export function createAssistantDispatcher({
1589
1850
  retryCost = accumulateCost(entry, retry);
1590
1851
  if (entry.provider === 'codex') settleCodexTurn(entry, liveState, retry);
1591
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; }
1592
1856
  retryCost = accumulateCost(entry, error);
1593
1857
  if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
1594
1858
  entry.notes.push(`result retry failed: ${error?.message || error}`);
@@ -1631,6 +1895,8 @@ export function createAssistantDispatcher({
1631
1895
  entry.turns = [...(entry.turns || []), turn].slice(-50);
1632
1896
  entry.lastResult = parsed;
1633
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);
1634
1900
  // Blocked or contract-less turns offer a one-tier escalation (the run
1635
1901
  // itself stays warm for follow-ups; the brain or the user decides). A
1636
1902
  // status Jev read from the message is a usable result.
@@ -1661,20 +1927,24 @@ export function createAssistantDispatcher({
1661
1927
  try {
1662
1928
  await runInnerTurn(prompt, meta, { origin: 'dispatch' });
1663
1929
  } catch (error) {
1930
+ pauseWork(entry, liveState);
1664
1931
  return handleTurnError(error, liveState, entry, ctx);
1665
1932
  }
1666
1933
  for (;;) {
1667
- // 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.
1668
1938
  refreshLimits();
1669
1939
  const spentOut = budgetBlock(entry);
1670
- if (spentOut) return budgetStop(entry, spentOut);
1940
+ if (spentOut && spentOut.code !== 'BUDGET_SHARE_EXHAUSTED') return budgetStop(entry, spentOut);
1671
1941
  entry.state = 'idle';
1672
1942
  entry.turnState = 'idle';
1673
1943
  ctx.setTurnState('idle');
1674
1944
  emit(entry, 'idle');
1675
1945
  const next = await waitForFollowUp();
1676
1946
  if (next.kind === 'abort') { entry.completionReason = entry.completionReason || liveState.abortReason || 'aborted'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1677
- if (next.kind === 'complete') { entry.completionReason = 'complete'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1947
+ if (next.kind === 'complete') { entry.completionReason = next.reason || 'complete'; liveState.completed = true; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1678
1948
  if (next.kind === 'timeout') { entry.completionReason = 'idle_timeout'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1679
1949
  if (liveState.turnCounter >= entry.maxTurns) {
1680
1950
  entry.completionReason = 'max_turns';
@@ -1682,15 +1952,24 @@ export function createAssistantDispatcher({
1682
1952
  return { providerSessionId: inner.identity?.()?.providerSessionId };
1683
1953
  }
1684
1954
  refreshLimits();
1685
- 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);
1686
1958
  if (blocked) {
1687
- 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' : ''}`);
1688
1960
  return budgetStop(entry, blocked);
1689
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
+ }
1690
1968
  try {
1691
- const followPrompt = buildFollowUpPrompt(next.text, state, { turn: liveState.turnCounter + 1, maxTurns: entry.maxTurns, origin: next.origin });
1969
+ const followPrompt = buildFollowUpPrompt(next.text, state, { turn: liveState.turnCounter + 1, maxTurns: entry.maxTurns, origin: next.origin, testDensity: changedTestDensity(entry) });
1692
1970
  await runInnerTurn(followPrompt, meta, { origin: next.origin, label: clip(next.text, 80), followUp: next.text, taskId: next.taskId || entry.taskId || null });
1693
1971
  } catch (error) {
1972
+ pauseWork(entry, liveState);
1694
1973
  return handleTurnError(error, liveState, entry, ctx);
1695
1974
  }
1696
1975
  }
@@ -1789,7 +2068,7 @@ export function createAssistantDispatcher({
1789
2068
  if (budgetStopReason(entry.completionReason)) return { providerSessionId: null };
1790
2069
  if (error?.code === 'BUDGET_CAP') {
1791
2070
  entry.completionReason = 'budget_cap';
1792
- 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)');
1793
2072
  const runtime = safeRuntime();
1794
2073
  runtime?.stop?.(entry.runId, 'budget_cap').catch(() => {});
1795
2074
  return { providerSessionId: null };
@@ -1804,7 +2083,7 @@ export function createAssistantDispatcher({
1804
2083
  entry.notes.push(`provider error: ${error?.message || error}`);
1805
2084
  throw error;
1806
2085
  }
1807
- 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'; }
1808
2087
  /** Stop a run that is out of money; the runtime ends it as stopped with this reason. */
1809
2088
  function budgetStop(entry, block) {
1810
2089
  entry.completionReason = block.reason;
@@ -1835,7 +2114,7 @@ export function createAssistantDispatcher({
1835
2114
  function announceUnpriced(entry) {
1836
2115
  if (!(Number(entry.unpricedTurns) > 0) || entry.unpricedAnnounced) return;
1837
2116
  entry.unpricedAnnounced = true;
1838
- 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.` });
1839
2118
  }
1840
2119
  function codexThreadId(entry) {
1841
2120
  try { return entry.providerThreadId || live.get(entry.runId)?.inner?.identity?.()?.providerThreadId || null; } catch { return entry.providerThreadId || null; }
@@ -2018,6 +2297,13 @@ export function createAssistantDispatcher({
2018
2297
  if (TERMINAL_STATES.has(entry.state)) meter.clearPending();
2019
2298
  const pending = meter.pending();
2020
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
+ }
2021
2307
  }
2022
2308
 
2023
2309
  // Codex: the rollout files of the run's thread and its sub-agent threads.
@@ -2340,12 +2626,17 @@ export function createAssistantDispatcher({
2340
2626
  const held = meters.get(entry.runId);
2341
2627
  if (held) { held.codex = null; held.opencode = null; }
2342
2628
  }
2343
- /** 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
+ */
2344
2634
  function enforceMidTurn(entry, liveState) {
2345
2635
  if (TERMINAL_STATES.has(entry.state) || liveState.budgetStopping) return;
2346
2636
  refreshLimits();
2347
2637
  const block = budgetBlock(entry);
2348
- if (block) {
2638
+ if (block?.code === 'BUDGET_SHARE_EXHAUSTED') shareRanDry(entry);
2639
+ else if (block) {
2349
2640
  liveState.budgetStopping = true;
2350
2641
  entry.completionReason = block.reason;
2351
2642
  entry.notes.push(`stopped mid-turn: ${block.message}`);
@@ -2353,6 +2644,25 @@ export function createAssistantDispatcher({
2353
2644
  }
2354
2645
  checkSession(entry.assistantSessionId, { source: entry });
2355
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
+ }
2356
2666
 
2357
2667
  // ── run ↔ memory ───────────────────────────────────────────────────────────
2358
2668
  /**
@@ -2393,7 +2703,16 @@ export function createAssistantDispatcher({
2393
2703
  refreshLimits();
2394
2704
  const blocked = budgetBlock(entry);
2395
2705
  if (blocked) {
2396
- 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) });
2397
2716
  }
2398
2717
  // A follow-up works for the task in progress when it is sent, which may be a later one than the run's.
2399
2718
  const item = { kind: 'follow-up', text: message, origin, at: iso(now()), taskId: taskNow(entry.assistantSessionId) || entry.taskId || null };
@@ -2404,12 +2723,42 @@ export function createAssistantDispatcher({
2404
2723
  emit(entry, 'follow_up_queued', { queued: liveState.followUps.length });
2405
2724
  return { ok: true, queued: true, position: liveState.followUps.length, run: view(entry) };
2406
2725
  }
2407
- function complete(runId) {
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
+ }
2738
+ function complete(runId, { reason = 'complete' } = {}) {
2408
2739
  const entry = requireEntry(runId);
2409
2740
  const liveState = live.get(runId);
2410
2741
  if (TERMINAL_STATES.has(entry.state)) return { ok: true, run: view(entry) };
2411
- if (liveState?.waitResolve) { liveState.waitResolve({ kind: 'complete' }); return { ok: true, run: view(entry) }; }
2412
- return stop(runId, 'complete');
2742
+ if (liveState?.waitResolve) { liveState.waitResolve({ kind: 'complete', reason }); return { ok: true, run: view(entry) }; }
2743
+ return stop(runId, reason);
2744
+ }
2745
+ /**
2746
+ * The user closed the sidepanel tab of a worker that reported its task done
2747
+ * and waits warm for a follow-up (its X, or Back to Assistant): the run is
2748
+ * completed the way agent_complete completes it, with the reason
2749
+ * `tab_closed`. Its result stays as the worker reported it, and the brain is
2750
+ * handed no stop to react to. Any other run is refused (RUN_NOT_FINISHED):
2751
+ * the tab then stops it as its X always did. A run that already ended is
2752
+ * left as it is.
2753
+ */
2754
+ function completeClosedTab(runId) {
2755
+ const entry = requireEntry(runId);
2756
+ if (TERMINAL_STATES.has(entry.state)) return { ok: true, completed: false, alreadyTerminal: true, run: view(entry) };
2757
+ if (!finishedIdle(entry, { outcomes: ['done'] }) || !live.get(runId)?.waitResolve) {
2758
+ const outcome = entry.lastResult?.status || null;
2759
+ throw new DispatchError('RUN_NOT_FINISHED', `Run ${runId} is ${entry.state}${outcome ? ` (last result: ${outcome})` : ''}: closing its tab completes only a worker that reported its task done and waits for a follow-up.`, { status: 409, state: entry.state, outcome });
2760
+ }
2761
+ return { ...complete(runId, { reason: 'tab_closed' }), completed: true };
2413
2762
  }
2414
2763
  async function stop(runId, reason = 'assistant') {
2415
2764
  const entry = requireEntry(runId);
@@ -2442,13 +2791,17 @@ export function createAssistantDispatcher({
2442
2791
  // ── runtime feed → registry ────────────────────────────────────────────────
2443
2792
  function finalize(entry, runtimeRun) {
2444
2793
  if (TERMINAL_STATES.has(entry.state)) return;
2445
- const status = runtimeRun?.status || 'completed';
2794
+ const liveState = live.get(entry.runId);
2795
+ pauseWork(entry, liveState);
2796
+ let status = runtimeRun?.status || 'completed';
2797
+ // A completed worker whose loop file was swept meanwhile (a stale-file cleanup) reaches the
2798
+ // runtime's next check as "stopped": its turn loop had already ended on the completion.
2799
+ if (status === 'stopped' && liveState?.completed && /^state_(removed|inactive)$/.test(String(runtimeRun?.stoppedReason || ''))) status = 'completed';
2446
2800
  entry.state = TERMINAL_STATES.has(status) ? status : 'completed';
2447
2801
  entry.turnState = 'terminal';
2448
2802
  entry.finishedAt = iso(now());
2449
2803
  entry.completionReason = entry.completionReason || runtimeRun?.stoppedReason || (status === 'completed' ? 'complete' : status);
2450
2804
  if (runtimeRun?.error) entry.error = runtimeRun.error;
2451
- const liveState = live.get(entry.runId);
2452
2805
  try { finishUsage(entry, liveState || null); } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
2453
2806
  if (liveState) {
2454
2807
  liveState.dead = true;
@@ -2458,7 +2811,9 @@ export function createAssistantDispatcher({
2458
2811
  }
2459
2812
  entry.pending = [];
2460
2813
  releaseDesktop(entry);
2461
- 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) {
2462
2817
  // State, queue and desktop move on now; the 'failed' event waits for its cause.
2463
2818
  emitFailed(entry, liveState?.evidence || null);
2464
2819
  processQueue().catch(() => {});
@@ -2654,9 +3009,9 @@ export function createAssistantDispatcher({
2654
3009
  * failed run whose cause is still being judged is not finished yet.
2655
3010
  */
2656
3011
  function removable(entry) { return TERMINAL_STATES.has(entry.state) && !entry.judging; }
2657
- function finishedIdle(entry) {
3012
+ function finishedIdle(entry, { outcomes = ['done', 'blocked'] } = {}) {
2658
3013
  return entry.state === 'idle' && !entry.judging
2659
- && ['done', 'blocked'].includes(entry.lastResult?.status)
3014
+ && outcomes.includes(entry.lastResult?.status)
2660
3015
  && !(live.get(entry.runId)?.followUps?.length);
2661
3016
  }
2662
3017
  async function finishIdleForRemoval(entry) {
@@ -2700,7 +3055,7 @@ export function createAssistantDispatcher({
2700
3055
  && (!assistantSessionId || entry.assistantSessionId === String(assistantSessionId))
2701
3056
  && (!workflowId || entry.workflowId === String(workflowId))
2702
3057
  && (!wanted || wanted.has(entry.runId)));
2703
- await Promise.all(scoped.filter(finishedIdle).map((entry) => finishIdleForRemoval(entry).catch(() => {})));
3058
+ await Promise.all(scoped.filter((entry) => finishedIdle(entry)).map((entry) => finishIdleForRemoval(entry).catch(() => {})));
2704
3059
  const targets = scoped.filter(removable);
2705
3060
  return { ok: true, removed: markRemoved(targets), skipped: scoped.filter((entry) => !removable(entry)).map((entry) => entry.runId) };
2706
3061
  }
@@ -2715,6 +3070,7 @@ export function createAssistantDispatcher({
2715
3070
  if (unsubscribeRuntime) { try { unsubscribeRuntime(); } catch {} unsubscribeRuntime = null; }
2716
3071
  for (const entry of entries.values()) {
2717
3072
  if (ACTIVE_STATES.has(entry.state)) {
3073
+ pauseWork(entry);
2718
3074
  entry.state = 'interrupted'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
2719
3075
  delete entry.rawSpec;
2720
3076
  // What the run's meter can still read is booked now; nothing provisional outlives the process.
@@ -2731,9 +3087,9 @@ export function createAssistantDispatcher({
2731
3087
  stalledTimer.unref?.();
2732
3088
 
2733
3089
  return {
2734
- dispatch, sendTurn, complete, stop, killAll, get, peek, list, totals, wait, transcript, pendingPermissions,
3090
+ dispatch, sendTurn, complete, completeClosedTab, stop, killAll, get, peek, list, totals, wait, transcript, pendingPermissions,
2735
3091
  respondPermission, subscribe, shutdown, limits,
2736
- sessionBudget, checkSession, refreshLimits,
3092
+ sessionBudget, checkSession, refreshLimits, providerSpend, coverFollowUp, budget,
2737
3093
  resolveRoute, declineRoute, heldRouteRefusal, escalate,
2738
3094
  markDelivered, deliveryState,
2739
3095
  remove, removeFinished,