synabun 1.3.0 → 2.0.0-beta

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 (1153) hide show
  1. package/CHANGELOG.md +197 -662
  2. package/LICENSE-COMMERCIAL.md +33 -0
  3. package/NOTICE +17 -0
  4. package/README.md +175 -853
  5. package/THIRD-PARTY-LICENSES.md +131 -0
  6. package/benchmarks/browser-live-results.json +79 -0
  7. package/benchmarks/browser-v2-results.json +258 -0
  8. package/benchmarks/fixtures/browser/README.md +57 -0
  9. package/benchmarks/fixtures/browser/page-state.json +132 -0
  10. package/benchmarks/fixtures/browser/social.json +92 -0
  11. package/benchmarks/fixtures/browser/target.json +319 -0
  12. package/benchmarks/fixtures/desktop/README.md +58 -0
  13. package/benchmarks/fixtures/desktop/abstention.json +59 -0
  14. package/benchmarks/fixtures/desktop/adversarial.json +22 -0
  15. package/benchmarks/fixtures/desktop/forbidden.json +93 -0
  16. package/benchmarks/fixtures/desktop/generate.mjs +1023 -0
  17. package/benchmarks/fixtures/desktop/target.json +136 -0
  18. package/bin/synabun.js +30 -0
  19. package/docs/browser-v2.md +109 -0
  20. package/docs/hook-integrity.md +89 -0
  21. package/docs/judgments.md +182 -0
  22. package/docs/style-guide.md +246 -0
  23. package/docs/whatsapp.md +325 -0
  24. package/hooks/claude-code/post-plan.mjs +613 -391
  25. package/hooks/claude-code/post-remember.mjs +246 -122
  26. package/hooks/claude-code/pre-compact.mjs +171 -110
  27. package/hooks/claude-code/pre-task.mjs +133 -0
  28. package/hooks/claude-code/pre-websearch.mjs +15 -4
  29. package/hooks/claude-code/prompt-origin.mjs +94 -0
  30. package/hooks/claude-code/prompt-submit.mjs +1244 -861
  31. package/hooks/claude-code/session-start.mjs +114 -156
  32. package/hooks/claude-code/shared.mjs +1228 -368
  33. package/hooks/claude-code/state.mjs +167 -0
  34. package/hooks/claude-code/stop.mjs +515 -140
  35. package/hooks/claude-code/subagent-start.mjs +154 -0
  36. package/hooks/claude-code/subagent-stop.mjs +102 -0
  37. package/hooks/claude-code/task-gate.mjs +150 -0
  38. package/lib/claude-hooks.js +84 -0
  39. package/lib/claude-usage-audit.js +141 -0
  40. package/lib/client-config-repair.js +144 -0
  41. package/lib/data-home-migration.js +498 -0
  42. package/lib/first-launch-protection.js +240 -0
  43. package/lib/paths.d.ts +21 -0
  44. package/lib/paths.js +103 -0
  45. package/mcp-server/dist/config.d.ts +22 -0
  46. package/mcp-server/dist/config.d.ts.map +1 -0
  47. package/mcp-server/dist/config.js +109 -0
  48. package/mcp-server/dist/config.js.map +1 -0
  49. package/mcp-server/dist/http.d.ts +30 -0
  50. package/mcp-server/dist/http.d.ts.map +1 -0
  51. package/mcp-server/dist/http.js +276 -0
  52. package/mcp-server/dist/http.js.map +1 -0
  53. package/mcp-server/dist/index.d.ts +48 -0
  54. package/mcp-server/dist/index.d.ts.map +1 -0
  55. package/mcp-server/dist/index.js +538 -0
  56. package/mcp-server/dist/index.js.map +1 -0
  57. package/mcp-server/dist/preload-http.d.ts +3 -0
  58. package/mcp-server/dist/preload-http.d.ts.map +1 -0
  59. package/mcp-server/dist/preload-http.js +28 -0
  60. package/mcp-server/dist/preload-http.js.map +1 -0
  61. package/mcp-server/dist/preload.d.ts +3 -0
  62. package/mcp-server/dist/preload.d.ts.map +1 -0
  63. package/mcp-server/dist/preload.js +32 -0
  64. package/mcp-server/dist/preload.js.map +1 -0
  65. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts +19 -0
  66. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.d.ts.map +1 -0
  67. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js +403 -0
  68. package/mcp-server/dist/scripts/migrate-qdrant-to-sqlite.js.map +1 -0
  69. package/mcp-server/dist/scripts/setup-embeddings.d.ts +3 -0
  70. package/mcp-server/dist/scripts/setup-embeddings.d.ts.map +1 -0
  71. package/mcp-server/dist/scripts/setup-embeddings.js +15 -0
  72. package/mcp-server/dist/scripts/setup-embeddings.js.map +1 -0
  73. package/mcp-server/dist/services/assistant-judgments.d.ts +79 -0
  74. package/mcp-server/dist/services/assistant-judgments.d.ts.map +1 -0
  75. package/mcp-server/dist/services/assistant-judgments.js +128 -0
  76. package/mcp-server/dist/services/assistant-judgments.js.map +1 -0
  77. package/mcp-server/dist/services/browser-assist-gate.d.ts +314 -0
  78. package/mcp-server/dist/services/browser-assist-gate.d.ts.map +1 -0
  79. package/mcp-server/dist/services/browser-assist-gate.js +344 -0
  80. package/mcp-server/dist/services/browser-assist-gate.js.map +1 -0
  81. package/mcp-server/dist/services/browser-assist.d.ts +104 -0
  82. package/mcp-server/dist/services/browser-assist.d.ts.map +1 -0
  83. package/mcp-server/dist/services/browser-assist.js +282 -0
  84. package/mcp-server/dist/services/browser-assist.js.map +1 -0
  85. package/mcp-server/dist/services/browser-bench.d.ts +772 -0
  86. package/mcp-server/dist/services/browser-bench.d.ts.map +1 -0
  87. package/mcp-server/dist/services/browser-bench.js +424 -0
  88. package/mcp-server/dist/services/browser-bench.js.map +1 -0
  89. package/mcp-server/dist/services/browser-judgments.d.ts +196 -0
  90. package/mcp-server/dist/services/browser-judgments.d.ts.map +1 -0
  91. package/mcp-server/dist/services/browser-judgments.js +363 -0
  92. package/mcp-server/dist/services/browser-judgments.js.map +1 -0
  93. package/mcp-server/dist/services/browser-risk.d.ts +166 -0
  94. package/mcp-server/dist/services/browser-risk.d.ts.map +1 -0
  95. package/mcp-server/dist/services/browser-risk.js +458 -0
  96. package/mcp-server/dist/services/browser-risk.js.map +1 -0
  97. package/mcp-server/dist/services/categories.d.ts +61 -0
  98. package/mcp-server/dist/services/categories.d.ts.map +1 -0
  99. package/mcp-server/dist/services/categories.js +377 -0
  100. package/mcp-server/dist/services/categories.js.map +1 -0
  101. package/mcp-server/dist/services/codex-config-heal.d.ts +49 -0
  102. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -0
  103. package/mcp-server/dist/services/codex-config-heal.js +346 -0
  104. package/mcp-server/dist/services/codex-config-heal.js.map +1 -0
  105. package/mcp-server/dist/services/desktop-assist-gate.d.ts +198 -0
  106. package/mcp-server/dist/services/desktop-assist-gate.d.ts.map +1 -0
  107. package/mcp-server/dist/services/desktop-assist-gate.js +202 -0
  108. package/mcp-server/dist/services/desktop-assist-gate.js.map +1 -0
  109. package/mcp-server/dist/services/desktop-assist.d.ts +104 -0
  110. package/mcp-server/dist/services/desktop-assist.d.ts.map +1 -0
  111. package/mcp-server/dist/services/desktop-assist.js +266 -0
  112. package/mcp-server/dist/services/desktop-assist.js.map +1 -0
  113. package/mcp-server/dist/services/desktop-bench.d.ts +553 -0
  114. package/mcp-server/dist/services/desktop-bench.d.ts.map +1 -0
  115. package/mcp-server/dist/services/desktop-bench.js +311 -0
  116. package/mcp-server/dist/services/desktop-bench.js.map +1 -0
  117. package/mcp-server/dist/services/desktop-client.d.ts +23 -0
  118. package/mcp-server/dist/services/desktop-client.d.ts.map +1 -0
  119. package/mcp-server/dist/services/desktop-client.js +79 -0
  120. package/mcp-server/dist/services/desktop-client.js.map +1 -0
  121. package/mcp-server/dist/services/desktop-judgments.d.ts +125 -0
  122. package/mcp-server/dist/services/desktop-judgments.d.ts.map +1 -0
  123. package/mcp-server/dist/services/desktop-judgments.js +177 -0
  124. package/mcp-server/dist/services/desktop-judgments.js.map +1 -0
  125. package/mcp-server/dist/services/desktop-risk.d.ts +283 -0
  126. package/mcp-server/dist/services/desktop-risk.d.ts.map +1 -0
  127. package/mcp-server/dist/services/desktop-risk.js +605 -0
  128. package/mcp-server/dist/services/desktop-risk.js.map +1 -0
  129. package/mcp-server/dist/services/discord.d.ts +112 -0
  130. package/mcp-server/dist/services/discord.d.ts.map +1 -0
  131. package/mcp-server/dist/services/discord.js +477 -0
  132. package/mcp-server/dist/services/discord.js.map +1 -0
  133. package/mcp-server/dist/services/edit-stale.d.ts +81 -0
  134. package/mcp-server/dist/services/edit-stale.d.ts.map +1 -0
  135. package/mcp-server/dist/services/edit-stale.js +358 -0
  136. package/mcp-server/dist/services/edit-stale.js.map +1 -0
  137. package/mcp-server/dist/services/embedding-service.d.ts +16 -0
  138. package/mcp-server/dist/services/embedding-service.d.ts.map +1 -0
  139. package/mcp-server/dist/services/embedding-service.js +126 -0
  140. package/mcp-server/dist/services/embedding-service.js.map +1 -0
  141. package/mcp-server/dist/services/embedding-worker.d.ts +2 -0
  142. package/mcp-server/dist/services/embedding-worker.d.ts.map +1 -0
  143. package/mcp-server/dist/services/embedding-worker.js +52 -0
  144. package/mcp-server/dist/services/embedding-worker.js.map +1 -0
  145. package/mcp-server/dist/services/fb-regions.d.ts +71 -0
  146. package/mcp-server/dist/services/fb-regions.d.ts.map +1 -0
  147. package/mcp-server/dist/services/fb-regions.js +211 -0
  148. package/mcp-server/dist/services/fb-regions.js.map +1 -0
  149. package/mcp-server/dist/services/file-checksums.d.ts +22 -0
  150. package/mcp-server/dist/services/file-checksums.d.ts.map +1 -0
  151. package/mcp-server/dist/services/file-checksums.js +55 -0
  152. package/mcp-server/dist/services/file-checksums.js.map +1 -0
  153. package/mcp-server/dist/services/file-excerpt.d.ts +24 -0
  154. package/mcp-server/dist/services/file-excerpt.d.ts.map +1 -0
  155. package/mcp-server/dist/services/file-excerpt.js +65 -0
  156. package/mcp-server/dist/services/file-excerpt.js.map +1 -0
  157. package/mcp-server/dist/services/identity.d.ts +124 -0
  158. package/mcp-server/dist/services/identity.d.ts.map +1 -0
  159. package/mcp-server/dist/services/identity.js +241 -0
  160. package/mcp-server/dist/services/identity.js.map +1 -0
  161. package/mcp-server/dist/services/local-embeddings.d.ts +2 -0
  162. package/mcp-server/dist/services/local-embeddings.d.ts.map +1 -0
  163. package/mcp-server/dist/services/local-embeddings.js +3 -0
  164. package/mcp-server/dist/services/local-embeddings.js.map +1 -0
  165. package/mcp-server/dist/services/memory-client.d.ts +4 -0
  166. package/mcp-server/dist/services/memory-client.d.ts.map +1 -0
  167. package/mcp-server/dist/services/memory-client.js +27 -0
  168. package/mcp-server/dist/services/memory-client.js.map +1 -0
  169. package/mcp-server/dist/services/memory-judge-backfill.d.ts +135 -0
  170. package/mcp-server/dist/services/memory-judge-backfill.d.ts.map +1 -0
  171. package/mcp-server/dist/services/memory-judge-backfill.js +510 -0
  172. package/mcp-server/dist/services/memory-judge-backfill.js.map +1 -0
  173. package/mcp-server/dist/services/memory-judgments.d.ts +362 -0
  174. package/mcp-server/dist/services/memory-judgments.d.ts.map +1 -0
  175. package/mcp-server/dist/services/memory-judgments.js +674 -0
  176. package/mcp-server/dist/services/memory-judgments.js.map +1 -0
  177. package/mcp-server/dist/services/memory-maintenance.d.ts +108 -0
  178. package/mcp-server/dist/services/memory-maintenance.d.ts.map +1 -0
  179. package/mcp-server/dist/services/memory-maintenance.js +403 -0
  180. package/mcp-server/dist/services/memory-maintenance.js.map +1 -0
  181. package/mcp-server/dist/services/memory-retrieval.d.ts +66 -0
  182. package/mcp-server/dist/services/memory-retrieval.d.ts.map +1 -0
  183. package/mcp-server/dist/services/memory-retrieval.js +412 -0
  184. package/mcp-server/dist/services/memory-retrieval.js.map +1 -0
  185. package/mcp-server/dist/services/memory-schema.d.ts +16 -0
  186. package/mcp-server/dist/services/memory-schema.d.ts.map +1 -0
  187. package/mcp-server/dist/services/memory-schema.js +282 -0
  188. package/mcp-server/dist/services/memory-schema.js.map +1 -0
  189. package/mcp-server/dist/services/memory-sessions.d.ts +24 -0
  190. package/mcp-server/dist/services/memory-sessions.d.ts.map +1 -0
  191. package/mcp-server/dist/services/memory-sessions.js +110 -0
  192. package/mcp-server/dist/services/memory-sessions.js.map +1 -0
  193. package/mcp-server/dist/services/memory-triage.d.ts +60 -0
  194. package/mcp-server/dist/services/memory-triage.d.ts.map +1 -0
  195. package/mcp-server/dist/services/memory-triage.js +189 -0
  196. package/mcp-server/dist/services/memory-triage.js.map +1 -0
  197. package/mcp-server/dist/services/memory-verification.d.ts +24 -0
  198. package/mcp-server/dist/services/memory-verification.d.ts.map +1 -0
  199. package/mcp-server/dist/services/memory-verification.js +92 -0
  200. package/mcp-server/dist/services/memory-verification.js.map +1 -0
  201. package/mcp-server/dist/services/memory-writer.d.ts +26 -0
  202. package/mcp-server/dist/services/memory-writer.d.ts.map +1 -0
  203. package/mcp-server/dist/services/memory-writer.js +126 -0
  204. package/mcp-server/dist/services/memory-writer.js.map +1 -0
  205. package/mcp-server/dist/services/neural-interface.d.ts +439 -0
  206. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -0
  207. package/mcp-server/dist/services/neural-interface.js +1135 -0
  208. package/mcp-server/dist/services/neural-interface.js.map +1 -0
  209. package/mcp-server/dist/services/profiles.d.ts +120 -0
  210. package/mcp-server/dist/services/profiles.d.ts.map +1 -0
  211. package/mcp-server/dist/services/profiles.js +387 -0
  212. package/mcp-server/dist/services/profiles.js.map +1 -0
  213. package/mcp-server/dist/services/secret-gate.d.ts +29 -0
  214. package/mcp-server/dist/services/secret-gate.d.ts.map +1 -0
  215. package/mcp-server/dist/services/secret-gate.js +43 -0
  216. package/mcp-server/dist/services/secret-gate.js.map +1 -0
  217. package/mcp-server/dist/services/server-instructions.d.ts +10 -0
  218. package/mcp-server/dist/services/server-instructions.d.ts.map +1 -0
  219. package/mcp-server/dist/services/server-instructions.js +101 -0
  220. package/mcp-server/dist/services/server-instructions.js.map +1 -0
  221. package/mcp-server/dist/services/sqlite.d.ts +235 -0
  222. package/mcp-server/dist/services/sqlite.d.ts.map +1 -0
  223. package/mcp-server/dist/services/sqlite.js +1185 -0
  224. package/mcp-server/dist/services/sqlite.js.map +1 -0
  225. package/mcp-server/dist/services/typesafe-bench.d.ts +99 -0
  226. package/mcp-server/dist/services/typesafe-bench.d.ts.map +1 -0
  227. package/mcp-server/dist/services/typesafe-bench.js +218 -0
  228. package/mcp-server/dist/services/typesafe-bench.js.map +1 -0
  229. package/mcp-server/dist/services/typesafe-config.d.ts +342 -0
  230. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -0
  231. package/mcp-server/dist/services/typesafe-config.js +746 -0
  232. package/mcp-server/dist/services/typesafe-config.js.map +1 -0
  233. package/mcp-server/dist/services/typesafe.d.ts +213 -0
  234. package/mcp-server/dist/services/typesafe.d.ts.map +1 -0
  235. package/mcp-server/dist/services/typesafe.js +330 -0
  236. package/mcp-server/dist/services/typesafe.js.map +1 -0
  237. package/mcp-server/dist/services/vector-cache.d.ts +68 -0
  238. package/mcp-server/dist/services/vector-cache.d.ts.map +1 -0
  239. package/mcp-server/dist/services/vector-cache.js +271 -0
  240. package/mcp-server/dist/services/vector-cache.js.map +1 -0
  241. package/mcp-server/dist/services/whiteboard-geometry.d.ts +305 -0
  242. package/mcp-server/dist/services/whiteboard-geometry.d.ts.map +1 -0
  243. package/mcp-server/dist/services/whiteboard-geometry.js +666 -0
  244. package/mcp-server/dist/services/whiteboard-geometry.js.map +1 -0
  245. package/mcp-server/dist/tools/agents.d.ts +212 -0
  246. package/mcp-server/dist/tools/agents.d.ts.map +1 -0
  247. package/mcp-server/dist/tools/agents.js +407 -0
  248. package/mcp-server/dist/tools/agents.js.map +1 -0
  249. package/mcp-server/dist/tools/bluesky.d.ts +39 -0
  250. package/mcp-server/dist/tools/bluesky.d.ts.map +1 -0
  251. package/mcp-server/dist/tools/bluesky.js +1144 -0
  252. package/mcp-server/dist/tools/bluesky.js.map +1 -0
  253. package/mcp-server/dist/tools/browser-advanced.d.ts +58 -0
  254. package/mcp-server/dist/tools/browser-advanced.d.ts.map +1 -0
  255. package/mcp-server/dist/tools/browser-advanced.js +106 -0
  256. package/mcp-server/dist/tools/browser-advanced.js.map +1 -0
  257. package/mcp-server/dist/tools/browser-batch.d.ts +99 -0
  258. package/mcp-server/dist/tools/browser-batch.d.ts.map +1 -0
  259. package/mcp-server/dist/tools/browser-batch.js +187 -0
  260. package/mcp-server/dist/tools/browser-batch.js.map +1 -0
  261. package/mcp-server/dist/tools/browser-cheatsheet.d.ts +21 -0
  262. package/mcp-server/dist/tools/browser-cheatsheet.d.ts.map +1 -0
  263. package/mcp-server/dist/tools/browser-cheatsheet.js +144 -0
  264. package/mcp-server/dist/tools/browser-cheatsheet.js.map +1 -0
  265. package/mcp-server/dist/tools/browser-interact.d.ts +239 -0
  266. package/mcp-server/dist/tools/browser-interact.d.ts.map +1 -0
  267. package/mcp-server/dist/tools/browser-interact.js +300 -0
  268. package/mcp-server/dist/tools/browser-interact.js.map +1 -0
  269. package/mcp-server/dist/tools/browser-navigate.d.ts +90 -0
  270. package/mcp-server/dist/tools/browser-navigate.d.ts.map +1 -0
  271. package/mcp-server/dist/tools/browser-navigate.js +142 -0
  272. package/mcp-server/dist/tools/browser-navigate.js.map +1 -0
  273. package/mcp-server/dist/tools/browser-observe.d.ts +561 -0
  274. package/mcp-server/dist/tools/browser-observe.d.ts.map +1 -0
  275. package/mcp-server/dist/tools/browser-observe.js +2009 -0
  276. package/mcp-server/dist/tools/browser-observe.js.map +1 -0
  277. package/mcp-server/dist/tools/browser.d.ts +34 -0
  278. package/mcp-server/dist/tools/browser.d.ts.map +1 -0
  279. package/mcp-server/dist/tools/browser.js +112 -0
  280. package/mcp-server/dist/tools/browser.js.map +1 -0
  281. package/mcp-server/dist/tools/card-tools.d.ts +82 -0
  282. package/mcp-server/dist/tools/card-tools.d.ts.map +1 -0
  283. package/mcp-server/dist/tools/card-tools.js +137 -0
  284. package/mcp-server/dist/tools/card-tools.js.map +1 -0
  285. package/mcp-server/dist/tools/card.d.ts +8 -0
  286. package/mcp-server/dist/tools/card.d.ts.map +1 -0
  287. package/mcp-server/dist/tools/card.js +16 -0
  288. package/mcp-server/dist/tools/card.js.map +1 -0
  289. package/mcp-server/dist/tools/category.d.ts +32 -0
  290. package/mcp-server/dist/tools/category.d.ts.map +1 -0
  291. package/mcp-server/dist/tools/category.js +290 -0
  292. package/mcp-server/dist/tools/category.js.map +1 -0
  293. package/mcp-server/dist/tools/choice.d.ts +160 -0
  294. package/mcp-server/dist/tools/choice.d.ts.map +1 -0
  295. package/mcp-server/dist/tools/choice.js +80 -0
  296. package/mcp-server/dist/tools/choice.js.map +1 -0
  297. package/mcp-server/dist/tools/computer.d.ts +71 -0
  298. package/mcp-server/dist/tools/computer.d.ts.map +1 -0
  299. package/mcp-server/dist/tools/computer.js +174 -0
  300. package/mcp-server/dist/tools/computer.js.map +1 -0
  301. package/mcp-server/dist/tools/discord-channel.d.ts +44 -0
  302. package/mcp-server/dist/tools/discord-channel.d.ts.map +1 -0
  303. package/mcp-server/dist/tools/discord-channel.js +236 -0
  304. package/mcp-server/dist/tools/discord-channel.js.map +1 -0
  305. package/mcp-server/dist/tools/discord-guild.d.ts +20 -0
  306. package/mcp-server/dist/tools/discord-guild.d.ts.map +1 -0
  307. package/mcp-server/dist/tools/discord-guild.js +138 -0
  308. package/mcp-server/dist/tools/discord-guild.js.map +1 -0
  309. package/mcp-server/dist/tools/discord-member.d.ts +26 -0
  310. package/mcp-server/dist/tools/discord-member.d.ts.map +1 -0
  311. package/mcp-server/dist/tools/discord-member.js +122 -0
  312. package/mcp-server/dist/tools/discord-member.js.map +1 -0
  313. package/mcp-server/dist/tools/discord-message.d.ts +36 -0
  314. package/mcp-server/dist/tools/discord-message.d.ts.map +1 -0
  315. package/mcp-server/dist/tools/discord-message.js +184 -0
  316. package/mcp-server/dist/tools/discord-message.js.map +1 -0
  317. package/mcp-server/dist/tools/discord-onboarding.d.ts +32 -0
  318. package/mcp-server/dist/tools/discord-onboarding.d.ts.map +1 -0
  319. package/mcp-server/dist/tools/discord-onboarding.js +179 -0
  320. package/mcp-server/dist/tools/discord-onboarding.js.map +1 -0
  321. package/mcp-server/dist/tools/discord-role.d.ts +34 -0
  322. package/mcp-server/dist/tools/discord-role.d.ts.map +1 -0
  323. package/mcp-server/dist/tools/discord-role.js +178 -0
  324. package/mcp-server/dist/tools/discord-role.js.map +1 -0
  325. package/mcp-server/dist/tools/discord-thread.d.ts +28 -0
  326. package/mcp-server/dist/tools/discord-thread.d.ts.map +1 -0
  327. package/mcp-server/dist/tools/discord-thread.js +88 -0
  328. package/mcp-server/dist/tools/discord-thread.js.map +1 -0
  329. package/mcp-server/dist/tools/discord-webhook.d.ts +32 -0
  330. package/mcp-server/dist/tools/discord-webhook.d.ts.map +1 -0
  331. package/mcp-server/dist/tools/discord-webhook.js +154 -0
  332. package/mcp-server/dist/tools/discord-webhook.js.map +1 -0
  333. package/mcp-server/dist/tools/discord.d.ts +7 -0
  334. package/mcp-server/dist/tools/discord.d.ts.map +1 -0
  335. package/mcp-server/dist/tools/discord.js +25 -0
  336. package/mcp-server/dist/tools/discord.js.map +1 -0
  337. package/mcp-server/dist/tools/fb-groups.d.ts +98 -0
  338. package/mcp-server/dist/tools/fb-groups.d.ts.map +1 -0
  339. package/mcp-server/dist/tools/fb-groups.js +369 -0
  340. package/mcp-server/dist/tools/fb-groups.js.map +1 -0
  341. package/mcp-server/dist/tools/forget.d.ts +14 -0
  342. package/mcp-server/dist/tools/forget.d.ts.map +1 -0
  343. package/mcp-server/dist/tools/forget.js +26 -0
  344. package/mcp-server/dist/tools/forget.js.map +1 -0
  345. package/mcp-server/dist/tools/git-tools.d.ts +24 -0
  346. package/mcp-server/dist/tools/git-tools.d.ts.map +1 -0
  347. package/mcp-server/dist/tools/git-tools.js +107 -0
  348. package/mcp-server/dist/tools/git-tools.js.map +1 -0
  349. package/mcp-server/dist/tools/git.d.ts +7 -0
  350. package/mcp-server/dist/tools/git.d.ts.map +1 -0
  351. package/mcp-server/dist/tools/git.js +11 -0
  352. package/mcp-server/dist/tools/git.js.map +1 -0
  353. package/mcp-server/dist/tools/gsc/experience.d.ts +135 -0
  354. package/mcp-server/dist/tools/gsc/experience.d.ts.map +1 -0
  355. package/mcp-server/dist/tools/gsc/experience.js +351 -0
  356. package/mcp-server/dist/tools/gsc/experience.js.map +1 -0
  357. package/mcp-server/dist/tools/gsc/helpers.d.ts +78 -0
  358. package/mcp-server/dist/tools/gsc/helpers.d.ts.map +1 -0
  359. package/mcp-server/dist/tools/gsc/helpers.js +258 -0
  360. package/mcp-server/dist/tools/gsc/helpers.js.map +1 -0
  361. package/mcp-server/dist/tools/gsc/indexing.d.ts +121 -0
  362. package/mcp-server/dist/tools/gsc/indexing.d.ts.map +1 -0
  363. package/mcp-server/dist/tools/gsc/indexing.js +357 -0
  364. package/mcp-server/dist/tools/gsc/indexing.js.map +1 -0
  365. package/mcp-server/dist/tools/gsc/inspect.d.ts +78 -0
  366. package/mcp-server/dist/tools/gsc/inspect.d.ts.map +1 -0
  367. package/mcp-server/dist/tools/gsc/inspect.js +321 -0
  368. package/mcp-server/dist/tools/gsc/inspect.js.map +1 -0
  369. package/mcp-server/dist/tools/gsc/navigate.d.ts +44 -0
  370. package/mcp-server/dist/tools/gsc/navigate.d.ts.map +1 -0
  371. package/mcp-server/dist/tools/gsc/navigate.js +197 -0
  372. package/mcp-server/dist/tools/gsc/navigate.js.map +1 -0
  373. package/mcp-server/dist/tools/gsc/performance.d.ts +84 -0
  374. package/mcp-server/dist/tools/gsc/performance.d.ts.map +1 -0
  375. package/mcp-server/dist/tools/gsc/performance.js +274 -0
  376. package/mcp-server/dist/tools/gsc/performance.js.map +1 -0
  377. package/mcp-server/dist/tools/gsc/settings.d.ts +153 -0
  378. package/mcp-server/dist/tools/gsc/settings.d.ts.map +1 -0
  379. package/mcp-server/dist/tools/gsc/settings.js +425 -0
  380. package/mcp-server/dist/tools/gsc/settings.js.map +1 -0
  381. package/mcp-server/dist/tools/gsc.d.ts +7 -0
  382. package/mcp-server/dist/tools/gsc.d.ts.map +1 -0
  383. package/mcp-server/dist/tools/gsc.js +51 -0
  384. package/mcp-server/dist/tools/gsc.js.map +1 -0
  385. package/mcp-server/dist/tools/image-tools.d.ts +24 -0
  386. package/mcp-server/dist/tools/image-tools.d.ts.map +1 -0
  387. package/mcp-server/dist/tools/image-tools.js +80 -0
  388. package/mcp-server/dist/tools/image-tools.js.map +1 -0
  389. package/mcp-server/dist/tools/image.d.ts +10 -0
  390. package/mcp-server/dist/tools/image.d.ts.map +1 -0
  391. package/mcp-server/dist/tools/image.js +14 -0
  392. package/mcp-server/dist/tools/image.js.map +1 -0
  393. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +97 -0
  394. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +1 -0
  395. package/mcp-server/dist/tools/leonardo-browser-tools.js +228 -0
  396. package/mcp-server/dist/tools/leonardo-browser-tools.js.map +1 -0
  397. package/mcp-server/dist/tools/leonardo.d.ts +13 -0
  398. package/mcp-server/dist/tools/leonardo.d.ts.map +1 -0
  399. package/mcp-server/dist/tools/leonardo.js +21 -0
  400. package/mcp-server/dist/tools/leonardo.js.map +1 -0
  401. package/mcp-server/dist/tools/loop.d.ts +30 -0
  402. package/mcp-server/dist/tools/loop.d.ts.map +1 -0
  403. package/mcp-server/dist/tools/loop.js +401 -0
  404. package/mcp-server/dist/tools/loop.js.map +1 -0
  405. package/mcp-server/dist/tools/memories.d.ts +56 -0
  406. package/mcp-server/dist/tools/memories.d.ts.map +1 -0
  407. package/mcp-server/dist/tools/memories.js +171 -0
  408. package/mcp-server/dist/tools/memories.js.map +1 -0
  409. package/mcp-server/dist/tools/morelogin.d.ts +24 -0
  410. package/mcp-server/dist/tools/morelogin.d.ts.map +1 -0
  411. package/mcp-server/dist/tools/morelogin.js +79 -0
  412. package/mcp-server/dist/tools/morelogin.js.map +1 -0
  413. package/mcp-server/dist/tools/profile.d.ts +17 -0
  414. package/mcp-server/dist/tools/profile.d.ts.map +1 -0
  415. package/mcp-server/dist/tools/profile.js +114 -0
  416. package/mcp-server/dist/tools/profile.js.map +1 -0
  417. package/mcp-server/dist/tools/recall.d.ts +71 -0
  418. package/mcp-server/dist/tools/recall.d.ts.map +1 -0
  419. package/mcp-server/dist/tools/recall.js +147 -0
  420. package/mcp-server/dist/tools/recall.js.map +1 -0
  421. package/mcp-server/dist/tools/reflect.d.ts +56 -0
  422. package/mcp-server/dist/tools/reflect.d.ts.map +1 -0
  423. package/mcp-server/dist/tools/reflect.js +157 -0
  424. package/mcp-server/dist/tools/reflect.js.map +1 -0
  425. package/mcp-server/dist/tools/remember.d.ts +62 -0
  426. package/mcp-server/dist/tools/remember.d.ts.map +1 -0
  427. package/mcp-server/dist/tools/remember.js +201 -0
  428. package/mcp-server/dist/tools/remember.js.map +1 -0
  429. package/mcp-server/dist/tools/response.d.ts +23 -0
  430. package/mcp-server/dist/tools/response.d.ts.map +1 -0
  431. package/mcp-server/dist/tools/response.js +24 -0
  432. package/mcp-server/dist/tools/response.js.map +1 -0
  433. package/mcp-server/dist/tools/restore.d.ts +14 -0
  434. package/mcp-server/dist/tools/restore.d.ts.map +1 -0
  435. package/mcp-server/dist/tools/restore.js +23 -0
  436. package/mcp-server/dist/tools/restore.js.map +1 -0
  437. package/mcp-server/dist/tools/style-guide-tools.d.ts +51 -0
  438. package/mcp-server/dist/tools/style-guide-tools.d.ts.map +1 -0
  439. package/mcp-server/dist/tools/style-guide-tools.js +231 -0
  440. package/mcp-server/dist/tools/style-guide-tools.js.map +1 -0
  441. package/mcp-server/dist/tools/style-guide.d.ts +8 -0
  442. package/mcp-server/dist/tools/style-guide.d.ts.map +1 -0
  443. package/mcp-server/dist/tools/style-guide.js +12 -0
  444. package/mcp-server/dist/tools/style-guide.js.map +1 -0
  445. package/mcp-server/dist/tools/sync.d.ts +20 -0
  446. package/mcp-server/dist/tools/sync.d.ts.map +1 -0
  447. package/mcp-server/dist/tools/sync.js +156 -0
  448. package/mcp-server/dist/tools/sync.js.map +1 -0
  449. package/mcp-server/dist/tools/tictactoe-tools.d.ts +18 -0
  450. package/mcp-server/dist/tools/tictactoe-tools.d.ts.map +1 -0
  451. package/mcp-server/dist/tools/tictactoe-tools.js +78 -0
  452. package/mcp-server/dist/tools/tictactoe-tools.js.map +1 -0
  453. package/mcp-server/dist/tools/tictactoe.d.ts +7 -0
  454. package/mcp-server/dist/tools/tictactoe.d.ts.map +1 -0
  455. package/mcp-server/dist/tools/tictactoe.js +11 -0
  456. package/mcp-server/dist/tools/tictactoe.js.map +1 -0
  457. package/mcp-server/dist/tools/utils.d.ts +19 -0
  458. package/mcp-server/dist/tools/utils.d.ts.map +1 -0
  459. package/mcp-server/dist/tools/utils.js +54 -0
  460. package/mcp-server/dist/tools/utils.js.map +1 -0
  461. package/mcp-server/dist/tools/whiteboard-format.d.ts +88 -0
  462. package/mcp-server/dist/tools/whiteboard-format.d.ts.map +1 -0
  463. package/mcp-server/dist/tools/whiteboard-format.js +337 -0
  464. package/mcp-server/dist/tools/whiteboard-format.js.map +1 -0
  465. package/mcp-server/dist/tools/whiteboard-tools.d.ts +326 -0
  466. package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -0
  467. package/mcp-server/dist/tools/whiteboard-tools.js +222 -0
  468. package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -0
  469. package/mcp-server/dist/tools/whiteboard.d.ts +8 -0
  470. package/mcp-server/dist/tools/whiteboard.d.ts.map +1 -0
  471. package/mcp-server/dist/tools/whiteboard.js +16 -0
  472. package/mcp-server/dist/tools/whiteboard.js.map +1 -0
  473. package/mcp-server/dist/tools/youtube/analytics.d.ts +46 -0
  474. package/mcp-server/dist/tools/youtube/analytics.d.ts.map +1 -0
  475. package/mcp-server/dist/tools/youtube/analytics.js +83 -0
  476. package/mcp-server/dist/tools/youtube/analytics.js.map +1 -0
  477. package/mcp-server/dist/tools/youtube/dedup.d.ts +35 -0
  478. package/mcp-server/dist/tools/youtube/dedup.d.ts.map +1 -0
  479. package/mcp-server/dist/tools/youtube/dedup.js +56 -0
  480. package/mcp-server/dist/tools/youtube/dedup.js.map +1 -0
  481. package/mcp-server/dist/tools/youtube/download.d.ts +31 -0
  482. package/mcp-server/dist/tools/youtube/download.d.ts.map +1 -0
  483. package/mcp-server/dist/tools/youtube/download.js +30 -0
  484. package/mcp-server/dist/tools/youtube/download.js.map +1 -0
  485. package/mcp-server/dist/tools/youtube/helpers.d.ts +66 -0
  486. package/mcp-server/dist/tools/youtube/helpers.d.ts.map +1 -0
  487. package/mcp-server/dist/tools/youtube/helpers.js +143 -0
  488. package/mcp-server/dist/tools/youtube/helpers.js.map +1 -0
  489. package/mcp-server/dist/tools/youtube/media.d.ts +43 -0
  490. package/mcp-server/dist/tools/youtube/media.d.ts.map +1 -0
  491. package/mcp-server/dist/tools/youtube/media.js +97 -0
  492. package/mcp-server/dist/tools/youtube/media.js.map +1 -0
  493. package/mcp-server/dist/tools/youtube/metadata.d.ts +33 -0
  494. package/mcp-server/dist/tools/youtube/metadata.d.ts.map +1 -0
  495. package/mcp-server/dist/tools/youtube/metadata.js +106 -0
  496. package/mcp-server/dist/tools/youtube/metadata.js.map +1 -0
  497. package/mcp-server/dist/tools/youtube/navigate.d.ts +30 -0
  498. package/mcp-server/dist/tools/youtube/navigate.d.ts.map +1 -0
  499. package/mcp-server/dist/tools/youtube/navigate.js +71 -0
  500. package/mcp-server/dist/tools/youtube/navigate.js.map +1 -0
  501. package/mcp-server/dist/tools/youtube/playlist.d.ts +29 -0
  502. package/mcp-server/dist/tools/youtube/playlist.d.ts.map +1 -0
  503. package/mcp-server/dist/tools/youtube/playlist.js +128 -0
  504. package/mcp-server/dist/tools/youtube/playlist.js.map +1 -0
  505. package/mcp-server/dist/tools/youtube/schedule.d.ts +31 -0
  506. package/mcp-server/dist/tools/youtube/schedule.d.ts.map +1 -0
  507. package/mcp-server/dist/tools/youtube/schedule.js +147 -0
  508. package/mcp-server/dist/tools/youtube/schedule.js.map +1 -0
  509. package/mcp-server/dist/tools/youtube/source.d.ts +36 -0
  510. package/mcp-server/dist/tools/youtube/source.d.ts.map +1 -0
  511. package/mcp-server/dist/tools/youtube/source.js +85 -0
  512. package/mcp-server/dist/tools/youtube/source.js.map +1 -0
  513. package/mcp-server/dist/tools/youtube/upload.d.ts +53 -0
  514. package/mcp-server/dist/tools/youtube/upload.d.ts.map +1 -0
  515. package/mcp-server/dist/tools/youtube/upload.js +255 -0
  516. package/mcp-server/dist/tools/youtube/upload.js.map +1 -0
  517. package/mcp-server/dist/tools/youtube.d.ts +12 -0
  518. package/mcp-server/dist/tools/youtube.d.ts.map +1 -0
  519. package/mcp-server/dist/tools/youtube.js +38 -0
  520. package/mcp-server/dist/tools/youtube.js.map +1 -0
  521. package/mcp-server/dist/tui.d.ts +3 -0
  522. package/mcp-server/dist/tui.d.ts.map +1 -0
  523. package/mcp-server/dist/tui.js +806 -0
  524. package/mcp-server/dist/tui.js.map +1 -0
  525. package/mcp-server/dist/types.d.ts +77 -0
  526. package/mcp-server/dist/types.d.ts.map +1 -0
  527. package/mcp-server/dist/types.js +2 -0
  528. package/mcp-server/dist/types.js.map +1 -0
  529. package/mcp-server/package-lock.json +4798 -0
  530. package/mcp-server/package.json +55 -38
  531. package/mcp-server/run.mjs +12 -2
  532. package/mcp-server/src/config.ts +48 -3
  533. package/mcp-server/src/http.ts +296 -93
  534. package/mcp-server/src/index.ts +546 -203
  535. package/mcp-server/src/preload-http.ts +19 -18
  536. package/mcp-server/src/preload.ts +19 -18
  537. package/mcp-server/src/scripts/setup-embeddings.ts +9 -0
  538. package/mcp-server/src/services/assistant-judgments.ts +157 -0
  539. package/mcp-server/src/services/browser-assist-gate.ts +383 -0
  540. package/mcp-server/src/services/browser-assist.ts +267 -0
  541. package/mcp-server/src/services/browser-bench.ts +477 -0
  542. package/mcp-server/src/services/browser-judgments.ts +414 -0
  543. package/mcp-server/src/services/browser-risk.ts +484 -0
  544. package/mcp-server/src/services/categories.ts +36 -19
  545. package/mcp-server/src/services/codex-config-heal.ts +403 -0
  546. package/mcp-server/src/services/desktop-assist-gate.ts +251 -0
  547. package/mcp-server/src/services/desktop-assist.ts +308 -0
  548. package/mcp-server/src/services/desktop-bench.ts +372 -0
  549. package/mcp-server/src/services/desktop-client.ts +76 -0
  550. package/mcp-server/src/services/desktop-judgments.ts +218 -0
  551. package/mcp-server/src/services/desktop-risk.ts +743 -0
  552. package/mcp-server/src/services/edit-stale.ts +299 -0
  553. package/mcp-server/src/services/embedding-service.ts +108 -0
  554. package/mcp-server/src/services/embedding-worker.ts +46 -0
  555. package/mcp-server/src/services/fb-regions.ts +221 -0
  556. package/mcp-server/src/services/file-checksums.ts +26 -9
  557. package/mcp-server/src/services/file-excerpt.ts +59 -0
  558. package/mcp-server/src/services/identity.ts +291 -0
  559. package/mcp-server/src/services/local-embeddings.ts +2 -77
  560. package/mcp-server/src/services/memory-client.ts +23 -0
  561. package/mcp-server/src/services/memory-judge-backfill.ts +462 -0
  562. package/mcp-server/src/services/memory-judgments.ts +901 -0
  563. package/mcp-server/src/services/memory-maintenance.ts +360 -0
  564. package/mcp-server/src/services/memory-retrieval.ts +349 -0
  565. package/mcp-server/src/services/memory-schema.ts +280 -0
  566. package/mcp-server/src/services/memory-sessions.ts +87 -0
  567. package/mcp-server/src/services/memory-triage.ts +182 -0
  568. package/mcp-server/src/services/memory-verification.ts +68 -0
  569. package/mcp-server/src/services/memory-writer.ts +125 -0
  570. package/mcp-server/src/services/neural-interface.ts +1217 -73
  571. package/mcp-server/src/services/profiles.ts +428 -0
  572. package/mcp-server/src/services/secret-gate.ts +52 -0
  573. package/mcp-server/src/services/server-instructions.ts +122 -0
  574. package/mcp-server/src/services/sqlite.ts +1456 -823
  575. package/mcp-server/src/services/typesafe-bench.ts +247 -0
  576. package/mcp-server/src/services/typesafe-config.ts +790 -0
  577. package/mcp-server/src/services/typesafe.ts +390 -0
  578. package/mcp-server/src/services/vector-cache.ts +255 -0
  579. package/mcp-server/src/services/whiteboard-geometry.ts +711 -0
  580. package/mcp-server/src/tools/agents.ts +539 -0
  581. package/mcp-server/src/tools/bluesky.ts +1186 -0
  582. package/mcp-server/src/tools/browser-advanced.ts +15 -6
  583. package/mcp-server/src/tools/browser-batch.ts +178 -0
  584. package/mcp-server/src/tools/browser-cheatsheet.ts +155 -0
  585. package/mcp-server/src/tools/browser-interact.ts +362 -255
  586. package/mcp-server/src/tools/browser-navigate.ts +164 -77
  587. package/mcp-server/src/tools/browser-observe.ts +2222 -1330
  588. package/mcp-server/src/tools/browser.ts +181 -107
  589. package/mcp-server/src/tools/card.ts +7 -5
  590. package/mcp-server/src/tools/choice.ts +103 -0
  591. package/mcp-server/src/tools/computer.ts +190 -0
  592. package/mcp-server/src/tools/discord.ts +10 -23
  593. package/mcp-server/src/tools/fb-groups.ts +387 -0
  594. package/mcp-server/src/tools/forget.ts +1 -1
  595. package/mcp-server/src/tools/git.ts +3 -1
  596. package/mcp-server/src/tools/gsc/experience.ts +440 -0
  597. package/mcp-server/src/tools/gsc/helpers.ts +288 -0
  598. package/mcp-server/src/tools/gsc/indexing.ts +495 -0
  599. package/mcp-server/src/tools/gsc/inspect.ts +391 -0
  600. package/mcp-server/src/tools/gsc/navigate.ts +236 -0
  601. package/mcp-server/src/tools/gsc/performance.ts +352 -0
  602. package/mcp-server/src/tools/gsc/settings.ts +564 -0
  603. package/mcp-server/src/tools/gsc.ts +95 -0
  604. package/mcp-server/src/tools/image.ts +3 -1
  605. package/mcp-server/src/tools/leonardo-browser-tools.ts +47 -37
  606. package/mcp-server/src/tools/leonardo.ts +7 -5
  607. package/mcp-server/src/tools/loop.ts +118 -25
  608. package/mcp-server/src/tools/memories.ts +65 -5
  609. package/mcp-server/src/tools/morelogin.ts +81 -0
  610. package/mcp-server/src/tools/profile.ts +122 -0
  611. package/mcp-server/src/tools/recall.ts +105 -261
  612. package/mcp-server/src/tools/reflect.ts +171 -145
  613. package/mcp-server/src/tools/remember.ts +223 -115
  614. package/mcp-server/src/tools/style-guide-tools.ts +250 -0
  615. package/mcp-server/src/tools/style-guide.ts +16 -0
  616. package/mcp-server/src/tools/sync.ts +66 -9
  617. package/mcp-server/src/tools/tictactoe.ts +3 -1
  618. package/mcp-server/src/tools/utils.ts +22 -0
  619. package/mcp-server/src/tools/whiteboard-format.ts +394 -0
  620. package/mcp-server/src/tools/whiteboard-tools.ts +191 -183
  621. package/mcp-server/src/tools/whiteboard.ts +7 -5
  622. package/mcp-server/src/tools/youtube/analytics.ts +106 -0
  623. package/mcp-server/src/tools/youtube/dedup.ts +57 -0
  624. package/mcp-server/src/tools/youtube/download.ts +41 -0
  625. package/mcp-server/src/tools/youtube/helpers.ts +183 -0
  626. package/mcp-server/src/tools/youtube/media.ts +109 -0
  627. package/mcp-server/src/tools/youtube/metadata.ts +112 -0
  628. package/mcp-server/src/tools/youtube/navigate.ts +81 -0
  629. package/mcp-server/src/tools/youtube/playlist.ts +134 -0
  630. package/mcp-server/src/tools/youtube/schedule.ts +158 -0
  631. package/mcp-server/src/tools/youtube/source.ts +101 -0
  632. package/mcp-server/src/tools/youtube/upload.ts +296 -0
  633. package/mcp-server/src/tools/youtube.ts +47 -0
  634. package/mcp-server/src/tui.ts +8 -9
  635. package/neural-interface/games/TicTacToe/1.svg +1 -0
  636. package/neural-interface/games/TicTacToe/2.svg +1 -0
  637. package/neural-interface/games/TicTacToe/3.svg +1 -0
  638. package/neural-interface/games/TicTacToe/4.svg +1 -0
  639. package/neural-interface/games/TicTacToe/5.svg +1 -0
  640. package/neural-interface/games/TicTacToe/6.svg +1 -0
  641. package/neural-interface/games/TicTacToe/7.svg +1 -0
  642. package/neural-interface/games/TicTacToe/8.svg +1 -0
  643. package/neural-interface/games/TicTacToe/9.svg +1 -0
  644. package/neural-interface/games/TicTacToe/Board.svg +1 -0
  645. package/neural-interface/games/TicTacToe/Circle.svg +1 -0
  646. package/neural-interface/games/TicTacToe/Cross.svg +1 -0
  647. package/neural-interface/i18n/en.json +6983 -0
  648. package/neural-interface/i18n/pt-BR.json +6987 -0
  649. package/neural-interface/lib/assistant-api.js +1005 -0
  650. package/neural-interface/lib/assistant-attachments.js +121 -0
  651. package/neural-interface/lib/assistant-brains/claude.js +207 -0
  652. package/neural-interface/lib/assistant-brains/codex-browser-policy-hook.mjs +43 -0
  653. package/neural-interface/lib/assistant-brains/codex-route-gate-hook.mjs +56 -0
  654. package/neural-interface/lib/assistant-brains/codex.js +213 -0
  655. package/neural-interface/lib/assistant-brains/opencode-browser-policy.js +47 -0
  656. package/neural-interface/lib/assistant-brains/opencode-route-gate.js +62 -0
  657. package/neural-interface/lib/assistant-brains/opencode.js +320 -0
  658. package/neural-interface/lib/assistant-budget.js +318 -0
  659. package/neural-interface/lib/assistant-catalog.js +731 -0
  660. package/neural-interface/lib/assistant-clarify.js +664 -0
  661. package/neural-interface/lib/assistant-config.js +232 -0
  662. package/neural-interface/lib/assistant-dispatch.js +2744 -0
  663. package/neural-interface/lib/assistant-envelope.js +579 -0
  664. package/neural-interface/lib/assistant-evidence.js +236 -0
  665. package/neural-interface/lib/assistant-hidden-models.js +272 -0
  666. package/neural-interface/lib/assistant-jev.js +117 -0
  667. package/neural-interface/lib/assistant-judgments.js +111 -0
  668. package/neural-interface/lib/assistant-media.js +335 -0
  669. package/neural-interface/lib/assistant-memory.js +398 -0
  670. package/neural-interface/lib/assistant-persona.js +444 -0
  671. package/neural-interface/lib/assistant-plan-permissions.js +448 -0
  672. package/neural-interface/lib/assistant-playbooks/design.md +87 -0
  673. package/neural-interface/lib/assistant-playbooks.js +72 -0
  674. package/neural-interface/lib/assistant-pricing.js +159 -0
  675. package/neural-interface/lib/assistant-route-gate-codex.js +163 -0
  676. package/neural-interface/lib/assistant-route-gate.js +212 -0
  677. package/neural-interface/lib/assistant-router.js +1324 -0
  678. package/neural-interface/lib/assistant-runtime.js +3337 -0
  679. package/neural-interface/lib/assistant-task-prompt.js +472 -0
  680. package/neural-interface/lib/assistant-usage.js +1688 -0
  681. package/neural-interface/lib/augmented-path.js +50 -0
  682. package/neural-interface/lib/automation-storage-migration.js +54 -0
  683. package/neural-interface/lib/backup-service.js +851 -0
  684. package/neural-interface/lib/backup-zip-reader.js +262 -0
  685. package/neural-interface/lib/browser-assist-api.js +41 -0
  686. package/neural-interface/lib/browser-console.js +176 -0
  687. package/neural-interface/lib/browser-execution.js +241 -0
  688. package/neural-interface/lib/browser-extraction.js +191 -0
  689. package/neural-interface/lib/browser-screenshot.js +399 -0
  690. package/neural-interface/lib/browser-semantic-context.js +639 -0
  691. package/neural-interface/lib/browser-snapshots.js +192 -0
  692. package/neural-interface/lib/browser-tab-recovery.js +119 -0
  693. package/neural-interface/lib/browser-targets.js +70 -0
  694. package/neural-interface/lib/browser-tool-policy.js +926 -0
  695. package/neural-interface/lib/claude-accounts.js +230 -0
  696. package/neural-interface/lib/claude-agent-bridge.js +3296 -0
  697. package/neural-interface/lib/claude-bypass-policy.js +99 -0
  698. package/neural-interface/lib/claude-history.js +429 -0
  699. package/neural-interface/lib/claude-model-catalog.js +483 -0
  700. package/neural-interface/lib/claude-panel-requests.js +165 -0
  701. package/neural-interface/lib/claude-panel-session.js +272 -0
  702. package/neural-interface/lib/claude-permission-rules.js +153 -0
  703. package/neural-interface/lib/claude-session-cleanup.js +71 -0
  704. package/neural-interface/lib/claude-session-list.js +82 -0
  705. package/neural-interface/lib/claude-session-ops.js +140 -0
  706. package/neural-interface/lib/claude-settings-view.js +51 -0
  707. package/neural-interface/lib/claude-skin-connect.js +79 -0
  708. package/neural-interface/lib/claude-stall-policy.js +7 -0
  709. package/neural-interface/lib/claude-temporary.js +435 -0
  710. package/neural-interface/lib/cli-update-planner.js +312 -0
  711. package/neural-interface/lib/codex-capabilities.d.ts +41 -0
  712. package/neural-interface/lib/codex-capabilities.js +148 -0
  713. package/neural-interface/lib/codex-collaboration-mode.js +17 -0
  714. package/neural-interface/lib/codex-compat-rpc.js +241 -0
  715. package/neural-interface/lib/codex-mcp-approvals.js +56 -0
  716. package/neural-interface/lib/codex-mcp-apps.js +47 -0
  717. package/neural-interface/lib/codex-model-context.js +202 -0
  718. package/neural-interface/lib/codex-plan-store.js +71 -0
  719. package/neural-interface/lib/codex-request-journal.js +242 -0
  720. package/neural-interface/lib/codex-runtime-path.js +213 -0
  721. package/neural-interface/lib/codex-session-catalog.js +231 -0
  722. package/neural-interface/lib/codex-session-settings.js +46 -0
  723. package/neural-interface/lib/codex-sidepanel-permissions.js +110 -0
  724. package/neural-interface/lib/codex-thread-history.js +165 -0
  725. package/neural-interface/lib/codex-user-verification.js +34 -0
  726. package/neural-interface/lib/codex-writer-lifecycle.js +152 -0
  727. package/neural-interface/lib/confined-fs.js +256 -0
  728. package/neural-interface/lib/db.js +922 -0
  729. package/neural-interface/lib/design-md.js +9 -0
  730. package/neural-interface/lib/desktop/api.js +92 -0
  731. package/neural-interface/lib/desktop/audit.js +62 -0
  732. package/neural-interface/lib/desktop/build.js +423 -0
  733. package/neural-interface/lib/desktop/config.js +606 -0
  734. package/neural-interface/lib/desktop/coords.js +76 -0
  735. package/neural-interface/lib/desktop/fake-helper.js +1192 -0
  736. package/neural-interface/lib/desktop/frames.js +77 -0
  737. package/neural-interface/lib/desktop/grants.js +84 -0
  738. package/neural-interface/lib/desktop/guards.js +170 -0
  739. package/neural-interface/lib/desktop/helper/SynabunDesktop.swift +3056 -0
  740. package/neural-interface/lib/desktop/lease.js +51 -0
  741. package/neural-interface/lib/desktop/manager.js +545 -0
  742. package/neural-interface/lib/desktop/press-contexts.js +103 -0
  743. package/neural-interface/lib/desktop/protocol.js +495 -0
  744. package/neural-interface/lib/desktop/service.js +1202 -0
  745. package/neural-interface/lib/effort-levels.js +148 -0
  746. package/neural-interface/lib/event-loop-monitor.js +67 -0
  747. package/neural-interface/lib/exec-loop-command.js +209 -0
  748. package/neural-interface/lib/folder-picker.ps1 +77 -0
  749. package/neural-interface/lib/http-guards.js +287 -0
  750. package/neural-interface/lib/jsonl-lines.js +94 -0
  751. package/neural-interface/lib/loop-logger.js +127 -0
  752. package/neural-interface/lib/mcp-client-config.js +133 -0
  753. package/neural-interface/lib/mcp-installer.js +487 -0
  754. package/neural-interface/lib/mcp-profile-refresh-coordinator.js +78 -0
  755. package/neural-interface/lib/mcp-profile-servers.js +120 -0
  756. package/neural-interface/lib/memo-cache.js +84 -0
  757. package/neural-interface/lib/memory-api.js +758 -0
  758. package/neural-interface/lib/memory-map-api.js +1150 -0
  759. package/neural-interface/lib/memory-map-layout.js +872 -0
  760. package/neural-interface/lib/memory-map-worker.js +165 -0
  761. package/neural-interface/lib/memory-session-capture.js +81 -0
  762. package/neural-interface/lib/memory-timers.js +62 -0
  763. package/neural-interface/lib/morelogin.js +301 -0
  764. package/neural-interface/lib/native-binary-runtime.js +650 -0
  765. package/neural-interface/lib/native-loop-goal.js +33 -0
  766. package/neural-interface/lib/native-loop-providers.js +1016 -0
  767. package/neural-interface/lib/native-loop-runtime.js +747 -0
  768. package/neural-interface/lib/opencode-hidden-models.js +113 -0
  769. package/neural-interface/lib/opencode-history.js +253 -0
  770. package/neural-interface/lib/opencode-runtime-config.js +58 -0
  771. package/neural-interface/lib/opencode-v2-client.js +486 -0
  772. package/neural-interface/lib/opencode-v2-ws-requests.js +955 -0
  773. package/neural-interface/lib/path-confine.js +213 -0
  774. package/neural-interface/lib/plugin-installer.js +264 -0
  775. package/neural-interface/lib/project-storage.js +766 -0
  776. package/neural-interface/lib/pty-host/core.js +603 -0
  777. package/neural-interface/lib/pty-host/flow.js +43 -0
  778. package/neural-interface/lib/pty-host/host.js +47 -0
  779. package/neural-interface/lib/pty-host/manager.js +360 -0
  780. package/neural-interface/lib/pty-host/protocol.js +81 -0
  781. package/neural-interface/lib/pty-host/snapshot.js +133 -0
  782. package/neural-interface/lib/remote-policy.js +582 -0
  783. package/neural-interface/lib/rulesets/api.js +175 -0
  784. package/neural-interface/lib/rulesets/fs-safe.js +245 -0
  785. package/neural-interface/lib/rulesets/installer.js +1453 -0
  786. package/neural-interface/lib/rulesets/json-edit.js +186 -0
  787. package/neural-interface/lib/rulesets/lock.js +170 -0
  788. package/neural-interface/lib/rulesets/managed-text.js +314 -0
  789. package/neural-interface/lib/rulesets/normalise.js +24 -0
  790. package/neural-interface/lib/rulesets/render.js +84 -0
  791. package/neural-interface/lib/schedule-launch-intent.js +40 -0
  792. package/neural-interface/lib/session-cache-builder.js +524 -0
  793. package/neural-interface/lib/session-chunker.js +344 -0
  794. package/neural-interface/lib/session-indexer.js +327 -0
  795. package/neural-interface/lib/session-title-generator.js +227 -0
  796. package/neural-interface/lib/skill-prompt-bridge.js +11 -0
  797. package/neural-interface/lib/style-guide/api.js +375 -0
  798. package/neural-interface/lib/style-guide/color.js +352 -0
  799. package/neural-interface/lib/style-guide/export-css.js +99 -0
  800. package/neural-interface/lib/style-guide/export-dtcg.js +209 -0
  801. package/neural-interface/lib/style-guide/export-tailwind.js +102 -0
  802. package/neural-interface/lib/style-guide/fonts.json +227 -0
  803. package/neural-interface/lib/style-guide/import.js +1268 -0
  804. package/neural-interface/lib/style-guide/pointers.js +103 -0
  805. package/neural-interface/lib/style-guide/presets.js +232 -0
  806. package/neural-interface/lib/style-guide/render-design-md.js +319 -0
  807. package/neural-interface/lib/style-guide/schema.js +848 -0
  808. package/neural-interface/lib/style-guide/security.js +43 -0
  809. package/neural-interface/lib/style-guide/store.js +491 -0
  810. package/neural-interface/lib/style-guide/summary.js +106 -0
  811. package/neural-interface/lib/style-guide/tokens.js +170 -0
  812. package/neural-interface/lib/synabun-update-plan.js +305 -0
  813. package/neural-interface/lib/synabun-version.js +179 -0
  814. package/neural-interface/lib/system-restore.js +99 -0
  815. package/neural-interface/lib/terminal-env.js +86 -0
  816. package/neural-interface/lib/ui-blob-store.js +106 -0
  817. package/neural-interface/lib/vector-cache.js +2 -0
  818. package/neural-interface/lib/virtual-socket.js +60 -0
  819. package/neural-interface/lib/whatsapp/api.js +146 -0
  820. package/neural-interface/lib/whatsapp/auth-store.js +389 -0
  821. package/neural-interface/lib/whatsapp/baileys-adapter.js +457 -0
  822. package/neural-interface/lib/whatsapp/brain.js +128 -0
  823. package/neural-interface/lib/whatsapp/bridge.js +1499 -0
  824. package/neural-interface/lib/whatsapp/cards.js +519 -0
  825. package/neural-interface/lib/whatsapp/commands.js +92 -0
  826. package/neural-interface/lib/whatsapp/config.js +291 -0
  827. package/neural-interface/lib/whatsapp/connector/entry.mjs +25 -0
  828. package/neural-interface/lib/whatsapp/connector/manifest.json +6 -0
  829. package/neural-interface/lib/whatsapp/connector/npm-shrinkwrap.json +717 -0
  830. package/neural-interface/lib/whatsapp/connector/package.json +8 -0
  831. package/neural-interface/lib/whatsapp/fake-baileys.js +444 -0
  832. package/neural-interface/lib/whatsapp/format.js +552 -0
  833. package/neural-interface/lib/whatsapp/host-core.js +1225 -0
  834. package/neural-interface/lib/whatsapp/host.js +119 -0
  835. package/neural-interface/lib/whatsapp/identity.js +288 -0
  836. package/neural-interface/lib/whatsapp/inbound.js +149 -0
  837. package/neural-interface/lib/whatsapp/installer.js +562 -0
  838. package/neural-interface/lib/whatsapp/manager.js +638 -0
  839. package/neural-interface/lib/whatsapp/paths.js +81 -0
  840. package/neural-interface/lib/whatsapp/protocol.js +327 -0
  841. package/neural-interface/lib/whatsapp/qr-svg.js +57 -0
  842. package/neural-interface/lib/whatsapp/redact.js +130 -0
  843. package/neural-interface/lib/whatsapp/service.js +1305 -0
  844. package/neural-interface/lib/whatsapp/vendor/qrcodegen.js +848 -0
  845. package/neural-interface/package-lock.json +4682 -0
  846. package/neural-interface/package.json +58 -27
  847. package/neural-interface/public/apple-touch-icon.png +0 -0
  848. package/neural-interface/public/category-logos/synabun.png +0 -0
  849. package/neural-interface/public/claude.svg +1 -0
  850. package/neural-interface/public/favicon-16x16.png +0 -0
  851. package/neural-interface/public/favicon-32x32.png +0 -0
  852. package/neural-interface/public/icon-192.png +0 -0
  853. package/neural-interface/public/icon-512.png +0 -0
  854. package/neural-interface/public/index.html +32 -182
  855. package/neural-interface/public/index2d.html +2 -2
  856. package/neural-interface/public/logo-synabun.png +0 -0
  857. package/neural-interface/public/logoHD.png +0 -0
  858. package/neural-interface/public/mcp.svg +4 -0
  859. package/neural-interface/public/offline.html +335 -299
  860. package/neural-interface/public/onboarding.html +692 -155
  861. package/neural-interface/public/opencode-logo-dark-square.svg +1 -0
  862. package/neural-interface/public/shared/agent-runtime-options.js +596 -0
  863. package/neural-interface/public/shared/api.js +339 -28
  864. package/neural-interface/public/shared/assistant/asst-accounts.js +318 -0
  865. package/neural-interface/public/shared/assistant/asst-agents-dock.js +296 -0
  866. package/neural-interface/public/shared/assistant/asst-api.js +270 -0
  867. package/neural-interface/public/shared/assistant/asst-brain-picker.js +744 -0
  868. package/neural-interface/public/shared/assistant/asst-budget.js +333 -0
  869. package/neural-interface/public/shared/assistant/asst-composer.js +656 -0
  870. package/neural-interface/public/shared/assistant/asst-computer.js +803 -0
  871. package/neural-interface/public/shared/assistant/asst-control.js +1018 -0
  872. package/neural-interface/public/shared/assistant/asst-hosts.js +262 -0
  873. package/neural-interface/public/shared/assistant/asst-mascot.js +311 -0
  874. package/neural-interface/public/shared/assistant/asst-menu.js +375 -0
  875. package/neural-interface/public/shared/assistant/asst-panel.js +1845 -0
  876. package/neural-interface/public/shared/assistant/asst-rack-styles.js +589 -0
  877. package/neural-interface/public/shared/assistant/asst-render.js +3296 -0
  878. package/neural-interface/public/shared/assistant/asst-route.js +1653 -0
  879. package/neural-interface/public/shared/assistant/asst-roving.js +75 -0
  880. package/neural-interface/public/shared/assistant/asst-sanitize.js +117 -0
  881. package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +488 -0
  882. package/neural-interface/public/shared/assistant/asst-sidepanel.js +675 -0
  883. package/neural-interface/public/shared/assistant/asst-slash.js +258 -0
  884. package/neural-interface/public/shared/assistant/asst-state.js +727 -0
  885. package/neural-interface/public/shared/assistant/asst-styles.js +1684 -0
  886. package/neural-interface/public/shared/assistant/asst-tool-kinds.js +1017 -0
  887. package/neural-interface/public/shared/assistant/asst-usage.js +444 -0
  888. package/neural-interface/public/shared/assistant/asst-ws.js +175 -0
  889. package/neural-interface/public/shared/automation-context.js +75 -0
  890. package/neural-interface/public/shared/blob-store.js +99 -0
  891. package/neural-interface/public/shared/cdx/cdx-capabilities.js +118 -0
  892. package/neural-interface/public/shared/cdx/cdx-cards.js +37 -0
  893. package/neural-interface/public/shared/cdx/cdx-commands.js +171 -0
  894. package/neural-interface/public/shared/cdx/cdx-context-menu.js +402 -0
  895. package/neural-interface/public/shared/cdx/cdx-context-model.js +220 -0
  896. package/neural-interface/public/shared/cdx/cdx-features.js +408 -0
  897. package/neural-interface/public/shared/cdx/cdx-icons.js +89 -0
  898. package/neural-interface/public/shared/cdx/cdx-mcp-app-host.js +414 -0
  899. package/neural-interface/public/shared/cdx/cdx-mcp-app-proxy.html +6 -0
  900. package/neural-interface/public/shared/cdx/cdx-mcp-app-proxy.js +56 -0
  901. package/neural-interface/public/shared/cdx/cdx-mcp-app.js +178 -0
  902. package/neural-interface/public/shared/cdx/cdx-model-catalog.js +120 -0
  903. package/neural-interface/public/shared/cdx/cdx-output-model.js +261 -0
  904. package/neural-interface/public/shared/cdx/cdx-output.js +382 -0
  905. package/neural-interface/public/shared/cdx/cdx-panel.js +1372 -0
  906. package/neural-interface/public/shared/cdx/cdx-plan.js +266 -0
  907. package/neural-interface/public/shared/cdx/cdx-protocol.js +682 -0
  908. package/neural-interface/public/shared/cdx/cdx-render.js +3381 -0
  909. package/neural-interface/public/shared/cdx/cdx-requests.js +1136 -0
  910. package/neural-interface/public/shared/cdx/cdx-styles.js +3251 -0
  911. package/neural-interface/public/shared/cdx/cdx-tabs.js +7992 -0
  912. package/neural-interface/public/shared/cdx/cdx-ws.js +363 -0
  913. package/neural-interface/public/shared/cli-status.js +169 -0
  914. package/neural-interface/public/shared/constants.js +22 -5
  915. package/neural-interface/public/shared/cp/cp-agents.js +186 -0
  916. package/neural-interface/public/shared/cp/cp-bash.js +252 -0
  917. package/neural-interface/public/shared/cp/cp-compose.js +159 -0
  918. package/neural-interface/public/shared/cp/cp-context-menu.js +309 -0
  919. package/neural-interface/public/shared/cp/cp-context-model.js +157 -0
  920. package/neural-interface/public/shared/cp/cp-context-window.js +80 -0
  921. package/neural-interface/public/shared/cp/cp-ctx.js +11 -0
  922. package/neural-interface/public/shared/cp/cp-diff.js +255 -0
  923. package/neural-interface/public/shared/cp/cp-event-rows.js +637 -0
  924. package/neural-interface/public/shared/cp/cp-events.js +846 -0
  925. package/neural-interface/public/shared/cp/cp-markdown.js +104 -0
  926. package/neural-interface/public/shared/cp/cp-permission-model.js +905 -0
  927. package/neural-interface/public/shared/cp/cp-permissions.js +626 -0
  928. package/neural-interface/public/shared/cp/cp-rehydrate.js +113 -0
  929. package/neural-interface/public/shared/cp/cp-restore.js +224 -0
  930. package/neural-interface/public/shared/cp/cp-session-model.js +338 -0
  931. package/neural-interface/public/shared/cp/cp-session-settings.js +254 -0
  932. package/neural-interface/public/shared/cp/cp-sessions.js +227 -0
  933. package/neural-interface/public/shared/cp/cp-statusline.js +153 -0
  934. package/neural-interface/public/shared/cp/cp-styles.js +349 -0
  935. package/neural-interface/public/shared/cp/cp-tasks-model.js +282 -0
  936. package/neural-interface/public/shared/cp/cp-tasks.js +107 -0
  937. package/neural-interface/public/shared/cp/cp-temporary-view.js +56 -0
  938. package/neural-interface/public/shared/cp/cp-temporary.js +231 -0
  939. package/neural-interface/public/shared/cp/cp-tool-cards.js +326 -0
  940. package/neural-interface/public/shared/cp/cp-tool-results.js +618 -0
  941. package/neural-interface/public/shared/cp/cp-usage-model.js +124 -0
  942. package/neural-interface/public/shared/graph-activity.js +68 -0
  943. package/neural-interface/public/shared/html-shell.js +107 -12
  944. package/neural-interface/public/shared/i18n.js +132 -34
  945. package/neural-interface/public/shared/node-positions-store.js +44 -0
  946. package/neural-interface/public/shared/ocp-hidden-models.js +116 -0
  947. package/neural-interface/public/shared/ocp-v2/ocp-v2-approvals.js +355 -0
  948. package/neural-interface/public/shared/ocp-v2/ocp-v2-autoaccept-button.js +35 -0
  949. package/neural-interface/public/shared/ocp-v2/ocp-v2-automation-ownership.js +72 -0
  950. package/neural-interface/public/shared/ocp-v2/ocp-v2-binding.js +280 -0
  951. package/neural-interface/public/shared/ocp-v2/ocp-v2-caps.js +67 -0
  952. package/neural-interface/public/shared/ocp-v2/ocp-v2-changes-logic.js +179 -0
  953. package/neural-interface/public/shared/ocp-v2/ocp-v2-changes.js +164 -0
  954. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +725 -0
  955. package/neural-interface/public/shared/ocp-v2/ocp-v2-compact-button.js +75 -0
  956. package/neural-interface/public/shared/ocp-v2/ocp-v2-composer-logic.js +1204 -0
  957. package/neural-interface/public/shared/ocp-v2/ocp-v2-confirm-logic.js +117 -0
  958. package/neural-interface/public/shared/ocp-v2/ocp-v2-confirm.js +59 -0
  959. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-gauge.js +252 -0
  960. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-menu.js +478 -0
  961. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-model.js +179 -0
  962. package/neural-interface/public/shared/ocp-v2/ocp-v2-events.js +233 -0
  963. package/neural-interface/public/shared/ocp-v2/ocp-v2-icons.js +30 -0
  964. package/neural-interface/public/shared/ocp-v2/ocp-v2-manager.js +1065 -0
  965. package/neural-interface/public/shared/ocp-v2/ocp-v2-mention-hints.js +182 -0
  966. package/neural-interface/public/shared/ocp-v2/ocp-v2-modelpicker.js +655 -0
  967. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +2636 -0
  968. package/neural-interface/public/shared/ocp-v2/ocp-v2-plan.js +601 -0
  969. package/neural-interface/public/shared/ocp-v2/ocp-v2-profile-inheritance.js +78 -0
  970. package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +649 -0
  971. package/neural-interface/public/shared/ocp-v2/ocp-v2-providers.js +35 -0
  972. package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +357 -0
  973. package/neural-interface/public/shared/ocp-v2/ocp-v2-render-logic.js +189 -0
  974. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +1869 -0
  975. package/neural-interface/public/shared/ocp-v2/ocp-v2-select.js +42 -0
  976. package/neural-interface/public/shared/ocp-v2/ocp-v2-send-logic.js +117 -0
  977. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +1714 -0
  978. package/neural-interface/public/shared/ocp-v2/ocp-v2-session-actions.js +468 -0
  979. package/neural-interface/public/shared/ocp-v2/ocp-v2-session-widgets.js +63 -0
  980. package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +366 -0
  981. package/neural-interface/public/shared/ocp-v2/ocp-v2-settings-link.js +19 -0
  982. package/neural-interface/public/shared/ocp-v2/ocp-v2-slash-hints.js +343 -0
  983. package/neural-interface/public/shared/ocp-v2/ocp-v2-state.js +746 -0
  984. package/neural-interface/public/shared/ocp-v2/ocp-v2-status-logic.js +347 -0
  985. package/neural-interface/public/shared/ocp-v2/ocp-v2-status.js +338 -0
  986. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +3543 -0
  987. package/neural-interface/public/shared/ocp-v2/ocp-v2-subagents-logic.js +482 -0
  988. package/neural-interface/public/shared/ocp-v2/ocp-v2-tools-logic.js +351 -0
  989. package/neural-interface/public/shared/ocp-v2/ocp-v2-tools.js +168 -0
  990. package/neural-interface/public/shared/ocp-v2/ocp-v2-variantpicker.js +315 -0
  991. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +545 -0
  992. package/neural-interface/public/shared/provider-icons.js +30 -0
  993. package/neural-interface/public/shared/session-title.js +100 -0
  994. package/neural-interface/public/shared/settings/settings-ia.js +389 -0
  995. package/neural-interface/public/shared/settings/settings-kit.js +234 -0
  996. package/neural-interface/public/shared/settings/settings-search.js +463 -0
  997. package/neural-interface/public/shared/settings/settings-shell.js +570 -0
  998. package/neural-interface/public/shared/state.js +5 -0
  999. package/neural-interface/public/shared/storage.js +50 -7
  1000. package/neural-interface/public/shared/styleguide/sg-agents.js +42 -0
  1001. package/neural-interface/public/shared/styleguide/sg-brand.js +4 -0
  1002. package/neural-interface/public/shared/styleguide/sg-color-client.js +388 -0
  1003. package/neural-interface/public/shared/styleguide/sg-colors.js +148 -0
  1004. package/neural-interface/public/shared/styleguide/sg-components.js +35 -0
  1005. package/neural-interface/public/shared/styleguide/sg-history.js +41 -0
  1006. package/neural-interface/public/shared/styleguide/sg-icons-logo.js +38 -0
  1007. package/neural-interface/public/shared/styleguide/sg-imagery.js +4 -0
  1008. package/neural-interface/public/shared/styleguide/sg-import-export.js +47 -0
  1009. package/neural-interface/public/shared/styleguide/sg-kit.js +281 -0
  1010. package/neural-interface/public/shared/styleguide/sg-labels.js +318 -0
  1011. package/neural-interface/public/shared/styleguide/sg-layout.js +4 -0
  1012. package/neural-interface/public/shared/styleguide/sg-motion.js +5 -0
  1013. package/neural-interface/public/shared/styleguide/sg-panel.js +229 -0
  1014. package/neural-interface/public/shared/styleguide/sg-preview.js +138 -0
  1015. package/neural-interface/public/shared/styleguide/sg-rules.js +9 -0
  1016. package/neural-interface/public/shared/styleguide/sg-shape.js +24 -0
  1017. package/neural-interface/public/shared/styleguide/sg-state.js +132 -0
  1018. package/neural-interface/public/shared/styleguide/sg-typography.js +124 -0
  1019. package/neural-interface/public/shared/styles.css +7883 -1155
  1020. package/neural-interface/public/shared/synabun-mascot.js +1625 -0
  1021. package/neural-interface/public/shared/synabun-ticker.js +376 -0
  1022. package/neural-interface/public/shared/term-renderer.js +343 -0
  1023. package/neural-interface/public/shared/ui-assistant-panel.js +10 -0
  1024. package/neural-interface/public/shared/ui-assistant.js +77 -0
  1025. package/neural-interface/public/shared/ui-automation-studio.js +5105 -3573
  1026. package/neural-interface/public/shared/ui-claude-panel.js +11685 -6333
  1027. package/neural-interface/public/shared/ui-codex-panel.js +12 -0
  1028. package/neural-interface/public/shared/ui-cost-widget.js +55 -34
  1029. package/neural-interface/public/shared/ui-detail.js +77 -14
  1030. package/neural-interface/public/shared/ui-explorer.js +360 -181
  1031. package/neural-interface/public/shared/ui-file-explorer.js +485 -107
  1032. package/neural-interface/public/shared/ui-help.js +2 -2
  1033. package/neural-interface/public/shared/ui-image-gallery.js +45 -21
  1034. package/neural-interface/public/shared/ui-judgments.js +1346 -0
  1035. package/neural-interface/public/shared/ui-keybinds.js +3 -2
  1036. package/neural-interface/public/shared/ui-link.js +105 -51
  1037. package/neural-interface/public/shared/ui-loading.js +4 -4
  1038. package/neural-interface/public/shared/ui-memory-maintenance.js +111 -0
  1039. package/neural-interface/public/shared/ui-menubar.js +89 -4
  1040. package/neural-interface/public/shared/ui-native-loop-router-state.js +94 -0
  1041. package/neural-interface/public/shared/ui-native-loop-router.js +493 -0
  1042. package/neural-interface/public/shared/ui-native-window-id.js +7 -0
  1043. package/neural-interface/public/shared/ui-navbar.js +357 -222
  1044. package/neural-interface/public/shared/ui-notifications.js +58 -12
  1045. package/neural-interface/public/shared/ui-opencode-panel-v2.js +17 -0
  1046. package/neural-interface/public/shared/ui-panels.js +15 -7
  1047. package/neural-interface/public/shared/ui-resume.js +823 -653
  1048. package/neural-interface/public/shared/ui-schedule-queue.js +172 -0
  1049. package/neural-interface/public/shared/ui-schedules-studio.js +1882 -0
  1050. package/neural-interface/public/shared/ui-scroll-follow.js +194 -0
  1051. package/neural-interface/public/shared/ui-sessions.js +789 -34
  1052. package/neural-interface/public/shared/ui-settings.js +5793 -2190
  1053. package/neural-interface/public/shared/ui-sidepanel-layout.js +109 -0
  1054. package/neural-interface/public/shared/ui-sidepanel-runtime.js +19 -0
  1055. package/neural-interface/public/shared/ui-sidepanel-session-entry.js +40 -0
  1056. package/neural-interface/public/shared/ui-sidepanel-sessions.js +221 -0
  1057. package/neural-interface/public/shared/ui-sidepanel-state.js +131 -0
  1058. package/neural-interface/public/shared/ui-sidepanel-tray.js +547 -0
  1059. package/neural-interface/public/shared/ui-sidepanel-windows.js +387 -0
  1060. package/neural-interface/public/shared/ui-skills.js +18 -32
  1061. package/neural-interface/public/shared/ui-styleguide.js +35 -0
  1062. package/neural-interface/public/shared/ui-sync.js +87 -0
  1063. package/neural-interface/public/shared/ui-terminal.js +7877 -6385
  1064. package/neural-interface/public/shared/ui-tool-updates.js +292 -0
  1065. package/neural-interface/public/shared/ui-tooltip.js +156 -15
  1066. package/neural-interface/public/shared/ui-tutorial-draw.js +119 -683
  1067. package/neural-interface/public/shared/ui-tutorial-layout.js +135 -0
  1068. package/neural-interface/public/shared/ui-tutorial-steps.js +59 -2209
  1069. package/neural-interface/public/shared/ui-tutorial.css +102 -0
  1070. package/neural-interface/public/shared/ui-tutorial.js +557 -1076
  1071. package/neural-interface/public/shared/ui-update.js +420 -68
  1072. package/neural-interface/public/shared/ui-whatsapp.js +941 -0
  1073. package/neural-interface/public/shared/ui-whiteboard.js +1076 -233
  1074. package/neural-interface/public/shared/ui-workspaces.js +2 -1
  1075. package/neural-interface/public/shared/utils.js +58 -0
  1076. package/neural-interface/public/shared/vterm-buffer.js +15 -5
  1077. package/neural-interface/public/shared/whatsapp/wa-phone.js +65 -0
  1078. package/neural-interface/public/shared/whatsapp/wa-view.js +411 -0
  1079. package/neural-interface/public/sidepanel-session.html +18 -0
  1080. package/neural-interface/public/sw.js +8 -2
  1081. package/neural-interface/public/synabun.png +0 -0
  1082. package/neural-interface/public/variant/2d/graph.js +1707 -1635
  1083. package/neural-interface/public/variant/2d/lasso.js +2 -1
  1084. package/neural-interface/public/variant/2d/layout.js +595 -594
  1085. package/neural-interface/public/variant/2d/main.js +556 -521
  1086. package/neural-interface/public/variant/2d/settings-gfx.js +18 -24
  1087. package/neural-interface/public/variant/3d/main.js +516 -500
  1088. package/neural-interface/public/variant/3d/map-controls.js +144 -0
  1089. package/neural-interface/public/variant/3d/map-data.js +251 -0
  1090. package/neural-interface/public/variant/3d/map-labels.js +241 -0
  1091. package/neural-interface/public/variant/3d/map-pick.js +90 -0
  1092. package/neural-interface/public/variant/3d/map-renderer.js +521 -0
  1093. package/neural-interface/public/variant/3d/map-shaders.js +158 -0
  1094. package/neural-interface/public/variant/3d/map-view.js +618 -0
  1095. package/neural-interface/public/vendor/three/manifest.json +3 -0
  1096. package/neural-interface/public/vendor/three/three-0.169.0.min.js +3844 -0
  1097. package/neural-interface/public/vendor/xterm/manifest.json +3 -0
  1098. package/neural-interface/public/vendor/xterm/xterm-6.0.0.css +285 -0
  1099. package/neural-interface/public/vendor/xterm/xterm-6.0.0.min.js +192 -0
  1100. package/neural-interface/scripts/build-vendor.mjs +64 -0
  1101. package/neural-interface/scripts/codex-protocol-fixture.mjs +83 -0
  1102. package/neural-interface/scripts/rebuild-pty.js +37 -23
  1103. package/neural-interface/scripts/vendor-three-entry.js +24 -0
  1104. package/neural-interface/scripts/vendor-xterm-entry.js +8 -0
  1105. package/neural-interface/server.js +33948 -15000
  1106. package/neural-interface/skins/default/skin.css +90 -0
  1107. package/neural-interface/skins/default/skin.json +8 -0
  1108. package/neural-interface/templates/rulesets/README.md +9 -0
  1109. package/neural-interface/templates/rulesets/claude.md +3 -0
  1110. package/neural-interface/templates/rulesets/codex.md +2 -0
  1111. package/neural-interface/templates/rulesets/coexistence.md +6 -0
  1112. package/neural-interface/templates/rulesets/core.md +26 -0
  1113. package/neural-interface/templates/rulesets/cursor.md +2 -0
  1114. package/neural-interface/templates/rulesets/gemini.md +2 -0
  1115. package/neural-interface/templates/rulesets/legacy-hashes.json +227 -0
  1116. package/neural-interface/templates/rulesets/manifest.json +12 -0
  1117. package/neural-interface/templates/rulesets/opencode.md +2 -0
  1118. package/package.json +119 -53
  1119. package/postinstall.js +125 -26
  1120. package/preuninstall.js +187 -0
  1121. package/scripts/audit-claude-hook-usage.mjs +12 -0
  1122. package/scripts/audit-memory-duplicates.mjs +44 -0
  1123. package/scripts/measure-claude-hook-overhead.mjs +77 -0
  1124. package/scripts/recover-user-config.mjs +254 -0
  1125. package/scripts/restore-data-home-from-backup.mjs +239 -0
  1126. package/setup.js +804 -260
  1127. package/skills/leonardo/SKILL.md +19 -10
  1128. package/skills/leonardo/modules/image-prompter.md +18 -12
  1129. package/skills/leonardo/modules/video-prompter.md +19 -13
  1130. package/skills/synabun/SKILL.md +209 -185
  1131. package/skills/synabun/icon.png +0 -0
  1132. package/skills/synabun/modules/audit.md +380 -374
  1133. package/skills/synabun/modules/changelog.md +15 -6
  1134. package/skills/synabun/modules/idea.md +148 -144
  1135. package/skills/synabun/modules/memorize.md +13 -4
  1136. package/skills/synabun/modules/schedule.md +198 -0
  1137. package/skills/synabun/synabunicon.png +0 -0
  1138. package/updater.cmd +31 -0
  1139. package/updater.mjs +382 -0
  1140. package/updater.sh +29 -0
  1141. package/hooks/claude-code/test-user-learning.mjs +0 -531
  1142. package/neural-interface/public/claude-chat.css +0 -878
  1143. package/neural-interface/public/claude-chat.html +0 -92
  1144. package/neural-interface/public/claude-chat.js +0 -1168
  1145. package/neural-interface/public/index-legacy.html +0 -14677
  1146. package/neural-interface/public/index2d-legacy.html +0 -8193
  1147. package/neural-interface/public/shared/html-term-renderer.js +0 -1022
  1148. package/neural-interface/public/variant/3d/background.js +0 -884
  1149. package/neural-interface/public/variant/3d/camera.js +0 -661
  1150. package/neural-interface/public/variant/3d/gfx.js +0 -116
  1151. package/neural-interface/public/variant/3d/graph.js +0 -2399
  1152. package/neural-interface/public/variant/3d/settings-gfx.js +0 -228
  1153. package/neural-interface/templates/CLAUDE-template.md +0 -211
@@ -0,0 +1,2744 @@
1
+ // ═══════════════════════════════════════════
2
+ // SynaBun — Assistant dispatcher (task-mode runs on NativeLoopRuntime)
3
+ // ═══════════════════════════════════════════
4
+ //
5
+ // A "dispatch" is one worker run launched by the central assistant: a single
6
+ // provider conversation (Claude / Codex / OpenCode) that executes one task,
7
+ // then stays warm for follow-up turns until the assistant completes it or the
8
+ // idle timeout fires. It rides on NativeLoopRuntime (ledger, claims, sidepanel
9
+ // attach) through the per-launch buildPrompt/wrapAdapter overrides, so the
10
+ // existing router shows it as an automation tab in the matching sidepanel.
11
+ //
12
+ // This module owns: validation + rails (concurrency, budgets, quarantine),
13
+ // the task adapter wrapper (turn loop), result capture, the permission broker
14
+ // for 'ask' dispatches, waits, transcripts, the dispatch registry, the
15
+ // auto-remember hook, and the runs' usage meters (assistant-usage.js: exact
16
+ // tokens per task into the usage ledger, and the Codex / OpenCode dollars).
17
+ // It never touches server.js internals directly — every server dependency is
18
+ // injected.
19
+
20
+ import { randomBytes } from 'node:crypto';
21
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
22
+ import { dirname, isAbsolute, resolve } from 'node:path';
23
+ import {
24
+ RESULT_STATUSES, buildTaskPrompt, buildFollowUpPrompt, buildResultRetryPrompt, parseResultContract,
25
+ } from './assistant-task-prompt.js';
26
+ import { normalizeCapability, normalizePermissionPolicy } from './native-loop-providers.js';
27
+ import { contextVariantOf, defaultIsHidden, defaultModelRow, findModel, hiddenModelId, providerAllHidden, providerEmpty, rowMakes } from './assistant-catalog.js';
28
+ import { TASK_CLASS_META, classPlaybook, collectedMedia, describeTarget, designFit, requiredOutput, requiresVision, runContract } from './assistant-router.js';
29
+ import { MAX_MEDIA_BYTES, MAX_RUN_MEDIA_BYTES, copyRunMedia, mediaType, mediaUrl, servableRunMedia } from './assistant-media.js';
30
+ import { loadPlaybook } from './assistant-playbooks.js';
31
+ import { loadStyleGuideForRun } from './style-guide/store.js';
32
+ import { normalizeEffort } from './effort-levels.js';
33
+ import { createTurnEvidence, recordProviderEvent, evidenceSnapshot, claimEvidenceLine } from './assistant-evidence.js';
34
+ import { codexUsageCostUsd } from './assistant-budget.js';
35
+ import { CLAUDE_ESTIMATE_SOURCE, CODEX_USAGE_KEYS, TOKEN_KEYS, addTokens, codexUsageTokens, createClaudeMeter, createCodexMeter, createOpenCodeMeter, totalTokens, withTotal, zeroTokens } from './assistant-usage.js';
36
+ import { claudeTokensCostUsd } from './assistant-pricing.js';
37
+ import { RemotePolicyError, clampDispatchSpec, getSessionPolicy, readRegisteredProjects } from './remote-policy.js';
38
+
39
+ export const NATIVE_PROVIDERS = new Set(['claude-code', 'codex', 'opencode']);
40
+ // awaiting_route: an un-routed dispatch held until the user picks a model on
41
+ // the route card (ask modes). It is active but consumes no rails.
42
+ export const ACTIVE_STATES = new Set(['queued', 'awaiting_route', 'starting', 'running', 'idle', 'awaiting_permission']);
43
+ export const TERMINAL_STATES = new Set(['completed', 'failed', 'stopped', 'interrupted']);
44
+ const WAITING_STATES = new Set(['queued', 'awaiting_route']);
45
+
46
+ /**
47
+ * The run status a view reports beside its state (the agent_status contract:
48
+ * queued, starting, running, completed, failed, stopped, interrupted). A warm
49
+ * worker between turns (idle, awaiting_permission) is still running; a run
50
+ * held for the user's model choice is still queued.
51
+ */
52
+ export function runStatusFromState(state) {
53
+ const value = String(state || '');
54
+ if (TERMINAL_STATES.has(value)) return value;
55
+ if (WAITING_STATES.has(value)) return 'queued';
56
+ if (value === 'starting') return 'starting';
57
+ return ACTIVE_STATES.has(value) ? 'running' : null;
58
+ }
59
+
60
+ export const DEFAULT_LIMITS = Object.freeze({
61
+ perProvider: Object.freeze({ 'claude-code': 3, codex: 4, opencode: 3 }),
62
+ perCodexAccount: 2,
63
+ perSession: 6,
64
+ queuedPerSession: 20,
65
+ heldPerSession: 10,
66
+ maxFanOut: 6,
67
+ defaultMaxMinutes: 45,
68
+ 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,
77
+ permissionTimeoutMs: 10 * 60_000,
78
+ questionTimeoutMs: 30 * 60_000,
79
+ idleTimeoutMs: 20 * 60_000,
80
+ minIdleTimeoutMs: 30_000,
81
+ maxIdleTimeoutMs: 6 * 60 * 60_000,
82
+ maxTurns: 6,
83
+ maxMaxTurns: 20,
84
+ waitCapMs: 120_000,
85
+ stalledAfterMs: 15 * 60_000,
86
+ ringSize: 2000,
87
+ ringChars: 200_000,
88
+ retention: 500,
89
+ idempotencyTtlMs: 24 * 60 * 60_000,
90
+ // How often a running Codex turn's rollout is read for new usage (assistant-usage.js).
91
+ usagePollMs: 2000,
92
+ // How long an OpenCode turn's end waits for the serve's stored messages. Past it the turn is
93
+ // settled as not reconciled (partial) and the run is free for its next turn.
94
+ usageReconcileMs: 10_000,
95
+ // What an image / video creation or design run may collect (assistant-media.js): per file, and every turn together.
96
+ mediaFileBytes: MAX_MEDIA_BYTES,
97
+ mediaRunBytes: MAX_RUN_MEDIA_BYTES,
98
+ });
99
+
100
+ /**
101
+ * What a class's run gets when the dispatch leaves a field out (undefined, null
102
+ * or ''): a design run works in its project with the SynaBun browser for an hour.
103
+ * Explicit values win. The browser profile comes with the browser: an explicit
104
+ * uses_browser:false keeps the run off it (no `browser` profile, which would turn
105
+ * browser use back on, and no tab), on the normal default profile. The browser
106
+ * rule for capability "full" (QUARANTINE_VIOLATION) still applies. Any class's
107
+ * browser run on Codex / OpenCode without an mcp_profile gets BROWSER_PROFILE
108
+ * (normalizeSpec): the stock default profiles have no browser tools.
109
+ */
110
+ export const CLASS_DEFAULTS = Object.freeze({
111
+ design: Object.freeze({ capability: 'workspace', usesBrowser: true, mcpProfile: 'browser', maxMinutes: 60 }),
112
+ });
113
+ /** The MCP profile of a Codex / OpenCode browser run that names none (Claude workers always get 'full'). */
114
+ export const BROWSER_PROFILE = 'browser';
115
+ // MCP presets that turn browser use on (normalizeSpec reads a run on one as a browser run),
116
+ // and the stock presets with no browser tools (profiles.ts: core, standard).
117
+ const BROWSER_PROFILE_RE = /^(browser|twitter|facebook|tiktok|whatsapp|instagram|linkedin|bluesky|gsc|youtube)$/i;
118
+ const NO_BROWSER_PRESETS = new Set(['core', 'standard']);
119
+ function turnsBrowserOn(profile) {
120
+ const name = String(profile || '');
121
+ return BROWSER_PROFILE_RE.test(name) || /browser/i.test(name);
122
+ }
123
+ /**
124
+ * Browser settings of a run (any class) that cannot work together, after CLASS_DEFAULTS (so a
125
+ * uses_browser false and any mcp_profile are the brain's own): the text of a
126
+ * BROWSER_SETTINGS_CONFLICT (400) that says what to change, else null. An explicit browser
127
+ * profile would turn an explicit uses_browser:false back on; a stock profile without the browser
128
+ * tools leaves a Codex / OpenCode worker unable to use the browser the run gets (Claude workers
129
+ * always get the full catalog). `provider` null: only the first check (it does not depend on it).
130
+ */
131
+ export function browserSettingsConflict(spec, provider = null) {
132
+ const profile = String(spec?.mcpProfile || '').trim();
133
+ if (!profile) return null;
134
+ if (spec.usesBrowser === false && turnsBrowserOn(profile)) {
135
+ return `uses_browser is false, but mcp_profile "${profile}" is a browser profile, which turns browser use back on (the run would take a browser tab). Drop mcp_profile or pick one without the browser (such as "standard"), or set uses_browser to true.`;
136
+ }
137
+ if (provider && provider !== 'claude-code' && spec.usesBrowser === true && NO_BROWSER_PRESETS.has(profile.toLowerCase())) {
138
+ return `This run uses the SynaBun browser (uses_browser is on), but mcp_profile "${profile}" has no browser tools, so the ${provider} worker could not use it. Use mcp_profile "browser" or drop mcp_profile, or set uses_browser to false.`;
139
+ }
140
+ return null;
141
+ }
142
+ /** `spec` with its class's defaults filled in where it leaves a field out. */
143
+ export function withClassDefaults(spec, taskClass) {
144
+ if (!spec) return spec;
145
+ const defaults = CLASS_DEFAULTS[taskClass];
146
+ // "true" / "false" strings are the booleans they name, in every class (normalizeSpec would read
147
+ // "false" as browser use, and browserSettingsConflict checks the booleans).
148
+ const named = spec.usesBrowser === 'true' || spec.usesBrowser === 'false';
149
+ if (!defaults && !named) return spec;
150
+ const out = { ...spec };
151
+ if (named) out.usesBrowser = out.usesBrowser === 'true';
152
+ if (!defaults) return out;
153
+ for (const [key, value] of Object.entries(defaults)) {
154
+ if (!(out[key] === undefined || out[key] === null || out[key] === '')) continue;
155
+ if (key === 'mcpProfile' && out.usesBrowser === false) continue;
156
+ out[key] = value;
157
+ }
158
+ return out;
159
+ }
160
+
161
+ export class DispatchError extends Error {
162
+ constructor(code, message, { status = 400, ...extra } = {}) {
163
+ super(message);
164
+ this.name = 'DispatchError';
165
+ this.code = code;
166
+ this.status = status;
167
+ Object.assign(this, extra);
168
+ }
169
+ toJSON() {
170
+ const { code, message, status, ...rest } = { code: this.code, message: this.message, status: this.status, ...this };
171
+ const out = { code, message };
172
+ for (const [key, value] of Object.entries(rest)) if (!['name', 'stack'].includes(key)) out[key] = value;
173
+ return out;
174
+ }
175
+ }
176
+
177
+ /**
178
+ * Why a run of a class that needs a model that can see (design) cannot run on provider/model,
179
+ * else null: MODEL_CANNOT_SEE or MODEL_UNAVAILABLE (409, with suggestions). The verdict is the
180
+ * router's designFit(), so a claude-* id the catalog does not list is not known to see here
181
+ * either. It fails closed: with no catalog value sight cannot be confirmed, so the run is refused
182
+ * (reason catalog_unavailable; agent_route declines design without a catalog too). Image / video
183
+ * creation's backstop still skips its check without a catalog.
184
+ */
185
+ function designRefusal(catalogValue, provider, model, taskClass) {
186
+ if (!requiresVision(taskClass)) return null;
187
+ const label = TASK_CLASS_META[taskClass].label;
188
+ if (!catalogValue) {
189
+ return new DispatchError('MODEL_CANNOT_SEE', `Can't confirm that ${model ? `model "${model}"` : `the ${provider} default model`} can see images right now: the model catalog is unavailable. ${label} only runs on a model known to see; try again shortly.`, { status: 409, reason: 'catalog_unavailable' });
190
+ }
191
+ const fit = designFit(catalogValue, { kind: 'dispatch', provider, model: model || null });
192
+ if (fit === 'ok') return null;
193
+ const who = model ? `Model "${model}"` : `The ${provider} default model`;
194
+ const suggestions = (catalogValue.models?.[provider] || []).filter((r) => r.vision === true && r.status !== 'unavailable').slice(0, 5).map((r) => r.id);
195
+ if (fit === 'unavailable') return new DispatchError('MODEL_UNAVAILABLE', `${who} is marked unavailable in the catalog; ${label} needs an available model that can see.`, { status: 409, suggestions });
196
+ return new DispatchError('MODEL_CANNOT_SEE', `${who} ${fit === 'blind' ? 'cannot see images' : 'is not known to see images'}; ${label} needs a model that can (agent_catalog marks them vision).`, { status: 409, suggestions });
197
+ }
198
+
199
+ function iso(now = Date.now()) { return new Date(now).toISOString(); }
200
+ function readJson(path, fallback = null) { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return fallback; } }
201
+ function writeJsonAtomic(path, value) {
202
+ mkdirSync(dirname(path), { recursive: true });
203
+ const tmp = `${path}.${process.pid}.tmp`;
204
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n', 'utf8');
205
+ renameSync(tmp, path);
206
+ }
207
+ function clip(value, max = 160) {
208
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
209
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
210
+ }
211
+ function isDirectory(path) { try { return statSync(path).isDirectory(); } catch { return false; } }
212
+
213
+ /** One-line human summary of a provider stream event (used for transcript tails). */
214
+ export function summarizeProviderEvent(payload = {}) {
215
+ const event = payload.event || {};
216
+ const eventType = payload.eventType || event.type || '';
217
+ if (eventType === 'synabun.user_prompt') return `user: ${clip(event.text, 200)}`;
218
+ if (eventType === 'synabun.permission_request') return `permission requested: ${event.toolName || event.kind || 'tool'} ${clip(JSON.stringify(event.input || {}), 120)}`;
219
+ if (eventType === 'synabun.permission_resolved') return `permission ${event.behavior || 'resolved'}: ${event.toolName || ''}`;
220
+ if (eventType === 'synabun.error') return `error: ${clip(event.message, 200)}`;
221
+ if (payload.provider === 'claude-code') {
222
+ if (event.type === 'assistant') {
223
+ const blocks = Array.isArray(event.message?.content) ? event.message.content : [];
224
+ const lines = [];
225
+ for (const block of blocks) {
226
+ if (block?.type === 'text' && block.text?.trim()) lines.push(`assistant: ${clip(block.text, 200)}`);
227
+ else if (block?.type === 'tool_use') lines.push(`tool: ${block.name}(${clip(JSON.stringify(block.input || {}), 140)})`);
228
+ }
229
+ return lines.length ? lines.join('\n') : null;
230
+ }
231
+ if (event.type === 'user') {
232
+ const blocks = Array.isArray(event.message?.content) ? event.message.content : [];
233
+ const results = blocks.filter((block) => block?.type === 'tool_result');
234
+ if (!results.length) return null;
235
+ return results.map((block) => {
236
+ const content = typeof block.content === 'string' ? block.content
237
+ : Array.isArray(block.content) ? block.content.map((c) => c?.text || '').join(' ') : '';
238
+ return `${block.is_error ? 'tool_error' : 'tool_result'}: ${clip(content, 160)}`;
239
+ }).join('\n');
240
+ }
241
+ if (event.type === 'result') return `result(${event.subtype || 'unknown'}): ${clip(event.result, 200)}`;
242
+ if (event.type === 'system' && event.subtype === 'init') return `system: session ${event.session_id || ''} model ${event.model || ''}`;
243
+ return null;
244
+ }
245
+ if (payload.provider === 'codex') {
246
+ if (event.type === 'item.completed' || event.type === 'item.started') {
247
+ const item = event.item || {};
248
+ const phase = event.type === 'item.started' ? 'started' : 'done';
249
+ if (item.type === 'agent_message') return phase === 'done' ? `assistant: ${clip(item.text, 200)}` : null;
250
+ if (item.type === 'command_execution') return `command ${phase}: ${clip(item.command, 140)}${item.exit_code !== undefined && phase === 'done' ? ` (exit ${item.exit_code})` : ''}`;
251
+ if (item.type === 'file_change') return `file_change ${phase}: ${clip((item.changes || []).map((c) => c.path).join(', '), 160)}`;
252
+ if (item.type === 'mcp_tool_call') return `tool ${phase}: ${item.server || ''}.${item.tool || ''}`;
253
+ if (item.type === 'reasoning') return null;
254
+ if (item.type === 'web_search') return `web_search: ${clip(item.query, 120)}`;
255
+ return `${item.type || 'item'} ${phase}`;
256
+ }
257
+ if (event.type === 'turn.completed') return `turn completed${event.usage ? ` (in ${event.usage.input_tokens || 0} / out ${event.usage.output_tokens || 0} tokens)` : ''}`;
258
+ if (event.type === 'turn.failed') return `error: ${clip(event.error?.message, 200)}`;
259
+ if (event.type === 'error') return `error: ${clip(event.message, 200)}`;
260
+ return null;
261
+ }
262
+ if (payload.provider === 'opencode') {
263
+ if (/^message[.:]part[.:]updated$/i.test(eventType)) {
264
+ const part = event.part || {};
265
+ if (part.type === 'tool') return `tool: ${part.tool || part.name || 'tool'} ${part.state?.status || ''}`.trim();
266
+ return null;
267
+ }
268
+ if (/^session[.:]idle$/i.test(eventType)) return 'turn completed';
269
+ if (/^session[.:]error$/i.test(eventType)) return `error: ${clip(event.error?.message || event.error || 'session error', 200)}`;
270
+ if (/permission\.asked/i.test(eventType)) return `permission asked: ${event.permission || event.type || ''}`;
271
+ if (/question\.asked/i.test(eventType)) return `question asked: ${clip((event.questions || []).map((q) => q.question).join(' | '), 200)}`;
272
+ return null;
273
+ }
274
+ return null;
275
+ }
276
+
277
+ function mergeLimits(overrides = {}) {
278
+ const limits = { ...DEFAULT_LIMITS, perProvider: { ...DEFAULT_LIMITS.perProvider } };
279
+ for (const [key, value] of Object.entries(overrides || {})) {
280
+ if (key === 'perProvider' && value && typeof value === 'object') Object.assign(limits.perProvider, value);
281
+ else if (value !== undefined && value !== null) limits[key] = value;
282
+ }
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
+ return limits;
287
+ }
288
+
289
+ const r4 = (value) => Number((Number(value) || 0).toFixed(4));
290
+ const usd = (value) => `$${(Number(value) || 0).toFixed(2)}`;
291
+ // How sure a run's cost is, weakest last: a run shows the weakest basis any of its turns had.
292
+ const COST_BASIS_RANK = { none: 0, tokens: 1, free: 2, reported: 3, estimated: 4, unpriced: 5 };
293
+ function weakerBasis(current, next) {
294
+ if (!next) return current || 'none';
295
+ return (COST_BASIS_RANK[next] ?? 0) > (COST_BASIS_RANK[current] ?? 0) ? next : (current || 'none');
296
+ }
297
+ function usageTokens(usage) {
298
+ if (!usage || typeof usage !== 'object') return 0;
299
+ return ['input_tokens', 'output_tokens', 'reasoning_tokens'].reduce((sum, key) => sum + (Number(usage[key]) || 0), 0);
300
+ }
301
+ /** The keys of a Codex thread's running usage total that are differenced (its total_tokens is input + output). */
302
+ const CODEX_TOTAL_KEYS = CODEX_USAGE_KEYS.filter((key) => key !== 'total_tokens');
303
+ /** OpenCode message tokens ({ input, output, reasoning, cache: { read, write } }) → usage keys. */
304
+ function openCodeUsage(tokens) {
305
+ if (!tokens || typeof tokens !== 'object') return null;
306
+ return {
307
+ input_tokens: Number(tokens.input) || 0, output_tokens: Number(tokens.output) || 0, reasoning_tokens: Number(tokens.reasoning) || 0,
308
+ cache_read_input_tokens: Number(tokens.cache?.read) || 0, cache_write_input_tokens: Number(tokens.cache?.write) || 0,
309
+ };
310
+ }
311
+ /** A message that is one JSON object (bare, or inside a single code fence), else null. */
312
+ function parseJsonObject(text) {
313
+ const raw = String(text || '').trim();
314
+ const fence = /^```(?:json)?\s*\n([\s\S]*?)\n```$/i.exec(raw);
315
+ let value = null;
316
+ try { value = JSON.parse(fence ? fence[1] : raw); } catch { return null; }
317
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
318
+ }
319
+
320
+ /**
321
+ * @param {object} deps see plan §1.4; everything is injected so the module is unit-testable.
322
+ */
323
+ export function createAssistantDispatcher({
324
+ getRuntime,
325
+ dataDir,
326
+ loopDir = null,
327
+ broadcastSync = () => {},
328
+ log = () => {},
329
+ PACKAGE_ROOT = process.cwd(),
330
+ findCodexAccount = () => null,
331
+ getCodexAccount = () => null,
332
+ CODEX_DEFAULT_HOME = null,
333
+ claudeAccounts = null,
334
+ isValidMcpProfileValue = () => true,
335
+ normalizeMcpProfileName = (value) => value,
336
+ readActiveMcpProfile = () => 'full',
337
+ acquireLoopBrowserAndTab = null,
338
+ releaseSharedBrowserTab = async () => {},
339
+ reconcileKeepAwake = () => {},
340
+ memory = null,
341
+ detectProject = () => 'global',
342
+ limits: limitOverrides = {},
343
+ now = Date.now,
344
+ randomId = () => randomBytes(16).toString('hex'),
345
+ strictResult = true,
346
+ // Routing (assistant-router.js) and the model catalog (assistant-catalog.js);
347
+ // both optional so the dispatcher still works (and tests) without them.
348
+ router = null,
349
+ catalog = null,
350
+ // Computer use (lib/desktop/service.js): { isSupported(), isReady(), setupState(),
351
+ // sessionAllows(sessionId), mintGrant(meta), revokeFor({runId}), releaseOwner({runId}) }.
352
+ desktop = null,
353
+ // Jev (assistant-jev.js): { workerOutcome(input, ask, ctx), annotate(logId, outcome) }.
354
+ // null keeps the pre-Jev behaviour: a result-retry turn, and every failure offers escalation.
355
+ judge = null,
356
+ // Clarification (assistant-clarify.js): { forDispatch(sessionId, { briefId, independent }) }.
357
+ // A brain's dispatch waits while its questions are open and carries the user's brief.
358
+ 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
361
+ // prices for Codex usage (assistant-budget.js createModelPricing).
362
+ readLimits = null,
363
+ brainSpend = () => 0,
364
+ pricing = null,
365
+ // Exact token accounting (assistant-usage.js): the ledger (createUsageLedger) and the task of
366
+ // the brain turn in progress, (assistantSessionId) => taskId | null. Both optional: without
367
+ // them the meters still run for cost and the run's own numbers, and nothing is written.
368
+ usage: ledger = null,
369
+ currentTask = null,
370
+ // Remote sessions (lib/remote-policy.js): the session's policy, or null, and
371
+ // the registered project paths a remote session's workers must run in.
372
+ sessionPolicy = getSessionPolicy,
373
+ registeredProjects = () => readRegisteredProjects(),
374
+ // The project's Style Guide for a run's prompt (lib/style-guide/store.js): (cwd, taskClass) =>
375
+ // { summary, designPath, tokenFiles, proposals } | null. Reads <dataDir>/style-guides.
376
+ loadStyleGuide = (cwd, taskClass) => loadStyleGuideForRun(cwd, taskClass, { dataDir }),
377
+ } = {}) {
378
+ if (typeof getRuntime !== 'function') throw new Error('createAssistantDispatcher requires getRuntime()');
379
+ if (!dataDir) throw new Error('createAssistantDispatcher requires dataDir');
380
+ const limits = mergeLimits(limitOverrides);
381
+ /**
382
+ * Re-read the live limits (the Budget tab writes assistant-config.json): a
383
+ * change applies to the next dispatch, follow-up, turn end and metered step
384
+ * without a restart. `limits` stays the same object (dispatcher.limits).
385
+ */
386
+ function refreshLimits() {
387
+ if (typeof readLimits !== 'function') return limits;
388
+ let fresh = null;
389
+ try { fresh = readLimits(); } catch { fresh = null; }
390
+ if (!fresh || typeof fresh !== 'object') return limits;
391
+ const next = mergeLimits({ ...limitOverrides, ...fresh });
392
+ for (const key of Object.keys(next)) limits[key] = next[key];
393
+ return limits;
394
+ }
395
+ const registryPath = resolve(dataDir, 'assistant-dispatches.json');
396
+ // What image / video creation runs generated, and design runs' screenshots: <dataDir>/media/<runId>/<n>-<name>.
397
+ const mediaRoot = resolve(dataDir, 'media');
398
+ /** A known task class, else null. */
399
+ const knownClass = (value) => (Object.prototype.hasOwnProperty.call(TASK_CLASS_META, String(value || '')) ? String(value) : null);
400
+ const entries = new Map(); // runId → entry (persisted shape)
401
+ const live = new Map(); // runId → live state (in-memory only)
402
+ const listeners = new Set();
403
+ const queue = []; // runIds in FIFO order (state 'queued')
404
+ const idempotency = new Map(); // `${sessionId}:${key}` → { runId, at }
405
+ const sessionBudgetWarned = new Map(); // assistantSessionId → the warning threshold it fired at
406
+ const sessionBudgetExceeded = new Map(); // assistantSessionId → the hard cap it was stopped at
407
+ // Usage meters (assistant-usage.js), apart from the live state: a Claude result can arrive
408
+ // after its turn was aborted or its run left the list.
409
+ const meters = new Map(); // runId → { claude, codex, opencode }
410
+ let persistTimer = null;
411
+ let stalledTimer = null;
412
+ let unsubscribeRuntime = null;
413
+
414
+ // ── persistence ────────────────────────────────────────────────────────────
415
+ function loadRegistry() {
416
+ const rows = readJson(registryPath, []);
417
+ if (!Array.isArray(rows)) return;
418
+ for (const row of rows) {
419
+ if (!row?.runId) continue;
420
+ const entry = { ...row };
421
+ // A failure cause being judged when the server went down is never coming.
422
+ delete entry.judging;
423
+ if (ACTIVE_STATES.has(entry.state)) {
424
+ entry.state = 'interrupted';
425
+ entry.completionReason = entry.completionReason || 'server_restart';
426
+ entry.finishedAt = entry.finishedAt || iso(now());
427
+ entry.pending = [];
428
+ entry.turnState = 'terminal';
429
+ closeUsageState(entry);
430
+ }
431
+ entries.set(entry.runId, entry);
432
+ }
433
+ }
434
+ function persist({ force = false } = {}) {
435
+ const write = () => {
436
+ const rows = [...entries.values()]
437
+ .sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
438
+ const active = rows.filter((row) => ACTIVE_STATES.has(row.state));
439
+ const terminal = rows.filter((row) => !ACTIVE_STATES.has(row.state));
440
+ const retained = [...active, ...terminal.slice(0, Math.max(0, limits.retention - active.length))];
441
+ const retainedIds = new Set(retained.map((row) => row.runId));
442
+ for (const row of terminal) if (!retainedIds.has(row.runId)) { entries.delete(row.runId); live.delete(row.runId); meters.delete(row.runId); }
443
+ try { writeJsonAtomic(registryPath, retained); } catch (error) { log(null, 'assistant:registry-error', error?.message || String(error)); }
444
+ };
445
+ if (force) { if (persistTimer) { clearTimeout(persistTimer); persistTimer = null; } write(); return; }
446
+ if (persistTimer) return;
447
+ persistTimer = setTimeout(() => { persistTimer = null; write(); }, 200);
448
+ persistTimer.unref?.();
449
+ }
450
+
451
+ // ── views + events ─────────────────────────────────────────────────────────
452
+ /** The task of a live run's turn in progress (or the last one it started); null for a run that ended or has not started one. */
453
+ function turnTaskOf(entry) {
454
+ return (ACTIVE_STATES.has(entry.state) ? live.get(entry.runId)?.usageTurn?.taskId : null) || null;
455
+ }
456
+ /**
457
+ * What a usage packet needs about one run, straight from the registry entry. The gauge asks
458
+ * several times a second, for every run of the task: no full view, no ledger read and no memory
459
+ * lookup (get() does all three).
460
+ */
461
+ function peek(runId) {
462
+ const entry = entries.get(String(runId || '')) || null;
463
+ if (!entry) return null;
464
+ return {
465
+ runId: entry.runId, title: entry.title || null, state: entry.state, turnState: entry.turnState || null,
466
+ taskId: entry.taskId || null, turnTaskId: turnTaskOf(entry), provider: entry.provider || null, model: entry.model || null,
467
+ };
468
+ }
469
+ function view(entry) {
470
+ if (!entry) return null;
471
+ const runtimeRun = safeRuntime()?.get?.(entry.runId) || null;
472
+ const startedAt = entry.startedAt || entry.createdAt;
473
+ const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() : now();
474
+ const elapsedMs = startedAt ? Math.max(0, end - new Date(startedAt).getTime()) : 0;
475
+ // The brief goes to the worker's prompt; views name it only. The meters' state stays inside.
476
+ const { rawSpec: _rawSpec, brief: _brief, usageState: _usageState, ...rest } = entry;
477
+ return {
478
+ ...rest,
479
+ briefId: entry.brief?.briefId || null,
480
+ taskId: entry.taskId || null,
481
+ // The task of the turn in progress (or the last one started): a follow-up may work for a
482
+ // later task than the run's own. null once the run has ended.
483
+ turnTaskId: turnTaskOf(entry),
484
+ tokens: tokensView(entry),
485
+ status: runStatusFromState(entry.state),
486
+ completedAt: entry.finishedAt || null,
487
+ removed: !!entry.removedAt,
488
+ route: entry.route || null,
489
+ escalation: entry.escalation || null,
490
+ modelInfo: entry.modelInfo || null,
491
+ usesComputer: !!entry.usesComputer,
492
+ awaitingRoute: entry.state === 'awaiting_route',
493
+ dispatchId: entry.runId,
494
+ active: ACTIVE_STATES.has(entry.state),
495
+ terminal: TERMINAL_STATES.has(entry.state),
496
+ outcome: entry.lastResult?.status || null,
497
+ elapsedMs,
498
+ turnCount: Array.isArray(entry.turns) ? entry.turns.length : 0,
499
+ // The turn whose result this view carries: what a brain has read once it sees it.
500
+ resultTurn: (Array.isArray(entry.turns) ? entry.turns.at(-1)?.n : null) ?? 0,
501
+ pending: Array.isArray(entry.pending) ? entry.pending : [],
502
+ runtime: runtimeRun ? {
503
+ status: runtimeRun.status, turnState: runtimeRun.turnState, stoppedReason: runtimeRun.stoppedReason,
504
+ error: runtimeRun.error, claimedBy: runtimeRun.claimedBy, providerSessionId: runtimeRun.providerSessionId,
505
+ providerThreadId: runtimeRun.providerThreadId,
506
+ } : (entry.runtime || null),
507
+ providerSessionId: runtimeRun?.providerSessionId || entry.providerSessionId || null,
508
+ providerThreadId: runtimeRun?.providerThreadId || entry.providerThreadId || null,
509
+ };
510
+ }
511
+ function safeRuntime() { try { return getRuntime(); } catch { return null; } }
512
+ function touch(entry) { entry.version = (Number(entry.version) || 0) + 1; entry.updatedAt = iso(now()); }
513
+ function emit(entry, reason, extra = {}) {
514
+ touch(entry);
515
+ persist({ force: TERMINAL_STATES.has(entry.state) });
516
+ const payload = { type: 'assistant:dispatch', reason, run: view(entry), ...extra };
517
+ try { broadcastSync(payload); } catch {}
518
+ for (const listener of listeners) { try { listener(payload); } catch {} }
519
+ notifyWaiters(entry.runId, reason);
520
+ }
521
+
522
+ // ── delivery (what the owning brain already read) ──────────────────────────
523
+ /**
524
+ * High-water mark of the run results a brain has read (agent_wait,
525
+ * agent_read, agent_status). The runtime drops `result` / `needs_input`
526
+ * mailbox items at or below it, so a result the brain waited for does not
527
+ * come back as a second turn. Persisted WITHOUT a version bump or an event:
528
+ * a bump would wake `until:'event'` waits. A read by another session is
529
+ * refused; a terminal state read while its cause is still being judged is
530
+ * not recorded (the 'failed' event with the cause is still to come).
531
+ */
532
+ function markDelivered(runId, { turn = 0, state = null, via = null, assistantSessionId = null } = {}) {
533
+ const entry = entries.get(runId);
534
+ if (!entry) return false;
535
+ if (assistantSessionId && entry.assistantSessionId && String(assistantSessionId) !== String(entry.assistantSessionId)) return false;
536
+ const prev = entry.delivered && typeof entry.delivered === 'object' ? entry.delivered : null;
537
+ const n = Math.max(0, Math.floor(Number(turn) || 0));
538
+ const nextTurn = Math.max(Number(prev?.turn) || 0, n);
539
+ let nextState = prev?.state || null;
540
+ if (state && !(TERMINAL_STATES.has(state) && entry.judging)) nextState = String(state);
541
+ if (prev && nextTurn === (Number(prev.turn) || 0) && nextState === (prev.state || null)) return true;
542
+ entry.delivered = { turn: nextTurn, state: nextState, via: via ? String(via) : (prev?.via || null), at: iso(now()) };
543
+ persist();
544
+ return true;
545
+ }
546
+ function deliveryState(runId) {
547
+ const mark = entries.get(runId)?.delivered;
548
+ return mark && typeof mark === 'object' ? { ...mark } : null;
549
+ }
550
+
551
+ // ── waiters ────────────────────────────────────────────────────────────────
552
+ const waiters = new Set(); // { check(): boolean, resolve() }
553
+ function notifyWaiters() {
554
+ for (const waiter of [...waiters]) {
555
+ try { if (waiter.check()) { waiters.delete(waiter); waiter.resolve(); } } catch {}
556
+ }
557
+ }
558
+ function satisfies(entry, until, sinceVersion) {
559
+ if (!entry) return true;
560
+ // A failed run whose cause is still being judged is not final yet: its
561
+ // 'failed' event (with the cause and the matching escalation) follows
562
+ // within the surface timeout.
563
+ const terminal = TERMINAL_STATES.has(entry.state) && !entry.judging;
564
+ if (until === 'terminal') return terminal;
565
+ if (until === 'event') return terminal || entry.state === 'awaiting_permission' || entry.state === 'awaiting_route' || (Number(entry.version) || 0) !== sinceVersion;
566
+ // 'idle' (default): the run is not busy — idle, awaiting a decision (a
567
+ // permission or the user's model choice), or finished.
568
+ return entry.state === 'idle' || entry.state === 'awaiting_permission' || entry.state === 'awaiting_route' || terminal;
569
+ }
570
+
571
+ // ── validation ─────────────────────────────────────────────────────────────
572
+ function suggestModels(catalogValue, provider, model) {
573
+ const rows = catalogValue?.models?.[provider] || [];
574
+ const needle = String(model || '').toLowerCase().replace(/[^a-z0-9]/g, '');
575
+ const score = (row) => { const id = String(row.id).toLowerCase().replace(/[^a-z0-9]/g, ''); let s = 0; for (const ch of new Set(needle)) if (id.includes(ch)) s += 1; return s; };
576
+ return [...rows].sort((a, b) => score(b) - score(a)).slice(0, 5).map((row) => row.id);
577
+ }
578
+
579
+ /** `taskClass`: the run's class (its route's, else the spec's); image / video creation needs a model that makes the medium. */
580
+ function normalizeSpec(spec = {}, { catalogValue = null, codexAskFallback = false, taskClass = null } = {}) {
581
+ const provider = String(spec.provider || '').trim().toLowerCase();
582
+ if (!NATIVE_PROVIDERS.has(provider)) throw new DispatchError('INVALID_PROVIDER', `provider must be one of ${[...NATIVE_PROVIDERS].join(', ')}`);
583
+ const task = String(spec.task || '').trim();
584
+ if (!task) throw new DispatchError('TASK_REQUIRED', 'task is required');
585
+ if (task.length > 20_000) throw new DispatchError('TASK_TOO_LONG', 'task must be at most 20000 characters');
586
+ const cwd = String(spec.cwd || spec.project || PACKAGE_ROOT).trim();
587
+ if (!cwd || !isDirectory(cwd)) throw new DispatchError('CWD_INVALID', `cwd is not a directory: ${cwd}`);
588
+ let permissionPolicy = normalizePermissionPolicy(spec.permissionPolicy);
589
+ const capability = normalizeCapability(spec.capability || 'full');
590
+ const notes = [];
591
+ if (provider === 'codex' && permissionPolicy === 'ask') {
592
+ // A route card may move an 'ask' dispatch onto Codex: degrade instead of failing.
593
+ if (!codexAskFallback) throw new DispatchError('PERMISSION_POLICY_UNSUPPORTED', 'Codex dispatches cannot use permissionPolicy "ask" (the Codex SDK has no approval channel). Use "restricted" for a workspace-write sandbox or "auto".', { suggested: 'restricted' });
594
+ permissionPolicy = 'restricted';
595
+ notes.push('permission policy "ask" became "restricted" (Codex has no approval channel)');
596
+ }
597
+ // "auto" (or nothing) = let routing pick / use the provider default.
598
+ let model = spec.model && String(spec.model).trim() && String(spec.model).trim().toLowerCase() !== 'auto' ? String(spec.model).trim() : null;
599
+ let modelInfo = null;
600
+ let modelContextWindow = null;
601
+ // Models the user hid (Assistant → Models; Settings → OpenCode) never run.
602
+ if (catalogValue && providerAllHidden(catalogValue, provider)) {
603
+ throw new DispatchError('MODEL_DISABLED', `Every ${provider} model is disabled in the Assistant's Models list. Pick another provider from agent_catalog, or ask the user to enable one (⋯ → Models…).`, { status: 400 });
604
+ }
605
+ // The provider answered and has no model connected (OpenCode's empty `connected` list): nothing can run there.
606
+ if (catalogValue && providerEmpty(catalogValue, provider)) {
607
+ throw new DispatchError('PROVIDER_NOT_CONNECTED', `No ${provider} model is connected. Pick another provider from agent_catalog, or ask the user to connect one.`, { status: 400 });
608
+ }
609
+ if (!model && catalogValue && defaultIsHidden(catalogValue, provider)) {
610
+ // No model = the provider default, which the user hid: pin the first visible model.
611
+ const pinned = defaultModelRow(catalogValue, provider);
612
+ if (pinned) {
613
+ model = pinned.id;
614
+ notes.push(`the ${provider} default model is disabled; using ${pinned.id}`);
615
+ }
616
+ }
617
+ if (model && catalogValue) {
618
+ const hit = findModel(catalogValue, provider, model);
619
+ const disabled = hit === null ? hiddenModelId(catalogValue, provider, model) : null;
620
+ if (disabled) {
621
+ throw new DispatchError('MODEL_DISABLED', `${provider} model "${disabled}" is disabled in the Assistant's Models list. Pick an enabled model from agent_catalog (provider "${provider}"), or ask the user to enable it there (⋯ → Models…).`, { status: 400, suggestions: suggestModels(catalogValue, provider, model) });
622
+ }
623
+ if (hit === null) {
624
+ throw new DispatchError('MODEL_UNKNOWN', `Unknown ${provider} model "${model}". Pick one from agent_catalog (provider "${provider}"), or omit model for the provider default.`, { status: 400, suggestions: suggestModels(catalogValue, provider, model) });
625
+ }
626
+ const row = hit.row;
627
+ modelInfo = { tier: row?.tier || null, vision: row ? row.vision ?? null : (hit.match === 'unlisted' ? true : null), price: row?.price || null, validated: hit.match === 'exact' || hit.match === 'resolved' ? 'catalog' : hit.match };
628
+ // Codex "<id>[extended]": the worker runs <id> with model_context_window at this size.
629
+ if (provider === 'codex' && contextVariantOf(row?.id) === 'extended') modelContextWindow = row.contextWindow || null;
630
+ }
631
+ // The model a run's usage is priced as: the provider default's row when none is named.
632
+ let pricingModel = model;
633
+ if (!model && catalogValue) {
634
+ const row = defaultModelRow(catalogValue, provider);
635
+ if (row) {
636
+ pricingModel = row.id;
637
+ if (!modelInfo) modelInfo = { tier: row.tier || null, vision: row.vision ?? null, price: row.price || null, validated: 'default' };
638
+ }
639
+ }
640
+ // The effort is checked against the model the run gets (effort-levels.js);
641
+ // an unsupported level becomes the nearest one it runs, recorded on the run.
642
+ const checked = normalizeEffort({ provider, model, effort: spec.effort, catalog: catalogValue });
643
+ const effortCorrection = checked.corrected || null;
644
+ if (effortCorrection) notes.push(`effort "${effortCorrection.from}" is not supported by ${model || `the ${provider} default model`}; using ${effortCorrection.to ? `"${effortCorrection.to}"` : 'the model default'}`);
645
+ // The run's own browser settings must agree on the provider it gets, whatever its class.
646
+ const conflict = browserSettingsConflict(spec, provider);
647
+ if (conflict) throw new DispatchError('BROWSER_SETTINGS_CONFLICT', conflict, { status: 400 });
648
+ const usesBrowser = !!spec.usesBrowser || turnsBrowserOn(spec.mcpProfile);
649
+ const usesComputer = spec.usesComputer === true || spec.usesComputer === 'true';
650
+ const tags = Array.isArray(spec.tags) ? spec.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 10) : [];
651
+ if ((usesBrowser || usesComputer) && capability === 'full' && !tags.includes('user-authorized-full')) {
652
+ throw new DispatchError('QUARANTINE_VIOLATION', `${usesComputer ? 'Computer-use' : 'Browser-reading'} runs must use capability "read-only" or "workspace" unless tagged "user-authorized-full" by explicit user request.`);
653
+ }
654
+ if (usesComputer) {
655
+ if (!desktop || !desktop.isSupported?.()) throw new DispatchError('COMPUTER_UNAVAILABLE', 'Computer use is not available on this machine (macOS only).', { status: 409 });
656
+ if (!desktop.isReady?.()) throw new DispatchError('COMPUTER_UNAVAILABLE', `Computer use is not set up yet (${desktop.setupState?.() || 'setup required'}). Ask the user to finish the one-time setup in the assistant panel.`, { status: 409 });
657
+ if (modelInfo && modelInfo.vision === false) throw new DispatchError('COMPUTER_NEEDS_VISION', `Model "${model}" cannot see images; computer use needs a vision-capable model.`, { status: 400, suggestions: (catalogValue?.models?.[provider] || []).filter((row) => row.vision).slice(0, 5).map((row) => row.id) });
658
+ }
659
+ // Image / video creation (the router already routed it to a maker; this is the backstop).
660
+ const medium = requiredOutput(taskClass);
661
+ if (medium && catalogValue) {
662
+ const row = model ? findModel(catalogValue, provider, model)?.row || null : defaultModelRow(catalogValue, provider);
663
+ if (!rowMakes(row, medium)) {
664
+ const words = medium === 'video' ? 'videos' : 'images';
665
+ throw new DispatchError('MODEL_CANNOT_GENERATE', `${model ? `Model "${model}"` : `The ${provider} default model`} cannot generate ${words}; ${TASK_CLASS_META[taskClass].label} needs a model that does (agent_catalog marks them ${medium}-out).`, { status: 400, suggestions: (catalogValue.models?.[provider] || []).filter((r) => rowMakes(r, medium)).slice(0, 5).map((r) => r.id) });
666
+ }
667
+ }
668
+ // Design (the router already routed it to a model that can see; this is the backstop for every way a
669
+ // target is chosen: routes, held picks, UI dispatches, escalations).
670
+ const sightRefusal = designRefusal(catalogValue, provider, model, taskClass);
671
+ if (sightRefusal) throw sightRefusal;
672
+ const agent = provider === 'opencode' && spec.agent ? String(spec.agent).trim().slice(0, 60) || null : null;
673
+ let mcpProfile = null;
674
+ if (provider === 'claude-code') mcpProfile = 'full';
675
+ else {
676
+ // A browser run needs the browser tools: the active profile (core by default) has none.
677
+ mcpProfile = normalizeMcpProfileName(spec.mcpProfile || (usesBrowser ? BROWSER_PROFILE : readActiveMcpProfile()));
678
+ if (mcpProfile && !isValidMcpProfileValue(mcpProfile)) throw new DispatchError('MCP_PROFILE_INVALID', `Invalid MCP profile: ${mcpProfile}`);
679
+ }
680
+ let codexAccount = null;
681
+ let claudeAccount = null;
682
+ const accountId = spec.accountId || spec.codexAccountId || spec.claudeAccountId || null;
683
+ 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);
693
+ const idleTimeoutMs = Math.min(Math.max(Number(spec.idleTimeoutMs) || limits.idleTimeoutMs, limits.minIdleTimeoutMs), limits.maxIdleTimeoutMs);
694
+ const maxTurns = Math.min(Math.max(Number(spec.maxTurns) || limits.maxTurns, 1), limits.maxMaxTurns);
695
+ const outputSchema = spec.outputSchema && typeof spec.outputSchema === 'object' ? spec.outputSchema : null;
696
+ const title = clip(spec.title || task.split('\n')[0], 80);
697
+ return {
698
+ provider, task, context: String(spec.context || '').trim() || null, cwd,
699
+ project: detectProject(cwd) || 'global',
700
+ model, modelContextWindow, pricingModel,
701
+ effort: checked.effort || null, effortCorrection,
702
+ agent, modelInfo, notes,
703
+ mcpProfile, permissionPolicy, capability, usesBrowser, usesComputer,
704
+ codexAccountId: codexAccount?.id || null,
705
+ codexHome: codexAccount ? (codexAccount.home || CODEX_DEFAULT_HOME) : null,
706
+ claudeAccountId: claudeAccount?.id || (provider === 'claude-code' ? 'default' : null),
707
+ claudeConfigDir: claudeAccount ? (claudeAccounts?.homeFor?.(claudeAccount.id) || null) : null,
708
+ accountId: provider === 'codex' ? (codexAccount?.id || 'default') : provider === 'claude-code' ? (claudeAccount?.id || 'default') : null,
709
+ maxMinutes, budgetUsd, idleTimeoutMs, maxTurns, outputSchema, title, tags,
710
+ focus: spec.focus === true,
711
+ workflowId: spec.workflowId ? String(spec.workflowId) : null,
712
+ parentRunId: spec.parentRunId ? String(spec.parentRunId) : null,
713
+ idempotencyKey: spec.idempotencyKey || spec.idempotency_key ? String(spec.idempotencyKey || spec.idempotency_key).slice(0, 200) : null,
714
+ queueIfBusy: spec.queueIfBusy !== false,
715
+ requestedBrowserSessionId: spec.browserSessionId || null,
716
+ };
717
+ }
718
+
719
+ // ── rails ──────────────────────────────────────────────────────────────────
720
+ function activeEntries(filter = () => true) {
721
+ return [...entries.values()].filter((entry) => ACTIVE_STATES.has(entry.state) && !WAITING_STATES.has(entry.state) && filter(entry));
722
+ }
723
+ function railBlock(spec, assistantSessionId) {
724
+ // One desktop, one controller: computer-use runs never overlap.
725
+ if (spec.usesComputer && activeEntries((e) => e.usesComputer).length >= 1) return 'another computer-use run is active (one at a time)';
726
+ const perProvider = limits.perProvider[spec.provider] ?? 3;
727
+ if (activeEntries((e) => e.provider === spec.provider).length >= perProvider) return `provider ${spec.provider} at its concurrency limit (${perProvider})`;
728
+ if (spec.provider === 'codex' && activeEntries((e) => e.provider === 'codex' && e.accountId === spec.accountId).length >= limits.perCodexAccount) {
729
+ return `Codex account ${spec.accountId} at its concurrency limit (${limits.perCodexAccount})`;
730
+ }
731
+ if (assistantSessionId && activeEntries((e) => e.assistantSessionId === assistantSessionId).length >= limits.perSession) {
732
+ return `assistant session at its concurrency limit (${limits.perSession})`;
733
+ }
734
+ if (spec.workflowId && activeEntries((e) => e.workflowId === spec.workflowId).length >= limits.maxFanOut) {
735
+ return `workflow ${spec.workflowId} at its fan-out limit (${limits.maxFanOut})`;
736
+ }
737
+ return null;
738
+ }
739
+ /** What the session's runs spent (removed runs included: the money was spent). */
740
+ function sessionSpend(assistantSessionId) {
741
+ if (!assistantSessionId) return 0;
742
+ let total = 0;
743
+ for (const entry of entries.values()) if (entry.assistantSessionId === assistantSessionId) total += Number(entry.costUsd) || 0;
744
+ return total;
745
+ }
746
+ function sessionBrainSpend(assistantSessionId) {
747
+ if (!assistantSessionId) return 0;
748
+ try { return Math.max(0, Number(brainSpend(assistantSessionId)) || 0); } catch { return 0; }
749
+ }
750
+ /** The session's whole spend: the central brain plus every run. The hard cap is checked against this. */
751
+ function sessionTotal(assistantSessionId) { return sessionSpend(assistantSessionId) + sessionBrainSpend(assistantSessionId); }
752
+ function runRemaining(entry) { return Math.max(0, (Number(entry.budgetUsd) || 0) - (Number(entry.costUsd) || 0)); }
753
+ /**
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).
757
+ */
758
+ function sessionReserved(assistantSessionId, { except = null } = {}) {
759
+ if (!assistantSessionId) return 0;
760
+ let total = 0;
761
+ 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);
765
+ }
766
+ return total;
767
+ }
768
+ /** The session's money view (Budget tab, cost chip, agent_list totals). */
769
+ function sessionBudget(assistantSessionId) {
770
+ 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
+ };
784
+ }
785
+ /**
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).
790
+ */
791
+ function unpricedReason(entry) {
792
+ if (!pricing || entry.provider === 'claude-code') return null;
793
+ const model = entry.pricingModel || entry.model || null;
794
+ if (entry.provider === 'codex') {
795
+ let price = null;
796
+ try { price = model ? pricing.codexPrice?.(model) || null : null; } catch { price = null; }
797
+ return price ? null : `no list price is known for Codex model ${model || '(the default model)'}`;
798
+ }
799
+ return entry.modelInfo?.price ? null : `no price is known for OpenCode model ${model || '(the default model)'}`;
800
+ }
801
+ function refuseUnpriced(entry) {
802
+ const why = unpricedReason(entry);
803
+ 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 });
805
+ }
806
+ /** Why this run may not spend more (unpriced usage, its own cap, or the session's hard cap), else null. */
807
+ function budgetBlock(entry) {
808
+ 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 } };
810
+ }
811
+ const cap = Number(entry.budgetUsd) || 0;
812
+ 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 } };
815
+ }
816
+ 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
+ }
822
+ }
823
+ return null;
824
+ }
825
+ /**
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.
828
+ */
829
+ function fitBudget(entry) {
830
+ 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
+ }
846
+ }
847
+ /**
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.
850
+ * Returns the session's budget view.
851
+ */
852
+ function checkSession(assistantSessionId, { source = null } = {}) {
853
+ if (!assistantSessionId) return null;
854
+ const money = sessionBudget(assistantSessionId);
855
+ const anchor = source || [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId)
856
+ .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);
859
+ if (anchor) {
860
+ 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.`,
863
+ });
864
+ }
865
+ }
866
+ if (money.exceeded) {
867
+ const victims = [...entries.values()].filter((e) => e.assistantSessionId === assistantSessionId && ACTIVE_STATES.has(e.state));
868
+ for (const victim of victims) {
869
+ 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)`);
871
+ stop(victim.runId, 'session_budget_cap').catch(() => {});
872
+ }
873
+ if (anchor && sessionBudgetExceeded.get(assistantSessionId) !== money.hardUsd) {
874
+ sessionBudgetExceeded.set(assistantSessionId, money.hardUsd);
875
+ 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).`,
878
+ });
879
+ }
880
+ }
881
+ return money;
882
+ }
883
+
884
+ // ── dispatch ───────────────────────────────────────────────────────────────
885
+ /** Entry fields that come straight from a normalized spec. */
886
+ function specFields(spec) {
887
+ return {
888
+ provider: spec.provider, model: spec.model, effort: spec.effort, effortCorrection: spec.effortCorrection || null, agent: spec.agent || null, accountId: spec.accountId,
889
+ codexAccountId: spec.codexAccountId, codexHome: spec.codexHome, claudeAccountId: spec.claudeAccountId, claudeConfigDir: spec.claudeConfigDir,
890
+ cwd: spec.cwd, project: spec.project, mcpProfile: spec.mcpProfile,
891
+ permissionPolicy: spec.permissionPolicy, capability: spec.capability, usesBrowser: spec.usesBrowser, usesComputer: !!spec.usesComputer,
892
+ 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,
894
+ outputSchema: spec.outputSchema, focus: spec.focus, workflowId: spec.workflowId, parentRunId: spec.parentRunId,
895
+ idempotencyKey: spec.idempotencyKey, requestedBrowserSessionId: spec.requestedBrowserSessionId,
896
+ modelInfo: spec.modelInfo || null, modelContextWindow: spec.modelContextWindow || null, pricingModel: spec.pricingModel || spec.model || null,
897
+ };
898
+ }
899
+ function baseEntry(runId, sessionId, origin) {
900
+ return {
901
+ runId, assistantSessionId: sessionId, origin,
902
+ state: 'queued', turnState: null, turns: [], lastResult: null, costUsd: 0, usage: null, costBasis: 'none',
903
+ pending: [], notes: [], createdAt: iso(now()), startedAt: null, finishedAt: null, completionReason: null,
904
+ memoryId: null, memoryStored: false, browserSessionId: null, browserTabId: null, version: 0, updatedAt: iso(now()),
905
+ runtime: null, providerSessionId: null, providerThreadId: null, degraded: null,
906
+ route: null, escalation: null, escalationDepth: 0,
907
+ taskId: null, // the task (one human prompt) this run was dispatched for: assistant-usage.js
908
+ };
909
+ }
910
+ async function catalogSnapshot() {
911
+ if (!catalog) return null;
912
+ try { return await (catalog.full ? catalog.full() : catalog.get()); } catch { return catalog.peek?.() || null; }
913
+ }
914
+
915
+ /**
916
+ * The user's brief for a brain's dispatch (assistant-clarify.js): null for a
917
+ * clear request; CLARIFICATION_PENDING (409) while the questions it depends
918
+ * on are open; BRIEF_UNKNOWN (400) for a brief_id this session does not have.
919
+ */
920
+ function briefFor(rawSpec, sessionId, origin) {
921
+ if (!clarifier?.forDispatch || origin === 'ui' || !sessionId) return null;
922
+ const independent = rawSpec.independent === true || rawSpec.independent === 'true';
923
+ try {
924
+ return clarifier.forDispatch(sessionId, { briefId: rawSpec.briefId ? String(rawSpec.briefId) : null, independent }) || null;
925
+ } catch (error) {
926
+ if (!error?.code) throw error;
927
+ throw new DispatchError(error.code, error.message, { status: Number(error.status) || 409, ...(error.briefId ? { briefId: error.briefId } : {}) });
928
+ }
929
+ }
930
+
931
+ /**
932
+ * A remote session's dispatch, clamped to its level (every origin: the level
933
+ * belongs to the session): REMOTE_READ_ONLY / REMOTE_CWD_NOT_ALLOWED (409),
934
+ * or a copy without computer use and, below autonomous, within the rules of
935
+ * clampDispatchSpec. What changed goes to `notes` (the run's notes).
936
+ */
937
+ function clampRemote(spec, sessionId, notes = []) {
938
+ if (!sessionId || typeof sessionPolicy !== 'function') return spec;
939
+ let policy = null;
940
+ try { policy = sessionPolicy(String(sessionId)) || null; } catch { policy = { level: 'read-only', failClosed: true }; }
941
+ if (!policy) return spec;
942
+ let projects = [];
943
+ try { projects = (typeof registeredProjects === 'function' ? registeredProjects() : registeredProjects) || []; } catch { projects = []; }
944
+ try {
945
+ return clampDispatchSpec(policy, spec, { registeredProjects: projects, defaultCwd: PACKAGE_ROOT, now: now(), notes });
946
+ } catch (error) {
947
+ if (error instanceof RemotePolicyError) throw new DispatchError(error.code, error.message, { status: error.status || 409 });
948
+ throw error;
949
+ }
950
+ }
951
+
952
+ /** BROWSER_SETTINGS_CONFLICT (400) for a spec whose browser settings cannot agree, whatever the provider. */
953
+ function refuseBrowserConflict(spec) {
954
+ const conflict = browserSettingsConflict(spec, null);
955
+ if (conflict) throw new DispatchError('BROWSER_SETTINGS_CONFLICT', conflict, { status: 400 });
956
+ }
957
+
958
+ /**
959
+ * `brief`: a brief carried over from an earlier run (escalation, retry); never from a request body.
960
+ * `notes`: what the caller changed in the spec (a planning brain's read-only clamp), for the run's notes.
961
+ */
962
+ async function dispatch(rawSpec = {}, { assistantSessionId = null, origin = 'assistant', brief: carried = null, notes: callerNotes = null } = {}) {
963
+ const sessionId = assistantSessionId ? String(assistantSessionId) : null;
964
+ const idempotencyKey = rawSpec.idempotencyKey || rawSpec.idempotency_key ? String(rawSpec.idempotencyKey || rawSpec.idempotency_key).slice(0, 200) : null;
965
+ if (idempotencyKey && sessionId) {
966
+ const hit = idempotency.get(`${sessionId}:${idempotencyKey}`);
967
+ if (hit && now() - hit.at < limits.idempotencyTtlMs && entries.has(hit.runId)) {
968
+ const prior = entries.get(hit.runId);
969
+ return { ok: true, replayed: true, queued: WAITING_STATES.has(prior.state), awaitingRoute: prior.state === 'awaiting_route', run: view(prior) };
970
+ }
971
+ }
972
+ 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
+ }
977
+ // "true" (a string) is computer use too: normalizeSpec reads it so, and so must the gate.
978
+ rawSpec = { ...rawSpec, usesComputer: rawSpec.usesComputer === true || rawSpec.usesComputer === 'true' };
979
+ // A design dispatch's defaults fill what it leaves out, before any clamp; any dispatch's own browser
980
+ // settings must agree before a route card is shown (normalizeSpec re-checks the final target, provider included).
981
+ rawSpec = withClassDefaults(rawSpec, knownClass(rawSpec.taskClass));
982
+ refuseBrowserConflict(rawSpec);
983
+ // A remote session (WhatsApp): its level decides what a worker may be (lib/remote-policy.js).
984
+ // Before the computer gate: a remote run drops computer use with a note on the run, rather than
985
+ // being refused with "turn the Computer toggle on", which the phone cannot do.
986
+ const remoteNotes = Array.isArray(callerNotes) ? callerNotes.map(String).filter(Boolean) : [];
987
+ rawSpec = clampRemote(rawSpec, sessionId, remoteNotes);
988
+ if (rawSpec.usesComputer && desktop && sessionId && typeof desktop.sessionAllows === 'function' && !desktop.sessionAllows(sessionId)) {
989
+ throw new DispatchError('COMPUTER_SESSION_OFF', 'Computer use is switched off for this assistant session. Ask the user to turn the Computer toggle on.', { status: 409 });
990
+ }
991
+ // Clarification first: work that depends on open questions waits for the
992
+ // user's answers (no route card, no hold), and carries them once answered.
993
+ const brief = carried && typeof carried === 'object' ? carried : briefFor(rawSpec, sessionId, origin);
994
+ const catalogValue = await catalogSnapshot();
995
+ // Routing (ask modes may hold the run until the user picks a model).
996
+ let routed = null;
997
+ if (router && origin !== 'ui' && sessionId) {
998
+ routed = await router.resolveDispatch({ sessionId, spec: rawSpec, origin });
999
+ // A hold names an open card, but an answer that was already in flight (it waited on the same
1000
+ // catalog read) can decide that card before this continuation runs. Approved for this
1001
+ // session: the dispatch is resolved again with that route_id, so the run starts on it as any
1002
+ // dispatch with an approved route does (its validation, the remote clamps and claim, the
1003
+ // rails). Declined, cancelled or expired: the attachment below fails and it is refused.
1004
+ for (let i = 0; i < 2 && routed?.action === 'hold' && routeApprovedFor(routed.routeId, sessionId); i += 1) {
1005
+ routed = { ...(await router.resolveDispatch({ sessionId, spec: { ...rawSpec, routeId: routed.routeId }, origin })), approvedMeanwhile: true };
1006
+ }
1007
+ }
1008
+ // A remote session's approved route, claimed by resolveDispatch, is used up only by the run it
1009
+ // starts: a dispatch refused before its run exists gives the route back, so retrying the route_id
1010
+ // gets the route again (it runs, or is refused for the same reason), never a fresh route of another class.
1011
+ let runId = null;
1012
+ try {
1013
+ // An approved route may have moved it to another provider: clamp for that one
1014
+ // (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;
1016
+ // A route approved for other work cannot carry image / video creation or design, nor the other way round:
1017
+ // the class decides the model's capability check, the worker's media block or rules and what is collected.
1018
+ const declaredClass = knownClass(rawSpec.taskClass);
1019
+ const routeClass = knownClass(routed?.route?.taskClass);
1020
+ if (declaredClass && routeClass && runContract(declaredClass) !== runContract(routeClass)) {
1021
+ throw new DispatchError('ROUTE_CLASS_MISMATCH', `Route ${routed.route.routeId || ''} was approved for ${routeClass} work, but this dispatch says task_class "${declaredClass}". Call agent_route with task_class "${declaredClass}" and dispatch with its route_id.`, { status: 409, routeId: routed.route.routeId || null, routeClass, taskClass: declaredClass });
1022
+ }
1023
+ // The route's spec (a route_id alone can make it a design dispatch, with design's defaults):
1024
+ // its browser settings must agree too (the route already exists).
1025
+ if (routed?.spec) refuseBrowserConflict(input);
1026
+ if (routed?.action === 'hold') return holdForRoute(input, routed, { sessionId, origin, idempotencyKey, brief, remoteNotes });
1027
+ // The run's class: its route's (the brain may omit task_class with a route_id), else the spec's.
1028
+ // Its capability backstop runs in normalizeSpec on the final target, however that was chosen.
1029
+ const taskClass = routeClass || knownClass(input.taskClass);
1030
+ const spec = normalizeSpec(input, { catalogValue, taskClass });
1031
+ const queuedCount = [...entries.values()].filter((e) => e.state === 'queued' && e.assistantSessionId === sessionId).length;
1032
+ runId = randomId();
1033
+ const entry = { ...baseEntry(runId, sessionId, origin), ...specFields(spec), notes: [...(spec.notes || []), ...remoteNotes] };
1034
+ if (taskClass) entry.taskClass = taskClass;
1035
+ if (brief) { entry.brief = brief; entry.notes.push(`carries the user's brief ${brief.briefId || ''}`.trim()); }
1036
+ if (routed?.route) entry.route = routed.route;
1037
+ entries.set(runId, entry);
1038
+ try { refuseUnpriced(entry); fitBudget(entry); } catch (error) { entries.delete(runId); throw error; }
1039
+ entry.taskId = taskAtDispatch(sessionId);
1040
+ if (spec.idempotencyKey && sessionId) idempotency.set(`${sessionId}:${spec.idempotencyKey}`, { runId, at: now() });
1041
+ const launched = await launchOrQueue(entry, spec, { queuedCount, strict: true });
1042
+ // The route's own "decided" event (it had no run attached) still tells the brain to dispatch.
1043
+ 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;
1046
+ } catch (error) {
1047
+ // A run that exists (it failed or stopped at start) used the route; a refusal before one did not.
1048
+ if (routed?.claimed && !(runId && entries.has(runId))) router.releaseRoute?.(routed.route?.routeId);
1049
+ throw error;
1050
+ }
1051
+ }
1052
+
1053
+ /** The router decided this route as approved, for this session (after a hold was returned on its card). */
1054
+ function routeApprovedFor(routeId, sessionId) {
1055
+ let decided = null;
1056
+ try { decided = router?.status?.(routeId) || null; } catch { decided = null; }
1057
+ return decided?.status === 'approved' && String(decided.sessionId || '') === String(sessionId || '');
1058
+ }
1059
+
1060
+ async function launchOrQueue(entry, spec, { queuedCount = 0, strict = false } = {}) {
1061
+ const block = railBlock(spec, entry.assistantSessionId);
1062
+ if (block) {
1063
+ if (strict && !spec.queueIfBusy) {
1064
+ entries.delete(entry.runId);
1065
+ throw new DispatchError('MAX_CONCURRENT', block, { status: 409 });
1066
+ }
1067
+ if (strict && queuedCount >= limits.queuedPerSession) {
1068
+ entries.delete(entry.runId);
1069
+ throw new DispatchError('QUEUE_FULL', `assistant session already has ${queuedCount} queued dispatches`, { status: 409 });
1070
+ }
1071
+ entry.notes.push(`queued: ${block}`);
1072
+ entry.state = 'queued';
1073
+ queue.push(entry.runId);
1074
+ emit(entry, 'queued', { position: queue.length });
1075
+ return { ok: true, queued: true, position: queue.length, run: view(entry) };
1076
+ }
1077
+ await start(entry);
1078
+ return { ok: true, queued: false, run: view(entry) };
1079
+ }
1080
+
1081
+ /** Accept a dispatch the router is holding for the user's model choice. */
1082
+ function holdForRoute(input, routed, { sessionId, origin, idempotencyKey, brief = null, remoteNotes = [] }) {
1083
+ const task = String(input.task || '').trim();
1084
+ if (!task) throw new DispatchError('TASK_REQUIRED', 'task is required');
1085
+ if (task.length > 20_000) throw new DispatchError('TASK_TOO_LONG', 'task must be at most 20000 characters');
1086
+ const cwd = String(input.cwd || input.project || PACKAGE_ROOT).trim();
1087
+ if (!cwd || !isDirectory(cwd)) throw new DispatchError('CWD_INVALID', `cwd is not a directory: ${cwd}`);
1088
+ const held = [...entries.values()].filter((e) => e.state === 'awaiting_route' && e.assistantSessionId === sessionId).length;
1089
+ if (held >= limits.heldPerSession) throw new DispatchError('HELD_FULL', `assistant session already has ${held} dispatches waiting for a model choice`, { status: 409 });
1090
+ const runId = randomId();
1091
+ const entry = {
1092
+ ...baseEntry(runId, sessionId, origin),
1093
+ state: 'awaiting_route',
1094
+ provider: NATIVE_PROVIDERS.has(String(input.provider || '')) ? String(input.provider) : null,
1095
+ model: input.model ? String(input.model) : null, effort: input.effort ? String(input.effort) : null,
1096
+ cwd, project: detectProject(cwd) || 'global', title: clip(input.title || task.split('\n')[0], 80), task,
1097
+ context: String(input.context || '').trim() || null,
1098
+ tags: Array.isArray(input.tags) ? input.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 10) : [],
1099
+ workflowId: input.workflowId ? String(input.workflowId) : null, parentRunId: input.parentRunId ? String(input.parentRunId) : null,
1100
+ usesBrowser: !!input.usesBrowser, usesComputer: input.usesComputer === true, capability: normalizeCapability(input.capability || 'full'),
1101
+ idempotencyKey, rawSpec: { ...input }, route: routed.route,
1102
+ notes: ['awaiting the user\'s model choice on the route card', ...remoteNotes],
1103
+ };
1104
+ const heldClass = knownClass(routed.route?.taskClass) || knownClass(input.taskClass);
1105
+ if (heldClass) entry.taskClass = heldClass;
1106
+ if (brief) entry.brief = brief;
1107
+ // A held run belongs to the task that asked for it, whenever the user picks its model.
1108
+ entry.taskId = taskAtDispatch(sessionId);
1109
+ // The run waits on its route card, so the card must still be open: one that closed meanwhile
1110
+ // (the user wrote something else, a Stop) would leave a run nothing ever starts or declines.
1111
+ // Attached before the run exists: a refusal leaves no entry and no idempotency key behind.
1112
+ if (typeof router?.holdRuns === 'function' && router.holdRuns(routed.routeId, [runId]) === false) {
1113
+ const closed = router.routeClosedError?.(routed.routeId);
1114
+ throw new DispatchError('ROUTE_CANCELLED', closed?.message || 'This route is no longer valid: its card closed before the run could wait on it. Nothing was started; ask the user how to proceed.', { status: 409, routeId: routed.routeId });
1115
+ }
1116
+ entries.set(runId, entry);
1117
+ if (idempotencyKey && sessionId) idempotency.set(`${sessionId}:${idempotencyKey}`, { runId, at: now() });
1118
+ emit(entry, 'route_pending', { routeId: routed.routeId });
1119
+ return {
1120
+ ok: true, queued: true, awaitingRoute: true, routeId: routed.routeId, run: view(entry),
1121
+ next: 'This run is waiting for the user to pick its model on the route card. Do not dispatch it again. It starts automatically after the pick; the decision arrives as a [SynaBun Mailbox] route_decided event.',
1122
+ };
1123
+ }
1124
+
1125
+ /**
1126
+ * Why the held run `runId` could not start on `target`, else null: the router's checkHeld, asked
1127
+ * before it approves a route card's pick, so a pick resolveRoute would refuse is not approved,
1128
+ * saved or reported started. The refusals resolveRoute raises for design runs (sight on the
1129
+ * catalog as it stands: MODEL_CANNOT_SEE, MODEL_UNAVAILABLE) and, for every run, browser
1130
+ * settings on that provider (BROWSER_SETTINGS_CONFLICT). Other refusals still come in resolveRoute.
1131
+ */
1132
+ function heldRouteRefusal(runId, target) {
1133
+ const entry = entries.get(String(runId || ''));
1134
+ if (!entry || entry.state !== 'awaiting_route' || !target?.provider) return null;
1135
+ const provider = String(target.provider).trim().toLowerCase();
1136
+ const model = target.model && String(target.model).trim() ? String(target.model).trim() : null;
1137
+ let catalogValue = null;
1138
+ try { catalogValue = catalog?.peek?.() || null; } catch { catalogValue = null; }
1139
+ const refusal = designRefusal(catalogValue, provider, model, entry.taskClass || null);
1140
+ if (refusal) return refusal;
1141
+ const conflict = browserSettingsConflict({ ...(entry.rawSpec || {}), provider, model }, provider);
1142
+ return conflict ? new DispatchError('BROWSER_SETTINGS_CONFLICT', conflict, { status: 400 }) : null;
1143
+ }
1144
+
1145
+ /** The user picked a target for a held run: validate, then start or queue it. */
1146
+ async function resolveRoute(runId, { target, routeId = null, decidedBy = 'user', remembered = false, optionId = null } = {}) {
1147
+ const entry = entries.get(runId);
1148
+ if (!entry || entry.state !== 'awaiting_route' || !target?.provider) return { ok: false, code: 'NOT_HELD' };
1149
+ const catalogValue = await catalogSnapshot();
1150
+ let input = { ...(entry.rawSpec || {}), provider: target.provider, model: target.model || null, effort: target.effort ?? entry.rawSpec?.effort ?? null };
1151
+ let spec;
1152
+ // The picked provider may need another clamp (Codex under strict worker approvals), or the level fell meanwhile.
1153
+ const remoteNotes = [];
1154
+ try { input = clampRemote(input, entry.assistantSessionId, remoteNotes); spec = normalizeSpec(input, { catalogValue, codexAskFallback: true, taskClass: entry.taskClass || null }); }
1155
+ catch (error) {
1156
+ entry.state = 'failed'; entry.completionReason = 'route_invalid'; entry.finishedAt = iso(now()); entry.turnState = 'terminal';
1157
+ entry.error = error?.message || String(error);
1158
+ entry.notes.push(`route target rejected: ${entry.error}`);
1159
+ delete entry.rawSpec;
1160
+ emit(entry, 'failed');
1161
+ return { ok: false, error };
1162
+ }
1163
+ Object.assign(entry, specFields(spec));
1164
+ entry.notes.push(...(spec.notes || []), ...remoteNotes.filter((note) => !entry.notes.includes(note)), `route decided by ${decidedBy}: ${spec.provider}/${spec.model || 'default'}`);
1165
+ entry.route = {
1166
+ ...(entry.route || {}), routeId: routeId || entry.route?.routeId || null, status: 'approved', decidedBy, remembered, optionId,
1167
+ target: describeTarget(target, { catalog: catalogValue }), decidedAt: iso(now()),
1168
+ };
1169
+ delete entry.rawSpec;
1170
+ entry.state = 'queued';
1171
+ emit(entry, 'route_decided', { routeId });
1172
+ try { return await launchOrQueue(entry, spec); }
1173
+ catch (error) { entry.notes.push(`start after route failed: ${error?.message || error}`); return { ok: false, error }; }
1174
+ }
1175
+
1176
+ function declineRoute(runId, { reason = 'route_declined' } = {}) {
1177
+ const entry = entries.get(runId);
1178
+ if (!entry || entry.state !== 'awaiting_route') return false;
1179
+ entry.state = 'stopped'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
1180
+ entry.route = { ...(entry.route || {}), status: reason === 'route_expired' ? 'expired' : 'declined', decidedBy: reason === 'route_expired' ? 'timeout' : 'user', decidedAt: iso(now()) };
1181
+ delete entry.rawSpec;
1182
+ emit(entry, 'stopped');
1183
+ return true;
1184
+ }
1185
+
1186
+ /** Re-dispatch a failed/blocked run one tier up, or on the same model after a transient failure (the user pressed Escalate / Retry). */
1187
+ async function escalate(runId, { target = null } = {}) {
1188
+ const entry = requireEntry(runId);
1189
+ const to = target?.provider ? target : entry.escalation?.to;
1190
+ if (!to?.provider) throw new DispatchError('NO_ESCALATION', `Run ${runId} has no escalation target`, { status: 409 });
1191
+ // A transient failure (timeout, rate limit, outage) is retried as it was, not escalated.
1192
+ const retry = entry.escalation?.kind === 'retry' && to.provider === entry.provider && String(to.model || '') === String(entry.model || '');
1193
+ const summary = entry.lastResult?.summary ? clip(entry.lastResult.summary, 1500) : null;
1194
+ const note = retry
1195
+ ? `RETRY: a previous attempt on ${entry.provider}/${entry.model || 'default'} ended ${entry.escalation?.reason || entry.state} on a temporary failure (a timeout, rate limit, outage or crash).${summary ? ` Its result: ${summary}` : ''} Try again, continuing from there.`
1196
+ : `ESCALATION: a previous attempt on ${entry.provider}/${entry.model || 'default'} ended ${entry.escalation?.reason || entry.state}.${summary ? ` Its result: ${summary}` : ''} Continue from there.`;
1197
+ const context = [entry.context, note].filter(Boolean).join('\n\n');
1198
+ const result = await dispatch({
1199
+ provider: to.provider, model: to.model || null, effort: to.effort || null, task: entry.task, cwd: entry.cwd, context,
1200
+ title: clip(`${entry.title} ${retry ? '↻' : '↑'}`, 80), tags: entry.tags, workflowId: entry.workflowId, parentRunId: runId,
1201
+ mcpProfile: entry.provider === to.provider ? entry.mcpProfile : undefined, capability: entry.capability,
1202
+ permissionPolicy: to.provider === 'codex' && entry.permissionPolicy === 'ask' ? 'restricted' : entry.permissionPolicy,
1203
+ usesBrowser: entry.usesBrowser, usesComputer: entry.usesComputer, maxMinutes: entry.maxMinutes, budgetUsd: entry.budgetUsd,
1204
+ outputSchema: entry.outputSchema, taskClass: entry.taskClass || null,
1205
+ }, { assistantSessionId: entry.assistantSessionId, origin: 'ui', brief: entry.brief || null });
1206
+ const child = entries.get(result?.run?.runId);
1207
+ if (child) { child.escalationDepth = (Number(entry.escalationDepth) || 0) + 1; child.escalatedFrom = runId; }
1208
+ entry.notes.push(`${retry ? 'retried on' : 'escalated to'} ${to.provider}/${to.model || 'default'} as ${result?.run?.runId || '?'}`);
1209
+ emit(entry, 'escalated', { childRunId: result?.run?.runId || null });
1210
+ return result;
1211
+ }
1212
+ function computeEscalation(entry, stateOverride = null) {
1213
+ if (!router?.escalationFor) return null;
1214
+ try { return router.escalationFor({ run: stateOverride ? { ...entry, state: stateOverride } : entry, depth: Number(entry.escalationDepth) || 0 }) || null; }
1215
+ catch { return null; }
1216
+ }
1217
+
1218
+ // ── Jev (worker-outcome, rider worker-claim) ────────────────────────────────
1219
+ // judge() is bounded by the surface timeout; this guard only keeps a wrapper
1220
+ // bug from holding a turn or a 'failed' event forever.
1221
+ const JUDGE_GUARD_MS = 10_000;
1222
+ function bounded(promise) {
1223
+ let timer = null;
1224
+ return Promise.race([
1225
+ Promise.resolve(promise).catch(() => null),
1226
+ new Promise((resolveGuard) => { timer = setTimeout(() => resolveGuard(null), JUDGE_GUARD_MS); timer.unref?.(); }),
1227
+ ]).finally(() => clearTimeout(timer));
1228
+ }
1229
+ function lastParagraph(text) {
1230
+ const parts = String(text || '').split(/\n\s*\n/).map((part) => part.trim()).filter(Boolean);
1231
+ return parts.length ? parts[parts.length - 1] : '';
1232
+ }
1233
+ function tailOf(text, max = 300) {
1234
+ const s = String(text || '').trim();
1235
+ return s.length > max ? s.slice(s.length - max) : s;
1236
+ }
1237
+ function judgeContext(entry) {
1238
+ return { runId: entry.runId, assistantSessionId: entry.assistantSessionId || null, project: entry.project || null };
1239
+ }
1240
+ /**
1241
+ * What Jev answered for the asked questions, applied or not. The log keeps it
1242
+ * beside what was applied, so a cause judged below minConfidence (the 0.61
1243
+ * "transient" of the acceptance run) does not read as a missing judgment.
1244
+ */
1245
+ function judgedView(judged) {
1246
+ const v = judged?.verdict;
1247
+ if (!v || typeof v !== 'object') return null;
1248
+ const round = (x) => (Number.isFinite(Number(x)) ? Math.round(Number(x) * 100) / 100 : null);
1249
+ const out = {};
1250
+ if (judged.asked?.status && typeof v.status === 'string') { out.status = v.status; out.statusConfidence = round(v.statusConfidence); }
1251
+ if (judged.asked?.cause && typeof v.cause === 'string') { out.cause = v.cause; out.causeConfidence = round(v.causeConfidence); }
1252
+ return Object.keys(out).length ? out : null;
1253
+ }
1254
+ /**
1255
+ * One request about a finished turn, before any result-retry turn: the
1256
+ * status (only without a ## Result block, and never for output_schema runs),
1257
+ * the cause (no block, or blocked), and the claim (a possible "done" with
1258
+ * something to check it against).
1259
+ */
1260
+ async function judgeTurn(entry, { text, parsed, noBlock, evidence, followUp }) {
1261
+ if (!judge?.workerOutcome) return null;
1262
+ const ask = {
1263
+ status: noBlock && !entry.outputSchema,
1264
+ cause: noBlock || parsed.status === 'blocked',
1265
+ claim: (noBlock || parsed.status === 'done') && evidence.hasEvidence,
1266
+ };
1267
+ if (!ask.status && !ask.cause && !ask.claim) return null;
1268
+ const judged = await bounded(judge.workerOutcome({
1269
+ task: followUp ? `${entry.task}\n\nFollow-up: ${followUp}` : entry.task,
1270
+ message: text,
1271
+ declared: parsed.found ? { status: parsed.status, summary: parsed.summary, question: parsed.question } : null,
1272
+ run: { provider: entry.provider, state: 'idle', completionReason: null, error: null },
1273
+ filesEdited: evidence.files, commands: evidence.commands, toolResults: evidence.results,
1274
+ }, ask, judgeContext(entry)));
1275
+ if (!judged || typeof judged !== 'object') return null;
1276
+ // Only answers to questions this turn asked count.
1277
+ return {
1278
+ ...judged,
1279
+ status: ask.status ? judged.status || null : null,
1280
+ cause: ask.cause ? judged.cause || null : null,
1281
+ claimFlagged: ask.claim ? !!judged.claimFlagged : false,
1282
+ claimProbability: ask.claim ? judged.claimProbability ?? null : null,
1283
+ };
1284
+ }
1285
+ /**
1286
+ * A failed run: what kept it from finishing (capability / access / needs the
1287
+ * user / transient) decides which escalation is offered, so 'failed' waits
1288
+ * for it — at most the surface timeout. Without Jev it is emitted at once.
1289
+ */
1290
+ function emitFailed(entry, evidenceAcc = null) {
1291
+ const done = () => { delete entry.judging; entry.escalation = computeEscalation(entry); emit(entry, 'failed'); };
1292
+ if (!judge?.workerOutcome || entry.completionReason === 'budget_cap') { done(); return Promise.resolve(); }
1293
+ entry.judging = true;
1294
+ const evidence = evidenceSnapshot(evidenceAcc);
1295
+ const error = [entry.error, ...(entry.notes || []).slice(-3)].filter(Boolean).map((line) => String(line)).join('\n');
1296
+ const last = entry.lastResult && typeof entry.lastResult === 'object' ? entry.lastResult : null;
1297
+ return bounded(judge.workerOutcome({
1298
+ task: entry.task,
1299
+ message: entry.lastText || '',
1300
+ declared: last?.found ? { status: last.status, summary: last.summary, question: last.question } : null,
1301
+ run: { provider: entry.provider, state: 'failed', completionReason: entry.completionReason || null, error },
1302
+ filesEdited: evidence.files, commands: evidence.commands, toolResults: evidence.results,
1303
+ }, { status: false, cause: true, claim: false }, judgeContext(entry))).then((judged) => {
1304
+ const view = judgedView(judged);
1305
+ try {
1306
+ if (judged?.cause) {
1307
+ entry.failure = { cause: judged.cause, confidence: judged.causeConfidence, needs: clip(entry.error || error || lastParagraph(entry.lastText) || tailOf(entry.lastText), 200) || null, source: 'jev' };
1308
+ } else if (view?.cause) {
1309
+ // Judged, but below minConfidence: not applied (the escalation stays the old ladder), still visible.
1310
+ entry.failure = { cause: null, confidence: null, needs: null, source: 'jev', judgedCause: view.cause, judgedConfidence: view.causeConfidence };
1311
+ }
1312
+ } catch { /* the failure stays unjudged */ }
1313
+ delete entry.judging;
1314
+ entry.escalation = computeEscalation(entry);
1315
+ if (judged?.logId) {
1316
+ try { judge.annotate?.(judged.logId, { run: entry.runId, turn: null, source: 'failure', retried: false, status: 'failed', cause: judged.cause || null, claim: null, escalation: entry.escalation?.kind || null, judged: view, applied: { cause: !!judged.cause } }); } catch {}
1317
+ }
1318
+ emit(entry, 'failed');
1319
+ }).catch(() => { if (entry.judging) done(); });
1320
+ }
1321
+
1322
+ async function start(entry) {
1323
+ const runtime = safeRuntime();
1324
+ 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.
1326
+ 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 = [];
1329
+ entry.notes.push(`not started: ${error?.message || error}`);
1330
+ emit(entry, 'stopped');
1331
+ throw error;
1332
+ }
1333
+ if (loopDir && !existsSync(loopDir)) mkdirSync(loopDir, { recursive: true });
1334
+ let browserSessionId = null;
1335
+ let browserTabId = null;
1336
+ if (entry.usesBrowser && typeof acquireLoopBrowserAndTab === 'function') {
1337
+ const acq = await acquireLoopBrowserAndTab({ requestedBrowserSessionId: entry.requestedBrowserSessionId, terminalSessionId: entry.runId, logTag: 'assistant:browser' });
1338
+ if (acq?.error) {
1339
+ entry.state = 'failed'; entry.completionReason = 'browser_unavailable'; entry.finishedAt = iso(now()); entry.notes.push(acq.error);
1340
+ emit(entry, 'failed');
1341
+ throw new DispatchError('PROVIDER_UNAVAILABLE', acq.error, { status: 500 });
1342
+ }
1343
+ browserSessionId = acq.browserSessionId || null;
1344
+ browserTabId = acq.browserTabId || null;
1345
+ }
1346
+ entry.browserSessionId = browserSessionId;
1347
+ entry.browserTabId = browserTabId;
1348
+ const liveState = {
1349
+ entry, ctx: null, ring: [], ringChars: 0, followUps: [], waitResolve: null, idleTimer: null,
1350
+ pendingResolvers: new Map(), dead: false, lastEventAt: now(), stalledFlagged: false, turnCounter: 0, abortReason: null,
1351
+ evidence: null, // the current turn's commands / tool results / edited files (assistant-evidence.js)
1352
+ };
1353
+ live.set(entry.runId, liveState);
1354
+ const extraEnv = {};
1355
+ if (entry.claudeConfigDir) extraEnv.CLAUDE_CONFIG_DIR = entry.claudeConfigDir;
1356
+ // Computer use: an unforgeable per-run grant unlocks the `computer` MCP
1357
+ // group for this worker only. Passed as a function so the loop runtime's
1358
+ // JSON state file never contains it.
1359
+ let desktopGrant = null;
1360
+ if (entry.usesComputer && desktop?.mintGrant) {
1361
+ try { desktopGrant = desktop.mintGrant({ kind: 'run', assistantSessionId: entry.assistantSessionId, runId: entry.runId, provider: entry.provider, model: entry.model, vision: entry.modelInfo?.vision ?? null }); }
1362
+ catch (error) { entry.notes.push(`desktop grant failed: ${error?.message || error}`); }
1363
+ }
1364
+ const state = {
1365
+ active: true, task: entry.task, context: entry.context, totalIterations: 1, currentIteration: 0,
1366
+ maxMinutes: entry.maxMinutes, startedAt: iso(now()), lastIterationAt: null, retries: 0, pending: false,
1367
+ terminalSessionId: entry.runId, usesBrowser: entry.usesBrowser, usesComputer: !!entry.usesComputer, browserSessionId, browserTabId,
1368
+ profile: entry.provider, provider: entry.provider, agent: entry.agent || null,
1369
+ model: entry.model, modelContextWindow: entry.modelContextWindow || null, effort: entry.effort, mcpProfile: entry.mcpProfile,
1370
+ codexAccountId: entry.codexAccountId, codexHome: entry.provider === 'codex' ? (entryCodexHome(entry)) : null,
1371
+ claudeAccountId: entry.claudeAccountId, claudeConfigDir: entry.claudeConfigDir,
1372
+ driverType: 'native', runtimeType: 'native', surface: 'sidepanel', runId: entry.runId, cwd: entry.cwd,
1373
+ runMode: 'task', assistantSessionId: entry.assistantSessionId, permissionPolicy: entry.permissionPolicy,
1374
+ capability: entry.capability, budgetUsd: entry.budgetUsd, maxBudgetUsd: entry.provider === 'claude-code' ? entry.budgetUsd : null,
1375
+ idleTimeoutMs: entry.idleTimeoutMs, maxTurns: entry.maxTurns, outputSchema: entry.outputSchema, project: entry.project,
1376
+ title: entry.title, tags: entry.tags, workflowId: entry.workflowId, parentRunId: entry.parentRunId,
1377
+ // The user's brief after a clarification (assistant-task-prompt.js briefBlock).
1378
+ brief: entry.brief || null,
1379
+ // Image / video creation: the prompt's media block and what the adapter collects ('image' | 'video');
1380
+ // design collects its screenshots and images the same way ('image').
1381
+ taskClass: entry.taskClass || null, collectMedia: collectedMedia(entry.taskClass),
1382
+ // extraEnv is a plain object, so it IS written to the loop state file;
1383
+ // keep secrets out of it. The desktop grant rides as a function instead.
1384
+ extraEnv,
1385
+ desktopGrantProvider: desktopGrant ? () => desktopGrant : null,
1386
+ // What the adapter reports while it starts (a Codex worker whose browser-policy hook is not trusted).
1387
+ onNote: (text) => { const note = String(text || '').trim(); if (note && !entry.notes.includes(note)) entry.notes.push(note); },
1388
+ };
1389
+ if (entry.permissionPolicy === 'ask') state.permissionBroker = createBroker(entry.runId);
1390
+ // A class's rules (design), read now so an edited override applies without a restart;
1391
+ // they go to the prompt only, not into the loop state file.
1392
+ const playbook = playbookFor(entry);
1393
+ // The project's Style Guide, read now like the rules: its summary and file paths go to the
1394
+ // prompt only (never the loop state file). null without a saved guide, or for a class it keeps out.
1395
+ const styleGuide = styleGuideFor(entry);
1396
+ entry.state = 'starting';
1397
+ entry.turnState = 'starting';
1398
+ entry.startedAt = state.startedAt;
1399
+ try {
1400
+ const descriptor = await runtime.launch({
1401
+ runId: entry.runId,
1402
+ state,
1403
+ source: 'assistant',
1404
+ focus: entry.focus === true,
1405
+ title: entry.title,
1406
+ buildPrompt: () => buildTaskPrompt(playbook || styleGuide ? { ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}) } : state),
1407
+ wrapAdapter: (adapter, ctx) => createTaskAdapter(adapter, ctx, liveState, state),
1408
+ });
1409
+ entry.runtime = { status: descriptor.status };
1410
+ emit(entry, 'started');
1411
+ reconcileKeepAwake();
1412
+ return descriptor;
1413
+ } catch (error) {
1414
+ entry.state = 'failed'; entry.completionReason = 'launch_error'; entry.finishedAt = iso(now());
1415
+ entry.notes.push(error?.message || String(error));
1416
+ if (browserSessionId) await releaseSharedBrowserTab(browserSessionId, entry.runId).catch(() => {});
1417
+ releaseDesktop(entry);
1418
+ // The escalation and the 'failed' event follow the cause (fire-and-forget; at once without Jev).
1419
+ emitFailed(entry, liveState.evidence || null);
1420
+ throw error;
1421
+ }
1422
+ }
1423
+ /**
1424
+ * What the run's prompt says about its project's Style Guide (lib/style-guide/store.js): the
1425
+ * summary for the run's class, DESIGN.md and the token files. null when the project has no saved
1426
+ * guide, when the guide keeps this class out, or when reading it fails: a run never waits on it.
1427
+ */
1428
+ function styleGuideFor(entry) {
1429
+ try { return loadStyleGuide(entry.cwd, entry.taskClass || null) || null; } catch { return null; }
1430
+ }
1431
+ /**
1432
+ * The rules a run of the entry's class follows (assistant-playbooks.js: the user's override
1433
+ * at <dataDir>/playbooks/<name>.md, else the shipped copy), or null. The run's notes say
1434
+ * when the override was used or why it was not.
1435
+ */
1436
+ function playbookFor(entry) {
1437
+ const name = classPlaybook(entry.taskClass);
1438
+ if (!name) return null;
1439
+ let playbook = null;
1440
+ try { playbook = loadPlaybook(name, { dataDir }); } catch { playbook = null; }
1441
+ if (!playbook) return { name, text: '', source: 'missing' };
1442
+ if (playbook.source === 'override') entry.notes.push(`${name} rules: your override ${playbook.path}`);
1443
+ if (playbook.note) entry.notes.push(`${name} rules: ${playbook.note}`);
1444
+ return playbook;
1445
+ }
1446
+ function releaseDesktop(entry) {
1447
+ if (!entry?.usesComputer || !desktop) return;
1448
+ try { desktop.revokeFor?.({ runId: entry.runId }); } catch {}
1449
+ try { desktop.releaseOwner?.({ runId: entry.runId }); } catch {}
1450
+ }
1451
+ function entryCodexHome(entry) {
1452
+ if (entry.codexHome) return entry.codexHome;
1453
+ const account = entry.codexAccountId ? findCodexAccount(entry.codexAccountId) : getCodexAccount(null);
1454
+ return account?.home || CODEX_DEFAULT_HOME || null;
1455
+ }
1456
+
1457
+ // ── permission broker ('ask' policy) ──────────────────────────────────────
1458
+ function createBroker(runId) {
1459
+ return {
1460
+ request: (request = {}) => new Promise((resolvePermission) => {
1461
+ const entry = entries.get(runId);
1462
+ const liveState = live.get(runId);
1463
+ if (!entry || !liveState) { resolvePermission({ behavior: 'deny', message: 'Run is no longer tracked.' }); return; }
1464
+ const requestId = `perm-${randomBytes(6).toString('hex')}`;
1465
+ const kind = request.kind === 'ask' ? 'question' : 'permission';
1466
+ const timeoutMs = kind === 'question' ? limits.questionTimeoutMs : limits.permissionTimeoutMs;
1467
+ const pending = {
1468
+ requestId, kind, toolName: request.toolName || null, input: request.input ?? null,
1469
+ questions: Array.isArray(request.questions) ? request.questions : [], createdAt: iso(now()), expiresAt: iso(now() + timeoutMs),
1470
+ };
1471
+ entry.pending = [...(entry.pending || []), pending];
1472
+ entry.state = 'awaiting_permission';
1473
+ entry.turnState = 'awaiting_permission';
1474
+ liveState.ctx?.setTurnState?.('awaiting_permission');
1475
+ liveState.ctx?.emit?.({ type: 'synabun.permission_request', requestId, kind, toolName: pending.toolName, input: pending.input, questions: pending.questions });
1476
+ const timer = setTimeout(() => finishPending(runId, requestId, { behavior: 'deny', message: `No decision within ${Math.round(timeoutMs / 60000)} minutes.`, resolvedBy: 'timeout' }), timeoutMs);
1477
+ timer.unref?.();
1478
+ liveState.pendingResolvers.set(requestId, { resolve: resolvePermission, timer });
1479
+ try { broadcastSync({ type: 'assistant:permission-request', runId, assistantSessionId: entry.assistantSessionId, provider: entry.provider, request_id: requestId, request: pending }); } catch {}
1480
+ emit(entry, kind === 'question' ? 'needs_input' : 'permission_request', { request: pending });
1481
+ const abortSignal = request.signal;
1482
+ if (abortSignal && typeof abortSignal.addEventListener === 'function') {
1483
+ abortSignal.addEventListener('abort', () => finishPending(runId, requestId, { behavior: 'deny', message: 'Run aborted.', resolvedBy: 'abort' }), { once: true });
1484
+ }
1485
+ }),
1486
+ };
1487
+ }
1488
+ function finishPending(runId, requestId, response, { origin = 'system' } = {}) {
1489
+ const entry = entries.get(runId);
1490
+ const liveState = live.get(runId);
1491
+ if (!entry || !liveState) return false;
1492
+ const resolver = liveState.pendingResolvers.get(requestId);
1493
+ if (!resolver) return false;
1494
+ clearTimeout(resolver.timer);
1495
+ liveState.pendingResolvers.delete(requestId);
1496
+ const request = (entry.pending || []).find((item) => item.requestId === requestId) || null;
1497
+ entry.pending = (entry.pending || []).filter((item) => item.requestId !== requestId);
1498
+ if (entry.state === 'awaiting_permission' && entry.pending.length === 0) {
1499
+ entry.state = 'running';
1500
+ entry.turnState = 'running';
1501
+ liveState.ctx?.setTurnState?.('running');
1502
+ }
1503
+ const reply = { behavior: response?.behavior === 'allow' ? 'allow' : 'deny', ...response };
1504
+ liveState.ctx?.emit?.({ type: 'synabun.permission_resolved', requestId, behavior: reply.behavior, toolName: request?.toolName || null, resolvedBy: reply.resolvedBy || origin });
1505
+ try { broadcastSync({ type: 'assistant:permission-resolved', runId, assistantSessionId: entry.assistantSessionId, requestId, behavior: reply.behavior, resolvedBy: reply.resolvedBy || origin }); } catch {}
1506
+ entry.notes.push(`${request?.kind || 'permission'} ${requestId} ${reply.behavior} by ${reply.resolvedBy || origin}`);
1507
+ resolver.resolve(reply);
1508
+ emit(entry, 'permission_resolved', { requestId, behavior: reply.behavior, resolvedBy: reply.resolvedBy || origin });
1509
+ return true;
1510
+ }
1511
+ function respondPermission(runId, requestId, response = {}, { origin = 'assistant' } = {}) {
1512
+ const entry = entries.get(runId);
1513
+ if (!entry) throw new DispatchError('RUN_NOT_FOUND', `Unknown run ${runId}`, { status: 404 });
1514
+ const liveState = live.get(runId);
1515
+ if (!liveState?.pendingResolvers.has(requestId)) {
1516
+ const known = (entry.notes || []).some((note) => note.includes(String(requestId)));
1517
+ throw new DispatchError(known ? 'PERMISSION_ALREADY_RESOLVED' : 'NO_PENDING_REQUEST', known ? `Request ${requestId} was already resolved` : `No pending request ${requestId} on run ${runId}`, { status: 409 });
1518
+ }
1519
+ const behavior = response.behavior === 'allow' || response.decision === 'allow' || response.decision === 'answer' ? 'allow' : 'deny';
1520
+ finishPending(runId, requestId, {
1521
+ behavior, updatedInput: response.updatedInput, message: response.message || response.note, always: !!response.always,
1522
+ answers: response.answers, resolvedBy: origin,
1523
+ }, { origin });
1524
+ return view(entry);
1525
+ }
1526
+
1527
+ // ── task adapter (turn loop) ───────────────────────────────────────────────
1528
+ function createTaskAdapter(inner, ctx, liveState, state) {
1529
+ const entry = liveState.entry;
1530
+ liveState.ctx = ctx;
1531
+ liveState.inner = inner;
1532
+ startUsageMetering(entry, liveState);
1533
+ const waitForFollowUp = () => new Promise((resolveWait) => {
1534
+ const finish = (value) => { if (liveState.idleTimer) { clearTimeout(liveState.idleTimer); liveState.idleTimer = null; } liveState.waitResolve = null; resolveWait(value); };
1535
+ if (liveState.followUps.length) { finish(liveState.followUps.shift()); return; }
1536
+ if (liveState.abortReason) { finish({ kind: 'abort' }); return; }
1537
+ liveState.waitResolve = finish;
1538
+ liveState.idleTimer = setTimeout(() => finish({ kind: 'timeout' }), entry.idleTimeoutMs);
1539
+ liveState.idleTimer.unref?.();
1540
+ });
1541
+ const runInnerTurn = async (prompt, meta, { origin = 'assistant', label = null, followUp = null, taskId = entry.taskId || null } = {}) => {
1542
+ liveState.turnCounter += 1;
1543
+ const turnNumber = liveState.turnCounter;
1544
+ const startedAt = iso(now());
1545
+ // The task this turn works for: every usage row it settles carries it (a result-retry turn included).
1546
+ liveState.usageTurn = { n: turnNumber, taskId };
1547
+ // Saved with the run: a result that arrives after a restart belongs to the turn that was
1548
+ // started, whether or not it was ever recorded.
1549
+ usageStateOf(entry).turn = { n: turnNumber, taskId };
1550
+ liveState.turnUsage = null;
1551
+ liveState.turnTokens = zeroTokens();
1552
+ liveState.turnOpen = true;
1553
+ startUsagePolling(entry, liveState);
1554
+ entry.state = 'running';
1555
+ entry.turnState = 'running';
1556
+ ctx.setTurnState('running');
1557
+ emit(entry, 'turn_started', { turn: turnNumber });
1558
+ // What this turn runs (commands, tool results, edited files): the evidence behind a "done".
1559
+ liveState.evidence = createTurnEvidence();
1560
+ // Money booked while this turn runs (OpenCode steps, Codex rollout records).
1561
+ liveState.turnMeteredUsd = 0;
1562
+ let raw = await inner.runTurn(prompt, { ...meta, turn: turnNumber, outputSchema: entry.outputSchema || undefined });
1563
+ if (entry.provider === 'codex') settleCodexTurn(entry, liveState, raw);
1564
+ let text = typeof raw?.text === 'string' ? raw.text : '';
1565
+ let parsed = parseResultContract(text);
1566
+ parsed.source = parsed.found ? 'contract' : null;
1567
+ // A run with an output schema answers with bare JSON: that object is its result, no retry turn.
1568
+ if (!parsed.found) parsed = structuredResult(entry, raw, text) || parsed;
1569
+ const noBlock = !parsed.found && strictResult && !!text.trim() && !liveState.dead;
1570
+ const evidence = evidenceSnapshot(liveState.evidence);
1571
+ // One Jev request before any retry turn (null without Jev: today's behaviour).
1572
+ const judged = await judgeTurn(entry, { text, parsed, noBlock, evidence, followUp });
1573
+ let retried = false;
1574
+ let retryCost = 0;
1575
+ if (judged?.status) {
1576
+ // The status its message reports is enough: no result-retry turn.
1577
+ parsed.status = judged.status;
1578
+ parsed.source = 'jev';
1579
+ parsed.judged = { status: judged.status, confidence: judged.statusConfidence };
1580
+ if (judged.status === 'needs_input') parsed.question = clip(lastParagraph(text), 500);
1581
+ if (evidence.files.length) { parsed.files = [...evidence.files]; parsed.changes = evidence.files.map((path) => ({ path, note: '' })); }
1582
+ } else if (noBlock && !liveState.dead) {
1583
+ retried = true;
1584
+ try {
1585
+ const retry = await inner.runTurn(buildResultRetryPrompt(), { ...meta, turn: turnNumber, retry: true });
1586
+ const retryText = typeof retry?.text === 'string' ? retry.text : '';
1587
+ const retryParsed = parseResultContract(retryText);
1588
+ if (retryParsed.found) { parsed = { ...retryParsed, source: 'retry' }; text = `${text}\n\n${retryText}`; }
1589
+ retryCost = accumulateCost(entry, retry);
1590
+ if (entry.provider === 'codex') settleCodexTurn(entry, liveState, retry);
1591
+ } catch (error) {
1592
+ retryCost = accumulateCost(entry, error);
1593
+ if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
1594
+ entry.notes.push(`result retry failed: ${error?.message || error}`);
1595
+ }
1596
+ }
1597
+ if (judged) {
1598
+ // Why it stopped short (blocked, or still no usable status): decides the escalation offered.
1599
+ const usable = parsed.found || parsed.source === 'jev';
1600
+ if (judged.cause && (parsed.status === 'blocked' || !usable)) {
1601
+ parsed.cause = judged.cause;
1602
+ parsed.causeConfidence = judged.causeConfidence;
1603
+ parsed.needs = clip(parsed.question || (parsed.found ? parsed.summary : '') || lastParagraph(text) || tailOf(text, 300), 200) || null;
1604
+ }
1605
+ // "Done" while the turn's own output does not show it.
1606
+ if (parsed.status === 'done' && judged.claimFlagged) {
1607
+ parsed.unverifiedClaim = { probability: judged.claimProbability, evidence: claimEvidenceLine(evidence) };
1608
+ }
1609
+ }
1610
+ // Image / video creation: the files this turn generated, copied under data/media/<runId>/.
1611
+ const media = await collectTurnMedia(entry, raw, parsed);
1612
+ parsed.media = media.map(({ kind, path, url, mime, bytes }) => ({ kind, path, url, mime, bytes }));
1613
+ if (media.length) parsed.files = [...new Set([...(parsed.files || []), ...media.map((m) => m.path)])];
1614
+ // The turn's own usage. Codex was read from its rollout at each provider turn's end; OpenCode
1615
+ // is reconciled with what its serve stored (child sessions included) before it is settled.
1616
+ liveState.turnOpen = false;
1617
+ stopUsagePolling(liveState);
1618
+ let turnUsage = raw?.usage || null;
1619
+ if (entry.provider === 'codex') turnUsage = liveState.turnUsage || null;
1620
+ else if (entry.provider === 'opencode') turnUsage = (await settleOpenCodeTurn(entry, liveState)) || turnUsage;
1621
+ // A reported cost below an earlier turn's estimate can make this negative: a turn never reads below $0.
1622
+ const turnCost = Math.max(0, accumulateCost(entry, raw) + retryCost + (Number(liveState.turnMeteredUsd) || 0));
1623
+ // `usage` is what the provider reported (Claude: the first result's main loop only). `tokens`
1624
+ // is the whole turn: every row settled for it, the retry turn and sub-agents included.
1625
+ const turn = {
1626
+ n: turnNumber, taskId, origin, label, startedAt, endedAt: iso(now()), promptPreview: clip(prompt, 200), text: text.slice(0, 20_000),
1627
+ usage: turnUsage, tokens: withTotal(liveState.turnTokens), costUsd: Number(turnCost.toFixed(8)), result: parsed, structured: raw?.structured ?? null,
1628
+ };
1629
+ // Rows that settle from here on (a late rollout record) go to the record itself.
1630
+ liveState.turnTokens = null;
1631
+ entry.turns = [...(entry.turns || []), turn].slice(-50);
1632
+ entry.lastResult = parsed;
1633
+ entry.lastText = text.slice(0, 20_000);
1634
+ // Blocked or contract-less turns offer a one-tier escalation (the run
1635
+ // itself stays warm for follow-ups; the brain or the user decides). A
1636
+ // status Jev read from the message is a usable result.
1637
+ entry.escalation = parsed.status === 'blocked' || (!parsed.found && parsed.source !== 'jev') ? computeEscalation(entry, 'idle') : null;
1638
+ if (judged?.logId) {
1639
+ try {
1640
+ judge.annotate?.(judged.logId, {
1641
+ run: entry.runId, turn: turnNumber, source: parsed.source, retried, status: parsed.status, cause: parsed.cause || null,
1642
+ claim: judged.claimProbability === null || judged.claimProbability === undefined ? null : !!parsed.unverifiedClaim,
1643
+ escalation: entry.escalation ? (entry.escalation.kind || 'escalate') : null,
1644
+ judged: judgedView(judged), applied: { status: parsed.source === 'jev', cause: !!parsed.cause },
1645
+ });
1646
+ } catch {}
1647
+ }
1648
+ // The worker remembers before its ## Result block: this turn's events carry that memory.
1649
+ syncMemory(entry);
1650
+ if (parsed.status === 'needs_input') emit(entry, 'needs_input', { turn: turnNumber, question: parsed.question });
1651
+ emit(entry, 'turn_completed', { turn: turnNumber });
1652
+ announceUnpriced(entry);
1653
+ checkSession(entry.assistantSessionId, { source: entry });
1654
+ return parsed;
1655
+ };
1656
+ return {
1657
+ identity: () => (typeof inner.identity === 'function' ? inner.identity() : {}),
1658
+ isAlive: () => !liveState.dead && (typeof inner.isAlive === 'function' ? inner.isAlive() : true),
1659
+ describe: () => ({ ...(typeof inner.describe === 'function' ? inner.describe() : {}), runMode: 'task' }),
1660
+ async runTurn(prompt, meta = {}) {
1661
+ try {
1662
+ await runInnerTurn(prompt, meta, { origin: 'dispatch' });
1663
+ } catch (error) {
1664
+ return handleTurnError(error, liveState, entry, ctx);
1665
+ }
1666
+ for (;;) {
1667
+ // Out of money (the run's cap or the session's): stop instead of idling warm.
1668
+ refreshLimits();
1669
+ const spentOut = budgetBlock(entry);
1670
+ if (spentOut) return budgetStop(entry, spentOut);
1671
+ entry.state = 'idle';
1672
+ entry.turnState = 'idle';
1673
+ ctx.setTurnState('idle');
1674
+ emit(entry, 'idle');
1675
+ const next = await waitForFollowUp();
1676
+ 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 }; }
1678
+ if (next.kind === 'timeout') { entry.completionReason = 'idle_timeout'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1679
+ if (liveState.turnCounter >= entry.maxTurns) {
1680
+ entry.completionReason = 'max_turns';
1681
+ entry.notes.push(`follow-up dropped: max turns (${entry.maxTurns}) reached`);
1682
+ return { providerSessionId: inner.identity?.()?.providerSessionId };
1683
+ }
1684
+ refreshLimits();
1685
+ const blocked = budgetBlock(entry);
1686
+ if (blocked) {
1687
+ entry.notes.push(`follow-up dropped: ${blocked.message}`);
1688
+ return budgetStop(entry, blocked);
1689
+ }
1690
+ try {
1691
+ const followPrompt = buildFollowUpPrompt(next.text, state, { turn: liveState.turnCounter + 1, maxTurns: entry.maxTurns, origin: next.origin });
1692
+ await runInnerTurn(followPrompt, meta, { origin: next.origin, label: clip(next.text, 80), followUp: next.text, taskId: next.taskId || entry.taskId || null });
1693
+ } catch (error) {
1694
+ return handleTurnError(error, liveState, entry, ctx);
1695
+ }
1696
+ }
1697
+ },
1698
+ async abort(reason = 'aborted') {
1699
+ liveState.abortReason = reason;
1700
+ if (liveState.waitResolve) liveState.waitResolve({ kind: 'abort' });
1701
+ for (const requestId of [...liveState.pendingResolvers.keys()]) finishPending(entry.runId, requestId, { behavior: 'deny', message: 'Run aborted.', resolvedBy: 'abort' });
1702
+ try { return await inner.abort?.(reason); } catch { return true; }
1703
+ },
1704
+ async dispose(options) {
1705
+ liveState.dead = true;
1706
+ stopUsagePolling(liveState);
1707
+ if (liveState.waitResolve) liveState.waitResolve({ kind: 'abort' });
1708
+ try { return await inner.dispose?.(options); } catch { return undefined; }
1709
+ },
1710
+ };
1711
+ }
1712
+ /**
1713
+ * The files one turn generated: what the adapter found (Codex rollout /
1714
+ * generated_images/, OpenCode file parts) plus, for an image / video creation
1715
+ * or design run, the paths the worker listed under media: (files made during
1716
+ * the run only). Each is copied once into <dataDir>/media/<runId>/ and appended to
1717
+ * entry.media, whose index is its URL (GET /api/assistant/runs/:runId/media/:n).
1718
+ */
1719
+ async function collectTurnMedia(entry, raw, parsed) {
1720
+ const found = Array.isArray(raw?.media) ? raw.media.filter((item) => item && (item.path || item.data)) : [];
1721
+ const named = collectedMedia(entry.taskClass) && Array.isArray(parsed?.media) ? parsed.media.filter((p) => typeof p === 'string' && p.trim()) : [];
1722
+ if (!found.length && !named.length) return [];
1723
+ const known = new Set((entry.media || []).map((m) => m.original).filter(Boolean));
1724
+ const items = [];
1725
+ const add = (item, path) => {
1726
+ if (path) { if (known.has(path)) return; known.add(path); }
1727
+ items.push(path ? { ...item, path } : item);
1728
+ };
1729
+ for (const item of found) add(item, item.path ? resolve(String(item.path)) : null);
1730
+ for (const path of named) add({ source: 'contract' }, isAbsolute(path) ? resolve(path) : resolve(entry.cwd || PACKAGE_ROOT, path));
1731
+ const start = (entry.media || []).length;
1732
+ const startedMs = Date.parse(entry.startedAt || entry.createdAt || '') || 0;
1733
+ // Every turn of the run shares one byte budget.
1734
+ const used = (entry.media || []).reduce((sum, m) => sum + (Number(m.bytes) || 0), 0);
1735
+ let copied = [];
1736
+ try {
1737
+ copied = await copyRunMedia({
1738
+ root: mediaRoot, runId: entry.runId, items, start, sinceMs: startedMs ? startedMs - 5000 : 0,
1739
+ maxBytes: limits.mediaFileBytes, budgetBytes: Math.max(0, limits.mediaRunBytes - used),
1740
+ });
1741
+ } catch (error) { entry.notes.push(`media copy failed: ${error?.message || error}`); }
1742
+ const records = copied.map((m, i) => ({ ...m, url: mediaUrl(entry.runId, start + i) }));
1743
+ if (records.length) entry.media = [...(entry.media || []), ...records];
1744
+ const skipped = items.length - records.length;
1745
+ if (skipped > 0) entry.notes.push(`${skipped} generated file${skipped === 1 ? '' : 's'} not collected (missing, a symlink, not an image or video, too large, over the run's media budget, or older than the run)`);
1746
+ return records;
1747
+ }
1748
+ /**
1749
+ * The n-th recorded file of a run for the media route: { path (its real path), mime, kind, bytes }
1750
+ * or null. A symlink put in place of the file or of media/<runId>/ is never followed.
1751
+ */
1752
+ function mediaFile(runId, n) {
1753
+ const entry = entries.get(String(runId));
1754
+ const item = entry && Number.isInteger(n) && n >= 0 ? entry.media?.[n] : null;
1755
+ const real = item?.path ? servableRunMedia(mediaRoot, entry.runId, item.path) : null;
1756
+ if (!real) return null;
1757
+ return { path: real, mime: mediaType(item.path)?.mime || null, kind: item.kind || null, bytes: item.bytes ?? null };
1758
+ }
1759
+
1760
+ /**
1761
+ * The result of a turn that answered with structured output and no ## Result block: Claude's
1762
+ * structured_output, or (a run with an output schema) a final message that is the JSON itself.
1763
+ * null when the turn carried no such object.
1764
+ */
1765
+ function structuredResult(entry, raw, text) {
1766
+ const given = raw?.structured;
1767
+ const json = given && typeof given === 'object' && !Array.isArray(given) ? given : (entry.outputSchema ? parseJsonObject(text) : null);
1768
+ if (!json) return null;
1769
+ const status = typeof json.status === 'string' ? json.status.trim().toLowerCase() : '';
1770
+ return {
1771
+ found: true, parseFailed: false, status: RESULT_STATUSES.includes(status) ? status : 'done',
1772
+ summary: typeof json.summary === 'string' ? json.summary : clip(text, 600), changes: [], files: [], follow_ups: [], media: [],
1773
+ question: typeof json.question === 'string' ? json.question : '', json, jsonError: null, raw: text, source: 'structured',
1774
+ };
1775
+ }
1776
+
1777
+ function handleTurnError(error, liveState, entry, ctx) {
1778
+ // A failed turn was still paid for (a budget-capped run used to read $0).
1779
+ liveState.turnOpen = false;
1780
+ stopUsagePolling(liveState);
1781
+ accumulateCost(entry, error);
1782
+ // Codex reports no usage for a failed or aborted turn: its rollout has it (the final poll).
1783
+ // Without a rollout nothing is known, so nothing is booked and nothing is flagged.
1784
+ if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
1785
+ // OpenCode: what streamed before the failure, as it stands (its serve may be gone: no reconcile).
1786
+ else if (entry.provider === 'opencode') bookOpenCodeTurn(entry, liveState, { reconciled: false });
1787
+ announceUnpriced(entry);
1788
+ checkSession(entry.assistantSessionId, { source: entry });
1789
+ if (budgetStopReason(entry.completionReason)) return { providerSessionId: null };
1790
+ if (error?.code === 'BUDGET_CAP') {
1791
+ entry.completionReason = 'budget_cap';
1792
+ entry.notes.push('stopped: per-run budget cap reached');
1793
+ const runtime = safeRuntime();
1794
+ runtime?.stop?.(entry.runId, 'budget_cap').catch(() => {});
1795
+ return { providerSessionId: null };
1796
+ }
1797
+ if (liveState.abortReason || /AbortError|aborted/i.test(String(error?.name || error?.message || ''))) {
1798
+ entry.completionReason = entry.completionReason || liveState.abortReason || 'aborted';
1799
+ return { providerSessionId: null };
1800
+ }
1801
+ // Fatal provider error: never let the runtime re-send the whole task.
1802
+ liveState.dead = true;
1803
+ entry.completionReason = entry.completionReason || 'provider_error';
1804
+ entry.notes.push(`provider error: ${error?.message || error}`);
1805
+ throw error;
1806
+ }
1807
+ function budgetStopReason(reason) { return reason === 'budget_cap' || reason === 'session_budget_cap' || reason === 'budget_unpriced'; }
1808
+ /** Stop a run that is out of money; the runtime ends it as stopped with this reason. */
1809
+ function budgetStop(entry, block) {
1810
+ entry.completionReason = block.reason;
1811
+ entry.notes.push(`stopped: ${block.message}`);
1812
+ safeRuntime()?.stop?.(entry.runId, block.reason).catch(() => {});
1813
+ return { providerSessionId: null };
1814
+ }
1815
+ /**
1816
+ * Codex usage (raw provider keys) at the list price (assistant-pricing.js, else models.dev) of
1817
+ * the model that spent it: the one its rollout recorded, else the run's. null when that model
1818
+ * has no list price; another model's rate never stands in for it. `long`: the usage is of
1819
+ * responses whose prompt was over / under the long-context size (the meter knows it per
1820
+ * response); null when that is not known, and the run's context window decides.
1821
+ */
1822
+ function priceCodexUsage(entry, usage, rowModel = null, long = null) {
1823
+ const model = rowModel || entry.pricingModel || entry.model || null;
1824
+ let price = null;
1825
+ try { price = model ? pricing?.codexPrice?.(model) || null : null; } catch { price = null; }
1826
+ if (!price) return null;
1827
+ return codexUsageCostUsd(usage, price, { contextWindow: entry.modelContextWindow || null, long });
1828
+ }
1829
+ /** Usage no price covers: recorded and flagged, never booked as $0. */
1830
+ function markUnpriced(entry, reason) {
1831
+ entry.unpricedTurns = (Number(entry.unpricedTurns) || 0) + 1;
1832
+ entry.unpricedReason = String(reason || 'no price');
1833
+ entry.costBasis = weakerBasis(entry.costBasis, 'unpriced');
1834
+ }
1835
+ function announceUnpriced(entry) {
1836
+ if (!(Number(entry.unpricedTurns) > 0) || entry.unpricedAnnounced) return;
1837
+ 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.` });
1839
+ }
1840
+ function codexThreadId(entry) {
1841
+ try { return entry.providerThreadId || live.get(entry.runId)?.inner?.identity?.()?.providerThreadId || null; } catch { return entry.providerThreadId || null; }
1842
+ }
1843
+ /** Add dollars to the run; returns the amount added. */
1844
+ function bookCost(entry, cost, basis) {
1845
+ const added = Number.isFinite(cost) && cost > 0 ? cost : 0;
1846
+ if (added) { entry.costUsd = Number(((Number(entry.costUsd) || 0) + added).toFixed(8)); entry.costBasis = weakerBasis(entry.costBasis, basis || 'reported'); }
1847
+ return added;
1848
+ }
1849
+ /** Add a usage object (provider keys) to the run's legacy running total, entry.usage. */
1850
+ function addLegacyUsage(entry, used) {
1851
+ if (!used || typeof used !== 'object') return;
1852
+ const prev = entry.usage || {};
1853
+ const next = {};
1854
+ for (const [key, value] of Object.entries(used)) if (Number.isFinite(Number(value))) next[key] = (Number(prev[key]) || 0) + Number(value);
1855
+ entry.usage = { ...prev, ...next };
1856
+ if (!entry.costBasis || entry.costBasis === 'none') entry.costBasis = 'tokens';
1857
+ }
1858
+ /**
1859
+ * Add one provider turn's charge to the run. `raw.costUsd` is that turn's own
1860
+ * charge (the Claude adapter turns the CLI's running total into it); a
1861
+ * rejected turn carries it on the error. Codex reports tokens only, and its
1862
+ * turn.completed.usage is the thread's running total: they are booked from
1863
+ * its rollout instead (settleCodexTurn). Returns the amount added.
1864
+ */
1865
+ function accumulateCost(entry, raw) {
1866
+ if (!raw) return 0;
1867
+ const cost = Number(raw.costUsd);
1868
+ const added = bookCost(entry, cost, raw.basis || 'reported');
1869
+ if (entry.provider !== 'codex') addLegacyUsage(entry, raw.usage);
1870
+ return added;
1871
+ }
1872
+
1873
+ // ── usage (exact tokens: assistant-usage.js) ───────────────────────────────
1874
+ // One meter per run. Each settled row goes to the ledger with the run's task and turn; the
1875
+ // legacy entry.usage / entry.costUsd keep the run's own numbers, with or without a ledger.
1876
+ const usageStateOf = (entry) => (entry.usageState && typeof entry.usageState === 'object' ? entry.usageState : (entry.usageState = {}));
1877
+ const metersOf = (entry) => { if (!meters.has(entry.runId)) meters.set(entry.runId, {}); return meters.get(entry.runId); };
1878
+ /** The task of the brain turn in progress (the runtime's), or null. */
1879
+ function taskNow(sessionId) {
1880
+ if (!sessionId || typeof currentTask !== 'function') return null;
1881
+ let task = null;
1882
+ try { task = currentTask(sessionId); } catch { task = null; }
1883
+ const id = task && typeof task === 'object' ? task.id : task;
1884
+ return id ? String(id) : null;
1885
+ }
1886
+ /** The task a new run belongs to: the brain's current one, else the ledger's (created when the session has none). */
1887
+ function taskAtDispatch(sessionId) {
1888
+ const id = taskNow(sessionId);
1889
+ if (id || !ledger || !sessionId) return id;
1890
+ try { return ledger.ensureTask(sessionId)?.id || null; } catch { return null; }
1891
+ }
1892
+ /**
1893
+ * The turn a row belongs to: the one running, else the last one started as saved with the run
1894
+ * (after a restart the live stamp is gone and that turn may never have been recorded), else
1895
+ * the last one recorded (a run saved before the stamp was).
1896
+ */
1897
+ function turnStamp(entry) {
1898
+ const running = live.get(entry.runId)?.usageTurn;
1899
+ if (running) return running;
1900
+ const started = entry.usageState?.turn;
1901
+ if (started && typeof started === 'object') return { n: started.n ?? null, taskId: started.taskId || entry.taskId || null };
1902
+ const last = Array.isArray(entry.turns) ? entry.turns.at(-1) : null;
1903
+ return { n: last?.n ?? null, taskId: last?.taskId || entry.taskId || null };
1904
+ }
1905
+ /** Count a settled row in its turn's tokens: the turn in progress, else that turn's record. */
1906
+ function countTurnTokens(entry, stamp, tokens) {
1907
+ const liveState = live.get(entry.runId);
1908
+ if (liveState?.turnTokens && liveState.usageTurn?.n === stamp.n) { liveState.turnTokens = addTokens(liveState.turnTokens, tokens); return; }
1909
+ const turn = stamp.n === null ? null : (entry.turns || []).find((item) => item.n === stamp.n);
1910
+ if (turn) turn.tokens = withTotal(addTokens(turn.tokens, tokens));
1911
+ }
1912
+ /** One settled row: into its turn's tokens, and to the ledger (skipped without one, or for a run outside an assistant session). */
1913
+ function settleUsage(entry, row) {
1914
+ const stamp = turnStamp(entry);
1915
+ countTurnTokens(entry, stamp, row.tokens);
1916
+ if (!ledger || !entry.assistantSessionId) return null;
1917
+ try {
1918
+ return ledger.settle({ sessionId: entry.assistantSessionId, taskId: stamp.taskId, scope: 'run', runId: entry.runId, turn: stamp.n, provider: entry.provider, ...row });
1919
+ } catch (error) { log(entry.runId, 'assistant:usage-error', error?.message || String(error)); return null; }
1920
+ }
1921
+ /** The run's provisional tokens (a turn still running). A finished run has none: what comes late is settled or dropped. */
1922
+ function setPendingUsage(entry, tokens, model = null) {
1923
+ if (!ledger || !entry.assistantSessionId) return;
1924
+ try {
1925
+ if (TERMINAL_STATES.has(entry.state) || totalTokens(tokens) === 0) ledger.clearPending(entry.assistantSessionId, entry.runId);
1926
+ else ledger.setPending(entry.assistantSessionId, entry.runId, { taskId: turnStamp(entry).taskId, scope: 'run', runId: entry.runId, provider: entry.provider, model: model || entry.model || null, tokens });
1927
+ } catch (error) { log(entry.runId, 'assistant:usage-error', error?.message || String(error)); }
1928
+ }
1929
+ /** The run's exact tokens from the ledger (settled + provisional); null without a ledger or a session. */
1930
+ function tokensView(entry) {
1931
+ if (!ledger || !entry.assistantSessionId) return null;
1932
+ let seen = null;
1933
+ try { seen = ledger.runView(entry.assistantSessionId, entry.runId); } catch { seen = null; }
1934
+ const zero = withTotal(zeroTokens());
1935
+ return { ...(seen?.tokens || zero), pending: seen?.pending || zero, subagents: { total: seen?.subagents?.total || 0 }, fidelity: seen?.fidelity || 'exact' };
1936
+ }
1937
+ /**
1938
+ * A run that will not take another turn: only what a late Claude result needs is kept (the
1939
+ * snapshot it is differenced against and the turn it belongs to), the rest is dropped.
1940
+ */
1941
+ function closeUsageState(entry) {
1942
+ const kept = entry.usageState?.claude || null;
1943
+ const turn = entry.provider === 'claude-code' ? entry.usageState?.turn || null : null;
1944
+ entry.usageState = { ...(kept ? { claude: kept } : {}), ...(turn ? { turn } : {}), closed: true };
1945
+ }
1946
+ function stopUsagePolling(liveState) {
1947
+ if (liveState?.usageTimer) { clearInterval(liveState.usageTimer); liveState.usageTimer = null; }
1948
+ }
1949
+ /** A run's worker is up: point its meter at the run (OpenCode's root session). */
1950
+ function startUsageMetering(entry, liveState) {
1951
+ if (entry.provider !== 'opencode') return;
1952
+ let root = null;
1953
+ try { root = liveState.inner?.identity?.()?.providerSessionId || null; } catch { root = null; }
1954
+ if (root) openCodeMeterOf(entry).setRoot(root);
1955
+ }
1956
+ /**
1957
+ * A Codex turn began: while it runs, new rollout records are booked as they land and the caps
1958
+ * are checked. The timer ends with the turn (an idle run writes nothing) and starts again with
1959
+ * the next one.
1960
+ */
1961
+ function startUsagePolling(entry, liveState) {
1962
+ if (entry.provider !== 'codex' || liveState.usageTimer) return;
1963
+ liveState.usageTimer = setInterval(() => {
1964
+ if (liveState.dead || !liveState.turnOpen || TERMINAL_STATES.has(entry.state)) { stopUsagePolling(liveState); return; }
1965
+ try { if (pollCodex(entry, liveState).rows) enforceMidTurn(entry, liveState); } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
1966
+ }, Math.max(20, Number(limits.usagePollMs) || 2000));
1967
+ liveState.usageTimer.unref?.();
1968
+ }
1969
+
1970
+ /**
1971
+ * Claude rows into the ledger. The CLI reports what a counted call cost. An estimate row (a
1972
+ * call the stream showed and no result counts) has no reported cost: it is priced at list
1973
+ * price and added to the run's dollars.
1974
+ */
1975
+ function settleClaudeRows(entry, rows, settled = null) {
1976
+ for (const row of rows || []) {
1977
+ const estimate = row.source === CLAUDE_ESTIMATE_SOURCE;
1978
+ const costUsd = estimate ? claudeTokensCostUsd(row.model, row.tokens) : row.costUsd;
1979
+ if (estimate && costUsd === null) markUnpriced(entry, `no list price for ${row.model}`);
1980
+ else if (estimate) bookCost(entry, costUsd, 'estimated');
1981
+ settleUsage(entry, {
1982
+ model: row.model, part: row.part, tokens: row.tokens, costUsd, costBasis: estimate ? (costUsd === null ? 'unpriced' : 'estimated') : 'reported',
1983
+ fidelity: row.fidelity || settled?.fidelity, reason: row.reason ?? settled?.reason ?? null, source: row.source || settled?.source,
1984
+ });
1985
+ }
1986
+ }
1987
+
1988
+ // Claude: every SDK message the adapter forwards.
1989
+ function claudeMeterOf(entry) {
1990
+ const held = metersOf(entry);
1991
+ if (!held.claude) {
1992
+ const saved = entry.usageState?.claude || null;
1993
+ held.claude = createClaudeMeter({ state: saved || undefined });
1994
+ // Earlier turns and no snapshot: the first result cannot be differenced, only its main loop is known.
1995
+ if (!saved && (entry.turns || []).length) held.claude.expectHistory();
1996
+ }
1997
+ return held.claude;
1998
+ }
1999
+ /**
2000
+ * A `result` settles the turn's per-model delta of result.modelUsage (main loop, sub-agents,
2001
+ * compaction, helper calls). It does not wait for the turn promise: a result that arrives after
2002
+ * an abort is still booked. Dollars stay with total_cost_usd (accumulateCost).
2003
+ */
2004
+ function meterClaude(entry, message) {
2005
+ if (!message || (message.type !== 'assistant' && message.type !== 'stream_event' && message.type !== 'result')) return;
2006
+ const meter = claudeMeterOf(entry);
2007
+ const { settled, pendingChanged } = meter.onEvent(message);
2008
+ if (settled) {
2009
+ settleClaudeRows(entry, settled.rows, settled);
2010
+ usageStateOf(entry).claude = meter.state();
2011
+ // At once: a kill inside the debounce would leave the saved snapshot behind the ledger, and
2012
+ // the next result would be counted against the older one.
2013
+ persist({ force: true });
2014
+ }
2015
+ if (!settled && !pendingChanged) return;
2016
+ // A result ends the estimate and settle() cleared the ledger's; what streams after it (a
2017
+ // background sub-agent) is provisional again until the next result, or dropped when none will come.
2018
+ if (TERMINAL_STATES.has(entry.state)) meter.clearPending();
2019
+ const pending = meter.pending();
2020
+ setPendingUsage(entry, pending.tokens, pending.byModel[0]?.model || null);
2021
+ }
2022
+
2023
+ // Codex: the rollout files of the run's thread and its sub-agent threads.
2024
+ function codexMeterOf(entry) {
2025
+ const state = entry.usageState || {};
2026
+ if (state.closed) return null;
2027
+ const held = metersOf(entry);
2028
+ if (held.codex) return held.codex;
2029
+ // Without a thread no model call was made.
2030
+ const threadId = codexThreadId(entry);
2031
+ if (!threadId) return null;
2032
+ held.codex = createCodexMeter({
2033
+ codexHome: entryCodexHome(entry) || undefined, threadId, sinceMs: Date.parse(entry.startedAt || entry.createdAt) || 0,
2034
+ // A response whose prompt is over its model's long-context size is priced whole at the long rates.
2035
+ longPromptTokens: (model) => { try { return pricing?.codexPrice?.(model || entry.pricingModel || entry.model)?.long?.size || 0; } catch { return 0; } },
2036
+ state: state.codex || undefined, now,
2037
+ });
2038
+ return held.codex;
2039
+ }
2040
+ /**
2041
+ * Book Codex usage (raw provider keys) at the list price of `model` ('estimated'), or flag it
2042
+ * unpriced. `model` is the one the row's rollout recorded; none means the run's own.
2043
+ * `reportedCostUsd`: the turn result carried its own charge, already booked by accumulateCost.
2044
+ * `long`: see priceCodexUsage. Returns { added, unpriced }.
2045
+ */
2046
+ function bookCodexUsage(entry, liveState, used, { model = null, part = 'main', tokens, fidelity, reason, source, reportedCostUsd = null, long = null }) {
2047
+ let cost = reportedCostUsd;
2048
+ let added = 0;
2049
+ if (cost === null) {
2050
+ cost = priceCodexUsage(entry, used, model, long);
2051
+ if (cost !== null) added = bookCost(entry, cost, 'estimated');
2052
+ }
2053
+ addLegacyUsage(entry, used);
2054
+ if (liveState) {
2055
+ const prev = liveState.turnUsage || {};
2056
+ liveState.turnUsage = { ...prev, ...Object.fromEntries(Object.entries(used).map(([key, value]) => [key, (Number(prev[key]) || 0) + (Number(value) || 0)])) };
2057
+ liveState.turnMeteredUsd = (Number(liveState.turnMeteredUsd) || 0) + added;
2058
+ }
2059
+ settleUsage(entry, {
2060
+ model: model || entry.model || null, part, tokens, costUsd: cost,
2061
+ costBasis: cost === null ? 'unpriced' : reportedCostUsd === null ? 'estimated' : 'reported', fidelity, reason, source,
2062
+ });
2063
+ return { added, unpriced: cost === null && usageTokens(used) > 0 };
2064
+ }
2065
+ /**
2066
+ * What is left of a main-thread rollout row once the usage a turn's own report already booked
2067
+ * for that thread (state.codexAdvance, see settleCodexTurn) is taken off it, key by key: the row
2068
+ * itself when nothing was advanced, null when the advance covers it.
2069
+ */
2070
+ function afterCodexAdvance(state, used) {
2071
+ const advance = state.codexAdvance;
2072
+ if (!advance) return used;
2073
+ const rest = { ...used };
2074
+ for (const key of CODEX_TOTAL_KEYS) {
2075
+ const take = Math.min(Number(used[key]) || 0, Number(advance[key]) || 0);
2076
+ rest[key] = (Number(used[key]) || 0) - take;
2077
+ advance[key] = (Number(advance[key]) || 0) - take;
2078
+ }
2079
+ if (!CODEX_TOTAL_KEYS.some((key) => advance[key] > 0)) delete state.codexAdvance;
2080
+ rest.total_tokens = rest.input_tokens + rest.output_tokens;
2081
+ return usageTokens(rest) > 0 ? rest : null;
2082
+ }
2083
+ /**
2084
+ * Read what the rollouts recorded since the last poll: one row per thread and turn, per model
2085
+ * response, compaction calls and sub-agent threads included. `discover`: look for new rollout
2086
+ * files now (the meter itself looks every 15 s). Returns { rows, added }.
2087
+ */
2088
+ function pollCodex(entry, liveState = live.get(entry.runId) || null, { discover = false } = {}) {
2089
+ const meter = codexMeterOf(entry);
2090
+ if (!meter) return { rows: 0, added: 0 };
2091
+ let rows = [];
2092
+ try { rows = meter.poll({ discover }); } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); return { rows: 0, added: 0 }; }
2093
+ if (!rows.length) return { rows: 0, added: 0 };
2094
+ const state = usageStateOf(entry);
2095
+ let added = 0;
2096
+ let unpriced = null;
2097
+ for (const row of rows) {
2098
+ const main = row.part === 'main';
2099
+ const used = main ? afterCodexAdvance(state, row.usage) : row.usage;
2100
+ if (!used) continue;
2101
+ const booked = bookCodexUsage(entry, liveState, used, {
2102
+ model: row.model, part: row.part, tokens: used === row.usage ? row.tokens : codexUsageTokens(used).tokens,
2103
+ fidelity: row.fidelity, reason: row.reason, source: row.source, long: row.long ?? null,
2104
+ });
2105
+ // What the main thread has booked, in the keys its turns report their running total in.
2106
+ if (main) state.codexBooked = Object.fromEntries(CODEX_TOTAL_KEYS.map((key) => [key, (Number(state.codexBooked?.[key]) || 0) + (Number(used[key]) || 0)]));
2107
+ added += booked.added;
2108
+ if (booked.unpriced) unpriced = row.model || entry.pricingModel || entry.model || 'the Codex default model';
2109
+ }
2110
+ if (unpriced) markUnpriced(entry, `no list price for ${unpriced}`);
2111
+ state.codex = meter.state();
2112
+ persist();
2113
+ return { rows: rows.length, added };
2114
+ }
2115
+ /**
2116
+ * The end of one Codex provider turn, completed or failed: the final poll (new rollout files
2117
+ * are looked for at once, so a sub-agent thread born seconds before the end is in this turn),
2118
+ * then the check against the turn's own report. turn.completed.usage is the main thread's
2119
+ * running total. What it shows above everything booked for that thread (its rollout was not
2120
+ * found, or this turn's records are not in it) is booked now, flagged partial, and taken off
2121
+ * the records should they turn up later. Records above the report (they include the compaction
2122
+ * call) book nothing more. A failed turn reports none, so without a rollout it books nothing.
2123
+ */
2124
+ function settleCodexTurn(entry, liveState, raw) {
2125
+ const state = usageStateOf(entry);
2126
+ if (state.closed) return;
2127
+ pollCodex(entry, liveState, { discover: true });
2128
+ const total = raw?.usage && typeof raw.usage === 'object' ? raw.usage : null;
2129
+ if (total) {
2130
+ const delta = Object.fromEntries(CODEX_TOTAL_KEYS.map((key) => [key, Math.max(0, (Number(total[key]) || 0) - (Number(state.codexBooked?.[key]) || 0))]));
2131
+ // The report is the base from here on: records above it (compaction) must not hide what a
2132
+ // later turn's records miss.
2133
+ state.codexBooked = Object.fromEntries(CODEX_TOTAL_KEYS.map((key) => [key, Number(total[key]) || 0]));
2134
+ if (usageTokens(delta) > 0) {
2135
+ delta.total_tokens = delta.input_tokens + delta.output_tokens;
2136
+ state.codexAdvance = Object.fromEntries(CODEX_TOTAL_KEYS.map((key) => [key, (Number(state.codexAdvance?.[key]) || 0) + delta[key]]));
2137
+ const reported = Number(raw?.costUsd);
2138
+ const result = bookCodexUsage(entry, liveState, delta, {
2139
+ tokens: codexUsageTokens(delta).tokens, fidelity: 'partial', reason: 'codex-rollout-missing', source: 'codex-turn-usage',
2140
+ reportedCostUsd: Number.isFinite(reported) ? reported : null,
2141
+ });
2142
+ if (result.unpriced) markUnpriced(entry, `no list price for ${entry.pricingModel || entry.model || 'the Codex default model'}`);
2143
+ }
2144
+ }
2145
+ // At once, not debounced: a kill must not leave the saved meter state behind the ledger.
2146
+ persist({ force: true });
2147
+ }
2148
+
2149
+ // OpenCode: every session of the run's own serve (the root and the `task` tool's child sessions).
2150
+ function openCodeMeterOf(entry) {
2151
+ const held = metersOf(entry);
2152
+ // Its state is not saved with the run: a run does not outlive the process that holds its serve.
2153
+ if (!held.opencode) held.opencode = createOpenCodeMeter();
2154
+ return held.opencode;
2155
+ }
2156
+ /** The run's own session id, for a message that names none. */
2157
+ function openCodeRoot(entry, liveState) {
2158
+ try { return liveState?.inner?.identity?.()?.providerSessionId || entry.providerSessionId || null; } catch { return entry.providerSessionId || null; }
2159
+ }
2160
+ /**
2161
+ * OpenCode reports each assistant message's cost ($) and tokens while the
2162
+ * turn runs (message.updated), for the run's session and its child sessions.
2163
+ * Tokens go to the meter (live until the turn settles). Cost deltas are booked
2164
+ * as they arrive, so a turn that fails or is aborted is still paid for, and
2165
+ * the caps are checked at every step instead of only at the turn's end.
2166
+ */
2167
+ function meterOpenCode(entry, liveState, payload = {}) {
2168
+ if (entry.usageState?.closed) return;
2169
+ const type = String(payload.eventType || '');
2170
+ const ev = payload.event || {};
2171
+ const meter = openCodeMeterOf(entry);
2172
+ if (/^session[.:](created|updated)$/i.test(type)) { meter.onEvent(type, ev); return; }
2173
+ if (!/^message[.:]updated$/i.test(type)) return;
2174
+ const found = ev.info || ev.message?.info || ev.properties?.info || null;
2175
+ if (!found?.id || found.role !== 'assistant') return;
2176
+ const info = found.sessionID ? found : { ...found, sessionID: ev.sessionID || openCodeRoot(entry, liveState) || 'root' };
2177
+ if (meter.onEvent('message.updated', { info })) {
2178
+ const running = meter.live();
2179
+ setPendingUsage(entry, running.tokens, openCodeModel(entry, running));
2180
+ }
2181
+ bookOpenCodeMessage(entry, liveState, info);
2182
+ }
2183
+ /** The model an OpenCode row names: the run's, else the one that spent most ("provider/model"). */
2184
+ function openCodeModel(entry, sum) {
2185
+ const top = sum?.byModel?.[0] || null;
2186
+ return entry.model || (top ? [top.provider, top.model].filter(Boolean).join('/') : null);
2187
+ }
2188
+ /**
2189
+ * The price of the model an OpenCode message ran on, with its name: the message's own
2190
+ * providerID / modelID (a child session may run another model than the run) from the catalog,
2191
+ * the run's for a message that names none. price is null when it is not known; the run
2192
+ * model's rate never stands in for another model's.
2193
+ */
2194
+ function openCodePrice(entry, info) {
2195
+ const own = info.modelID ? [info.providerID, info.modelID].filter(Boolean).join('/') : null;
2196
+ const runModel = entry.pricingModel || entry.model || null;
2197
+ if (!own || (runModel && own.toLowerCase() === String(runModel).toLowerCase())) return { price: entry.modelInfo?.price || null, model: runModel };
2198
+ let row = null;
2199
+ try { const known = catalog?.peek?.() || null; row = known ? findModel(known, 'opencode', own)?.row || null : null; } catch { row = null; }
2200
+ return { price: row?.price || null, model: own };
2201
+ }
2202
+ /**
2203
+ * One assistant message's dollars and legacy usage, by what grew since it was last seen. A
2204
+ * finished message with tokens and no cost is a free model, or one OpenCode could not price:
2205
+ * it is estimated at its own model's price. The estimate is provisional. When the message
2206
+ * reports a cost later (the stored copy at the turn's end), only the difference is booked.
2207
+ */
2208
+ function bookOpenCodeMessage(entry, liveState, info) {
2209
+ // A finished run keeps no message book: what comes late is dropped, not booked from zero.
2210
+ if (entry.usageState?.closed) return;
2211
+ const book = liveState.meter || (liveState.meter = new Map());
2212
+ const key = `${info.sessionID || ''}:${info.id}`;
2213
+ const prev = book.get(key) || { cost: 0, usage: null, completed: false, estimate: 0 };
2214
+ const reported = Number(info.cost);
2215
+ const cost = Number.isFinite(reported) && reported > prev.cost ? reported : prev.cost;
2216
+ const used = openCodeUsage(info.tokens) || prev.usage;
2217
+ const completed = prev.completed || !!info.time?.completed;
2218
+ const usageDelta = used ? Object.fromEntries(Object.entries(used).map(([name, value]) => [name, Math.max(0, value - (Number(prev.usage?.[name]) || 0))])) : null;
2219
+ let estimate = Number(prev.estimate) || 0;
2220
+ let delta = cost - prev.cost;
2221
+ // The reported cost replaces the estimate booked for this message (the difference can be negative).
2222
+ if (delta > 0 && estimate > 0) { delta -= estimate; estimate = 0; }
2223
+ // Dollars of this turn (the turn record) and of the run's OpenCode messages (the ledger row).
2224
+ const paid = (amount) => {
2225
+ liveState.turnMeteredUsd = (Number(liveState.turnMeteredUsd) || 0) + amount;
2226
+ liveState.openCodeUsd = (Number(liveState.openCodeUsd) || 0) + amount;
2227
+ };
2228
+ if (delta > 0 || usageTokens(usageDelta) > 0) accumulateCost(entry, { costUsd: delta > 0 ? delta : 0, usage: usageDelta, basis: 'reported' });
2229
+ if (delta < 0) entry.costUsd = Number(Math.max(0, (Number(entry.costUsd) || 0) + delta).toFixed(8));
2230
+ if (delta) paid(delta);
2231
+ if (completed && !prev.completed && !(cost > 0) && usageTokens(used) > 0) {
2232
+ const { price, model } = openCodePrice(entry, info);
2233
+ if (price?.basis === 'free') entry.costBasis = weakerBasis(entry.costBasis, 'free');
2234
+ else if (price && (Number(price.input) > 0 || Number(price.output) > 0)) {
2235
+ estimate = Number((((used.input_tokens * price.input) + ((used.output_tokens + used.reasoning_tokens) * price.output)
2236
+ + (used.cache_read_input_tokens * (price.cacheRead ?? price.input)) + (used.cache_write_input_tokens * price.input)) / 1e6).toFixed(8));
2237
+ accumulateCost(entry, { costUsd: estimate, basis: 'estimated' });
2238
+ paid(estimate);
2239
+ } else markUnpriced(entry, `OpenCode reported no cost for ${model || 'its default model'} and it has no list price`);
2240
+ }
2241
+ book.set(key, { cost, usage: used, completed, estimate });
2242
+ if (delta > 0 || completed) enforceMidTurn(entry, liveState);
2243
+ }
2244
+ /**
2245
+ * Settle what the meter counted since the last turn: the root session as 'main', the child
2246
+ * sessions as 'subagents'. Exact when the serve's stored messages were merged first. Returns the
2247
+ * turn's own usage (provider keys), or null when it used nothing.
2248
+ */
2249
+ function bookOpenCodeTurn(entry, liveState, { reconciled = false } = {}) {
2250
+ if (entry.usageState?.closed) return null;
2251
+ const meter = openCodeMeterOf(entry);
2252
+ const delta = meter.markTurn();
2253
+ const fidelity = reconciled ? 'exact' : 'partial';
2254
+ const reason = reconciled ? null : 'opencode-not-reconciled';
2255
+ const source = reconciled ? 'opencode-messages' : 'opencode-events';
2256
+ const model = openCodeModel(entry, delta);
2257
+ // The dollars were booked per message as they streamed: the main row carries what was booked
2258
+ // since the last settle (reported steps and estimates, child sessions included).
2259
+ const usdNow = Number(liveState?.openCodeUsd) || 0;
2260
+ const paidUsd = Number((usdNow - (Number(liveState?.openCodeUsdSettled) || 0)).toFixed(8));
2261
+ // A net refund (a reported cost below an estimate an earlier row carried) cannot be a row:
2262
+ // the mark stays, so the next dollars are booked net of it.
2263
+ if (liveState && paidUsd >= 0) liveState.openCodeUsdSettled = usdNow;
2264
+ const shared = { model, costBasis: ['none', 'tokens'].includes(entry.costBasis) ? null : entry.costBasis, fidelity, reason, source };
2265
+ settleUsage(entry, { ...shared, part: 'main', tokens: delta.main, costUsd: paidUsd > 0 ? paidUsd : null });
2266
+ settleUsage(entry, { ...shared, part: 'subagents', tokens: delta.subagents, costUsd: null });
2267
+ setPendingUsage(entry, zeroTokens());
2268
+ // At once, not debounced: a kill must not leave the saved run behind the ledger.
2269
+ persist({ force: true });
2270
+ if (totalTokens(delta.tokens) === 0) return null;
2271
+ const t = delta.tokens;
2272
+ return { input_tokens: t.input, output_tokens: t.output, reasoning_tokens: t.reasoning, cache_read_input_tokens: t.cacheRead, cache_write_input_tokens: t.cacheWrite };
2273
+ }
2274
+ /** A finished OpenCode turn: merge what the serve stored (a message the stream missed, child sessions), then settle. */
2275
+ async function settleOpenCodeTurn(entry, liveState) {
2276
+ let fetchers = null;
2277
+ try { fetchers = liveState.inner?.usageFetchers?.() || null; } catch { fetchers = null; }
2278
+ let reconciled = false;
2279
+ if (fetchers && !entry.usageState?.closed) {
2280
+ let failed = false;
2281
+ const root = openCodeRoot(entry, liveState);
2282
+ // One failed read ends the reconcile: the rest fail at once instead of each waiting on a dead
2283
+ // serve. An answer that comes after that (or after the time limit below) is not used: the
2284
+ // turn was settled without it, and the next turn's reconcile reads the serve again.
2285
+ const guarded = (name, after = () => {}) => async (sessionId) => {
2286
+ if (failed) throw new Error('OpenCode usage was not reconciled');
2287
+ try {
2288
+ const rows = await fetchers[name](sessionId);
2289
+ if (failed) throw new Error('OpenCode usage was not reconciled');
2290
+ after(rows, sessionId);
2291
+ return rows;
2292
+ } catch (error) { failed = true; throw error; }
2293
+ };
2294
+ const meter = openCodeMeterOf(entry);
2295
+ // A serve that never answers must not hold the run's next turn.
2296
+ let timer = null;
2297
+ const expired = new Promise((resolveExpired) => {
2298
+ timer = setTimeout(() => {
2299
+ failed = true;
2300
+ log(entry.runId, 'assistant:meter-error', 'OpenCode usage reconcile timed out');
2301
+ resolveExpired();
2302
+ }, Math.max(1, Number(limits.usageReconcileMs) || 10_000));
2303
+ timer.unref?.();
2304
+ });
2305
+ try {
2306
+ await Promise.race([expired, meter.reconcile({
2307
+ children: guarded('children'),
2308
+ // A stored message the stream never delivered is paid for like any other.
2309
+ messages: guarded('messages', (rows, sessionId) => {
2310
+ for (const item of Array.isArray(rows) ? rows : []) {
2311
+ const info = item?.info || item;
2312
+ if (info?.id && info.role === 'assistant') bookOpenCodeMessage(entry, liveState, info.sessionID ? info : { ...info, sessionID: sessionId || root || 'root' });
2313
+ }
2314
+ }),
2315
+ })]);
2316
+ reconciled = !failed;
2317
+ } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
2318
+ clearTimeout(timer);
2319
+ }
2320
+ return bookOpenCodeTurn(entry, liveState, { reconciled });
2321
+ }
2322
+ /** The run ended: last rows in, nothing provisional left, and only what a late Claude result needs is kept. */
2323
+ function finishUsage(entry, liveState) {
2324
+ stopUsagePolling(liveState);
2325
+ if (liveState) liveState.turnOpen = false;
2326
+ if (entry.provider === 'codex') settleCodexTurn(entry, liveState, null);
2327
+ else if (entry.provider === 'opencode' && meters.get(entry.runId)?.opencode) bookOpenCodeTurn(entry, liveState, { reconciled: false });
2328
+ // Settled: the per-message book (one snapshot per message of the run) is not needed again.
2329
+ if (liveState?.meter) liveState.meter = null;
2330
+ // A Claude call the stream showed and no result counted (the run was stopped mid-turn, or a
2331
+ // background sub-agent reported after the last result) was still spent: no further turn will
2332
+ // settle it, so it is booked as an estimate. A result that arrives late takes it off its delta.
2333
+ const claude = meters.get(entry.runId)?.claude;
2334
+ if (claude) {
2335
+ const rows = typeof claude.settlePending === 'function' ? claude.settlePending({ reason: 'no-result' }) : (claude.clearPending(), []);
2336
+ if (rows.length) { settleClaudeRows(entry, rows); usageStateOf(entry).claude = claude.state(); }
2337
+ }
2338
+ if (ledger && entry.assistantSessionId) { try { ledger.clearPending(entry.assistantSessionId, entry.runId); } catch {} }
2339
+ closeUsageState(entry);
2340
+ const held = meters.get(entry.runId);
2341
+ if (held) { held.codex = null; held.opencode = null; }
2342
+ }
2343
+ /** Caps checked between provider steps: the run stops as soon as it is out of money. */
2344
+ function enforceMidTurn(entry, liveState) {
2345
+ if (TERMINAL_STATES.has(entry.state) || liveState.budgetStopping) return;
2346
+ refreshLimits();
2347
+ const block = budgetBlock(entry);
2348
+ if (block) {
2349
+ liveState.budgetStopping = true;
2350
+ entry.completionReason = block.reason;
2351
+ entry.notes.push(`stopped mid-turn: ${block.message}`);
2352
+ safeRuntime()?.stop?.(entry.runId, block.reason).catch(() => {});
2353
+ }
2354
+ checkSession(entry.assistantSessionId, { source: entry });
2355
+ }
2356
+
2357
+ // ── run ↔ memory ───────────────────────────────────────────────────────────
2358
+ /**
2359
+ * A memory whose source_ref is the run id covers the run: the worker's own
2360
+ * (its task prompt asks for it), the brain's, or the auto memory. Without
2361
+ * this lookup a run whose worker remembered read memoryStored:false for
2362
+ * good, and the brain got memory_due for work that was already stored.
2363
+ */
2364
+ function syncMemory(entry, { announce = false } = {}) {
2365
+ if (!entry || entry.memoryStored) return !!entry?.memoryStored;
2366
+ if (typeof memory?.memoryForRun !== 'function') return false;
2367
+ let memoryId = null;
2368
+ try { memoryId = memory.memoryForRun(entry.runId) || null; } catch { memoryId = null; }
2369
+ if (!memoryId) return false;
2370
+ entry.memoryId = String(memoryId);
2371
+ entry.memoryStored = true;
2372
+ if (announce) emit(entry, 'memory_stored', { memoryId: entry.memoryId });
2373
+ else persist();
2374
+ return true;
2375
+ }
2376
+
2377
+ // ── follow-ups / lifecycle controls ────────────────────────────────────────
2378
+ function requireEntry(runId) {
2379
+ const entry = entries.get(runId);
2380
+ if (!entry) throw new DispatchError('RUN_NOT_FOUND', `Unknown run ${runId}`, { status: 404 });
2381
+ return entry;
2382
+ }
2383
+ function sendTurn(runId, text, { origin = 'assistant', queue: allowQueue = true } = {}) {
2384
+ const entry = requireEntry(runId);
2385
+ const message = String(text || '').trim();
2386
+ if (!message) throw new DispatchError('MESSAGE_REQUIRED', 'text is required');
2387
+ if (message.length > 20_000) throw new DispatchError('MESSAGE_TOO_LONG', 'text must be at most 20000 characters');
2388
+ if (TERMINAL_STATES.has(entry.state)) throw new DispatchError('RUN_NOT_ACTIVE', `Run ${runId} is ${entry.state}; dispatch a new run with the previous result as context.`, { status: 409, resumable: false });
2389
+ if (entry.state === 'queued') throw new DispatchError('RUN_NOT_STARTED', `Run ${runId} is still queued`, { status: 409 });
2390
+ const liveState = live.get(runId);
2391
+ if (!liveState) throw new DispatchError('RUN_NOT_ACTIVE', `Run ${runId} has no live worker`, { status: 409 });
2392
+ if (liveState.turnCounter >= entry.maxTurns) throw new DispatchError('MAX_TURNS', `Run ${runId} reached its max turns (${entry.maxTurns})`, { status: 409 });
2393
+ refreshLimits();
2394
+ const blocked = budgetBlock(entry);
2395
+ 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 });
2397
+ }
2398
+ // A follow-up works for the task in progress when it is sent, which may be a later one than the run's.
2399
+ const item = { kind: 'follow-up', text: message, origin, at: iso(now()), taskId: taskNow(entry.assistantSessionId) || entry.taskId || null };
2400
+ if (origin === 'user') emit(entry, 'user_intervened', { text: clip(message, 200) });
2401
+ if (liveState.waitResolve) { liveState.waitResolve(item); return { ok: true, queued: false, turn: liveState.turnCounter + 1, run: view(entry) }; }
2402
+ if (!allowQueue) throw new DispatchError('TURN_ACTIVE', `Run ${runId} is busy; retry when idle or pass queue:true`, { status: 409 });
2403
+ liveState.followUps.push(item);
2404
+ emit(entry, 'follow_up_queued', { queued: liveState.followUps.length });
2405
+ return { ok: true, queued: true, position: liveState.followUps.length, run: view(entry) };
2406
+ }
2407
+ function complete(runId) {
2408
+ const entry = requireEntry(runId);
2409
+ const liveState = live.get(runId);
2410
+ 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');
2413
+ }
2414
+ async function stop(runId, reason = 'assistant') {
2415
+ const entry = requireEntry(runId);
2416
+ if (WAITING_STATES.has(entry.state)) {
2417
+ const held = entry.state === 'awaiting_route';
2418
+ const index = queue.indexOf(runId);
2419
+ if (index !== -1) queue.splice(index, 1);
2420
+ entry.state = 'stopped'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal';
2421
+ delete entry.rawSpec;
2422
+ if (held) { try { router?.cancelForRun?.(runId, 'run_stopped'); } catch {} }
2423
+ emit(entry, 'stopped');
2424
+ return { ok: true, run: view(entry) };
2425
+ }
2426
+ if (TERMINAL_STATES.has(entry.state)) return { ok: true, run: view(entry), alreadyTerminal: true };
2427
+ entry.completionReason = entry.completionReason || reason;
2428
+ const runtime = safeRuntime();
2429
+ const stopped = runtime ? await runtime.stop(runId, reason) : false;
2430
+ if (!stopped) finalize(entry, { status: 'stopped', stoppedReason: reason });
2431
+ return { ok: true, run: view(entry) };
2432
+ }
2433
+ async function killAll({ assistantSessionId = null, workflowId = null, reason = 'kill_all' } = {}) {
2434
+ const targets = [...entries.values()].filter((entry) => ACTIVE_STATES.has(entry.state)
2435
+ && (!assistantSessionId || entry.assistantSessionId === assistantSessionId)
2436
+ && (!workflowId || entry.workflowId === workflowId));
2437
+ const stopped = [];
2438
+ for (const entry of targets) { try { await stop(entry.runId, reason); stopped.push(entry.runId); } catch {} }
2439
+ return { ok: true, stopped };
2440
+ }
2441
+
2442
+ // ── runtime feed → registry ────────────────────────────────────────────────
2443
+ function finalize(entry, runtimeRun) {
2444
+ if (TERMINAL_STATES.has(entry.state)) return;
2445
+ const status = runtimeRun?.status || 'completed';
2446
+ entry.state = TERMINAL_STATES.has(status) ? status : 'completed';
2447
+ entry.turnState = 'terminal';
2448
+ entry.finishedAt = iso(now());
2449
+ entry.completionReason = entry.completionReason || runtimeRun?.stoppedReason || (status === 'completed' ? 'complete' : status);
2450
+ if (runtimeRun?.error) entry.error = runtimeRun.error;
2451
+ const liveState = live.get(entry.runId);
2452
+ try { finishUsage(entry, liveState || null); } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
2453
+ if (liveState) {
2454
+ liveState.dead = true;
2455
+ if (liveState.idleTimer) clearTimeout(liveState.idleTimer);
2456
+ if (liveState.waitResolve) liveState.waitResolve({ kind: 'abort' });
2457
+ for (const requestId of [...liveState.pendingResolvers.keys()]) finishPending(entry.runId, requestId, { behavior: 'deny', message: 'Run ended.', resolvedBy: 'run_ended' });
2458
+ }
2459
+ entry.pending = [];
2460
+ releaseDesktop(entry);
2461
+ if (entry.state === 'failed' && entry.completionReason !== 'budget_cap' && judge?.workerOutcome) {
2462
+ // State, queue and desktop move on now; the 'failed' event waits for its cause.
2463
+ emitFailed(entry, liveState?.evidence || null);
2464
+ processQueue().catch(() => {});
2465
+ return;
2466
+ }
2467
+ if (entry.state === 'failed' || entry.lastResult?.status === 'blocked') entry.escalation = computeEscalation(entry);
2468
+ emit(entry, entry.state);
2469
+ // A memory already stored for the run (by the worker or the brain) is the run's memory.
2470
+ if (memory?.rememberDispatch && !syncMemory(entry, { announce: true }) && (entry.state === 'completed' || (entry.state === 'stopped' && entry.lastResult))) {
2471
+ Promise.resolve(memory.rememberDispatch(view(entry))).then((memoryId) => {
2472
+ if (!memoryId) return;
2473
+ entry.memoryId = memoryId;
2474
+ entry.memoryStored = true;
2475
+ emit(entry, 'memory_stored', { memoryId });
2476
+ }).catch(() => {});
2477
+ }
2478
+ processQueue().catch(() => {});
2479
+ }
2480
+ function onRuntimeMessage(message) {
2481
+ const runId = message?.run?.runId;
2482
+ if (!runId || !entries.has(runId)) return;
2483
+ const entry = entries.get(runId);
2484
+ if (message.type === 'sidepanel:provider-event') {
2485
+ const liveState = live.get(runId);
2486
+ // Not tied to the live turn: a result that arrives after an abort is still settled.
2487
+ if (entry.provider === 'claude-code') { try { meterClaude(entry, message.event?.event); } catch (error) { log(runId, 'assistant:meter-error', error?.message || String(error)); } }
2488
+ if (liveState) {
2489
+ liveState.lastEventAt = now();
2490
+ liveState.stalledFlagged = false;
2491
+ if (liveState.evidence) { try { recordProviderEvent(liveState.evidence, message.event); } catch {} }
2492
+ if (entry.provider === 'opencode') { try { meterOpenCode(entry, liveState, message.event || {}); } catch (error) { log(runId, 'assistant:meter-error', error?.message || String(error)); } }
2493
+ const summary = summarizeProviderEvent(message.event || {});
2494
+ if (summary) {
2495
+ const line = `${iso(now()).slice(11, 19)} ${summary}`;
2496
+ liveState.ring.push(line);
2497
+ liveState.ringChars += line.length;
2498
+ while (liveState.ring.length > limits.ringSize || liveState.ringChars > limits.ringChars) {
2499
+ const removed = liveState.ring.shift();
2500
+ liveState.ringChars -= removed?.length || 0;
2501
+ }
2502
+ }
2503
+ }
2504
+ return;
2505
+ }
2506
+ const run = message.run;
2507
+ entry.runtime = { status: run.status, turnState: run.turnState, stoppedReason: run.stoppedReason, error: run.error, claimedBy: run.claimedBy, providerSessionId: run.providerSessionId, providerThreadId: run.providerThreadId };
2508
+ if (run.providerSessionId) entry.providerSessionId = run.providerSessionId;
2509
+ if (run.providerThreadId) entry.providerThreadId = run.providerThreadId;
2510
+ if (message.type === 'sidepanel:run-updated' && message.reason === 'started' && entry.state === 'starting') {
2511
+ entry.state = 'running';
2512
+ entry.turnState = 'running';
2513
+ emit(entry, 'running');
2514
+ return;
2515
+ }
2516
+ if (['sidepanel:run-completed', 'sidepanel:run-failed', 'sidepanel:run-stopped'].includes(message.type) || TERMINAL_STATES.has(run.status)) {
2517
+ finalize(entry, run);
2518
+ }
2519
+ }
2520
+ function attachRuntime() {
2521
+ const runtime = safeRuntime();
2522
+ if (!runtime?.subscribe || unsubscribeRuntime) return;
2523
+ unsubscribeRuntime = runtime.subscribe(onRuntimeMessage);
2524
+ }
2525
+
2526
+ // ── queue + stalled watchdog ───────────────────────────────────────────────
2527
+ async function processQueue() {
2528
+ refreshLimits();
2529
+ for (const runId of [...queue]) {
2530
+ const entry = entries.get(runId);
2531
+ if (!entry || entry.state !== 'queued') { queue.splice(queue.indexOf(runId), 1); continue; }
2532
+ const spec = { provider: entry.provider, accountId: entry.accountId, workflowId: entry.workflowId, usesComputer: !!entry.usesComputer };
2533
+ if (railBlock(spec, entry.assistantSessionId)) continue;
2534
+ queue.splice(queue.indexOf(runId), 1);
2535
+ try { await start(entry); emit(entry, 'queue_started'); } catch (error) { entry.notes.push(`queue start failed: ${error?.message || error}`); }
2536
+ }
2537
+ }
2538
+ function checkStalled() {
2539
+ for (const [runId, liveState] of live) {
2540
+ const entry = entries.get(runId);
2541
+ if (!entry || entry.state !== 'running' || liveState.stalledFlagged) continue;
2542
+ if (now() - liveState.lastEventAt > limits.stalledAfterMs) {
2543
+ liveState.stalledFlagged = true;
2544
+ entry.notes.push(`stalled: no provider events for ${Math.round((now() - liveState.lastEventAt) / 60000)} min`);
2545
+ emit(entry, 'stalled', { sinceMs: now() - liveState.lastEventAt });
2546
+ }
2547
+ }
2548
+ }
2549
+
2550
+ // ── queries ────────────────────────────────────────────────────────────────
2551
+ function get(runId) {
2552
+ const entry = entries.get(runId) || null;
2553
+ // agent_status reads here: a remember made since the run's last event counts.
2554
+ if (entry && !entry.memoryStored && (entry.lastResult || TERMINAL_STATES.has(entry.state))) syncMemory(entry);
2555
+ return view(entry);
2556
+ }
2557
+ function list({ activeOnly = false, assistantSessionId = null, workflowId = null, status = null, limit = 100, includeRemoved = false } = {}) {
2558
+ return [...entries.values()]
2559
+ .filter((entry) => (includeRemoved || !entry.removedAt)
2560
+ && (!activeOnly || ACTIVE_STATES.has(entry.state))
2561
+ && (!assistantSessionId || entry.assistantSessionId === assistantSessionId)
2562
+ && (!workflowId || entry.workflowId === workflowId)
2563
+ && (!status || status === 'all' || (status === 'active' ? ACTIVE_STATES.has(entry.state) : status === 'terminal' ? TERMINAL_STATES.has(entry.state) : entry.state === status)))
2564
+ .sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')))
2565
+ .slice(0, Math.max(1, Math.min(500, Number(limit) || 100)))
2566
+ .map((entry) => view(entry));
2567
+ }
2568
+ function totals({ assistantSessionId = null, workflowId = null } = {}) {
2569
+ const rows = [...entries.values()].filter((entry) => (!assistantSessionId || entry.assistantSessionId === assistantSessionId) && (!workflowId || entry.workflowId === workflowId));
2570
+ // tokens: the five classes plus total, from the runs' ledger views (a run without one counts its legacy input / output).
2571
+ const out = { active: 0, queued: 0, awaitingRoute: 0, completed: 0, failed: 0, stopped: 0, interrupted: 0, costUsd: 0, tokens: withTotal(zeroTokens()) };
2572
+ for (const entry of rows) {
2573
+ // A removed run leaves the counts but keeps its spend and its tokens: both were spent.
2574
+ if (!entry.removedAt) {
2575
+ if (entry.state === 'queued') out.queued += 1;
2576
+ else if (entry.state === 'awaiting_route') out.awaitingRoute += 1;
2577
+ else if (ACTIVE_STATES.has(entry.state)) out.active += 1;
2578
+ else if (out[entry.state] !== undefined) out[entry.state] += 1;
2579
+ }
2580
+ out.costUsd += Number(entry.costUsd) || 0;
2581
+ const used = tokensView(entry);
2582
+ if (used) { for (const key of [...TOKEN_KEYS, 'total', 'inputTotal', 'outputTotal']) out.tokens[key] += used[key]; continue; }
2583
+ const input = Number(entry.usage?.input_tokens) || 0;
2584
+ const output = Number(entry.usage?.output_tokens) || 0;
2585
+ out.tokens.input += input;
2586
+ out.tokens.output += output;
2587
+ out.tokens.inputTotal += input;
2588
+ out.tokens.outputTotal += output;
2589
+ out.tokens.total += input + output;
2590
+ }
2591
+ out.costUsd = Number(out.costUsd.toFixed(4));
2592
+ if (assistantSessionId) out.budget = sessionBudget(assistantSessionId);
2593
+ out.queue = queue.map((runId, index) => ({ runId, position: index + 1, title: entries.get(runId)?.title || null, provider: entries.get(runId)?.provider || null }));
2594
+ return out;
2595
+ }
2596
+ function transcript(runId, { format = 'result', tail = 40, maxChars = 4000 } = {}) {
2597
+ const entry = requireEntry(runId);
2598
+ const liveState = live.get(runId);
2599
+ const cap = Math.max(200, Math.min(20_000, Number(maxChars) || 4000));
2600
+ const base = { runId, state: entry.state, turnState: entry.turnState, outcome: entry.lastResult?.status || null, turns: (entry.turns || []).length };
2601
+ if (format === 'result') return { ...base, result: entry.lastResult, turnsSummary: (entry.turns || []).map((turn) => ({ n: turn.n, origin: turn.origin, status: turn.result?.status || null, summary: clip(turn.result?.summary, 200), costUsd: turn.costUsd })) };
2602
+ if (format === 'text') {
2603
+ const text = String(entry.lastText || '');
2604
+ return { ...base, text: text.length > cap ? text.slice(-cap) : text, truncated: text.length > cap };
2605
+ }
2606
+ if (format === 'files') return { ...base, files: entry.lastResult?.files || [] };
2607
+ if (format === 'events' || format === 'tail') {
2608
+ const lines = liveState ? liveState.ring.slice(-Math.max(1, Math.min(500, Number(tail) || 40))) : [];
2609
+ const joined = lines.join('\n');
2610
+ return { ...base, tail: lines, text: joined.length > cap ? joined.slice(-cap) : joined, truncated: joined.length > cap, unavailable: !liveState ? 'transcript buffer is only kept for live runs' : null };
2611
+ }
2612
+ throw new DispatchError('INVALID_FORMAT', `Unknown transcript format ${format}`);
2613
+ }
2614
+ function pendingPermissions(runId) { return [...(requireEntry(runId).pending || [])]; }
2615
+
2616
+ async function wait({ runId = null, runIds = null, workflowId = null, assistantSessionId = null, until = 'idle', mode = 'all', timeoutMs = 30_000 } = {}) {
2617
+ let ids = Array.isArray(runIds) ? [...runIds] : runId ? [runId] : [];
2618
+ if (workflowId) ids = [...new Set([...ids, ...[...entries.values()].filter((e) => e.workflowId === workflowId && (!assistantSessionId || e.assistantSessionId === assistantSessionId)).map((e) => e.runId)])];
2619
+ ids = ids.slice(0, 20);
2620
+ if (!ids.length) throw new DispatchError('RUN_IDS_REQUIRED', 'runId, runIds or workflowId is required');
2621
+ for (const id of ids) requireEntry(id);
2622
+ const started = now();
2623
+ const budget = Math.max(1000, Math.min(limits.waitCapMs, Number(timeoutMs) || 30_000));
2624
+ const baseline = new Map(ids.map((id) => [id, Number(entries.get(id)?.version) || 0]));
2625
+ const check = () => {
2626
+ const satisfied = ids.filter((id) => satisfies(entries.get(id), until, baseline.get(id)));
2627
+ return mode === 'any' ? satisfied.length > 0 : satisfied.length === ids.length;
2628
+ };
2629
+ if (!check()) {
2630
+ await new Promise((resolveWait) => {
2631
+ const waiter = { check, resolve: () => { clearTimeout(timer); resolveWait(); } };
2632
+ const timer = setTimeout(() => { waiters.delete(waiter); resolveWait(); }, budget);
2633
+ timer.unref?.();
2634
+ waiters.add(waiter);
2635
+ });
2636
+ }
2637
+ const done = ids.filter((id) => satisfies(entries.get(id), until, baseline.get(id)));
2638
+ const pendingIds = ids.filter((id) => !done.includes(id));
2639
+ const timedOut = !check();
2640
+ return {
2641
+ ok: true, timedOut, waitedMs: now() - started, until, mode,
2642
+ done: done.map((id) => view(entries.get(id))),
2643
+ pending: pendingIds.map((id) => view(entries.get(id))),
2644
+ needsInput: ids.map((id) => entries.get(id)).filter((e) => e?.lastResult?.status === 'needs_input' && e.state === 'idle').map((e) => ({ runId: e.runId, question: e.lastResult.question })),
2645
+ permissions: ids.flatMap((id) => (entries.get(id)?.pending || []).map((request) => ({ runId: id, ...request }))),
2646
+ };
2647
+ }
2648
+
2649
+ // ── removal (the user clears finished runs from the agents tray) ──────────
2650
+ /**
2651
+ * A removed run leaves lists and trays but stays in the registry until
2652
+ * retention drops it: its spend still counts against the session caps, and a
2653
+ * brain holding its id can still read it. Only finished runs qualify — a
2654
+ * failed run whose cause is still being judged is not finished yet.
2655
+ */
2656
+ function removable(entry) { return TERMINAL_STATES.has(entry.state) && !entry.judging; }
2657
+ function finishedIdle(entry) {
2658
+ return entry.state === 'idle' && !entry.judging
2659
+ && ['done', 'blocked'].includes(entry.lastResult?.status)
2660
+ && !(live.get(entry.runId)?.followUps?.length);
2661
+ }
2662
+ async function finishIdleForRemoval(entry) {
2663
+ if (!finishedIdle(entry)) return;
2664
+ await complete(entry.runId);
2665
+ await wait({ runId: entry.runId, until: 'terminal', timeoutMs: 5_000 });
2666
+ }
2667
+ function announceRemoved(removedEntries) {
2668
+ const bySession = new Map();
2669
+ for (const entry of removedEntries) {
2670
+ const key = entry.assistantSessionId || null;
2671
+ bySession.set(key, [...(bySession.get(key) || []), entry.runId]);
2672
+ }
2673
+ for (const [assistantSessionId, runIds] of bySession) {
2674
+ const payload = { type: 'assistant:run-removed', assistantSessionId, runIds };
2675
+ try { broadcastSync(payload); } catch {}
2676
+ for (const listener of listeners) { try { listener(payload); } catch {} }
2677
+ }
2678
+ }
2679
+ function markRemoved(targets) {
2680
+ const at = iso(now());
2681
+ for (const entry of targets) { entry.removedAt = at; live.delete(entry.runId); meters.delete(entry.runId); }
2682
+ if (targets.length) { persist({ force: true }); announceRemoved(targets); }
2683
+ return targets.map((entry) => entry.runId);
2684
+ }
2685
+ async function remove(runId, { assistantSessionId = null } = {}) {
2686
+ const entry = requireEntry(runId);
2687
+ if (assistantSessionId && String(assistantSessionId) !== String(entry.assistantSessionId || '')) {
2688
+ throw new DispatchError('RUN_NOT_IN_SESSION', `Run ${runId} belongs to another assistant session`, { status: 403 });
2689
+ }
2690
+ if (entry.removedAt) return { ok: true, removed: [], alreadyRemoved: true };
2691
+ await finishIdleForRemoval(entry);
2692
+ if (!removable(entry)) {
2693
+ throw new DispatchError('RUN_ACTIVE', `Run ${runId} is ${entry.judging ? 'still finishing' : entry.state}; stop it before removing it.`, { status: 409, state: entry.state });
2694
+ }
2695
+ return { ok: true, removed: markRemoved([entry]) };
2696
+ }
2697
+ async function removeFinished({ assistantSessionId = null, workflowId = null, runIds = null } = {}) {
2698
+ const wanted = Array.isArray(runIds) ? new Set(runIds.map(String)) : null;
2699
+ const scoped = [...entries.values()].filter((entry) => !entry.removedAt
2700
+ && (!assistantSessionId || entry.assistantSessionId === String(assistantSessionId))
2701
+ && (!workflowId || entry.workflowId === String(workflowId))
2702
+ && (!wanted || wanted.has(entry.runId)));
2703
+ await Promise.all(scoped.filter(finishedIdle).map((entry) => finishIdleForRemoval(entry).catch(() => {})));
2704
+ const targets = scoped.filter(removable);
2705
+ return { ok: true, removed: markRemoved(targets), skipped: scoped.filter((entry) => !removable(entry)).map((entry) => entry.runId) };
2706
+ }
2707
+
2708
+ function subscribe(listener) {
2709
+ if (typeof listener !== 'function') throw new Error('subscribe requires a function');
2710
+ listeners.add(listener);
2711
+ return () => listeners.delete(listener);
2712
+ }
2713
+ async function shutdown(reason = 'server_shutdown') {
2714
+ if (stalledTimer) clearInterval(stalledTimer);
2715
+ if (unsubscribeRuntime) { try { unsubscribeRuntime(); } catch {} unsubscribeRuntime = null; }
2716
+ for (const entry of entries.values()) {
2717
+ if (ACTIVE_STATES.has(entry.state)) {
2718
+ entry.state = 'interrupted'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
2719
+ delete entry.rawSpec;
2720
+ // What the run's meter can still read is booked now; nothing provisional outlives the process.
2721
+ try { finishUsage(entry, live.get(entry.runId) || null); } catch {}
2722
+ releaseDesktop(entry);
2723
+ }
2724
+ }
2725
+ persist({ force: true });
2726
+ }
2727
+
2728
+ loadRegistry();
2729
+ attachRuntime();
2730
+ stalledTimer = setInterval(() => { attachRuntime(); checkStalled(); }, 60_000);
2731
+ stalledTimer.unref?.();
2732
+
2733
+ return {
2734
+ dispatch, sendTurn, complete, stop, killAll, get, peek, list, totals, wait, transcript, pendingPermissions,
2735
+ respondPermission, subscribe, shutdown, limits,
2736
+ sessionBudget, checkSession, refreshLimits,
2737
+ resolveRoute, declineRoute, heldRouteRefusal, escalate,
2738
+ markDelivered, deliveryState,
2739
+ remove, removeFinished,
2740
+ mediaFile,
2741
+ accountInUse: (provider, accountId) => activeEntries((e) => e.provider === provider && e.accountId === accountId).length > 0,
2742
+ _internals: { entries, live, queue, processQueue, attachRuntime, registryPath },
2743
+ };
2744
+ }