synabun 2.0.9 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (679) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/README.md +25 -2
  3. package/THIRD-PARTY-LICENSES.md +68 -4
  4. package/bin/synabun.js +71 -24
  5. package/docs/whatsapp.md +2 -2
  6. package/hooks/claude-code/pre-websearch.mjs +12 -1
  7. package/hooks/claude-code/shared.mjs +5 -0
  8. package/hooks/claude-code/stop.mjs +1 -1
  9. package/launcher.mjs +9 -3
  10. package/lib/claude-hooks.js +172 -18
  11. package/lib/client-config-repair.js +17 -8
  12. package/lib/desktop-app.js +7 -1
  13. package/lib/external-tools.js +1 -1
  14. package/lib/first-launch-protection.js +57 -12
  15. package/lib/mcp-dist.js +176 -0
  16. package/lib/release-npm-ci.mjs +138 -0
  17. package/lib/release-publish.mjs +91 -0
  18. package/lib/release-scan.mjs +50 -0
  19. package/lib/server-crash-record.js +123 -0
  20. package/lib/server-exit.js +318 -0
  21. package/lib/server-supervisor.js +508 -0
  22. package/lib/start-launcher.js +43 -5
  23. package/lib/tools-cli.js +45 -0
  24. package/lib/update-snapshot.js +228 -0
  25. package/lib/virustotal.mjs +192 -0
  26. package/lib/windows-crash-event.js +269 -0
  27. package/mcp-server/dist/http.d.ts +20 -1
  28. package/mcp-server/dist/http.d.ts.map +1 -1
  29. package/mcp-server/dist/http.js +59 -12
  30. package/mcp-server/dist/http.js.map +1 -1
  31. package/mcp-server/dist/index.d.ts +33 -27
  32. package/mcp-server/dist/index.d.ts.map +1 -1
  33. package/mcp-server/dist/index.js +128 -143
  34. package/mcp-server/dist/index.js.map +1 -1
  35. package/mcp-server/dist/preload.js +1 -1
  36. package/mcp-server/dist/preload.js.map +1 -1
  37. package/mcp-server/dist/services/codex-config-heal.d.ts +6 -2
  38. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  39. package/mcp-server/dist/services/codex-config-heal.js +81 -28
  40. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  41. package/mcp-server/dist/services/memory-judge-backfill.d.ts +6 -0
  42. package/mcp-server/dist/services/memory-judge-backfill.d.ts.map +1 -1
  43. package/mcp-server/dist/services/memory-judge-backfill.js +7 -1
  44. package/mcp-server/dist/services/memory-judge-backfill.js.map +1 -1
  45. package/mcp-server/dist/services/neural-interface.d.ts +16 -10
  46. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  47. package/mcp-server/dist/services/neural-interface.js +67 -11
  48. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  49. package/mcp-server/dist/services/server-instructions.d.ts +7 -2
  50. package/mcp-server/dist/services/server-instructions.d.ts.map +1 -1
  51. package/mcp-server/dist/services/server-instructions.js +20 -62
  52. package/mcp-server/dist/services/server-instructions.js.map +1 -1
  53. package/mcp-server/dist/services/tool-catalog.d.ts +120 -0
  54. package/mcp-server/dist/services/tool-catalog.d.ts.map +1 -0
  55. package/mcp-server/dist/services/tool-catalog.js +339 -0
  56. package/mcp-server/dist/services/tool-catalog.js.map +1 -0
  57. package/mcp-server/dist/services/tool-list-refresh-barrier.d.ts +12 -0
  58. package/mcp-server/dist/services/tool-list-refresh-barrier.d.ts.map +1 -0
  59. package/mcp-server/dist/services/tool-list-refresh-barrier.js +40 -0
  60. package/mcp-server/dist/services/tool-list-refresh-barrier.js.map +1 -0
  61. package/mcp-server/dist/services/tool-list.d.ts +108 -0
  62. package/mcp-server/dist/services/tool-list.d.ts.map +1 -0
  63. package/mcp-server/dist/services/tool-list.js +334 -0
  64. package/mcp-server/dist/services/tool-list.js.map +1 -0
  65. package/mcp-server/dist/services/tool-runtime.d.ts +161 -0
  66. package/mcp-server/dist/services/tool-runtime.d.ts.map +1 -0
  67. package/mcp-server/dist/services/tool-runtime.js +400 -0
  68. package/mcp-server/dist/services/tool-runtime.js.map +1 -0
  69. package/mcp-server/dist/services/whiteboard-geometry.d.ts +26 -2
  70. package/mcp-server/dist/services/whiteboard-geometry.d.ts.map +1 -1
  71. package/mcp-server/dist/services/whiteboard-geometry.js +91 -8
  72. package/mcp-server/dist/services/whiteboard-geometry.js.map +1 -1
  73. package/mcp-server/dist/tools/agents.d.ts +10 -10
  74. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  75. package/mcp-server/dist/tools/agents.js +22 -13
  76. package/mcp-server/dist/tools/agents.js.map +1 -1
  77. package/mcp-server/dist/tools/browser-batch.d.ts +3 -3
  78. package/mcp-server/dist/tools/image-tools.d.ts +1 -1
  79. package/mcp-server/dist/tools/image-tools.js +1 -1
  80. package/mcp-server/dist/tools/skill-tools.d.ts +505 -0
  81. package/mcp-server/dist/tools/skill-tools.d.ts.map +1 -0
  82. package/mcp-server/dist/tools/skill-tools.js +639 -0
  83. package/mcp-server/dist/tools/skill-tools.js.map +1 -0
  84. package/mcp-server/dist/tools/skill.d.ts +7 -0
  85. package/mcp-server/dist/tools/skill.d.ts.map +1 -0
  86. package/mcp-server/dist/tools/skill.js +11 -0
  87. package/mcp-server/dist/tools/skill.js.map +1 -0
  88. package/mcp-server/dist/tools/tool-groups.d.ts +68 -0
  89. package/mcp-server/dist/tools/tool-groups.d.ts.map +1 -0
  90. package/mcp-server/dist/tools/tool-groups.js +147 -0
  91. package/mcp-server/dist/tools/tool-groups.js.map +1 -0
  92. package/mcp-server/dist/tools/whiteboard-format.d.ts.map +1 -1
  93. package/mcp-server/dist/tools/whiteboard-format.js +7 -4
  94. package/mcp-server/dist/tools/whiteboard-format.js.map +1 -1
  95. package/mcp-server/dist/tools/whiteboard-tools.d.ts +64 -13
  96. package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -1
  97. package/mcp-server/dist/tools/whiteboard-tools.js +30 -5
  98. package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -1
  99. package/mcp-server/package-lock.json +2 -2
  100. package/mcp-server/package.json +1 -1
  101. package/mcp-server/src/http.ts +75 -12
  102. package/mcp-server/src/index.ts +147 -154
  103. package/mcp-server/src/preload.ts +12 -12
  104. package/mcp-server/src/services/codex-config-heal.ts +80 -29
  105. package/mcp-server/src/services/memory-judge-backfill.ts +7 -1
  106. package/mcp-server/src/services/neural-interface.ts +79 -20
  107. package/mcp-server/src/services/server-instructions.ts +27 -63
  108. package/mcp-server/src/services/tool-catalog.ts +403 -0
  109. package/mcp-server/src/services/tool-list-refresh-barrier.ts +33 -0
  110. package/mcp-server/src/services/tool-list.ts +410 -0
  111. package/mcp-server/src/services/tool-runtime.ts +457 -0
  112. package/mcp-server/src/services/whiteboard-geometry.ts +94 -9
  113. package/mcp-server/src/tools/agents.ts +22 -14
  114. package/mcp-server/src/tools/image-tools.ts +1 -1
  115. package/mcp-server/src/tools/skill-tools.ts +589 -0
  116. package/mcp-server/src/tools/skill.ts +13 -0
  117. package/mcp-server/src/tools/tool-groups.ts +164 -0
  118. package/mcp-server/src/tools/whiteboard-format.ts +9 -3
  119. package/mcp-server/src/tools/whiteboard-tools.ts +39 -5
  120. package/neural-interface/i18n/en.json +3680 -206
  121. package/neural-interface/i18n/pt-BR.json +3628 -157
  122. package/neural-interface/lib/account-usage.js +5 -3
  123. package/neural-interface/lib/app-controls-api.js +161 -0
  124. package/neural-interface/lib/assistant-api.js +148 -35
  125. package/neural-interface/lib/assistant-brains/claude.js +4 -3
  126. package/neural-interface/lib/assistant-brains/codex-browser-policy-hook.mjs +12 -2
  127. package/neural-interface/lib/assistant-brains/codex-route-gate-hook.mjs +32 -9
  128. package/neural-interface/lib/assistant-brains/codex.js +9 -5
  129. package/neural-interface/lib/assistant-brains/opencode.js +122 -44
  130. package/neural-interface/lib/assistant-brains/opencode2-browser-policy/index.js +59 -0
  131. package/neural-interface/lib/assistant-brains/opencode2-route-gate/index.js +97 -0
  132. package/neural-interface/lib/assistant-catalog.js +23 -9
  133. package/neural-interface/lib/assistant-dispatch.js +874 -156
  134. package/neural-interface/lib/assistant-envelope.js +22 -2
  135. package/neural-interface/lib/assistant-persona.js +80 -11
  136. package/neural-interface/lib/assistant-plan-permissions.js +10 -9
  137. package/neural-interface/lib/assistant-route-gate-codex.js +3 -0
  138. package/neural-interface/lib/assistant-route-gate.js +6 -4
  139. package/neural-interface/lib/assistant-router.js +13 -8
  140. package/neural-interface/lib/assistant-runtime.js +183 -26
  141. package/neural-interface/lib/assistant-task-prompt.js +78 -14
  142. package/neural-interface/lib/assistant-tools.js +51 -0
  143. package/neural-interface/lib/backup-service.js +620 -3
  144. package/neural-interface/lib/backup-zip-reader.js +10 -5
  145. package/neural-interface/lib/backup-zip-writer.js +257 -0
  146. package/neural-interface/lib/browser-execution.js +10 -2
  147. package/neural-interface/lib/browser-guard-cdp.js +83 -0
  148. package/neural-interface/lib/browser-network-guard.js +284 -0
  149. package/neural-interface/lib/browser-policy-roots.js +64 -0
  150. package/neural-interface/lib/browser-tool-policy.js +186 -31
  151. package/neural-interface/lib/claude-agent-bridge.js +4 -2
  152. package/neural-interface/lib/claude-hooks-status.js +103 -0
  153. package/neural-interface/lib/claude-model-catalog.js +64 -14
  154. package/neural-interface/lib/claude-temporary.js +5 -11
  155. package/neural-interface/lib/cli-install.js +18 -138
  156. package/neural-interface/lib/cli-resolver.js +4 -1
  157. package/neural-interface/lib/cli-update-planner.js +32 -31
  158. package/neural-interface/lib/cloudflared/tunnel.js +42 -2
  159. package/neural-interface/lib/codex-mcp-approvals.js +11 -0
  160. package/neural-interface/lib/codex-settings-schema.js +109 -47
  161. package/neural-interface/lib/codex-worker-app-server.js +172 -0
  162. package/neural-interface/lib/confined-fs.js +30 -3
  163. package/neural-interface/lib/crash-notice-api.js +98 -0
  164. package/neural-interface/lib/editor-completion-opencode.js +164 -67
  165. package/neural-interface/lib/exec-loop-command.js +4 -1
  166. package/neural-interface/lib/http-guards.js +8 -2
  167. package/neural-interface/lib/install-details.js +4 -1
  168. package/neural-interface/lib/launch-cache.js +293 -0
  169. package/neural-interface/lib/login-start.js +524 -0
  170. package/neural-interface/lib/mcp-agent-servers.js +67 -0
  171. package/neural-interface/lib/mcp-client-config.js +0 -29
  172. package/neural-interface/lib/mcp-external-servers.js +323 -0
  173. package/neural-interface/lib/{mcp-profile-refresh-coordinator.js → mcp-tools-refresh-coordinator.js} +4 -4
  174. package/neural-interface/lib/morelogin.js +30 -0
  175. package/neural-interface/lib/native-loop-providers.js +565 -50
  176. package/neural-interface/lib/native-loop-runtime.js +287 -41
  177. package/neural-interface/lib/opencode-config.js +557 -0
  178. package/neural-interface/lib/opencode-db-schema.js +58 -0
  179. package/neural-interface/lib/opencode-history.js +49 -204
  180. package/neural-interface/lib/opencode-mcp-ready.js +108 -0
  181. package/neural-interface/lib/opencode-native-boundary.js +44 -0
  182. package/neural-interface/lib/opencode-protocol.js +310 -0
  183. package/neural-interface/lib/opencode-provider-view.js +81 -0
  184. package/neural-interface/lib/opencode-runtime-config.js +32 -15
  185. package/neural-interface/lib/opencode-serve-records.js +624 -0
  186. package/neural-interface/lib/opencode-v2-client.js +53 -131
  187. package/neural-interface/lib/opencode-v2-ws-requests.js +35 -195
  188. package/neural-interface/lib/opencode-worker-activity.js +229 -0
  189. package/neural-interface/lib/opencode-worker-data.js +27 -0
  190. package/neural-interface/lib/opencode2-client.js +796 -0
  191. package/neural-interface/lib/opencode2-proxy.js +76 -0
  192. package/neural-interface/lib/opencode2-translate.js +1057 -0
  193. package/neural-interface/lib/owner-authority.js +249 -0
  194. package/neural-interface/lib/page-session.js +275 -0
  195. package/neural-interface/lib/peer-identity.js +763 -0
  196. package/neural-interface/lib/process-identity.js +361 -0
  197. package/neural-interface/lib/request-gate.js +17 -2
  198. package/neural-interface/lib/rulesets/api.js +1 -1
  199. package/neural-interface/lib/rulesets/fs-safe.js +1 -1
  200. package/neural-interface/lib/rulesets/installer.js +92 -27
  201. package/neural-interface/lib/rulesets/json-edit.js +1 -1
  202. package/neural-interface/lib/session-cache-builder.js +18 -10
  203. package/neural-interface/lib/session-title-generator.js +58 -18
  204. package/neural-interface/lib/skills/api-v2.js +495 -0
  205. package/neural-interface/lib/skills/bundle.js +418 -0
  206. package/neural-interface/lib/skills/catalog.js +294 -0
  207. package/neural-interface/lib/skills/creation-api.js +191 -0
  208. package/neural-interface/lib/skills/creation-brief.js +247 -0
  209. package/neural-interface/lib/skills/creation-sources.js +267 -0
  210. package/neural-interface/lib/skills/creation.js +843 -0
  211. package/neural-interface/lib/skills/deploy.js +564 -0
  212. package/neural-interface/lib/skills/digest.js +119 -0
  213. package/neural-interface/lib/skills/discover.js +111 -0
  214. package/neural-interface/lib/skills/draft-confinement.js +214 -0
  215. package/neural-interface/lib/skills/eval-confinement.js +397 -0
  216. package/neural-interface/lib/skills/eval-sandbox.js +280 -0
  217. package/neural-interface/lib/skills/eval-trace.js +119 -0
  218. package/neural-interface/lib/skills/evals-api.js +212 -0
  219. package/neural-interface/lib/skills/evals.js +1573 -0
  220. package/neural-interface/lib/skills/export.js +200 -0
  221. package/neural-interface/lib/skills/frontmatter.js +64 -0
  222. package/neural-interface/lib/skills/health.js +75 -0
  223. package/neural-interface/lib/skills/json-edit.js +165 -0
  224. package/neural-interface/lib/skills/known-bridge-digests.json +387 -0
  225. package/neural-interface/lib/skills/known-bridges.js +96 -0
  226. package/neural-interface/lib/skills/library.js +823 -0
  227. package/neural-interface/lib/skills/local-store.js +21 -0
  228. package/neural-interface/lib/skills/migration.js +433 -0
  229. package/neural-interface/lib/skills/operations.js +45 -0
  230. package/neural-interface/lib/skills/ownership.js +427 -0
  231. package/neural-interface/lib/skills/packages.js +88 -0
  232. package/neural-interface/lib/skills/reviews.js +212 -0
  233. package/neural-interface/lib/skills/revision.js +80 -0
  234. package/neural-interface/lib/skills/roots.js +262 -0
  235. package/neural-interface/lib/skills/runtime-checks.js +355 -0
  236. package/neural-interface/lib/skills/scan-assets.js +252 -0
  237. package/neural-interface/lib/skills/scan-context.js +26 -0
  238. package/neural-interface/lib/skills/scan-shell.js +324 -0
  239. package/neural-interface/lib/skills/scan.js +364 -0
  240. package/neural-interface/lib/skills/scanners.js +54 -0
  241. package/neural-interface/lib/skills/service.js +522 -0
  242. package/neural-interface/lib/skills/telemetry.js +113 -0
  243. package/neural-interface/lib/sqlite-integrity-worker.js +22 -0
  244. package/neural-interface/lib/style-guide/api.js +4 -3
  245. package/neural-interface/lib/tool-lists.js +124 -0
  246. package/neural-interface/lib/untrusted-file-headers.js +27 -0
  247. package/neural-interface/lib/whatsapp/bridge.js +6 -3
  248. package/neural-interface/lib/worker-confinement.js +1192 -0
  249. package/neural-interface/lib/worker-guard-setting.js +56 -0
  250. package/neural-interface/lib/worker-mcp-channel.js +175 -0
  251. package/neural-interface/lib/worker-relay-ports.js +227 -0
  252. package/neural-interface/lib/worker-resume.js +229 -0
  253. package/neural-interface/lib/worker-sandbox.js +299 -0
  254. package/neural-interface/package-lock.json +1354 -50
  255. package/neural-interface/package.json +6 -2
  256. package/neural-interface/public/index.html +5 -3
  257. package/neural-interface/public/index2d.html +19 -11
  258. package/neural-interface/public/onboarding.html +2 -2
  259. package/neural-interface/public/shared/account-menu.js +8 -5
  260. package/neural-interface/public/shared/agent-runtime-options.js +76 -16
  261. package/neural-interface/public/shared/api.js +1527 -1336
  262. package/neural-interface/public/shared/app-frame.js +192 -0
  263. package/neural-interface/public/shared/assistant/asst-agents-dock.js +205 -19
  264. package/neural-interface/public/shared/assistant/asst-api.js +11 -3
  265. package/neural-interface/public/shared/assistant/asst-ask-styles.js +270 -0
  266. package/neural-interface/public/shared/assistant/asst-ask-tail.js +118 -0
  267. package/neural-interface/public/shared/assistant/asst-ask.js +397 -0
  268. package/neural-interface/public/shared/assistant/asst-brain-picker.js +241 -112
  269. package/neural-interface/public/shared/assistant/asst-composer.js +1 -1
  270. package/neural-interface/public/shared/assistant/asst-control.js +157 -26
  271. package/neural-interface/public/shared/assistant/asst-followups-card.js +383 -0
  272. package/neural-interface/public/shared/assistant/asst-followups.js +417 -0
  273. package/neural-interface/public/shared/assistant/asst-hosts.js +49 -2
  274. package/neural-interface/public/shared/assistant/asst-limit-dock.js +40 -0
  275. package/neural-interface/public/shared/assistant/asst-limit-wait.js +294 -0
  276. package/neural-interface/public/shared/assistant/asst-limit.js +168 -0
  277. package/neural-interface/public/shared/assistant/asst-mascot.js +49 -12
  278. package/neural-interface/public/shared/assistant/asst-menu.js +84 -12
  279. package/neural-interface/public/shared/assistant/asst-panel.js +303 -44
  280. package/neural-interface/public/shared/assistant/asst-rack-styles.js +93 -58
  281. package/neural-interface/public/shared/assistant/asst-redact.js +186 -0
  282. package/neural-interface/public/shared/assistant/asst-render.js +770 -100
  283. package/neural-interface/public/shared/assistant/asst-route.js +83 -12
  284. package/neural-interface/public/shared/assistant/asst-run-stage.js +231 -0
  285. package/neural-interface/public/shared/assistant/asst-sanitize.js +193 -3
  286. package/neural-interface/public/shared/assistant/asst-scene-styles.js +273 -0
  287. package/neural-interface/public/shared/assistant/asst-scenes.js +530 -0
  288. package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +3 -15
  289. package/neural-interface/public/shared/assistant/asst-sidepanel.js +67 -4
  290. package/neural-interface/public/shared/assistant/asst-slash.js +9 -7
  291. package/neural-interface/public/shared/assistant/asst-state.js +25 -15
  292. package/neural-interface/public/shared/assistant/asst-stop.js +127 -0
  293. package/neural-interface/public/shared/assistant/asst-styles.js +102 -21
  294. package/neural-interface/public/shared/assistant/asst-tool-kinds.js +165 -13
  295. package/neural-interface/public/shared/automation-context.js +5 -2
  296. package/neural-interface/public/shared/cc-dropdown.js +550 -0
  297. package/neural-interface/public/shared/cdx/cdx-context-menu.js +10 -9
  298. package/neural-interface/public/shared/cdx/cdx-copy.js +16 -0
  299. package/neural-interface/public/shared/cdx/cdx-output-model.js +22 -1
  300. package/neural-interface/public/shared/cdx/cdx-output.js +8 -7
  301. package/neural-interface/public/shared/cdx/cdx-panel.js +58 -150
  302. package/neural-interface/public/shared/cdx/cdx-protocol.js +28 -1
  303. package/neural-interface/public/shared/cdx/cdx-render.js +62 -32
  304. package/neural-interface/public/shared/cdx/cdx-requests.js +3 -3
  305. package/neural-interface/public/shared/cdx/cdx-skill-hints.js +54 -0
  306. package/neural-interface/public/shared/cdx/cdx-styles.js +15 -3
  307. package/neural-interface/public/shared/cdx/cdx-tabs.js +201 -74
  308. package/neural-interface/public/shared/cdx/cdx-ws.js +0 -1
  309. package/neural-interface/public/shared/cli-install-view.js +4 -8
  310. package/neural-interface/public/shared/cli-status.js +2 -2
  311. package/neural-interface/public/shared/constants.js +2 -2
  312. package/neural-interface/public/shared/context-menu-areas.js +34 -0
  313. package/neural-interface/public/shared/context-menu-logic.js +243 -0
  314. package/neural-interface/public/shared/context-menu-model.js +366 -0
  315. package/neural-interface/public/shared/context-menu.js +372 -0
  316. package/neural-interface/public/shared/cp/cp-context-menu.js +10 -9
  317. package/neural-interface/public/shared/cp/cp-markdown.js +4 -57
  318. package/neural-interface/public/shared/crash-notice-view.js +168 -0
  319. package/neural-interface/public/shared/data-cache.js +223 -0
  320. package/neural-interface/public/shared/desktop-bridge.js +11 -0
  321. package/neural-interface/public/shared/desktop-titlebar-view.js +78 -1
  322. package/neural-interface/public/shared/desktop-titlebar.js +214 -28
  323. package/neural-interface/public/shared/desktop-update-view.js +35 -5
  324. package/neural-interface/public/shared/editor/editor-panel-logic.js +2 -2
  325. package/neural-interface/public/shared/file-links.js +73 -8
  326. package/neural-interface/public/shared/html-shell.js +78 -86
  327. package/neural-interface/public/shared/i18n.js +2 -0
  328. package/neural-interface/public/shared/launch-prefetch.js +59 -0
  329. package/neural-interface/public/shared/ocp-v2/ocp-v2-approvals.js +7 -7
  330. package/neural-interface/public/shared/ocp-v2/ocp-v2-binding.js +4 -4
  331. package/neural-interface/public/shared/ocp-v2/ocp-v2-caps.js +3 -2
  332. package/neural-interface/public/shared/ocp-v2/ocp-v2-changes-logic.js +3 -101
  333. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +9 -2
  334. package/neural-interface/public/shared/ocp-v2/ocp-v2-compact-button.js +1 -3
  335. package/neural-interface/public/shared/ocp-v2/ocp-v2-composer-logic.js +2 -6
  336. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-menu.js +11 -11
  337. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-model.js +0 -3
  338. package/neural-interface/public/shared/ocp-v2/ocp-v2-events.js +8 -4
  339. package/neural-interface/public/shared/ocp-v2/ocp-v2-manager.js +7 -7
  340. package/neural-interface/public/shared/ocp-v2/ocp-v2-modelpicker.js +54 -17
  341. package/neural-interface/public/shared/ocp-v2/ocp-v2-outcome-notifications.js +16 -0
  342. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +140 -254
  343. package/neural-interface/public/shared/ocp-v2/ocp-v2-plan.js +1 -2
  344. package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +97 -203
  345. package/neural-interface/public/shared/ocp-v2/ocp-v2-providers.js +11 -9
  346. package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +15 -4
  347. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +7 -22
  348. package/neural-interface/public/shared/ocp-v2/ocp-v2-send-logic.js +401 -0
  349. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +20 -13
  350. package/neural-interface/public/shared/ocp-v2/ocp-v2-session-actions.js +8 -61
  351. package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +53 -82
  352. package/neural-interface/public/shared/ocp-v2/ocp-v2-slash-hints.js +16 -15
  353. package/neural-interface/public/shared/ocp-v2/ocp-v2-state.js +8 -21
  354. package/neural-interface/public/shared/ocp-v2/ocp-v2-status-logic.js +1 -99
  355. package/neural-interface/public/shared/ocp-v2/ocp-v2-status.js +5 -35
  356. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +48 -40
  357. package/neural-interface/public/shared/ocp-v2/{ocp-v2-profile-inheritance.js → ocp-v2-tools-inheritance.js} +16 -16
  358. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +205 -62
  359. package/neural-interface/public/shared/opencode-providers.js +39 -0
  360. package/neural-interface/public/shared/page-session.js +96 -0
  361. package/neural-interface/public/shared/registry.js +5 -3
  362. package/neural-interface/public/shared/retired-2d.js +3 -0
  363. package/neural-interface/public/shared/schedules-view.js +452 -0
  364. package/neural-interface/public/shared/settings/settings-app-pane.js +335 -0
  365. package/neural-interface/public/shared/settings/settings-app.js +243 -0
  366. package/neural-interface/public/shared/settings/settings-claude-hooks.js +135 -0
  367. package/neural-interface/public/shared/settings/settings-data.js +90 -0
  368. package/neural-interface/public/shared/settings/settings-ia.js +23 -6
  369. package/neural-interface/public/shared/settings/settings-opencode.js +89 -0
  370. package/neural-interface/public/shared/settings/settings-skill-folders-pane.js +265 -0
  371. package/neural-interface/public/shared/settings/settings-skill-folders.js +130 -0
  372. package/neural-interface/public/shared/settings/settings-worker-guard.js +72 -0
  373. package/neural-interface/public/shared/sidepanel-session-status.js +49 -0
  374. package/neural-interface/public/shared/skills/ss-args.js +96 -0
  375. package/neural-interface/public/shared/skills/ss-creation.js +219 -0
  376. package/neural-interface/public/shared/skills/ss-dialog.js +130 -0
  377. package/neural-interface/public/shared/skills/ss-doc.js +173 -0
  378. package/neural-interface/public/shared/skills/ss-eval-adapters.js +269 -0
  379. package/neural-interface/public/shared/skills/ss-eval-grade.js +359 -0
  380. package/neural-interface/public/shared/skills/ss-eval-optimise.js +153 -0
  381. package/neural-interface/public/shared/skills/ss-eval-plan.js +315 -0
  382. package/neural-interface/public/shared/skills/ss-evals.js +417 -0
  383. package/neural-interface/public/shared/skills/ss-form.js +159 -0
  384. package/neural-interface/public/shared/skills/ss-frontmatter.js +376 -0
  385. package/neural-interface/public/shared/skills/ss-ids.js +32 -0
  386. package/neural-interface/public/shared/skills/ss-lint.js +141 -0
  387. package/neural-interface/public/shared/skills/ss-presets.js +117 -0
  388. package/neural-interface/public/shared/skills/ss-profiles.js +475 -0
  389. package/neural-interface/public/shared/skills/ss-resources.js +165 -0
  390. package/neural-interface/public/shared/skills/ss-review-view.js +190 -0
  391. package/neural-interface/public/shared/skills/ss-tabs.js +79 -0
  392. package/neural-interface/public/shared/skills/ss-unicode.js +135 -0
  393. package/neural-interface/public/shared/skills/studio/sx-actions.js +222 -0
  394. package/neural-interface/public/shared/skills/studio/sx-cm.js +101 -0
  395. package/neural-interface/public/shared/skills/studio/sx-create-ai-view.js +749 -0
  396. package/neural-interface/public/shared/skills/studio/sx-create-ai.js +236 -0
  397. package/neural-interface/public/shared/skills/studio/sx-create-view.js +254 -0
  398. package/neural-interface/public/shared/skills/studio/sx-create.js +126 -0
  399. package/neural-interface/public/shared/skills/studio/sx-diff-view.js +43 -0
  400. package/neural-interface/public/shared/skills/studio/sx-diff.js +133 -0
  401. package/neural-interface/public/shared/skills/studio/sx-docs.js +480 -0
  402. package/neural-interface/public/shared/skills/studio/sx-dom.js +229 -0
  403. package/neural-interface/public/shared/skills/studio/sx-edit-view.js +848 -0
  404. package/neural-interface/public/shared/skills/studio/sx-editor-model.js +193 -0
  405. package/neural-interface/public/shared/skills/studio/sx-errors.js +22 -0
  406. package/neural-interface/public/shared/skills/studio/sx-geometry.js +82 -0
  407. package/neural-interface/public/shared/skills/studio/sx-import.js +262 -0
  408. package/neural-interface/public/shared/skills/studio/sx-inspector.js +308 -0
  409. package/neural-interface/public/shared/skills/studio/sx-install-flow.js +279 -0
  410. package/neural-interface/public/shared/skills/studio/sx-install.js +296 -0
  411. package/neural-interface/public/shared/skills/studio/sx-library-view.js +639 -0
  412. package/neural-interface/public/shared/skills/studio/sx-library.js +475 -0
  413. package/neural-interface/public/shared/skills/studio/sx-main.js +681 -0
  414. package/neural-interface/public/shared/skills/studio/sx-operations-view.js +98 -0
  415. package/neural-interface/public/shared/skills/studio/sx-operations.js +11 -0
  416. package/neural-interface/public/shared/skills/studio/sx-plan.js +158 -0
  417. package/neural-interface/public/shared/skills/studio/sx-publish-view.js +621 -0
  418. package/neural-interface/public/shared/skills/studio/sx-review-view.js +369 -0
  419. package/neural-interface/public/shared/skills/studio/sx-review.js +148 -0
  420. package/neural-interface/public/shared/skills/studio/sx-scan.js +35 -0
  421. package/neural-interface/public/shared/skills/studio/sx-snippets.js +96 -0
  422. package/neural-interface/public/shared/skills/studio/sx-subject.js +248 -0
  423. package/neural-interface/public/shared/skills/studio/sx-test-view.js +994 -0
  424. package/neural-interface/public/shared/skills/studio/sx-test.js +328 -0
  425. package/neural-interface/public/shared/skills/studio/sx-versions-view.js +241 -0
  426. package/neural-interface/public/shared/skills/studio/sx-workspace-view.js +425 -0
  427. package/neural-interface/public/shared/styleguide/sg-panel.js +4 -2
  428. package/neural-interface/public/shared/styles.css +2065 -2150
  429. package/neural-interface/public/shared/synabun-mascot.js +1115 -9
  430. package/neural-interface/public/shared/tool-list-picker.js +55 -0
  431. package/neural-interface/public/shared/tool-list-storage.js +24 -0
  432. package/neural-interface/public/shared/tool-list-view.js +112 -0
  433. package/neural-interface/public/shared/tour/tour-actor.js +268 -0
  434. package/neural-interface/public/shared/tour/tour-beats.js +336 -0
  435. package/neural-interface/public/shared/tour/tour-engine.js +884 -0
  436. package/neural-interface/public/shared/tour/tour-ink.js +390 -0
  437. package/neural-interface/public/shared/tour/tour-stage.js +440 -0
  438. package/neural-interface/public/shared/tour/tour-state.js +40 -0
  439. package/neural-interface/public/shared/tour/tour-tips.js +99 -0
  440. package/neural-interface/public/shared/tour/tour.css +144 -0
  441. package/neural-interface/public/shared/ui-assistant.js +27 -5
  442. package/neural-interface/public/shared/ui-automation-studio.js +155 -152
  443. package/neural-interface/public/shared/ui-bookmarks.js +2 -1
  444. package/neural-interface/public/shared/ui-claude-panel.js +153 -13
  445. package/neural-interface/public/shared/ui-codex-panel.js +1 -0
  446. package/neural-interface/public/shared/ui-command-runner.js +6 -3
  447. package/neural-interface/public/shared/ui-context-menu.js +295 -0
  448. package/neural-interface/public/shared/ui-cost-widget.js +57 -59
  449. package/neural-interface/public/shared/ui-crash-notice.js +187 -0
  450. package/neural-interface/public/shared/ui-detail.js +21 -9
  451. package/neural-interface/public/shared/ui-file-links.js +1 -0
  452. package/neural-interface/public/shared/ui-image-gallery.js +56 -52
  453. package/neural-interface/public/shared/ui-invite.js +18 -2
  454. package/neural-interface/public/shared/ui-keybinds.js +26 -1
  455. package/neural-interface/public/shared/ui-link.js +9 -9
  456. package/neural-interface/public/shared/ui-menubar.js +229 -40
  457. package/neural-interface/public/shared/ui-native-loop-router-state.js +5 -0
  458. package/neural-interface/public/shared/ui-native-loop-router.js +9 -1
  459. package/neural-interface/public/shared/ui-navbar.js +4 -24
  460. package/neural-interface/public/shared/ui-opencode-panel-v2.js +1 -0
  461. package/neural-interface/public/shared/ui-panels.js +96 -65
  462. package/neural-interface/public/shared/ui-schedules-studio.js +2267 -953
  463. package/neural-interface/public/shared/ui-sessions.js +20 -7
  464. package/neural-interface/public/shared/ui-settings.js +679 -496
  465. package/neural-interface/public/shared/ui-sidebar.js +4 -4
  466. package/neural-interface/public/shared/ui-sidepanel-session-entry.js +13 -0
  467. package/neural-interface/public/shared/ui-sidepanel-sessions.js +56 -5
  468. package/neural-interface/public/shared/ui-sidepanel-state.js +23 -12
  469. package/neural-interface/public/shared/ui-sidepanel-tray.js +84 -8
  470. package/neural-interface/public/shared/ui-sidepanel-windows.js +16 -11
  471. package/neural-interface/public/shared/ui-skills.js +42 -2781
  472. package/neural-interface/public/shared/ui-styleguide.js +5 -2
  473. package/neural-interface/public/shared/ui-sync.js +22 -5
  474. package/neural-interface/public/shared/ui-terminal.js +159 -199
  475. package/neural-interface/public/shared/ui-tool-updates.js +3 -1
  476. package/neural-interface/public/shared/ui-tooltip.js +10 -5
  477. package/neural-interface/public/shared/ui-trash.js +14 -5
  478. package/neural-interface/public/shared/ui-tutorial-layout.js +3 -117
  479. package/neural-interface/public/shared/ui-tutorial-preview.js +103 -0
  480. package/neural-interface/public/shared/ui-tutorial-steps.js +3 -61
  481. package/neural-interface/public/shared/ui-tutorial.css +47 -100
  482. package/neural-interface/public/shared/ui-tutorial.js +285 -499
  483. package/neural-interface/public/shared/ui-update.js +121 -16
  484. package/neural-interface/public/shared/ui-whiteboard.js +313 -59
  485. package/neural-interface/public/shared/ui-worker-end.js +4 -0
  486. package/neural-interface/public/shared/ui-workspaces.js +3 -2
  487. package/neural-interface/public/shared/view-variant.js +66 -0
  488. package/neural-interface/public/shared/whatsapp/wa-view.js +1 -1
  489. package/neural-interface/public/shared/whiteboard-list.js +172 -0
  490. package/neural-interface/public/shared/worker-end.js +10 -3
  491. package/neural-interface/public/shared/worker-restart.js +12 -0
  492. package/neural-interface/public/sidepanel-session.html +1 -1
  493. package/neural-interface/public/variant/2d/background.js +2 -1
  494. package/neural-interface/public/variant/2d/context-menu.js +8 -2
  495. package/neural-interface/public/variant/2d/graph.js +42 -20
  496. package/neural-interface/public/variant/2d/hulls.js +14 -9
  497. package/neural-interface/public/variant/2d/lasso.js +11 -7
  498. package/neural-interface/public/variant/2d/main.js +36 -2
  499. package/neural-interface/public/variant/2d/minimap.js +11 -13
  500. package/neural-interface/public/variant/3d/main.js +72 -6
  501. package/neural-interface/public/variant/3d/map-controls.js +24 -9
  502. package/neural-interface/public/vendor/caveat/OFL.txt +93 -0
  503. package/neural-interface/public/vendor/caveat/caveat-latin-400-normal.woff2 +0 -0
  504. package/neural-interface/public/vendor/caveat/caveat-latin-700-normal.woff2 +0 -0
  505. package/neural-interface/public/vendor/caveat/caveat.css +17 -0
  506. package/neural-interface/public/vendor/caveat/manifest.json +12 -0
  507. package/neural-interface/public/vendor/highlight/LICENSE +29 -0
  508. package/neural-interface/public/vendor/highlight/github-dark-dimmed-11.12.0.min.css +9 -0
  509. package/neural-interface/public/vendor/highlight/highlight-11.12.0.min.js +1262 -0
  510. package/neural-interface/public/vendor/highlight/manifest.json +5 -0
  511. package/neural-interface/public/vendor/html2canvas/LICENSE +22 -0
  512. package/neural-interface/public/vendor/html2canvas/html2canvas-1.4.1.min.js +20 -0
  513. package/neural-interface/public/vendor/html2canvas/manifest.json +4 -0
  514. package/neural-interface/public/vendor/marked/LICENSE.md +44 -0
  515. package/neural-interface/public/vendor/marked/manifest.json +4 -0
  516. package/neural-interface/public/vendor/marked/marked-14.1.4.esm.js +2502 -0
  517. package/neural-interface/public/vendor/mermaid/LICENSE +21 -0
  518. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/abnfDiagram-PAR5IXJI.mjs +1 -0
  519. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/architecture-7GRP2DOG-M7G3SWCO.mjs +1 -0
  520. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/architectureDiagram-2PNWDENW.mjs +36 -0
  521. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/blockDiagram-PUWGUBOA.mjs +129 -0
  522. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/c4Diagram-3NLNHZP5.mjs +38 -0
  523. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-2NMSUPJH.mjs +1 -0
  524. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-32MCUW2P.mjs +206 -0
  525. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-3NPNU5GP.mjs +1 -0
  526. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-3SMGFRGC.mjs +1 -0
  527. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-4CAJECRU.mjs +14 -0
  528. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-4OADUZ5Q.mjs +10 -0
  529. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5IMINLNL.mjs +1 -0
  530. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5IQCK5RY.mjs +3 -0
  531. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5MVFYWFU.mjs +1 -0
  532. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-AQ6EADP3.mjs +1 -0
  533. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-AZZRMDJM.mjs +15 -0
  534. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-CLS4B6BI.mjs +168 -0
  535. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-DZP67EKU.mjs +70 -0
  536. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-EICP7K5X.mjs +1 -0
  537. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-GDY2UAB4.mjs +1 -0
  538. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-H7VHZCWX.mjs +1 -0
  539. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-IJBDOHL6.mjs +1 -0
  540. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JFFVMJIA.mjs +1 -0
  541. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JQRUD6KW.mjs +1 -0
  542. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JZGDI5JK.mjs +1 -0
  543. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-KD6SPIPN.mjs +2 -0
  544. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-NF366YI6.mjs +231 -0
  545. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-NUDPKBEF.mjs +32 -0
  546. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-OJINMHTS.mjs +1 -0
  547. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-Q6NGPIAH.mjs +2 -0
  548. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-QJSWEUOL.mjs +132 -0
  549. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-R7JDMUKV.mjs +1 -0
  550. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-RCXAM2GK.mjs +1 -0
  551. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-RT4MAWR3.mjs +1 -0
  552. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-SFMINX4W.mjs +1 -0
  553. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-SLDQ463B.mjs +88 -0
  554. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-STOV2HOB.mjs +1 -0
  555. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-T5OCTHI4.mjs +1 -0
  556. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-TCEHX7R3.mjs +1 -0
  557. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VQC2EELJ.mjs +1 -0
  558. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VRZKL3EH.mjs +1 -0
  559. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VTHDQ7BV.mjs +1 -0
  560. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VY2OSTJR.mjs +2 -0
  561. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-WUBWJARI.mjs +1 -0
  562. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-YBMQ5JGJ.mjs +2 -0
  563. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-YDMLL4PJ.mjs +1 -0
  564. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-Z6V44ARX.mjs +62 -0
  565. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZJJ7VQJP.mjs +339 -0
  566. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZNLK7HRR.mjs +1 -0
  567. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZQN7UHIL.mjs +1 -0
  568. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZS6VBONO.mjs +174 -0
  569. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZUNWM646.mjs +1 -0
  570. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-FGAMPOII.mjs +1 -0
  571. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-v2-AI5RJEK4.mjs +1 -0
  572. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cose-bilkent-Q72C2OJ3.mjs +1 -0
  573. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cynefin-OW5HDTMX-6OCTWOGG.mjs +1 -0
  574. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cynefinDiagram-2OOWUY4M.mjs +62 -0
  575. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/dagre-MPVFI544.mjs +4 -0
  576. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-AE4NWXFW.mjs +24 -0
  577. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-BUJID5UG.mjs +41 -0
  578. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-C44CZ5NI.mjs +24 -0
  579. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-EYOLEXXS.mjs +30 -0
  580. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-P7B6PWOJ.mjs +3 -0
  581. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ebnfDiagram-FTPZ3PV6.mjs +1 -0
  582. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/erDiagram-H3ERR26V.mjs +99 -0
  583. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/eventmodeling-NTZA5JFV-WKSWQHFS.mjs +1 -0
  584. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/flowDiagram-YHGXBVSY.mjs +1 -0
  585. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ganttDiagram-RWFTB7ED.mjs +292 -0
  586. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/gitGraph-4MIJSDKK-JH2HKYUO.mjs +1 -0
  587. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/gitGraphDiagram-MAR6GKOP.mjs +106 -0
  588. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/info-A6RAGUB7-3TX3OFWW.mjs +1 -0
  589. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/infoDiagram-LV547AFH.mjs +2 -0
  590. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ishikawaDiagram-PUJQGNDS.mjs +70 -0
  591. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/journeyDiagram-EW5QJT4W.mjs +139 -0
  592. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/kanban-definition-JSQERAZB.mjs +89 -0
  593. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/katex-CZ4GXH2S.mjs +257 -0
  594. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/mindmap-definition-VBJCLQLM.mjs +96 -0
  595. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/packet-AYTQ26CC-MKII6P3Y.mjs +1 -0
  596. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pegDiagram-7WILOQTJ.mjs +1 -0
  597. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pie-WAS4IAKB-6B4OPMJ5.mjs +1 -0
  598. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pieDiagram-EFA7CBSB.mjs +39 -0
  599. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/quadrantDiagram-WARVYA7P.mjs +7 -0
  600. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/radar-RG4KPBEZ-UPTR3XUL.mjs +1 -0
  601. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-74A4TZTK-MEFUPFMS.mjs +1 -0
  602. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-abnf-HS5TGJTU-R5YG2S5D.mjs +1 -0
  603. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-ebnf-LZEXJU2U-G5QEX2L6.mjs +1 -0
  604. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-peg-WCYAUIDC-H5I2ZRY2.mjs +1 -0
  605. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroadDiagram-5NIQVNLQ.mjs +1 -0
  606. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/requirementDiagram-SXREGOKZ.mjs +84 -0
  607. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sankeyDiagram-VV74M2VP.mjs +40 -0
  608. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sequenceDiagram-52ZFIFDL.mjs +162 -0
  609. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sizeCapture-FGA5AGYP.mjs +1 -0
  610. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-3QAKPIVI.mjs +1 -0
  611. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-v2-HN2P4RDZ.mjs +1 -0
  612. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/swimlanes-QLVP42QD.mjs +1 -0
  613. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/swimlanesDiagram-JKPDBFCN.mjs +8 -0
  614. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/timeline-definition-GS45XAXW.mjs +120 -0
  615. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/treeView-Q6P3EWNA-ZRJGANNQ.mjs +1 -0
  616. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/treemap-WGGIJYW6-K64VN5AS.mjs +1 -0
  617. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/vennDiagram-QIQU2ZRJ.mjs +34 -0
  618. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/wardley-WFR3VGLG-D67OWCCH.mjs +1 -0
  619. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/wardleyDiagram-U6YNM66N.mjs +78 -0
  620. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/xychartDiagram-XJAZEDNM.mjs +7 -0
  621. package/neural-interface/public/vendor/mermaid/manifest.json +4 -0
  622. package/neural-interface/public/vendor/mermaid/mermaid-11.17.2.esm.min.mjs +14 -0
  623. package/neural-interface/public/vendor/yaml/LICENSE +13 -0
  624. package/neural-interface/public/vendor/yaml/manifest.json +4 -0
  625. package/neural-interface/public/vendor/yaml/yaml-2.9.1.min.js +137 -0
  626. package/neural-interface/scripts/build-vendor.mjs +116 -3
  627. package/neural-interface/server.js +4834 -2541
  628. package/neural-interface/templates/rulesets/core.md +1 -1
  629. package/neural-interface/templates/rulesets/legacy-hashes.json +18 -0
  630. package/neural-interface/templates/rulesets/manifest.json +8 -8
  631. package/npm-shrinkwrap.json +18 -12
  632. package/package.json +16 -3
  633. package/postinstall.js +9 -4
  634. package/preuninstall.js +1 -1
  635. package/setup.js +97 -322
  636. package/skills/skill-forge/SKILL.md +177 -0
  637. package/skills/skill-forge/evals/evals.json +54 -0
  638. package/skills/skill-forge/references/package.md +105 -0
  639. package/skills/skill-forge/references/review-and-recovery.md +63 -0
  640. package/skills/skill-forge/references/runtime-profiles.md +20 -0
  641. package/skills/skill-forge/references/sources-and-capabilities.md +51 -0
  642. package/skills/synabun/SKILL.md +10 -7
  643. package/skills/synabun/modules/audit.md +1 -5
  644. package/skills/synabun/modules/changelog.md +1 -5
  645. package/skills/synabun/modules/idea.md +1 -1
  646. package/skills/synabun/modules/memorize.md +6 -9
  647. package/skills/synabun/modules/schedule.md +5 -5
  648. package/mcp-server/dist/services/profiles.d.ts +0 -120
  649. package/mcp-server/dist/services/profiles.d.ts.map +0 -1
  650. package/mcp-server/dist/services/profiles.js +0 -387
  651. package/mcp-server/dist/services/profiles.js.map +0 -1
  652. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +0 -97
  653. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +0 -1
  654. package/mcp-server/dist/tools/leonardo-browser-tools.js +0 -228
  655. package/mcp-server/dist/tools/leonardo-browser-tools.js.map +0 -1
  656. package/mcp-server/dist/tools/leonardo.d.ts +0 -13
  657. package/mcp-server/dist/tools/leonardo.d.ts.map +0 -1
  658. package/mcp-server/dist/tools/leonardo.js +0 -21
  659. package/mcp-server/dist/tools/leonardo.js.map +0 -1
  660. package/mcp-server/dist/tools/profile.d.ts +0 -17
  661. package/mcp-server/dist/tools/profile.d.ts.map +0 -1
  662. package/mcp-server/dist/tools/profile.js +0 -114
  663. package/mcp-server/dist/tools/profile.js.map +0 -1
  664. package/mcp-server/src/services/profiles.ts +0 -428
  665. package/mcp-server/src/tools/leonardo-browser-tools.ts +0 -291
  666. package/mcp-server/src/tools/leonardo.ts +0 -30
  667. package/mcp-server/src/tools/profile.ts +0 -122
  668. package/neural-interface/lib/assistant-brains/opencode-browser-policy.js +0 -47
  669. package/neural-interface/lib/assistant-brains/opencode-route-gate.js +0 -62
  670. package/neural-interface/lib/mcp-profile-servers.js +0 -120
  671. package/neural-interface/public/shared/ui-tutorial-draw.js +0 -311
  672. package/skills/leonardo/SKILL.md +0 -168
  673. package/skills/leonardo/icon.svg +0 -1
  674. package/skills/leonardo/modules/browser-guide.md +0 -378
  675. package/skills/leonardo/modules/image-prompter.md +0 -283
  676. package/skills/leonardo/modules/model-advisor.md +0 -100
  677. package/skills/leonardo/modules/prompt-library.md +0 -110
  678. package/skills/leonardo/modules/style-guide.md +0 -186
  679. package/skills/leonardo/modules/video-prompter.md +0 -317
@@ -1,3 +1,5 @@
1
+ import { retainExternalRefs } from './mcp-external-servers.js';
2
+ import { readStoredToolListRecord } from './tool-lists.js';
1
3
  // ═══════════════════════════════════════════
2
4
  // SynaBun — Assistant dispatcher (task-mode runs on NativeLoopRuntime)
3
5
  // ═══════════════════════════════════════════
@@ -18,14 +20,22 @@
18
20
  // It never touches server.js internals directly — every server dependency is
19
21
  // injected.
20
22
 
23
+ import { isBrowserGroup } from '../../mcp-server/dist/services/tool-catalog.js';
24
+ import { browserToolsConflict, dispatchTools, launchTools, toolsView } from './assistant-tools.js';
25
+ import { storedToolList, toolListRecord } from './tool-lists.js';
21
26
  import { randomBytes } from 'node:crypto';
22
27
  import { chooseUsageAccount } from './account-usage.js';
23
- import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
28
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmdirSync, statSync, writeFileSync } from 'node:fs';
24
29
  import { dirname, isAbsolute, resolve } from 'node:path';
25
30
  import {
26
- RESULT_STATUSES, buildTaskPrompt, buildFollowUpPrompt, buildResultRetryPrompt, parseResultContract,
31
+ RESULT_STATUSES, buildTaskPrompt, buildFollowUpPrompt, buildResultRetryPrompt, buildResumePrompt, parseResultContract,
27
32
  } from './assistant-task-prompt.js';
33
+ import { MAX_RESUMES, clockTime, endOrphanProcess, endProcessTree, findClaudeTranscript, isInside, readCostState, resumeRefusal } from './worker-resume.js';
28
34
  import { normalizeCapability, normalizePermissionPolicy } from './native-loop-providers.js';
35
+ import { WORKER_GUARD_DEFAULT, describeConfinementEvent, normalizeWorkerGuardMode } from './worker-confinement.js';
36
+ import { draftConfinementSupport } from './skills/draft-confinement.js';
37
+ import { EVAL_CONFINEMENT, EVAL_ROLES, evalConfinementSupport } from './skills/eval-confinement.js';
38
+ import { initOf as evalInitOf, toolCallsOf as evalToolCallsOf } from './skills/eval-trace.js';
29
39
  import { contextVariantOf, defaultIsHidden, defaultModelRow, findModel, hiddenModelId, providerAllHidden, providerEmpty, rowMakes } from './assistant-catalog.js';
30
40
  import { TASK_CLASS_META, classPlaybook, collectedMedia, describeTarget, designFit, requiredOutput, requiresVision, runContract } from './assistant-router.js';
31
41
  import { MAX_MEDIA_BYTES, MAX_RUN_MEDIA_BYTES, copyRunMedia, mediaType, mediaUrl, servableRunMedia } from './assistant-media.js';
@@ -40,25 +50,31 @@ import { createBudgetPlanner } from './assistant-budget-planner.js';
40
50
  import { CLAUDE_ESTIMATE_SOURCE, CODEX_USAGE_KEYS, TOKEN_KEYS, addTokens, codexUsageTokens, createClaudeMeter, createCodexMeter, createOpenCodeMeter, totalTokens, withTotal, zeroTokens } from './assistant-usage.js';
41
51
  import { claudeTokensCostUsd } from './assistant-pricing.js';
42
52
  import { RemotePolicyError, clampDispatchSpec, getSessionPolicy, readRegisteredProjects } from './remote-policy.js';
53
+ import { limitFromText } from '../public/shared/assistant/asst-limit.js';
43
54
 
44
55
  export const NATIVE_PROVIDERS = new Set(['claude-code', 'codex', 'opencode']);
45
56
  // awaiting_route: an un-routed dispatch held until the user picks a model on
46
57
  // the route card (ask modes). It is active but consumes no rails.
47
- export const ACTIVE_STATES = new Set(['queued', 'awaiting_route', 'starting', 'running', 'idle', 'awaiting_permission']);
58
+ // suspended: a native worker the server suspended as it went down (its CLI
59
+ // ended); resuming: the next start continues it (resumeRun). Neither is an end.
60
+ export const ACTIVE_STATES = new Set(['queued', 'awaiting_route', 'starting', 'running', 'idle', 'awaiting_permission', 'suspended', 'resuming']);
48
61
  export const TERMINAL_STATES = new Set(['completed', 'failed', 'stopped', 'interrupted']);
49
62
  const WAITING_STATES = new Set(['queued', 'awaiting_route']);
63
+ // What a run can be suspended from at a shutdown: its worker is up, in a turn or between turns.
64
+ const SUSPENDABLE_STATES = new Set(['starting', 'running', 'idle', 'awaiting_permission']);
50
65
 
51
66
  /**
52
67
  * The run status a view reports beside its state (the agent_status contract:
53
68
  * queued, starting, running, completed, failed, stopped, interrupted). A warm
54
- * worker between turns (idle, awaiting_permission) is still running; a run
55
- * held for the user's model choice is still queued.
69
+ * worker between turns (idle, awaiting_permission) is still running, and so is
70
+ * one the server suspended while it restarts; a run held for the user's model
71
+ * choice is still queued; one being resumed after a restart is starting.
56
72
  */
57
73
  export function runStatusFromState(state) {
58
74
  const value = String(state || '');
59
75
  if (TERMINAL_STATES.has(value)) return value;
60
76
  if (WAITING_STATES.has(value)) return 'queued';
61
- if (value === 'starting') return 'starting';
77
+ if (value === 'starting' || value === 'resuming') return 'starting';
62
78
  return ACTIVE_STATES.has(value) ? 'running' : null;
63
79
  }
64
80
 
@@ -105,47 +121,13 @@ export const DEFAULT_LIMITS = Object.freeze({
105
121
  mediaRunBytes: MAX_RUN_MEDIA_BYTES,
106
122
  });
107
123
 
108
- /**
109
- * What a class's run gets when the dispatch leaves a field out (undefined, null
110
- * or ''): a design run works in its project with the SynaBun browser for an hour.
111
- * Explicit values win. The browser profile comes with the browser: an explicit
112
- * uses_browser:false keeps the run off it (no `browser` profile, which would turn
113
- * browser use back on, and no tab), on the normal default profile. The browser
114
- * rule for capability "full" (QUARANTINE_VIOLATION) still applies. Any class's
115
- * browser run on Codex / OpenCode without an mcp_profile gets BROWSER_PROFILE
116
- * (normalizeSpec): the stock default profiles have no browser tools.
117
- */
124
+ /** Class defaults; the shared resolver owns automatic groups. */
118
125
  export const CLASS_DEFAULTS = Object.freeze({
119
- design: Object.freeze({ capability: 'workspace', usesBrowser: true, mcpProfile: 'browser', maxMinutes: 60 }),
126
+ design: Object.freeze({ capability: 'workspace', usesBrowser: true, maxMinutes: 60 }),
120
127
  });
121
- /** The MCP profile of a Codex / OpenCode browser run that names none (Claude workers always get 'full'). */
122
- export const BROWSER_PROFILE = 'browser';
123
- // MCP presets that turn browser use on (normalizeSpec reads a run on one as a browser run),
124
- // and the stock presets with no browser tools (profiles.ts: core, standard).
125
- const BROWSER_PROFILE_RE = /^(browser|twitter|facebook|tiktok|whatsapp|instagram|linkedin|bluesky|gsc|youtube)$/i;
126
- const NO_BROWSER_PRESETS = new Set(['core', 'standard']);
127
- function turnsBrowserOn(profile) {
128
- const name = String(profile || '');
129
- return BROWSER_PROFILE_RE.test(name) || /browser/i.test(name);
130
- }
131
- /**
132
- * Browser settings of a run (any class) that cannot work together, after CLASS_DEFAULTS (so a
133
- * uses_browser false and any mcp_profile are the brain's own): the text of a
134
- * BROWSER_SETTINGS_CONFLICT (400) that says what to change, else null. An explicit browser
135
- * profile would turn an explicit uses_browser:false back on; a stock profile without the browser
136
- * tools leaves a Codex / OpenCode worker unable to use the browser the run gets (Claude workers
137
- * always get the full catalog). `provider` null: only the first check (it does not depend on it).
138
- */
139
- export function browserSettingsConflict(spec, provider = null) {
140
- const profile = String(spec?.mcpProfile || '').trim();
141
- if (!profile) return null;
142
- if (spec.usesBrowser === false && turnsBrowserOn(profile)) {
143
- 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.`;
144
- }
145
- if (provider && provider !== 'claude-code' && spec.usesBrowser === true && NO_BROWSER_PRESETS.has(profile.toLowerCase())) {
146
- 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.`;
147
- }
148
- return null;
128
+ export function browserSettingsConflict(spec) {
129
+ try { return browserToolsConflict(spec); }
130
+ catch (error) { throw new DispatchError(error.code, error.message, { status: error.status || 400, problems: error.problems }); }
149
131
  }
150
132
  /** `spec` with its class's defaults filled in where it leaves a field out. */
151
133
  export function withClassDefaults(spec, taskClass) {
@@ -160,7 +142,6 @@ export function withClassDefaults(spec, taskClass) {
160
142
  if (!defaults) return out;
161
143
  for (const [key, value] of Object.entries(defaults)) {
162
144
  if (!(out[key] === undefined || out[key] === null || out[key] === '')) continue;
163
- if (key === 'mcpProfile' && out.usesBrowser === false) continue;
164
145
  out[key] = value;
165
146
  }
166
147
  return out;
@@ -218,6 +199,43 @@ function clip(value, max = 160) {
218
199
  }
219
200
  function isDirectory(path) { try { return statSync(path).isDirectory(); } catch { return false; } }
220
201
 
202
+ /** Internal Skills workers own their runtime selection and confinement. */
203
+ function isSkillsRun(entry) { return ['skill-eval', 'skill-draft'].includes(entry?.confinement?.kind); }
204
+
205
+ function requireConfinementProvider(entry) {
206
+ if (entry.confinement && entry.confinement.provider !== entry.provider) {
207
+ throw new DispatchError('CONFINEMENT_PROVIDER_MISMATCH', `Confinement was built for ${entry.confinement.provider || 'an unknown provider'}, but the run uses ${entry.provider}. The run was refused.`, { status: 409 });
208
+ }
209
+ }
210
+
211
+ /** A skill-evaluation run: its own prompt, result, memory and escalation rules. */
212
+ export function isEvalRun(entry) { return entry?.confinement?.kind === EVAL_CONFINEMENT; }
213
+
214
+ /**
215
+ * Keep an evaluation run's tool calls (one entry per call id: a later update of the same call replaces its earlier
216
+ * one, so a command's "done" with its exit code wins over its "started") and the catalog its init named. Bounded.
217
+ */
218
+ export function recordEvalCalls(liveState, payload = {}) {
219
+ const init = evalInitOf(payload);
220
+ if (init && !liveState.evalInit) liveState.evalInit = init;
221
+ const calls = evalToolCallsOf(payload);
222
+ if (!calls.length) return;
223
+ if (!liveState.evalCalls) { liveState.evalCalls = []; liveState.evalCallIndex = new Map(); }
224
+ for (const call of calls) {
225
+ const key = call.id ? `${call.tool}:${call.id}` : null;
226
+ const at = key ? liveState.evalCallIndex.get(key) : undefined;
227
+ if (at !== undefined) {
228
+ const prev = liveState.evalCalls[at];
229
+ // An update without the input (an OpenCode part's later state) keeps the input seen first.
230
+ liveState.evalCalls[at] = { ...prev, ...call, input: call.input && Object.keys(call.input).length ? call.input : prev.input };
231
+ continue;
232
+ }
233
+ if (liveState.evalCalls.length >= 300) return;
234
+ if (key) liveState.evalCallIndex.set(key, liveState.evalCalls.length);
235
+ liveState.evalCalls.push(call);
236
+ }
237
+ }
238
+
221
239
  /** One-line human summary of a provider stream event (used for transcript tails). */
222
240
  export function summarizeProviderEvent(payload = {}) {
223
241
  const event = payload.event || {};
@@ -226,6 +244,7 @@ export function summarizeProviderEvent(payload = {}) {
226
244
  if (eventType === 'synabun.permission_request') return `permission requested: ${event.toolName || event.kind || 'tool'} ${clip(JSON.stringify(event.input || {}), 120)}`;
227
245
  if (eventType === 'synabun.permission_resolved') return `permission ${event.behavior || 'resolved'}: ${event.toolName || ''}`;
228
246
  if (eventType === 'synabun.error') return `error: ${clip(event.message, 200)}`;
247
+ if (eventType === 'synabun.confinement') return describeConfinementEvent(event);
229
248
  if (payload.provider === 'claude-code') {
230
249
  if (event.type === 'assistant') {
231
250
  const blocks = Array.isArray(event.message?.content) ? event.message.content : [];
@@ -330,9 +349,12 @@ function parseJsonObject(text) {
330
349
  */
331
350
  export function createAssistantDispatcher({
332
351
  getRuntime,
352
+ resolveExternalTools = () => ({ names: [], disabled: [] }),
333
353
  dataDir,
334
354
  loopDir = null,
335
355
  broadcastSync = () => {},
356
+ observeSkillEvent = () => {},
357
+ completeSkillUsage = () => {},
336
358
  log = () => {},
337
359
  PACKAGE_ROOT = process.cwd(),
338
360
  findCodexAccount = () => null,
@@ -345,9 +367,13 @@ export function createAssistantDispatcher({
345
367
  codexAccounts = null,
346
368
  accountUsage = null,
347
369
  codexWorkerAccount = () => null,
348
- isValidMcpProfileValue = () => true,
349
- normalizeMcpProfileName = (value) => value,
350
- readActiveMcpProfile = () => 'full',
370
+ // The Protect workers setting (lib/worker-guard-setting.js) as a run starts: 'enforce' | 'warn' | 'off'.
371
+ workerGuard = () => WORKER_GUARD_DEFAULT,
372
+ // SynaBun's own port: a guarded worker's shell may not call it (lib/worker-confinement.js).
373
+ serverPort = null,
374
+ // The ports SynaBun keeps for its OpenCode serves ({ from, to }): a guarded worker's shell calls none of them.
375
+ localPortRange = null,
376
+ toolListOptions = () => ({}),
351
377
  acquireLoopBrowserAndTab = null,
352
378
  releaseSharedBrowserTab = async () => {},
353
379
  reconcileKeepAwake = () => {},
@@ -398,6 +424,9 @@ export function createAssistantDispatcher({
398
424
  // The session's Test density (assistant-test-density.js): (assistantSessionId) => level | null.
399
425
  // Read when a prompt is composed; null or an unknown value is the default level.
400
426
  testDensity = () => null,
427
+ // After a restart, resolves once this server answers its workers (a resumed CLI connects to SynaBun's MCP as it
428
+ // starts): runs are resumed only then. null: at once.
429
+ serverReady = null,
401
430
  } = {}) {
402
431
  if (typeof getRuntime !== 'function') throw new Error('createAssistantDispatcher requires getRuntime()');
403
432
  if (!dataDir) throw new Error('createAssistantDispatcher requires dataDir');
@@ -419,6 +448,8 @@ export function createAssistantDispatcher({
419
448
  const registryPath = resolve(dataDir, 'assistant-dispatches.json');
420
449
  // What image / video creation runs generated, and design runs' screenshots: <dataDir>/media/<runId>/<n>-<name>.
421
450
  const mediaRoot = resolve(dataDir, 'media');
451
+ // A guarded worker's own scratch folder (lib/worker-confinement.js): <dataDir>/scratch/<runId>, removed when empty.
452
+ const scratchRoot = resolve(dataDir, 'scratch');
422
453
  /** A known task class, else null. */
423
454
  const knownClass = (value) => (Object.prototype.hasOwnProperty.call(TASK_CLASS_META, String(value || '')) ? String(value) : null);
424
455
  const entries = new Map(); // runId → entry (persisted shape)
@@ -438,29 +469,93 @@ export function createAssistantDispatcher({
438
469
  let persistTimer = null;
439
470
  let stalledTimer = null;
440
471
  let unsubscribeRuntime = null;
472
+ // A server restart: the runs this start resumes (resumeOnBoot), and the ones it ends whose end is said once the
473
+ // Assistant listens (both filled by loadRegistry). Follow-ups sent to a run while it is away wait here for it.
474
+ const bootResumes = [];
475
+ const bootInterrupts = [];
476
+ const awaitingResume = new Map(); // runId → follow-up items (sendTurn) for a suspended or resuming run
477
+ const resumeInProgress = new Set(); // runIds resumeRun works on (they hold their slot; the others waiting do not)
478
+ // The server is going down (suspendForShutdown ran): a resume under way stops where it is, for the next start.
479
+ let closing = false;
441
480
 
442
481
  // ── persistence ────────────────────────────────────────────────────────────
482
+ /** Why the original provider account of a run cannot be used now ('' when it can). */
483
+ function providerAccountUnusable(accountId, provider = 'claude-code', entry = null) {
484
+ if (provider === 'codex') {
485
+ const home = codexAccounts?.homeFor?.(accountId);
486
+ if (home && entry?.codexHome && resolve(home) !== resolve(entry.codexHome)) return 'its CODEX_HOME changed';
487
+ return codexAccounts?.unusableReason?.(accountId) || '';
488
+ }
489
+ try { return typeof claudeAccounts?.unusableReason === 'function' ? String(claudeAccounts.unusableReason(accountId || 'default') || '') : ''; } catch (error) { return error?.message || 'it could not be read'; }
490
+ }
443
491
  function loadRegistry() {
444
492
  const rows = readJson(registryPath, []);
445
493
  if (!Array.isArray(rows)) return;
446
494
  for (const row of rows) {
447
495
  if (!row?.runId) continue;
448
- const entry = { ...row };
496
+ let entry = { ...row };
497
+ try {
498
+ entry = readStoredToolListRecord(row, toolListOptions());
499
+ entry.toolsRequested = row.toolsRequested !== undefined ? row.toolsRequested : storedToolList(row, toolListOptions());
500
+ dispatchTools({ tools: entry.toolsRequested ?? undefined });
501
+ if (row.rawSpec) entry.rawSpec = toolListRecord(row.rawSpec, toolListOptions());
502
+ if (!entry.tools) entry.tools = launchTools(dispatchTools({ ...entry, tools: entry.toolsRequested ?? undefined }));
503
+ } catch (error) {
504
+ entry.tools = []; entry.toolsRequested = [];
505
+ entry.toolListError = { code: error.code, message: error.message, original: row.tools ?? row.mcpProfile };
506
+ delete entry.mcpProfile; delete entry.rawSpec;
507
+ entry.state = 'failed'; entry.completionReason = 'tools_invalid';
508
+ }
449
509
  // A failure cause being judged when the server went down is never coming.
450
510
  delete entry.judging;
451
- if (ACTIVE_STATES.has(entry.state)) {
452
- entry.state = 'interrupted';
453
- entry.completionReason = entry.completionReason || 'server_restart';
454
- entry.finishedAt = entry.finishedAt || iso(now());
455
- entry.pending = [];
456
- entry.turnState = 'terminal';
457
- closeUsageState(entry);
458
- }
459
- // A turn cut off by the restart worked until the run was last updated (as far as anything shows).
511
+ // When the server was last seen with the run live: its last update, or the last beat() after it.
512
+ const lastSeen = Math.max(Date.parse(entry.updatedAt || '') || 0, Math.min(now(), Date.parse(entry.aliveAt || '') || 0));
513
+ delete entry.aliveAt;
514
+ // A turn cut off by the restart worked until then (as far as anything shows).
460
515
  if (entry.workingSince) {
461
- entry.workMs = workUsedMs(entry, Math.max(Date.parse(entry.workingSince) || 0, Date.parse(entry.updatedAt || '') || 0));
516
+ entry.workMs = workUsedMs(entry, Math.max(Date.parse(entry.workingSince) || 0, lastSeen));
462
517
  entry.workingSince = null;
463
518
  }
519
+ // A run the last shutdown could not suspend ended then (suspendForShutdown): it is said now, as a refusal here is.
520
+ if (entry.refusedAtShutdown) {
521
+ delete entry.refusedAtShutdown;
522
+ if (entry.resumeRefused && TERMINAL_STATES.has(entry.state)) bootInterrupts.push(entry.runId);
523
+ }
524
+ if (ACTIVE_STATES.has(entry.state)) {
525
+ // What the restart cut off: a run the last shutdown suspended, one a crash left working (no shutdown ran), or
526
+ // one a restart caught while it resumed. A native worker that may continue is resumed (resumeOnBoot);
527
+ // everything else ends as interrupted, as every cut-off run did before, with why in its notes.
528
+ const candidate = entry.state === 'suspended' || entry.state === 'resuming' || SUSPENDABLE_STATES.has(entry.state);
529
+ // A crash suspended nothing: such a run was left when the server was last seen with it.
530
+ const crashed = candidate && entry.state !== 'suspended' && entry.state !== 'resuming';
531
+ const leftAt = crashed ? iso(lastSeen || now()) : null;
532
+ const why = candidate
533
+ ? resumeRefusal(crashed ? { ...entry, suspendedAt: leftAt } : entry, { now: now(), workLeftMs: workLeftMs(entry), isDirectory, accountUnusable: providerAccountUnusable })
534
+ : null;
535
+ if (candidate && !why) {
536
+ if (crashed) {
537
+ entry.suspendedAt = leftAt;
538
+ entry.suspendReason = 'server_restart';
539
+ entry.crashed = true;
540
+ }
541
+ entry.state = 'resuming';
542
+ entry.turnState = 'resuming';
543
+ entry.pending = [];
544
+ bootResumes.push(entry.runId);
545
+ } else {
546
+ entry.state = 'interrupted';
547
+ entry.completionReason = entry.completionReason || 'server_restart';
548
+ entry.finishedAt = entry.finishedAt || iso(now());
549
+ entry.pending = [];
550
+ entry.turnState = 'terminal';
551
+ if (why) {
552
+ entry.notes = [...(Array.isArray(entry.notes) ? entry.notes : []), `not resumed: ${why}`];
553
+ entry.resumeRefused = why;
554
+ bootInterrupts.push(entry.runId);
555
+ }
556
+ closeUsageState(entry);
557
+ }
558
+ }
464
559
  entries.set(entry.runId, entry);
465
560
  }
466
561
  }
@@ -480,6 +575,21 @@ export function createAssistantDispatcher({
480
575
  persistTimer = setTimeout(() => { persistTimer = null; write(); }, 200);
481
576
  persistTimer.unref?.();
482
577
  }
578
+ /**
579
+ * Every minute, on each live run (a worker up or about to be): the server is alive with it now (aliveAt, saved, not an
580
+ * update). After a crash loadRegistry measures from it, which can be long after the run's last update (a warm worker
581
+ * idle for a follow-up, a long silent turn).
582
+ */
583
+ function beat() {
584
+ const at = iso(now());
585
+ let alive = 0;
586
+ for (const entry of entries.values()) {
587
+ if (!ACTIVE_STATES.has(entry.state) || WAITING_STATES.has(entry.state)) continue;
588
+ entry.aliveAt = at;
589
+ alive += 1;
590
+ }
591
+ if (alive) persist();
592
+ }
483
593
 
484
594
  // ── views + events ─────────────────────────────────────────────────────────
485
595
  /** 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. */
@@ -508,10 +618,13 @@ export function createAssistantDispatcher({
508
618
  const startedAt = entry.startedAt || entry.createdAt;
509
619
  const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() : at;
510
620
  const elapsedMs = startedAt ? Math.max(0, end - new Date(startedAt).getTime()) : 0;
511
- // The brief goes to the worker's prompt; views name it only. The meters' state stays inside.
512
- const { rawSpec: _rawSpec, brief: _brief, usageState: _usageState, ...rest } = entry;
621
+ // The brief goes to the worker's prompt; views name it only. The meters' state stays inside, and so does what a
622
+ // resume after a restart needs (resumeInfo: the CLI's pid, the turn in progress); `resumes` counts them.
623
+ const { rawSpec: _rawSpec, brief: _brief, usageState: _usageState, resumeInfo: _resumeInfo, crashed: _crashed, refusedAtShutdown: _refusedAtShutdown, aliveAt: _aliveAt, ...rest } = entry;
513
624
  return {
514
625
  ...rest,
626
+ tools: toolsView({ ...entry, tools: runtimeRun?.tools ?? entry.tools }),
627
+ resumes: Number(entry.resumes) || 0,
515
628
  briefId: entry.brief?.briefId || null,
516
629
  taskId: entry.taskId || null,
517
630
  // The task of the turn in progress (or the last one started): a follow-up may work for a
@@ -594,6 +707,7 @@ export function createAssistantDispatcher({
594
707
  touch(entry);
595
708
  persist({ force: TERMINAL_STATES.has(entry.state) });
596
709
  const payload = { type: 'assistant:dispatch', reason, run: view(entry), ...extra };
710
+ if (['turn_completed', 'idle', 'failed', 'stopped', 'completed'].includes(reason)) { try { completeSkillUsage(entry); } catch {} }
597
711
  try { broadcastSync(payload); } catch {}
598
712
  for (const listener of listeners) { try { listener(payload); } catch {} }
599
713
  notifyWaiters(entry.runId, reason);
@@ -666,7 +780,7 @@ export function createAssistantDispatcher({
666
780
  const cwd = String(spec.cwd || spec.project || PACKAGE_ROOT).trim();
667
781
  if (!cwd || !isDirectory(cwd)) throw new DispatchError('CWD_INVALID', `cwd is not a directory: ${cwd}`);
668
782
  let permissionPolicy = normalizePermissionPolicy(spec.permissionPolicy);
669
- const capability = normalizeCapability(spec.capability || 'full');
783
+ let capability = normalizeCapability(spec.capability || 'full');
670
784
  const notes = [];
671
785
  if (provider === 'codex' && permissionPolicy === 'ask') {
672
786
  // A route card may move an 'ask' dispatch onto Codex: degrade instead of failing.
@@ -687,6 +801,7 @@ export function createAssistantDispatcher({
687
801
  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 });
688
802
  }
689
803
  if (!model && catalogValue && defaultIsHidden(catalogValue, provider)) {
804
+ if (isSkillsRun(spec)) throw new DispatchError('MODEL_DISABLED', `The ${provider} default model is disabled; the Skills run cannot substitute another model.`, { status: 400 });
690
805
  // No model = the provider default, which the user hid: pin the first visible model.
691
806
  const pinned = defaultModelRow(catalogValue, provider);
692
807
  if (pinned) {
@@ -721,16 +836,46 @@ export function createAssistantDispatcher({
721
836
  // an unsupported level becomes the nearest one it runs, recorded on the run.
722
837
  const checked = normalizeEffort({ provider, model, effort: spec.effort, catalog: catalogValue });
723
838
  const effortCorrection = checked.corrected || null;
839
+ if (isSkillsRun(spec) && effortCorrection) throw new DispatchError('EFFORT_UNSUPPORTED', `The Skills run requested effort ${spec.effort}, which ${provider}/${model || 'default'} cannot run.`, { status: 400 });
724
840
  if (effortCorrection) notes.push(`effort "${effortCorrection.from}" is not supported by ${model || `the ${provider} default model`}; using ${effortCorrection.to ? `"${effortCorrection.to}"` : 'the model default'}`);
725
841
  // The run's own browser settings must agree on the provider it gets, whatever its class.
726
842
  const conflict = browserSettingsConflict(spec, provider);
727
843
  if (conflict) throw new DispatchError('BROWSER_SETTINGS_CONFLICT', conflict, { status: 400 });
728
- const usesBrowser = !!spec.usesBrowser || turnsBrowserOn(spec.mcpProfile);
844
+ const selection = dispatchTools({ ...spec, taskClass, provider });
845
+ const externalServers = resolveExternalTools({ ...spec, provider }, selection.servers);
846
+ const usesBrowser = spec.usesBrowser === true || selection.groups.some(isBrowserGroup);
729
847
  const usesComputer = spec.usesComputer === true || spec.usesComputer === 'true';
730
848
  const tags = Array.isArray(spec.tags) ? spec.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 10) : [];
731
849
  if ((usesBrowser || usesComputer) && capability === 'full' && !tags.includes('user-authorized-full')) {
732
850
  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.`);
733
851
  }
852
+ // A skill-drafting run (lib/skills/creation.js): held to its folder by lib/skills/draft-confinement.js, each
853
+ // provider the way it can (draftConfinementSupport); one that cannot is refused, never started unconfined.
854
+ // It reads no page, drives no computer and is never asked anything.
855
+ let confinement = null;
856
+ if (spec.confinement !== undefined && spec.confinement !== null) {
857
+ if (spec.confinement.provider) requireConfinementProvider({ provider, confinement: spec.confinement });
858
+ if (spec.confinement?.kind === EVAL_CONFINEMENT) {
859
+ // A skill-evaluation run (lib/skills/evals.js): held to its job folder by lib/skills/eval-confinement.js, the
860
+ // same way on every provider that can hold it; its task is sent as it is (the case's own words), and it has
861
+ // no Result contract, no Jev reading, no result retry and no escalation.
862
+ const support = evalConfinementSupport(provider);
863
+ if (!support.ok) throw new DispatchError('CONFINEMENT_UNSUPPORTED', `A skill-evaluation run cannot run on ${provider}: ${support.reason}`, { status: 400 });
864
+ if (usesBrowser || usesComputer) throw new DispatchError('CONFINEMENT_UNSUPPORTED', 'A skill-evaluation run reads no page and drives no computer.', { status: 400 });
865
+ const role = EVAL_ROLES.includes(spec.confinement.role) ? spec.confinement.role : null;
866
+ if (!role) throw new DispatchError('CONFINEMENT_INVALID', 'Unknown evaluation role.', { status: 400 });
867
+ const skill = typeof spec.confinement.skill === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(spec.confinement.skill) ? spec.confinement.skill : null;
868
+ confinement = { kind: EVAL_CONFINEMENT, provider, role, shell: role === 'trial' && spec.confinement.shell === true, skill };
869
+ } else {
870
+ if (spec.confinement?.kind !== 'skill-draft') throw new DispatchError('CONFINEMENT_INVALID', 'Unknown confinement.', { status: 400 });
871
+ const support = draftConfinementSupport(provider);
872
+ if (!support.ok) throw new DispatchError('CONFINEMENT_UNSUPPORTED', `A skill-drafting run cannot run on ${provider}: ${support.reason}`, { status: 400 });
873
+ if (usesBrowser || usesComputer) throw new DispatchError('CONFINEMENT_UNSUPPORTED', 'A skill-drafting run reads no page and drives no computer.', { status: 400 });
874
+ confinement = { kind: 'skill-draft', provider };
875
+ }
876
+ capability = 'workspace';
877
+ permissionPolicy = 'auto';
878
+ }
734
879
  if (usesComputer) {
735
880
  if (!desktop || !desktop.isSupported?.()) throw new DispatchError('COMPUTER_UNAVAILABLE', 'Computer use is not available on this machine (macOS only).', { status: 409 });
736
881
  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 });
@@ -750,13 +895,6 @@ export function createAssistantDispatcher({
750
895
  const sightRefusal = designRefusal(catalogValue, provider, model, taskClass);
751
896
  if (sightRefusal) throw sightRefusal;
752
897
  const agent = provider === 'opencode' && spec.agent ? String(spec.agent).trim().slice(0, 60) || null : null;
753
- let mcpProfile = null;
754
- if (provider === 'claude-code') mcpProfile = 'full';
755
- else {
756
- // A browser run needs the browser tools: the active profile (core by default) has none.
757
- mcpProfile = normalizeMcpProfileName(spec.mcpProfile || (usesBrowser ? BROWSER_PROFILE : readActiveMcpProfile()));
758
- if (mcpProfile && !isValidMcpProfileValue(mcpProfile)) throw new DispatchError('MCP_PROFILE_INVALID', `Invalid MCP profile: ${mcpProfile}`);
759
- }
760
898
  let codexAccount = null;
761
899
  let claudeAccount = null;
762
900
  const accountId = spec.accountId || spec.codexAccountId || spec.claudeAccountId || null;
@@ -809,7 +947,7 @@ export function createAssistantDispatcher({
809
947
  model, modelContextWindow, pricingModel,
810
948
  effort: checked.effort || null, effortCorrection,
811
949
  agent, modelInfo, notes,
812
- mcpProfile, permissionPolicy, capability, usesBrowser, usesComputer,
950
+ toolsRequested: selection.requested, tools: launchTools(selection), externalMcpVersion: 2, externalServers, permissionPolicy, capability, usesBrowser, usesComputer, confinement,
813
951
  codexAccountId: codexAccount?.id || null,
814
952
  codexHome: codexAccount ? (codexAccount.home || CODEX_DEFAULT_HOME) : null,
815
953
  claudeAccountId: claudeAccount?.id || (provider === 'claude-code' ? 'default' : null),
@@ -826,21 +964,39 @@ export function createAssistantDispatcher({
826
964
  }
827
965
 
828
966
  // ── rails ──────────────────────────────────────────────────────────────────
967
+ // A suspended run holds no slot (its worker is gone with the server); one being resumed holds its own again.
829
968
  function activeEntries(filter = () => true) {
830
- return [...entries.values()].filter((entry) => ACTIVE_STATES.has(entry.state) && !WAITING_STATES.has(entry.state) && filter(entry));
969
+ return [...entries.values()].filter((entry) => ACTIVE_STATES.has(entry.state) && !WAITING_STATES.has(entry.state) && entry.state !== 'suspended' && filter(entry));
970
+ }
971
+ /**
972
+ * The runs that hold a provider's slots, as a queued note says them: every Assistant session's runs
973
+ * count (a warm worker waiting for a follow-up too; never a brain, which is no run), so a slot taken
974
+ * in another session is named as such.
975
+ */
976
+ function slotHolders(holders, assistantSessionId) {
977
+ const named = holders.map((e) => `${String(e.runId).slice(0, 8)}${(e.assistantSessionId || null) !== (assistantSessionId || null) ? ' (another session)' : ''}${e.state === 'idle' ? ' (idle, waiting for a follow-up)' : ''}`);
978
+ return `held by ${named.join(', ')}`;
831
979
  }
832
- function railBlock(spec, assistantSessionId) {
980
+ /**
981
+ * `except`: a run being resumed, which counts the others but not itself, nor the runs still waiting for their resume
982
+ * (resumeRun has not taken them up): runs that wait for a slot never hold each other back. A new dispatch counts them,
983
+ * so it waits behind them.
984
+ */
985
+ function railBlock(spec, assistantSessionId, { except = null } = {}) {
986
+ const others = (filter) => activeEntries((e) => e.runId !== except && (!except || e.state !== 'resuming' || resumeInProgress.has(e.runId)) && filter(e));
833
987
  // One desktop, one controller: computer-use runs never overlap.
834
- if (spec.usesComputer && activeEntries((e) => e.usesComputer).length >= 1) return 'another computer-use run is active (one at a time)';
988
+ if (spec.usesComputer && others((e) => e.usesComputer).length >= 1) return 'another computer-use run is active (one at a time)';
835
989
  const perProvider = limits.perProvider[spec.provider] ?? 3;
836
- if (activeEntries((e) => e.provider === spec.provider).length >= perProvider) return `provider ${spec.provider} at its concurrency limit (${perProvider})`;
837
- if (spec.provider === 'codex' && activeEntries((e) => e.provider === 'codex' && e.accountId === spec.accountId).length >= limits.perCodexAccount) {
838
- return `Codex account ${spec.accountId} at its concurrency limit (${limits.perCodexAccount})`;
990
+ const providerRuns = others((e) => e.provider === spec.provider);
991
+ if (providerRuns.length >= perProvider) return `provider ${spec.provider} at its concurrency limit (${perProvider}), ${slotHolders(providerRuns, assistantSessionId)}`;
992
+ const accountRuns = spec.provider === 'codex' ? others((e) => e.provider === 'codex' && e.accountId === spec.accountId) : [];
993
+ if (spec.provider === 'codex' && accountRuns.length >= limits.perCodexAccount) {
994
+ return `Codex account ${spec.accountId} at its concurrency limit (${limits.perCodexAccount}), ${slotHolders(accountRuns, assistantSessionId)}`;
839
995
  }
840
- if (assistantSessionId && activeEntries((e) => e.assistantSessionId === assistantSessionId).length >= limits.perSession) {
996
+ if (assistantSessionId && others((e) => e.assistantSessionId === assistantSessionId).length >= limits.perSession) {
841
997
  return `assistant session at its concurrency limit (${limits.perSession})`;
842
998
  }
843
- if (spec.workflowId && activeEntries((e) => e.workflowId === spec.workflowId).length >= limits.maxFanOut) {
999
+ if (spec.workflowId && others((e) => e.workflowId === spec.workflowId).length >= limits.maxFanOut) {
844
1000
  return `workflow ${spec.workflowId} at its fan-out limit (${limits.maxFanOut})`;
845
1001
  }
846
1002
  return null;
@@ -989,16 +1145,29 @@ export function createAssistantDispatcher({
989
1145
  /**
990
1146
  * The Claude CLI's own stop (--max-budget-usd) for a Claude worker under a budget: the run's ceiling,
991
1147
  * never more than the session has left when it starts. A backstop: the dispatcher stops the run at its
992
- * provider's share between model calls. null under No limit (no CLI cap at all).
1148
+ * provider's share between model calls. null under No limit (no CLI cap at all). `spentUsd`: what the run
1149
+ * spent already (a resume after a restart): its ceiling less that is what it may still spend.
993
1150
  */
994
- function claudeBackstop(entry) {
1151
+ function claudeBackstop(entry, { spentUsd = 0 } = {}) {
995
1152
  if (!limitedNow()) return null;
996
1153
  const cap = Number(entry.budgetUsd) || 0;
1154
+ const capLeft = cap > 0 ? Math.max(0, cap - Math.max(0, Number(spentUsd) || 0)) : Infinity;
997
1155
  const left = entry.assistantSessionId ? (budget.coverage(entry.assistantSessionId)?.leftUsd ?? null) : null;
998
- const value = Math.min(cap > 0 ? cap : Infinity, left === null ? Infinity : left);
1156
+ const value = Math.min(capLeft, left === null ? Infinity : left);
999
1157
  if (!Number.isFinite(value)) return cap > 0 ? cap : null;
1000
1158
  return Math.max(MIN_SPEND_USD, floorCents(value) / 100);
1001
1159
  }
1160
+ /**
1161
+ * The Claude CLI's stop for a run: claudeBackstop() under a budget; a skill-drafting run (confined) also stops at its
1162
+ * own ceiling whatever the session's mode, No limit included (its budget_usd, or what No limit set aside).
1163
+ */
1164
+ function claudeRunCap(entry, options = {}) {
1165
+ const backstop = claudeBackstop(entry, options);
1166
+ if (!entry.confinement) return backstop;
1167
+ const own = Number(entry.budgetUsd) || Number(entry.budgetIgnoredUsd) || 0;
1168
+ const caps = [backstop, own > 0 ? own : null].filter((v) => Number(v) > 0);
1169
+ return caps.length ? Math.min(...caps) : null;
1170
+ }
1002
1171
  /**
1003
1172
  * After money moved in a session: one warning at 80 % of the budget (per budget amount), and at
1004
1173
  * the budget every live, queued and held run of the session stops. Nothing under No limit.
@@ -1058,8 +1227,9 @@ export function createAssistantDispatcher({
1058
1227
  return {
1059
1228
  provider: spec.provider, model: spec.model, effort: spec.effort, effortCorrection: spec.effortCorrection || null, agent: spec.agent || null, accountId: spec.accountId,
1060
1229
  codexAccountId: spec.codexAccountId, codexHome: spec.codexHome, claudeAccountId: spec.claudeAccountId, claudeConfigDir: spec.claudeConfigDir,
1061
- cwd: spec.cwd, project: spec.project, mcpProfile: spec.mcpProfile,
1230
+ cwd: spec.cwd, project: spec.project, toolsRequested: spec.toolsRequested, tools: spec.tools, externalMcpVersion: 2, externalServers: spec.externalServers,
1062
1231
  permissionPolicy: spec.permissionPolicy, capability: spec.capability, usesBrowser: spec.usesBrowser, usesComputer: !!spec.usesComputer,
1232
+ confinement: spec.confinement || null,
1063
1233
  title: spec.title, task: spec.task, context: spec.context, tags: spec.tags,
1064
1234
  maxMinutes: spec.maxMinutes, budgetUsd: spec.budgetUsd ?? null, freeModel: spec.freeModel === true, idleTimeoutMs: spec.idleTimeoutMs, maxTurns: spec.maxTurns,
1065
1235
  outputSchema: spec.outputSchema, focus: spec.focus, workflowId: spec.workflowId, parentRunId: spec.parentRunId,
@@ -1163,7 +1333,8 @@ export function createAssistantDispatcher({
1163
1333
  const catalogValue = await catalogSnapshot();
1164
1334
  // Routing (ask modes may hold the run until the user picks a model).
1165
1335
  let routed = null;
1166
- if (router && origin !== 'ui' && sessionId) {
1336
+ // Skills owns the runtime selection and its provider-specific workspace. Never bind Assistant routes.
1337
+ if (router && !isSkillsRun(rawSpec) && origin !== 'ui' && sessionId) {
1167
1338
  routed = await router.resolveDispatch({ sessionId, spec: rawSpec, origin });
1168
1339
  // A hold names an open card, but an answer that was already in flight (it waited on the same
1169
1340
  // catalog read) can decide that card before this continuation runs. Approved for this
@@ -1330,6 +1501,12 @@ export function createAssistantDispatcher({
1330
1501
  };
1331
1502
  const heldClass = knownClass(routed.route?.taskClass) || knownClass(input.taskClass);
1332
1503
  if (heldClass) entry.taskClass = heldClass;
1504
+ const selection = dispatchTools({ ...input, taskClass: heldClass });
1505
+ entry.externalServers = resolveExternalTools({ ...entry, ...input }, selection.servers);
1506
+ entry.externalMcpVersion = 2;
1507
+ entry.toolsRequested = selection.requested;
1508
+ entry.tools = launchTools(selection);
1509
+ entry.usesBrowser = input.usesBrowser === true || selection.groups.some(isBrowserGroup);
1333
1510
  if (brief) entry.brief = brief;
1334
1511
  // A held run belongs to the task that asked for it, whenever the user picks its model.
1335
1512
  entry.taskId = taskAtDispatch(sessionId);
@@ -1374,7 +1551,7 @@ export function createAssistantDispatcher({
1374
1551
  const entry = entries.get(runId);
1375
1552
  if (!entry || entry.state !== 'awaiting_route' || !target?.provider) return { ok: false, code: 'NOT_HELD' };
1376
1553
  const catalogValue = await catalogSnapshot();
1377
- let input = { ...(entry.rawSpec || {}), provider: target.provider, model: target.model || null, effort: target.effort ?? entry.rawSpec?.effort ?? null, ...(target.accountId ? { accountId: target.accountId, codexAccountId: null, claudeAccountId: null } : {}) };
1554
+ let input = { ...(entry.rawSpec || {}), provider: target.provider, model: target.model || null, effort: target.effort ?? null, accountId: target.accountId || null, codexAccountId: null, claudeAccountId: null };
1378
1555
  let spec;
1379
1556
  // The picked provider may need another clamp (Codex under strict worker approvals), or the level fell meanwhile.
1380
1557
  const remoteNotes = [];
@@ -1413,6 +1590,7 @@ export function createAssistantDispatcher({
1413
1590
  /** Re-dispatch a failed/blocked run one tier up, or on the same model after a transient failure (the user pressed Escalate / Retry). */
1414
1591
  async function escalate(runId, { target = null } = {}) {
1415
1592
  const entry = requireEntry(runId);
1593
+ if (isSkillsRun(entry)) throw new DispatchError('NO_ESCALATION', 'Skills runs cannot change runtime or model through escalation.', { status: 409 });
1416
1594
  const to = target?.provider ? target : entry.escalation?.to;
1417
1595
  if (!to?.provider) throw new DispatchError('NO_ESCALATION', `Run ${runId} has no escalation target`, { status: 409 });
1418
1596
  // A transient failure (timeout, rate limit, outage) is retried as it was, not escalated.
@@ -1426,7 +1604,7 @@ export function createAssistantDispatcher({
1426
1604
  provider: to.provider, model: to.model || null, effort: to.effort || null, task: entry.task, cwd: entry.cwd, context,
1427
1605
  accountId: to.accountId || (to.provider === entry.provider ? entry.accountId : null),
1428
1606
  title: clip(`${entry.title} ${retry ? '↻' : '↑'}`, 80), tags: entry.tags, workflowId: entry.workflowId, parentRunId: runId,
1429
- mcpProfile: entry.provider === to.provider ? entry.mcpProfile : undefined, capability: entry.capability,
1607
+ tools: entry.tools?.some(item => item.startsWith('mcp:')) ? retainExternalRefs(entry.toolsRequested ?? dispatchTools({}).items, entry.tools) : entry.toolsRequested ?? undefined, capability: entry.capability,
1430
1608
  permissionPolicy: to.provider === 'codex' && entry.permissionPolicy === 'ask' ? 'restricted' : entry.permissionPolicy,
1431
1609
  usesBrowser: entry.usesBrowser, usesComputer: entry.usesComputer, maxMinutes: entry.maxMinutes, budgetUsd: entry.budgetUsd,
1432
1610
  outputSchema: entry.outputSchema, taskClass: entry.taskClass || null,
@@ -1434,13 +1612,47 @@ export function createAssistantDispatcher({
1434
1612
  const child = entries.get(result?.run?.runId);
1435
1613
  if (child) { child.escalationDepth = (Number(entry.escalationDepth) || 0) + 1; child.escalatedFrom = runId; }
1436
1614
  entry.notes.push(`${retry ? 'retried on' : 'escalated to'} ${to.provider}/${to.model || 'default'} as ${result?.run?.runId || '?'}`);
1615
+ if (entry.limit && typeof entry.limit === 'object') entry.limit = { ...entry.limit, retriedAt: iso(now()) };
1437
1616
  emit(entry, 'escalated', { childRunId: result?.run?.runId || null });
1438
1617
  return result;
1439
1618
  }
1440
1619
  function computeEscalation(entry, stateOverride = null) {
1441
- if (!router?.escalationFor) return null;
1442
- try { return router.escalationFor({ run: stateOverride ? { ...entry, state: stateOverride } : entry, depth: Number(entry.escalationDepth) || 0 }) || null; }
1620
+ if (isSkillsRun(entry) || !router?.escalationFor) return null;
1621
+ let found = null;
1622
+ try { found = router.escalationFor({ run: stateOverride ? { ...entry, state: stateOverride } : entry, depth: Number(entry.escalationDepth) || 0 }) || null; }
1443
1623
  catch { return null; }
1624
+ return found;
1625
+ }
1626
+ /** The Protect workers setting now, or the default when the host cannot say. */
1627
+ function safeWorkerGuard() {
1628
+ try { return workerGuard?.() ?? WORKER_GUARD_DEFAULT; } catch { return WORKER_GUARD_DEFAULT; }
1629
+ }
1630
+ /**
1631
+ * A run a provider's usage limit stopped (failed, or a turn that ended blocked or without a result):
1632
+ * entry.limit = { provider, accountId, resetsAt (ISO | null), source, detectedAt } for the panel's
1633
+ * waiting card and the brain's mailbox line, else null. The reset is the account's own usage window
1634
+ * when SynaBun reads one (account-usage.js), else the time the worker's message names (asst-limit.js).
1635
+ */
1636
+ function noteLimit(entry) {
1637
+ const prev = entry.limit && typeof entry.limit === 'object' ? entry.limit : null;
1638
+ const text = [entry.error, entry.lastResult?.summary, entry.lastResult?.question, lastParagraph(entry.lastText), ...(entry.notes || []).slice(-3)]
1639
+ .filter(Boolean).map((line) => String(line)).join('\n');
1640
+ const read = limitFromText(text, { now: now() });
1641
+ let usage = null;
1642
+ try { usage = accountUsage?.peek?.(entry.provider, entry.accountId || 'default', { stale: true }) || null; } catch { usage = null; }
1643
+ const limited = usage?.limited === true;
1644
+ if (!read && !limited) { entry.limit = null; return null; }
1645
+ const accountReset = Date.parse(usage?.resetsAt || '');
1646
+ const fromAccount = Number.isFinite(accountReset) && accountReset > now() && (limited || read?.resetsAt == null);
1647
+ const resetsAt = fromAccount ? accountReset : read?.resetsAt ?? null;
1648
+ entry.limit = {
1649
+ provider: entry.provider || null,
1650
+ accountId: entry.accountId || 'default',
1651
+ resetsAt: Number.isFinite(resetsAt) ? new Date(resetsAt).toISOString() : null,
1652
+ source: fromAccount ? 'account' : resetsAt != null ? 'message' : null,
1653
+ detectedAt: prev?.detectedAt || iso(now()),
1654
+ };
1655
+ return entry.limit;
1444
1656
  }
1445
1657
 
1446
1658
  // ── Jev (worker-outcome, rider worker-claim) ────────────────────────────────
@@ -1518,8 +1730,8 @@ export function createAssistantDispatcher({
1518
1730
  * for it — at most the surface timeout. Without Jev it is emitted at once.
1519
1731
  */
1520
1732
  function emitFailed(entry, evidenceAcc = null) {
1521
- const done = () => { delete entry.judging; entry.escalation = computeEscalation(entry); emit(entry, 'failed'); };
1522
- if (!judge?.workerOutcome || budgetStopReason(entry.completionReason)) { done(); return Promise.resolve(); }
1733
+ const done = () => { delete entry.judging; noteLimit(entry); entry.escalation = computeEscalation(entry); emit(entry, 'failed'); };
1734
+ if (!judge?.workerOutcome || budgetStopReason(entry.completionReason) || isEvalRun(entry)) { done(); return Promise.resolve(); }
1523
1735
  entry.judging = true;
1524
1736
  const evidence = evidenceSnapshot(evidenceAcc);
1525
1737
  const error = [entry.error, ...(entry.notes || []).slice(-3)].filter(Boolean).map((line) => String(line)).join('\n');
@@ -1541,6 +1753,7 @@ export function createAssistantDispatcher({
1541
1753
  }
1542
1754
  } catch { /* the failure stays unjudged */ }
1543
1755
  delete entry.judging;
1756
+ noteLimit(entry);
1544
1757
  entry.escalation = computeEscalation(entry);
1545
1758
  if (judged?.logId) {
1546
1759
  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 {}
@@ -1550,6 +1763,12 @@ export function createAssistantDispatcher({
1550
1763
  }
1551
1764
 
1552
1765
  async function start(entry) {
1766
+ try { requireConfinementProvider(entry); } catch (error) {
1767
+ entry.state = 'failed'; entry.error = error.message; entry.completionReason = 'confinement_provider_mismatch';
1768
+ entry.finishedAt = iso(now()); entry.turnState = 'terminal';
1769
+ emit(entry, 'failed');
1770
+ throw error;
1771
+ }
1553
1772
  const runtime = safeRuntime();
1554
1773
  if (!runtime) throw new DispatchError('RUNTIME_UNAVAILABLE', 'Native loop runtime is not available', { status: 503 });
1555
1774
  // A queued or held run starts under the budget as it is now (its provider's share included).
@@ -1577,49 +1796,18 @@ export function createAssistantDispatcher({
1577
1796
  }
1578
1797
  entry.browserSessionId = browserSessionId;
1579
1798
  entry.browserTabId = browserTabId;
1580
- const liveState = {
1581
- entry, ctx: null, ring: [], ringChars: 0, followUps: [], waitResolve: null, idleTimer: null,
1582
- pendingResolvers: new Map(), dead: false, lastEventAt: now(), stalledFlagged: false, turnCounter: 0, abortReason: null,
1583
- evidence: null, // the current turn's commands / tool results / edited files (assistant-evidence.js)
1584
- completed: false, // its turn loop ended on a completion (agent_complete, a closed finished tab, a removal)
1585
- };
1799
+ const liveState = newLiveState(entry);
1586
1800
  live.set(entry.runId, liveState);
1587
- const extraEnv = {};
1588
- if (entry.claudeConfigDir) extraEnv.CLAUDE_CONFIG_DIR = entry.claudeConfigDir;
1589
- // Computer use: an unforgeable per-run grant unlocks the `computer` MCP
1590
- // group for this worker only. Passed as a function so the loop runtime's
1591
- // JSON state file never contains it.
1592
- let desktopGrant = null;
1593
- if (entry.usesComputer && desktop?.mintGrant) {
1594
- try { desktopGrant = desktop.mintGrant({ kind: 'run', assistantSessionId: entry.assistantSessionId, runId: entry.runId, provider: entry.provider, model: entry.model, vision: entry.modelInfo?.vision ?? null }); }
1595
- catch (error) { entry.notes.push(`desktop grant failed: ${error?.message || error}`); }
1596
- }
1597
- const state = {
1598
- active: true, task: entry.task, context: entry.context, totalIterations: 1, currentIteration: 0,
1599
- maxMinutes: entry.maxMinutes, startedAt: iso(now()), lastIterationAt: null, retries: 0, pending: false,
1600
- terminalSessionId: entry.runId, usesBrowser: entry.usesBrowser, usesComputer: !!entry.usesComputer, browserSessionId, browserTabId,
1601
- profile: entry.provider, provider: entry.provider, agent: entry.agent || null,
1602
- model: entry.model, modelContextWindow: entry.modelContextWindow || null, effort: entry.effort, mcpProfile: entry.mcpProfile,
1603
- codexAccountId: entry.codexAccountId, codexHome: entry.provider === 'codex' ? (entryCodexHome(entry)) : null,
1604
- claudeAccountId: entry.claudeAccountId, claudeConfigDir: entry.claudeConfigDir,
1605
- driverType: 'native', runtimeType: 'native', surface: 'sidepanel', runId: entry.runId, cwd: entry.cwd,
1606
- runMode: 'task', assistantSessionId: entry.assistantSessionId, permissionPolicy: entry.permissionPolicy,
1607
- // A Claude worker's CLI stop (--max-budget-usd): a backstop under a budget, none under No limit.
1608
- capability: entry.capability, budgetUsd: entry.budgetUsd, maxBudgetUsd: entry.provider === 'claude-code' ? claudeBackstop(entry) : null,
1609
- idleTimeoutMs: entry.idleTimeoutMs, maxTurns: entry.maxTurns, outputSchema: entry.outputSchema, project: entry.project,
1610
- title: entry.title, tags: entry.tags, workflowId: entry.workflowId, parentRunId: entry.parentRunId,
1611
- // The user's brief after a clarification (assistant-task-prompt.js briefBlock).
1612
- brief: entry.brief || null,
1613
- // Image / video creation: the prompt's media block and what the adapter collects ('image' | 'video');
1614
- // design collects its screenshots and images the same way ('image').
1615
- taskClass: entry.taskClass || null, collectMedia: collectedMedia(entry.taskClass),
1616
- // extraEnv is a plain object, so it IS written to the loop state file;
1617
- // keep secrets out of it. The desktop grant rides as a function instead.
1618
- extraEnv,
1619
- desktopGrantProvider: desktopGrant ? () => desktopGrant : null,
1620
- // What the adapter reports while it starts (a Codex worker whose browser-policy hook is not trusted).
1621
- onNote: (text) => { const note = String(text || '').trim(); if (note && !entry.notes.includes(note)) entry.notes.push(note); },
1622
- };
1801
+ // The guard a read-only / workspace run starts under (never for full, never for a drafting run): the setting
1802
+ // now, and a scratch folder of its own. Recorded on the run so the panel and the brain can see it.
1803
+ entry.guardMode = normalizeWorkerGuardMode(safeWorkerGuard());
1804
+ if (entry.provider === 'opencode' && !entry.confinement) resumeInfoOf(entry).persistentOpenCode = true;
1805
+ if ((!entry.confinement || isEvalRun(entry)) && entry.guardMode !== 'off' && (entry.capability === 'read-only' || entry.capability === 'workspace')) {
1806
+ const scratch = resolve(scratchRoot, entry.runId);
1807
+ try { mkdirSync(scratch, { recursive: true }); entry.scratchDir = scratch; } catch (error) { entry.scratchDir = null; entry.notes.push(`scratch folder not made: ${error?.message || error}`); }
1808
+ } else entry.scratchDir = null;
1809
+ const desktopGrant = mintDesktopGrant(entry);
1810
+ const state = runState(entry, { browserSessionId, browserTabId, desktopGrant, startedAt: iso(now()) });
1623
1811
  if (entry.permissionPolicy === 'ask') state.permissionBroker = createBroker(entry.runId);
1624
1812
  // A class's rules (design), read now so an edited override applies without a restart;
1625
1813
  // they go to the prompt only, not into the loop state file.
@@ -1637,7 +1825,9 @@ export function createAssistantDispatcher({
1637
1825
  source: 'assistant',
1638
1826
  focus: entry.focus === true,
1639
1827
  title: entry.title,
1640
- buildPrompt: () => buildTaskPrompt({ ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}), testDensity: briefTestDensity(entry) }),
1828
+ // A skill-evaluation run gets its task as written (a case's request, a grader's brief): the worker preamble and
1829
+ // the Result contract would change what is measured.
1830
+ buildPrompt: () => (isEvalRun(entry) ? entry.task : buildTaskPrompt({ ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}), testDensity: briefTestDensity(entry) })),
1641
1831
  wrapAdapter: (adapter, ctx) => createTaskAdapter(adapter, ctx, liveState, state),
1642
1832
  });
1643
1833
  entry.runtime = { status: descriptor.status };
@@ -1654,6 +1844,97 @@ export function createAssistantDispatcher({
1654
1844
  throw error;
1655
1845
  }
1656
1846
  }
1847
+ /** What a run's worker keeps in memory while it lives (never saved): its turn loop, transcript tail, waits. */
1848
+ function newLiveState(entry) {
1849
+ return {
1850
+ entry, ctx: null, ring: [], ringChars: 0, followUps: [], waitResolve: null, idleTimer: null,
1851
+ pendingResolvers: new Map(), dead: false, lastEventAt: now(), stalledFlagged: false, turnCounter: 0, abortReason: null,
1852
+ evidence: null, // the current turn's commands / tool results / edited files (assistant-evidence.js)
1853
+ completed: false, // its turn loop ended on a completion (agent_complete, a closed finished tab, a removal)
1854
+ };
1855
+ }
1856
+ /**
1857
+ * Computer use: an unforgeable per-run grant unlocks the `computer` MCP group for this worker only. Passed as a
1858
+ * function so the loop runtime's JSON state file never contains it. null for a run without computer use.
1859
+ */
1860
+ function mintDesktopGrant(entry) {
1861
+ if (!entry.usesComputer || !desktop?.mintGrant) return null;
1862
+ try { return desktop.mintGrant({ kind: 'run', assistantSessionId: entry.assistantSessionId, runId: entry.runId, provider: entry.provider, model: entry.model, vision: entry.modelInfo?.vision ?? null }) || null; }
1863
+ catch (error) { entry.notes.push(`desktop grant failed: ${error?.message || error}`); return null; }
1864
+ }
1865
+ /**
1866
+ * The loop state a run starts (or resumes, with `extra`) with: what its provider factory and its prompt read. Its
1867
+ * functions (notes, the CLI's process, the desktop grant) stay in memory: the runtime writes the rest to the run's
1868
+ * state file.
1869
+ */
1870
+ function runState(entry, { browserSessionId = null, browserTabId = null, desktopGrant = null, startedAt = iso(now()), extra = {} } = {}) {
1871
+ const extraEnv = {};
1872
+ if (entry.claudeConfigDir) extraEnv.CLAUDE_CONFIG_DIR = entry.claudeConfigDir;
1873
+ return {
1874
+ active: true, task: entry.task, context: entry.context, totalIterations: 1, currentIteration: 0,
1875
+ maxMinutes: entry.maxMinutes, startedAt, lastIterationAt: null, retries: 0, pending: false,
1876
+ terminalSessionId: entry.runId, usesBrowser: entry.usesBrowser, usesComputer: !!entry.usesComputer, browserSessionId, browserTabId,
1877
+ profile: entry.provider, provider: entry.provider, agent: entry.agent || null,
1878
+ model: entry.model, modelContextWindow: entry.modelContextWindow || null, effort: entry.effort, tools: entry.tools,
1879
+ codexAccountId: entry.codexAccountId, codexHome: entry.provider === 'codex' ? (entryCodexHome(entry)) : null,
1880
+ claudeAccountId: entry.claudeAccountId, claudeConfigDir: entry.claudeConfigDir,
1881
+ driverType: 'native', runtimeType: 'native', surface: 'sidepanel', runId: entry.runId, cwd: entry.cwd,
1882
+ runMode: 'task', assistantSessionId: entry.assistantSessionId, permissionPolicy: entry.permissionPolicy,
1883
+ // A Claude worker's CLI stop (--max-budget-usd): a backstop under a budget, none under No limit.
1884
+ capability: entry.capability, budgetUsd: entry.budgetUsd, maxBudgetUsd: entry.provider === 'claude-code' ? claudeRunCap(entry) : null,
1885
+ // A skill-drafting run's confinement (lib/skills/draft-confinement.js): its adapter enforces it, or refuses to start.
1886
+ confinement: entry.confinement || null,
1887
+ // The worker confinement (lib/worker-confinement.js): the Protect workers setting as the run starts, kept for
1888
+ // its life, and the run's own scratch folder (writable beside the working directory and the temp folder).
1889
+ persistentOpenCode: !!entry.resumeInfo?.persistentOpenCode,
1890
+ sandboxPlan: entry.resumeInfo?.sandboxPlan || null,
1891
+ providerTurnBase: Number(entry.resumeInfo?.providerTurn) || 0,
1892
+ adminSandbox: entry.resumeInfo?.adminSandbox === true,
1893
+ onCodexSandbox: (value) => { resumeInfoOf(entry).adminSandbox = value === true; persist({ force: true }); },
1894
+ onSandboxPlan: (plan) => { resumeInfoOf(entry).sandboxPlan = plan; persist({ force: true }); },
1895
+ guardMode: entry.guardMode, scratchDir: entry.scratchDir || null, serverPort: Number(serverPort) > 0 ? Number(serverPort) : null,
1896
+ localPortRange: localPortRange && Number(localPortRange.from) > 0 && Number(localPortRange.to) >= Number(localPortRange.from) ? { from: Number(localPortRange.from), to: Number(localPortRange.to) } : null,
1897
+ idleTimeoutMs: entry.idleTimeoutMs, maxTurns: entry.maxTurns, outputSchema: entry.outputSchema, project: entry.project,
1898
+ title: entry.title, tags: entry.tags, workflowId: entry.workflowId, parentRunId: entry.parentRunId,
1899
+ // The user's brief after a clarification (assistant-task-prompt.js briefBlock).
1900
+ brief: entry.brief || null,
1901
+ // Image / video creation: the prompt's media block and what the adapter collects ('image' | 'video');
1902
+ // design collects its screenshots and images the same way ('image').
1903
+ taskClass: entry.taskClass || null, collectMedia: collectedMedia(entry.taskClass),
1904
+ // extraEnv is a plain object, so it IS written to the loop state file;
1905
+ // keep secrets out of it. The desktop grant rides as a function instead.
1906
+ extraEnv,
1907
+ desktopGrantProvider: desktopGrant ? () => desktopGrant : null,
1908
+ // What the adapter reports while it starts (a Codex worker whose browser-policy hook is not trusted).
1909
+ onNote: (text) => { const note = String(text || '').trim(); if (note && !entry.notes.includes(note)) entry.notes.push(note); },
1910
+ // A Claude Code worker's CLI (lib/native-loop-providers.js onProcess): saved with the run at once, so the next
1911
+ // start can end it should it outlive this server (a crash ends no process) before the session is resumed.
1912
+ onProcess: (info) => {
1913
+ const pid = Number(info?.pid);
1914
+ if (!Number.isInteger(pid) || pid <= 0) return;
1915
+ resumeInfoOf(entry).pid = pid;
1916
+ entry.resumeInfo.pidStartedAt = info.startedAt || iso(now());
1917
+ persist({ force: true });
1918
+ },
1919
+ ...extra,
1920
+ };
1921
+ }
1922
+ /** What a resume after a restart needs that is saved with the run (the CLI's pid, the turn in progress). */
1923
+ function resumeInfoOf(entry) {
1924
+ if (!entry.resumeInfo || typeof entry.resumeInfo !== 'object') entry.resumeInfo = {};
1925
+ return entry.resumeInfo;
1926
+ }
1927
+ /** The number of the run's last recorded turn (0 before its first): a resumed run's turns go on from it. */
1928
+ function lastTurnNumber(entry) {
1929
+ return (Array.isArray(entry.turns) ? entry.turns : []).reduce((max, turn) => Math.max(max, Number(turn?.n) || 0), 0);
1930
+ }
1931
+ /**
1932
+ * The turns a run away for a restart will have had when a follow-up reaches it (as turnCounter counts a turn in
1933
+ * progress): its recorded turns, the one the restart cut off (its resume continues it), or its first, not yet run.
1934
+ */
1935
+ function awayTurns(entry) {
1936
+ return Math.max(lastTurnNumber(entry), Number(entry.resumeInfo?.inFlight?.turn) || 0, 1);
1937
+ }
1657
1938
  /**
1658
1939
  * What the run's prompt says about its project's Style Guide (lib/style-guide/store.js): the
1659
1940
  * summary for the run's class, DESIGN.md and the token files. null when the project has no saved
@@ -1805,6 +2086,8 @@ export function createAssistantDispatcher({
1805
2086
  // Saved with the run: a result that arrives after a restart belongs to the turn that was
1806
2087
  // started, whether or not it was ever recorded.
1807
2088
  usageStateOf(entry).turn = { n: turnNumber, taskId };
2089
+ // So is the turn in progress: a restart in it is resumed with the continuation prompt (resumeRun).
2090
+ resumeInfoOf(entry).inFlight = { turn: turnNumber, taskId, origin, startedAt };
1808
2091
  liveState.turnUsage = null;
1809
2092
  liveState.turnTokens = zeroTokens();
1810
2093
  liveState.turnOpen = true;
@@ -1815,6 +2098,7 @@ export function createAssistantDispatcher({
1815
2098
  startWork(entry, liveState);
1816
2099
  ctx.setTurnState('running');
1817
2100
  emit(entry, 'turn_started', { turn: turnNumber });
2101
+ persist({ force: true });
1818
2102
  // What this turn runs (commands, tool results, edited files): the evidence behind a "done".
1819
2103
  liveState.evidence = createTurnEvidence();
1820
2104
  // Money booked while this turn runs (OpenCode steps, Codex rollout records).
@@ -1822,14 +2106,23 @@ export function createAssistantDispatcher({
1822
2106
  let raw = await inner.runTurn(prompt, { ...meta, turn: turnNumber, outputSchema: entry.outputSchema || undefined });
1823
2107
  if (entry.provider === 'codex') settleCodexTurn(entry, liveState, raw);
1824
2108
  let text = typeof raw?.text === 'string' ? raw.text : '';
1825
- let parsed = parseResultContract(text);
2109
+ const evalRun = isEvalRun(entry);
2110
+ let parsed = evalRun ? { found: false } : parseResultContract(text);
1826
2111
  parsed.source = parsed.found ? 'contract' : null;
1827
2112
  // A run with an output schema answers with bare JSON: that object is its result, no retry turn.
1828
2113
  if (!parsed.found) parsed = structuredResult(entry, raw, text) || parsed;
2114
+ // A skill-evaluation run's answer is what is measured: it is its result as it is (no contract to find).
2115
+ if (evalRun && !parsed.found) parsed = { found: true, parseFailed: false, status: 'done', summary: clip(text, 2000), changes: [], files: [], follow_ups: [], media: [], question: '', json: null, jsonError: null, raw: text, source: 'eval' };
2116
+ // The server is going down (suspendForShutdown) as the turn ended without its ## Result block: no retry turn can
2117
+ // run now. The turn stays in flight, so the resumed worker is asked to finish it; what it spent is booked.
2118
+ if (liveState.suspending && !parsed.found) {
2119
+ accumulateCost(entry, raw);
2120
+ throw Object.assign(new Error('suspended for a server restart'), { name: 'AbortError' });
2121
+ }
1829
2122
  const noBlock = !parsed.found && strictResult && !!text.trim() && !liveState.dead;
1830
2123
  const evidence = evidenceSnapshot(liveState.evidence);
1831
- // One Jev request before any retry turn (null without Jev: today's behaviour).
1832
- const judged = await judgeTurn(entry, { text, parsed, noBlock, evidence, followUp });
2124
+ // One Jev request before any retry turn (null without Jev: today's behaviour). Never for an evaluation run.
2125
+ const judged = evalRun ? null : await judgeTurn(entry, { text, parsed, noBlock, evidence, followUp });
1833
2126
  let retried = false;
1834
2127
  let retryCost = 0;
1835
2128
  if (judged?.status) {
@@ -1852,7 +2145,8 @@ export function createAssistantDispatcher({
1852
2145
  } catch (error) {
1853
2146
  // Stopped during the retry: the run ends here (handleTurnError books the retry), with no
1854
2147
  // turn_completed or follow-up after it. The first provider turn was paid for: book it first.
1855
- if (liveState.abortReason) { accumulateCost(entry, raw); throw error; }
2148
+ // Suspended for a server restart during it: the turn stays in flight for the resume.
2149
+ if (liveState.abortReason || liveState.suspending) { accumulateCost(entry, raw); throw error; }
1856
2150
  retryCost = accumulateCost(entry, error);
1857
2151
  if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
1858
2152
  entry.notes.push(`result retry failed: ${error?.message || error}`);
@@ -1893,7 +2187,11 @@ export function createAssistantDispatcher({
1893
2187
  // Rows that settle from here on (a late rollout record) go to the record itself.
1894
2188
  liveState.turnTokens = null;
1895
2189
  entry.turns = [...(entry.turns || []), turn].slice(-50);
2190
+ if (entry.resumeInfo) delete entry.resumeInfo.inFlight;
1896
2191
  entry.lastResult = parsed;
2192
+ // The run's own descriptor (lib/native-loop-runtime.js) carries it too, for the surfaces that read that one
2193
+ // (the OpenCode panel's pill): a warm worker whose turn ended asking waits on the user, it does not rest.
2194
+ ctx.setOutcome?.(parsed.status || null);
1897
2195
  entry.lastText = text.slice(0, 20_000);
1898
2196
  // The turn has ended: its result is in. Idle time from here on is not work.
1899
2197
  pauseWork(entry, liveState);
@@ -1901,6 +2199,8 @@ export function createAssistantDispatcher({
1901
2199
  // itself stays warm for follow-ups; the brain or the user decides). A
1902
2200
  // status Jev read from the message is a usable result.
1903
2201
  entry.escalation = parsed.status === 'blocked' || (!parsed.found && parsed.source !== 'jev') ? computeEscalation(entry, 'idle') : null;
2202
+ // A provider's usage limit that stopped it: when it resets (the panel's waiting card counts to it).
2203
+ if (parsed.status === 'blocked' || !parsed.found) noteLimit(entry); else entry.limit = null;
1904
2204
  if (judged?.logId) {
1905
2205
  try {
1906
2206
  judge.annotate?.(judged.logId, {
@@ -1924,13 +2224,24 @@ export function createAssistantDispatcher({
1924
2224
  isAlive: () => !liveState.dead && (typeof inner.isAlive === 'function' ? inner.isAlive() : true),
1925
2225
  describe: () => ({ ...(typeof inner.describe === 'function' ? inner.describe() : {}), runMode: 'task' }),
1926
2226
  async runTurn(prompt, meta = {}) {
1927
- try {
1928
- await runInnerTurn(prompt, meta, { origin: 'dispatch' });
1929
- } catch (error) {
1930
- pauseWork(entry, liveState);
1931
- return handleTurnError(error, liveState, entry, ctx);
2227
+ // A run resumed after a server restart (resumeRun): one that was in a turn sends the continuation prompt (the
2228
+ // runtime's buildPrompt for it) as a turn of its own; one that waited for a follow-up waits again, with a fresh
2229
+ // idle timer; one that never got its first turn going sends its task as a new run would.
2230
+ const resume = liveState.resume || null;
2231
+ liveState.resume = null;
2232
+ if (resume?.mode !== 'idle') {
2233
+ try {
2234
+ await runInnerTurn(prompt, meta, resume?.mode === 'mid_turn'
2235
+ ? { origin: 'resume', label: 'resumed after a server restart', taskId: resume.taskId || entry.taskId || null }
2236
+ : { origin: 'dispatch' });
2237
+ } catch (error) {
2238
+ pauseWork(entry, liveState);
2239
+ return handleTurnError(error, liveState, entry, ctx);
2240
+ }
1932
2241
  }
1933
2242
  for (;;) {
2243
+ // Suspended for a server shutdown (suspendForShutdown): the next start resumes it; nothing ends here.
2244
+ if (liveState.suspending) return { providerSessionId: null };
1934
2245
  // Stopped while the turn ran (a stop that did not fail it): never idle, never take a queued follow-up.
1935
2246
  if (liveState.abortReason) { entry.completionReason = entry.completionReason || liveState.abortReason; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1936
2247
  // Out of money (its own ceiling, the session's budget, unpriced usage): stop instead of idling
@@ -1943,6 +2254,7 @@ export function createAssistantDispatcher({
1943
2254
  ctx.setTurnState('idle');
1944
2255
  emit(entry, 'idle');
1945
2256
  const next = await waitForFollowUp();
2257
+ if (liveState.suspending) return { providerSessionId: null };
1946
2258
  if (next.kind === 'abort') { entry.completionReason = entry.completionReason || liveState.abortReason || 'aborted'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1947
2259
  if (next.kind === 'complete') { entry.completionReason = next.reason || 'complete'; liveState.completed = true; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1948
2260
  if (next.kind === 'timeout') { entry.completionReason = 'idle_timeout'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
@@ -2054,6 +2366,14 @@ export function createAssistantDispatcher({
2054
2366
  }
2055
2367
 
2056
2368
  function handleTurnError(error, liveState, entry, ctx) {
2369
+ // Suspended for a server shutdown (suspendForShutdown): the turn the shutdown cut off is resumed on the next start
2370
+ // (its in-flight mark stays), and what it spent was settled when it was suspended. Nothing ends here.
2371
+ if (liveState.suspending) {
2372
+ liveState.turnOpen = false;
2373
+ stopUsagePolling(liveState);
2374
+ return { providerSessionId: null };
2375
+ }
2376
+ if (entry.resumeInfo) delete entry.resumeInfo.inFlight;
2057
2377
  // A failed turn was still paid for (a budget-capped run used to read $0).
2058
2378
  liveState.turnOpen = false;
2059
2379
  stopUsagePolling(liveState);
@@ -2435,8 +2755,8 @@ export function createAssistantDispatcher({
2435
2755
  // OpenCode: every session of the run's own serve (the root and the `task` tool's child sessions).
2436
2756
  function openCodeMeterOf(entry) {
2437
2757
  const held = metersOf(entry);
2438
- // Its state is not saved with the run: a run does not outlive the process that holds its serve.
2439
- if (!held.opencode) held.opencode = createOpenCodeMeter();
2758
+ // Message and mark snapshots survive with the session; replayed history must not be charged again.
2759
+ if (!held.opencode) held.opencode = createOpenCodeMeter({ state: entry.usageState?.opencode });
2440
2760
  return held.opencode;
2441
2761
  }
2442
2762
  /** The run's own session id, for a message that names none. */
@@ -2494,7 +2814,7 @@ export function createAssistantDispatcher({
2494
2814
  function bookOpenCodeMessage(entry, liveState, info) {
2495
2815
  // A finished run keeps no message book: what comes late is dropped, not booked from zero.
2496
2816
  if (entry.usageState?.closed) return;
2497
- const book = liveState.meter || (liveState.meter = new Map());
2817
+ const book = liveState.meter || (liveState.meter = new Map(Object.entries(entry.usageState?.opencodeMessages || {})));
2498
2818
  const key = `${info.sessionID || ''}:${info.id}`;
2499
2819
  const prev = book.get(key) || { cost: 0, usage: null, completed: false, estimate: 0 };
2500
2820
  const reported = Number(info.cost);
@@ -2525,6 +2845,8 @@ export function createAssistantDispatcher({
2525
2845
  } else markUnpriced(entry, `OpenCode reported no cost for ${model || 'its default model'} and it has no list price`);
2526
2846
  }
2527
2847
  book.set(key, { cost, usage: used, completed, estimate });
2848
+ usageStateOf(entry).opencodeMessages = Object.fromEntries(book);
2849
+ usageStateOf(entry).opencode = openCodeMeterOf(entry).state();
2528
2850
  if (delta > 0 || completed) enforceMidTurn(entry, liveState);
2529
2851
  }
2530
2852
  /**
@@ -2536,6 +2858,7 @@ export function createAssistantDispatcher({
2536
2858
  if (entry.usageState?.closed) return null;
2537
2859
  const meter = openCodeMeterOf(entry);
2538
2860
  const delta = meter.markTurn();
2861
+ usageStateOf(entry).opencode = meter.state();
2539
2862
  const fidelity = reconciled ? 'exact' : 'partial';
2540
2863
  const reason = reconciled ? null : 'opencode-not-reconciled';
2541
2864
  const source = reconciled ? 'opencode-messages' : 'opencode-events';
@@ -2684,6 +3007,335 @@ export function createAssistantDispatcher({
2684
3007
  return true;
2685
3008
  }
2686
3009
 
3010
+ // ── server restart: suspend, then resume ───────────────────────────────────
3011
+ // Every restart ends the provider processes of its workers (they are its children). A native worker that may
3012
+ // continue (lib/worker-resume.js resumeRefusal) is suspended as the server goes down and resumed on the next start
3013
+ // as the same run: its runId, provider conversation and sidepanel tab. See docs/assistant.md.
3014
+
3015
+ /**
3016
+ * The server is going down (`reason`: the shutdown's): every eligible native worker is suspended
3017
+ * (state 'suspended', no end, no end card), and its runId returned for the runtime to keep (native-loop-runtime.js
3018
+ * shutdown's `keep`: its CLI is ended, its record kept). Called first by the graceful shutdown (server.js), and by
3019
+ * shutdown() for an exit that skipped it. Idempotent.
3020
+ */
3021
+ function suspendForShutdown(reason = 'server_shutdown') {
3022
+ closing = true;
3023
+ const kept = [];
3024
+ for (const entry of entries.values()) {
3025
+ if (!SUSPENDABLE_STATES.has(entry.state)) continue;
3026
+ const liveState = live.get(entry.runId) || null;
3027
+ // Only a run whose worker is up, or whose resume is starting it (its session is known): one still starting for
3028
+ // the first time has no session to resume yet.
3029
+ if (!liveState || liveState.dead || (!liveState.inner && !liveState.resuming)) continue;
3030
+ // Skill evaluation and drafting runs end as before (the runtime stops them).
3031
+ if (entry.confinement) continue;
3032
+ const refused = resumeRefusal(entry, { atShutdown: true, workLeftMs: workLeftMs(entry) });
3033
+ if (refused) {
3034
+ if (entry.refusedAtShutdown) continue;
3035
+ // A Claude Code worker that may not continue (resumed too often, out of work time) ends interrupted with why
3036
+ // when the runtime stops it (finalize), and is said again once the server is back (loadRegistry): the brain's
3037
+ // mailbox goes down with this server.
3038
+ entry.resumeRefused = refused;
3039
+ entry.refusedAtShutdown = true;
3040
+ entry.completionReason = entry.completionReason || 'server_restart';
3041
+ entry.error = `Not resumed after the server restart: ${refused}`;
3042
+ entry.notes.push(`not resumed: ${refused}`);
3043
+ continue;
3044
+ }
3045
+ // A resume still starting keeps what it was resuming: the turn the earlier restart cut off (resumeInfo.inFlight)
3046
+ // or the wait for a follow-up.
3047
+ const midTurn = entry.state !== 'idle' && liveState.resume?.mode !== 'idle';
3048
+ pauseWork(entry, liveState);
3049
+ liveState.suspending = true;
3050
+ if (liveState.idleTimer) { clearTimeout(liveState.idleTimer); liveState.idleTimer = null; }
3051
+ entry.state = 'suspended';
3052
+ entry.turnState = 'suspended';
3053
+ entry.suspendedAt = iso(now());
3054
+ entry.suspendReason = String(reason || 'server_shutdown');
3055
+ delete entry.crashed;
3056
+ // An open permission request goes with the process: the resumed worker asks again.
3057
+ entry.pending = [];
3058
+ // What the cut-off turn spent so far is booked now (an estimate per call the stream showed: no result will
3059
+ // count them); the meter takes the resumed CLI's counters as its new baseline (rebaseClaudeUsage).
3060
+ try { suspendUsage(entry, liveState); } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
3061
+ entry.notes.push(`suspended: the server went down (${entry.suspendReason}) ${midTurn ? 'during a turn' : 'while it waited for a follow-up'}; it resumes when the server is back`);
3062
+ emit(entry, 'suspended', { text: 'suspended: SynaBun is restarting; this run resumes as soon as the server is back (no action needed)' });
3063
+ kept.push(entry.runId);
3064
+ }
3065
+ if (kept.length) persist({ force: true });
3066
+ return kept;
3067
+ }
3068
+ /** Book pending provider usage and save the meter baseline before the process ends. */
3069
+ function suspendUsage(entry, liveState) {
3070
+ if (entry.provider === 'codex') pollCodex(entry, liveState, { discover: true });
3071
+ if (entry.provider === 'opencode' && liveState) bookOpenCodeTurn(entry, liveState);
3072
+ stopUsagePolling(liveState);
3073
+ if (liveState) liveState.turnOpen = false;
3074
+ const claude = meters.get(entry.runId)?.claude;
3075
+ if (claude && typeof claude.settlePending === 'function') {
3076
+ const rows = claude.settlePending({ reason: 'no-result' });
3077
+ if (rows.length) settleClaudeRows(entry, rows);
3078
+ usageStateOf(entry).claude = claude.state();
3079
+ }
3080
+ if (ledger && entry.assistantSessionId) { try { ledger.clearPending(entry.assistantSessionId, entry.runId); } catch { /* nothing pending */ } }
3081
+ }
3082
+ /**
3083
+ * The process of a suspended run's CLI, if it still runs: told to stop (an exit handler cannot wait for it). Not
3084
+ * when the runtime is ending it already (its record is suspended): a second signal could cut its clean exit short.
3085
+ * Windows has no SIGTERM (a signal ends the CLI alone and leaves its tool's processes): the tree is ended with
3086
+ * taskkill, which returns once it is done.
3087
+ */
3088
+ function signalWorkerProcess(entry) {
3089
+ try { if (safeRuntime()?.get?.(entry.runId)?.status === 'suspended') return; } catch { /* no runtime: signal it */ }
3090
+ let pid = null;
3091
+ try { pid = live.get(entry.runId)?.inner?.processInfo?.()?.pid || null; } catch { pid = null; }
3092
+ if (!pid) return;
3093
+ if (process.platform === 'win32') { try { endProcessTree(pid); } catch { /* gone, or not allowed */ } return; }
3094
+ try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
3095
+ }
3096
+
3097
+ /**
3098
+ * The resumed CLI starts its counters where its transcript's last cost-state left them (lib/worker-resume.js
3099
+ * readCostState): the Claude meter takes that as its baseline and the adapter its cost counter, so the first turn
3100
+ * after the resume is charged for itself only. What the cut-off turn spent was booked when the run was suspended; a
3101
+ * crash booked nothing, so what the CLI counted after the run's last settled result is settled now, from its own
3102
+ * counts (one result made of the cost-state). Returns the cost base for the adapter (costBaseUsd).
3103
+ */
3104
+ function rebaseClaudeUsage(entry) {
3105
+ let costState = null;
3106
+ try {
3107
+ const transcript = findClaudeTranscript({ sessionId: entry.providerSessionId, configDir: entry.claudeConfigDir || null });
3108
+ costState = transcript ? readCostState(transcript, entry.providerSessionId) : null;
3109
+ } catch { costState = null; }
3110
+ const state = usageStateOf(entry);
3111
+ if (entry.crashed && costState?.modelUsage) {
3112
+ try {
3113
+ const meter = claudeMeterOf(entry);
3114
+ const before = meter.state().snapshot;
3115
+ if (!before || countersCover(costState.modelUsage, before)) {
3116
+ const { settled } = meter.onEvent({ type: 'result', subtype: 'success', uuid: `resume:${entry.runId}:${Number(entry.resumes) || 0}`, session_id: entry.providerSessionId, modelUsage: costState.modelUsage, usage: {} });
3117
+ if (settled) { settleClaudeRows(entry, settled.rows, settled); bookCost(entry, Number(settled.costUsd) || 0, 'reported'); }
3118
+ }
3119
+ } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
3120
+ }
3121
+ const held = metersOf(entry);
3122
+ const saved = held.claude ? held.claude.state() : (state.claude || {});
3123
+ state.claude = { ...saved, snapshot: costState?.modelUsage || null, unbaselined: null, history: false };
3124
+ held.claude = null;
3125
+ delete entry.crashed;
3126
+ persist({ force: true });
3127
+ return costState ? costState.totalCostUsd : 0;
3128
+ }
3129
+ /** Whether per-model counters `now` are at or above `before` for every model and counter (the same process went on). */
3130
+ function countersCover(nowUsage, before) {
3131
+ const keys = ['inputTokens', 'outputTokens', 'cacheReadInputTokens', 'cacheCreationInputTokens'];
3132
+ return Object.entries(before || {}).every(([model, counters]) => {
3133
+ const current = nowUsage?.[model];
3134
+ return !!current && keys.every((key) => (Number(current[key]) || 0) >= (Number(counters?.[key]) || 0));
3135
+ });
3136
+ }
3137
+
3138
+ /**
3139
+ * After a restart (loadRegistry filled bootResumes / bootInterrupts): the runs that will not resume are said
3140
+ * ('interrupted', with why) and their runtime records ended; the others are resumed one at a time, in the order they
3141
+ * were dispatched, ahead of every queued dispatch. Runs a lowered concurrency limit holds back wait at the head of
3142
+ * the queue (processQueue resumes them).
3143
+ */
3144
+ async function resumeOnBoot() {
3145
+ // The limits as the user set them (assistant-config.json), not the defaults.
3146
+ refreshLimits();
3147
+ const runtime = safeRuntime();
3148
+ const ids = bootResumes.splice(0)
3149
+ .map((runId) => entries.get(runId)).filter((entry) => entry?.state === 'resuming')
3150
+ .sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')))
3151
+ .map((entry) => entry.runId);
3152
+ // Held for this dispatcher from here on: the runtime's own hold no longer ends them.
3153
+ for (const runId of ids) { try { runtime?.keepSuspended?.(runId); } catch { /* ended meanwhile */ } }
3154
+ for (const runId of bootInterrupts.splice(0)) {
3155
+ const entry = entries.get(runId);
3156
+ if (!entry) continue;
3157
+ // A CLI a crash left behind goes with the run.
3158
+ await endOldProcess(entry);
3159
+ try { await runtime?.endSuspended?.(runId, 'server_restart', { error: entry.resumeRefused ? `not resumed: ${entry.resumeRefused}` : null }); } catch { /* not held */ }
3160
+ emit(entry, 'interrupted', { text: `not resumed after the server restart: ${entry.resumeRefused || 'it could not continue'}` });
3161
+ }
3162
+ // A resumed CLI connects to SynaBun's MCP as it starts: not before this server answers.
3163
+ if (ids.length && typeof serverReady === 'function') { try { await serverReady(); } catch { /* resume anyway */ } }
3164
+ const held = [];
3165
+ for (const runId of ids) {
3166
+ const entry = entries.get(runId);
3167
+ if (!entry || entry.state !== 'resuming') continue;
3168
+ const spec = { provider: entry.provider, accountId: entry.accountId, workflowId: entry.workflowId, usesComputer: !!entry.usesComputer };
3169
+ const block = railBlock(spec, entry.assistantSessionId, { except: runId });
3170
+ if (block) { entry.notes.push(`resume queued: ${block}`); held.push(runId); persist(); continue; }
3171
+ // What it threw (a folder that cannot be made, a full disk) ends it, with why: nothing else would.
3172
+ try { await resumeRun(entry); } catch (error) { log(runId, 'assistant:resume-error', error?.message || String(error)); notResumed(entry, error?.message || String(error)); }
3173
+ }
3174
+ if (held.length) { queue.unshift(...held); processQueue().catch(() => {}); }
3175
+ }
3176
+
3177
+ /**
3178
+ * Continue a run a server restart cut off (state 'resuming'): what start() does for a new run (the budget as it is
3179
+ * now, a browser tab, the desktop grant, the permission broker), then the runtime resumes the run's record with its
3180
+ * Claude session (resumeSessionId). A run that was in a turn gets the continuation prompt (buildResumePrompt) as its
3181
+ * next turn; one that waited for a follow-up waits again; one that never got its first turn going is sent its task.
3182
+ * A run that cannot be resumed ends as interrupted, with why. It holds its provider's slot from the call on (the
3183
+ * callers check the rails just before it, in the same tick).
3184
+ */
3185
+ async function resumeRun(entry) {
3186
+ if (entry.state !== 'resuming') return null;
3187
+ resumeInProgress.add(entry.runId);
3188
+ try { return await continueRun(entry); } finally { resumeInProgress.delete(entry.runId); }
3189
+ }
3190
+ async function continueRun(entry) {
3191
+ if (closing) return null;
3192
+ const runtime = safeRuntime();
3193
+ if (!runtime?.resume) return notResumed(entry, 'the native loop runtime is not available');
3194
+ refreshLimits();
3195
+ const refusal = resumeRefusal(entry, { now: now(), workLeftMs: workLeftMs(entry), isDirectory, accountUnusable: providerAccountUnusable });
3196
+ if (refusal) return notResumed(entry, refusal);
3197
+ try { await fitBudget(entry, { routed: true }); } catch (error) { return notResumed(entry, `the budget does not allow it (${error?.message || error})`); }
3198
+ if (entry.state !== 'resuming' || closing) return null; // stopped meanwhile, or the server is going down again
3199
+ // Its own ceiling (budget_usd), with what its cut-off turn was booked at the suspension (fitBudget checks the
3200
+ // session and the share only): a run out of money is not resumed, as a turn that ends out of money does not idle.
3201
+ const spentOut = budgetBlock(entry);
3202
+ if (spentOut && spentOut.code !== 'BUDGET_SHARE_EXHAUSTED') {
3203
+ entry.completionReason = spentOut.reason;
3204
+ return notResumed(entry, `the budget does not allow it (${spentOut.message})`);
3205
+ }
3206
+ if (!runtime.isSuspended?.(entry.runId)) return notResumed(entry, 'its runtime record is gone');
3207
+ // A crash ended no process: the CLI the run had may still be there, finishing the cut-off tool call. It is ended
3208
+ // first; one that cannot be checked keeps the run from resuming (two processes on one session would both write its
3209
+ // transcript).
3210
+ const orphan = await endOldProcess(entry);
3211
+ if (orphan.state === 'unknown' || orphan.state === 'still_running') {
3212
+ return notResumed(entry, `its old ${providerName(entry.provider)} process (pid ${orphan.pid}) ${orphan.state === 'unknown' ? 'may still be running and could not be checked' : 'did not stop'}`);
3213
+ }
3214
+ if (entry.state !== 'resuming' || closing) return null;
3215
+ const costBaseUsd = entry.provider === 'claude-code' ? rebaseClaudeUsage(entry) : 0;
3216
+ if (loopDir && !existsSync(loopDir)) mkdirSync(loopDir, { recursive: true });
3217
+ // Its browser session went with the server: a new one (the resume prompt names it).
3218
+ let browserSessionId = null;
3219
+ let browserTabId = null;
3220
+ if (entry.usesBrowser && typeof acquireLoopBrowserAndTab === 'function') {
3221
+ const acq = await acquireLoopBrowserAndTab({ requestedBrowserSessionId: entry.requestedBrowserSessionId, terminalSessionId: entry.runId, logTag: 'assistant:browser' }).catch((error) => ({ error: error?.message || String(error) }));
3222
+ if (acq?.error) return notResumed(entry, `no browser tab: ${acq.error}`);
3223
+ browserSessionId = acq.browserSessionId || null;
3224
+ browserTabId = acq.browserTabId || null;
3225
+ }
3226
+ if (entry.state !== 'resuming' || closing) {
3227
+ if (browserSessionId) await releaseSharedBrowserTab(browserSessionId, entry.runId).catch(() => {});
3228
+ return null;
3229
+ }
3230
+ entry.browserSessionId = browserSessionId;
3231
+ entry.browserTabId = browserTabId;
3232
+ if (entry.scratchDir) { try { mkdirSync(entry.scratchDir, { recursive: true }); } catch { /* the guard says so when it needs it */ } }
3233
+ const inFlight = entry.resumeInfo?.inFlight || null;
3234
+ const turned = lastTurnNumber(entry);
3235
+ const mode = inFlight ? 'mid_turn' : turned > 0 ? 'idle' : 'fresh';
3236
+ const liveState = newLiveState(entry);
3237
+ liveState.turnCounter = turned;
3238
+ liveState.resume = { mode, taskId: inFlight?.taskId || null };
3239
+ // Follow-ups sent while it was away wait for it like any queued follow-up.
3240
+ const waiting = awaitingResume.get(entry.runId) || [];
3241
+ awaitingResume.delete(entry.runId);
3242
+ liveState.followUps.push(...waiting);
3243
+ live.set(entry.runId, liveState);
3244
+ const desktopGrant = mintDesktopGrant(entry);
3245
+ const state = runState(entry, {
3246
+ browserSessionId, browserTabId, desktopGrant,
3247
+ startedAt: entry.startedAt || iso(now()),
3248
+ extra: { resumeSessionId: entry.providerSessionId, resumeThreadId: entry.providerThreadId, costBaseUsd, resumingWorker: true },
3249
+ });
3250
+ // The CLI's spend stop (--max-budget-usd) counts from the total the resumed CLI restores (costBaseUsd): what the run
3251
+ // may still spend (its ceiling less what it spent, never more than the session has left) goes on top of that. A
3252
+ // backstop only: the dispatcher holds the run to its ceiling and its share between calls.
3253
+ const allowanceUsd = claudeRunCap(entry, { spentUsd: entry.costUsd });
3254
+ if (Number(allowanceUsd) > 0) state.maxBudgetUsd = Number((Number(allowanceUsd) + Math.max(0, Number(costBaseUsd) || 0)).toFixed(2));
3255
+ if (entry.permissionPolicy === 'ask') state.permissionBroker = createBroker(entry.runId);
3256
+ const at = clockTime(entry.suspendedAt);
3257
+ const selfRestarts = isInside(entry.cwd, PACKAGE_ROOT);
3258
+ const playbook = mode === 'fresh' ? playbookFor(entry) : null;
3259
+ const styleGuide = mode === 'fresh' ? styleGuideFor(entry) : null;
3260
+ const prompt = () => (mode === 'mid_turn'
3261
+ ? buildResumePrompt(state, { at, selfRestarts })
3262
+ : buildTaskPrompt({ ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}), testDensity: briefTestDensity(entry) }));
3263
+ entry.resumes = (Number(entry.resumes) || 0) + 1;
3264
+ entry.resumedAt = iso(now());
3265
+ entry.state = 'starting';
3266
+ entry.turnState = 'starting';
3267
+ entry.notes.push(`resumed after the server restart${at ? ` at ${at}` : ''} (${entry.resumes} of ${MAX_RESUMES}): ${mode === 'mid_turn' ? 'it continues the turn the restart cut off' : mode === 'idle' ? 'it waits for a follow-up again' : 'its first turn starts again'}`);
3268
+ // A shutdown while its CLI starts suspends it again (suspendForShutdown): the next start resumes it.
3269
+ liveState.resuming = true;
3270
+ try {
3271
+ const descriptor = await runtime.resume({
3272
+ runId: entry.runId,
3273
+ state,
3274
+ buildPrompt: prompt,
3275
+ wrapAdapter: (adapter, ctx) => createTaskAdapter(adapter, ctx, liveState, state),
3276
+ });
3277
+ entry.runtime = { status: descriptor.status };
3278
+ } catch (error) {
3279
+ // Suspended again by a shutdown while its CLI started: the start was cancelled, nothing ended.
3280
+ if (entry.state === 'suspended') return null;
3281
+ // The runtime ended its record (resume_failed): its run-stopped event ended the run here too.
3282
+ const why = error?.message || String(error);
3283
+ if (browserSessionId) await releaseSharedBrowserTab(browserSessionId, entry.runId).catch(() => {});
3284
+ if (!TERMINAL_STATES.has(entry.state)) return notResumed(entry, why);
3285
+ entry.notes.push(`not resumed: ${why}`);
3286
+ persist();
3287
+ return null;
3288
+ }
3289
+ if (entry.state === 'suspended') return null;
3290
+ delete entry.suspendedAt;
3291
+ delete entry.suspendReason;
3292
+ emit(entry, 'resumed', {
3293
+ resumes: entry.resumes,
3294
+ text: `resumed after the server restart${at ? ` at ${at}` : ''} (${entry.resumes} of ${MAX_RESUMES}): ${mode === 'mid_turn' ? 'it continues the turn the restart cut off; its result arrives as usual' : mode === 'idle' ? 'it waits for a follow-up again' : 'it starts its task again'}. Nothing to do.`,
3295
+ });
3296
+ reconcileKeepAwake();
3297
+ return view(entry);
3298
+ }
3299
+ /**
3300
+ * The CLI a run had before a crash may still be there, finishing its cut-off tool call (a crash ends no process): it
3301
+ * is ended once its command line shows it is this run's (lib/worker-resume.js endOrphanProcess), before a resume and
3302
+ * when a run a restart cut off ends without one. → { state, pid } ('gone' without a saved pid).
3303
+ */
3304
+ async function endOldProcess(entry) {
3305
+ const pid = Number(entry?.resumeInfo?.pid) || null;
3306
+ if (!pid) return { state: 'gone', pid: null };
3307
+ let orphan = null;
3308
+ try { orphan = await endOrphanProcess({ pid, runId: entry.runId }); } catch { orphan = { state: 'unknown' }; }
3309
+ if (orphan.state === 'ended' || orphan.state === 'killed') entry.notes.push(`its old ${providerName(entry.provider)} process (pid ${pid}) still ran after the restart and was ${orphan.state}`);
3310
+ if (orphan.state !== 'unknown' && orphan.state !== 'still_running' && entry.resumeInfo) {
3311
+ delete entry.resumeInfo.pid;
3312
+ delete entry.resumeInfo.pidStartedAt;
3313
+ }
3314
+ return { ...orphan, pid };
3315
+ }
3316
+ /** A run that could not be resumed after a restart ends as interrupted, with why (its notes, its error, its event). */
3317
+ function notResumed(entry, why) {
3318
+ if (TERMINAL_STATES.has(entry.state)) return null;
3319
+ const index = queue.indexOf(entry.runId);
3320
+ if (index !== -1) queue.splice(index, 1);
3321
+ // Follow-ups accepted for it while it was away (agent_send) reach nobody: its end says so, with the first of them.
3322
+ const waiting = awaitingResume.get(entry.runId) || [];
3323
+ awaitingResume.delete(entry.runId);
3324
+ const undelivered = waiting.length ? `; ${waiting.length} follow-up${waiting.length === 1 ? '' : 's'} sent while it was away ${waiting.length === 1 ? 'was' : 'were'} not delivered (${waiting.length === 1 ? '' : 'the first: '}"${clip(waiting[0].text, 160)}")` : '';
3325
+ // Nothing of it may go on: a CLI a crash left behind is ended too.
3326
+ endOldProcess(entry).then(() => persist()).catch(() => {});
3327
+ entry.notes.push(`not resumed: ${why}${undelivered}`);
3328
+ entry.resumeRefused = why;
3329
+ entry.completionReason = entry.completionReason || 'server_restart';
3330
+ const error = `Not resumed after the server restart: ${why}${undelivered}`;
3331
+ let ended = false;
3332
+ try { ended = !!safeRuntime()?.isSuspended?.(entry.runId); } catch { ended = false; }
3333
+ // The runtime's run-stopped event finalizes the run here (onRuntimeMessage); without a record it is done here.
3334
+ if (ended) safeRuntime().endSuspended(entry.runId, 'server_restart', { error }).catch(() => {});
3335
+ if (!TERMINAL_STATES.has(entry.state)) finalize(entry, { status: 'interrupted', stoppedReason: 'server_restart', error });
3336
+ return null;
3337
+ }
3338
+
2687
3339
  // ── follow-ups / lifecycle controls ────────────────────────────────────────
2688
3340
  function requireEntry(runId) {
2689
3341
  const entry = entries.get(runId);
@@ -2697,9 +3349,12 @@ export function createAssistantDispatcher({
2697
3349
  if (message.length > 20_000) throw new DispatchError('MESSAGE_TOO_LONG', 'text must be at most 20000 characters');
2698
3350
  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 });
2699
3351
  if (entry.state === 'queued') throw new DispatchError('RUN_NOT_STARTED', `Run ${runId} is still queued`, { status: 409 });
2700
- const liveState = live.get(runId);
2701
- if (!liveState) throw new DispatchError('RUN_NOT_ACTIVE', `Run ${runId} has no live worker`, { status: 409 });
2702
- if (liveState.turnCounter >= entry.maxTurns) throw new DispatchError('MAX_TURNS', `Run ${runId} reached its max turns (${entry.maxTurns})`, { status: 409 });
3352
+ // Away for a server restart (suspended, or being resumed): the follow-up waits for the run and goes to it after
3353
+ // its resume, like a follow-up queued behind a turn. Should the run not resume, it ends and says why.
3354
+ const away = entry.state === 'suspended' || entry.state === 'resuming';
3355
+ const liveState = away ? null : live.get(runId);
3356
+ if (!away && !liveState) throw new DispatchError('RUN_NOT_ACTIVE', `Run ${runId} has no live worker`, { status: 409 });
3357
+ if ((away ? awayTurns(entry) : liveState.turnCounter) >= entry.maxTurns) throw new DispatchError('MAX_TURNS', `Run ${runId} reached its max turns (${entry.maxTurns})`, { status: 409 });
2703
3358
  refreshLimits();
2704
3359
  const blocked = budgetBlock(entry);
2705
3360
  if (blocked) {
@@ -2717,6 +3372,14 @@ export function createAssistantDispatcher({
2717
3372
  // A follow-up works for the task in progress when it is sent, which may be a later one than the run's.
2718
3373
  const item = { kind: 'follow-up', text: message, origin, at: iso(now()), taskId: taskNow(entry.assistantSessionId) || entry.taskId || null };
2719
3374
  if (origin === 'user') emit(entry, 'user_intervened', { text: clip(message, 200) });
3375
+ if (away) {
3376
+ if (!allowQueue) throw new DispatchError('TURN_ACTIVE', `Run ${runId} is ${entry.state === 'suspended' ? 'suspended while the server restarts' : 'being resumed after a server restart'}; retry when it is idle or pass queue:true`, { status: 409 });
3377
+ const waiting = awaitingResume.get(runId) || [];
3378
+ waiting.push(item);
3379
+ awaitingResume.set(runId, waiting);
3380
+ emit(entry, 'follow_up_queued', { queued: waiting.length });
3381
+ return { ok: true, queued: true, position: waiting.length, run: view(entry), note: 'The run resumes after the server restart; this follow-up goes to it then.' };
3382
+ }
2720
3383
  if (liveState.waitResolve) { liveState.waitResolve(item); return { ok: true, queued: false, turn: liveState.turnCounter + 1, run: view(entry) }; }
2721
3384
  if (!allowQueue) throw new DispatchError('TURN_ACTIVE', `Run ${runId} is busy; retry when idle or pass queue:true`, { status: 409 });
2722
3385
  liveState.followUps.push(item);
@@ -2774,6 +3437,15 @@ export function createAssistantDispatcher({
2774
3437
  }
2775
3438
  if (TERMINAL_STATES.has(entry.state)) return { ok: true, run: view(entry), alreadyTerminal: true };
2776
3439
  entry.completionReason = entry.completionReason || reason;
3440
+ // A run waiting for its resume after a restart leaves the queue and drops the follow-ups that waited for it; its
3441
+ // runtime record (suspended) ends as stopped.
3442
+ if (entry.state === 'resuming' || entry.state === 'suspended') {
3443
+ const index = queue.indexOf(runId);
3444
+ if (index !== -1) queue.splice(index, 1);
3445
+ awaitingResume.delete(runId);
3446
+ // A CLI a crash left behind (this start has not ended it yet) goes with the run.
3447
+ if (entry.state === 'resuming') await endOldProcess(entry);
3448
+ }
2777
3449
  const runtime = safeRuntime();
2778
3450
  const stopped = runtime ? await runtime.stop(runId, reason) : false;
2779
3451
  if (!stopped) finalize(entry, { status: 'stopped', stoppedReason: reason });
@@ -2792,11 +3464,15 @@ export function createAssistantDispatcher({
2792
3464
  function finalize(entry, runtimeRun) {
2793
3465
  if (TERMINAL_STATES.has(entry.state)) return;
2794
3466
  const liveState = live.get(entry.runId);
3467
+ // Follow-ups that waited for its resume after a restart go with it.
3468
+ awaitingResume.delete(entry.runId);
2795
3469
  pauseWork(entry, liveState);
2796
3470
  let status = runtimeRun?.status || 'completed';
2797
3471
  // A completed worker whose loop file was swept meanwhile (a stale-file cleanup) reaches the
2798
3472
  // runtime's next check as "stopped": its turn loop had already ended on the completion.
2799
3473
  if (status === 'stopped' && liveState?.completed && /^state_(removed|inactive)$/.test(String(runtimeRun?.stoppedReason || ''))) status = 'completed';
3474
+ // A Claude Code worker the shutdown could not suspend (suspendForShutdown) ends interrupted, with why.
3475
+ if (status === 'stopped' && entry.refusedAtShutdown) status = 'interrupted';
2800
3476
  entry.state = TERMINAL_STATES.has(status) ? status : 'completed';
2801
3477
  entry.turnState = 'terminal';
2802
3478
  entry.finishedAt = iso(now());
@@ -2811,18 +3487,21 @@ export function createAssistantDispatcher({
2811
3487
  }
2812
3488
  entry.pending = [];
2813
3489
  releaseDesktop(entry);
3490
+ // The run's scratch folder goes when it left nothing there (what it wrote is the user's to look at).
3491
+ if (entry.scratchDir) { try { rmdirSync(entry.scratchDir); } catch { /* not empty, or already gone */ } }
2814
3492
  // A run that ended gives back what it did not spend: the session's split is made again (debounced).
2815
3493
  if (entry.assistantSessionId) { try { budget.onRunEnded(entry.assistantSessionId); } catch { /* the split stays as it is */ } }
2816
- if (entry.state === 'failed' && !budgetStopReason(entry.completionReason) && judge?.workerOutcome) {
3494
+ if (entry.state === 'failed' && !budgetStopReason(entry.completionReason) && judge?.workerOutcome && !isEvalRun(entry)) {
2817
3495
  // State, queue and desktop move on now; the 'failed' event waits for its cause.
2818
3496
  emitFailed(entry, liveState?.evidence || null);
2819
3497
  processQueue().catch(() => {});
2820
3498
  return;
2821
3499
  }
2822
- if (entry.state === 'failed' || entry.lastResult?.status === 'blocked') entry.escalation = computeEscalation(entry);
3500
+ if (entry.state === 'failed' || entry.lastResult?.status === 'blocked') { noteLimit(entry); entry.escalation = computeEscalation(entry); }
2823
3501
  emit(entry, entry.state);
2824
3502
  // A memory already stored for the run (by the worker or the brain) is the run's memory.
2825
- if (memory?.rememberDispatch && !syncMemory(entry, { announce: true }) && (entry.state === 'completed' || (entry.state === 'stopped' && entry.lastResult))) {
3503
+ // An evaluation run (lib/skills/evals.js) is a measurement, not work to remember: it stores no memory.
3504
+ if (memory?.rememberDispatch && !isEvalRun(entry) && !syncMemory(entry, { announce: true }) && (entry.state === 'completed' || (entry.state === 'stopped' && entry.lastResult))) {
2826
3505
  Promise.resolve(memory.rememberDispatch(view(entry))).then((memoryId) => {
2827
3506
  if (!memoryId) return;
2828
3507
  entry.memoryId = memoryId;
@@ -2837,6 +3516,8 @@ export function createAssistantDispatcher({
2837
3516
  if (!runId || !entries.has(runId)) return;
2838
3517
  const entry = entries.get(runId);
2839
3518
  if (message.type === 'sidepanel:provider-event') {
3519
+ if (!isEvalRun(entry)) { try { observeSkillEvent(entry, message.event || {}); } catch {} }
3520
+ if (entry.provider === 'codex' && Number(message.event?.providerTurn) > 0) resumeInfoOf(entry).providerTurn = Number(message.event.providerTurn);
2840
3521
  const liveState = live.get(runId);
2841
3522
  // Not tied to the live turn: a result that arrives after an abort is still settled.
2842
3523
  if (entry.provider === 'claude-code') { try { meterClaude(entry, message.event?.event); } catch (error) { log(runId, 'assistant:meter-error', error?.message || String(error)); } }
@@ -2844,8 +3525,18 @@ export function createAssistantDispatcher({
2844
3525
  liveState.lastEventAt = now();
2845
3526
  liveState.stalledFlagged = false;
2846
3527
  if (liveState.evidence) { try { recordProviderEvent(liveState.evidence, message.event); } catch {} }
3528
+ // A skill-evaluation run: its tool calls (one entry per call id) and the catalog it started with, for its
3529
+ // trigger reading (lib/skills/eval-trace.js; read with transcript format 'tools').
3530
+ if (isEvalRun(entry)) { try { recordEvalCalls(liveState, message.event || {}); } catch {} }
2847
3531
  if (entry.provider === 'opencode') { try { meterOpenCode(entry, liveState, message.event || {}); } catch (error) { log(runId, 'assistant:meter-error', error?.message || String(error)); } }
2848
3532
  const summary = summarizeProviderEvent(message.event || {});
3533
+ // A guard decision (lib/worker-confinement.js) is also a note of the run: what was refused or warned about,
3534
+ // with its reason, so the brain and the panel see it without the transcript. The first twenty.
3535
+ if ((message.event?.eventType || message.event?.event?.type) === 'synabun.confinement') {
3536
+ const count = (liveState.guardEvents = (liveState.guardEvents || 0) + 1);
3537
+ if (count <= 20) entry.notes.push(`guard ${message.event.event?.action || 'refused'}: ${clip(summary, 300)}`);
3538
+ else if (count === 21) entry.notes.push('guard: more decisions follow in the transcript');
3539
+ }
2849
3540
  if (summary) {
2850
3541
  const line = `${iso(now()).slice(11, 19)} ${summary}`;
2851
3542
  liveState.ring.push(line);
@@ -2859,9 +3550,20 @@ export function createAssistantDispatcher({
2859
3550
  return;
2860
3551
  }
2861
3552
  const run = message.run;
3553
+ if (message.reason === 'mcp-tools' && Array.isArray(run.tools)) {
3554
+ entry.tools = storedToolList({ tools: retainExternalRefs(run.tools, entry.tools) }, toolListOptions());
3555
+ // Keep the original request for escalation, and the current selection for restart.
3556
+ persist({ force: true });
3557
+ emit(entry, 'tools_changed');
3558
+ }
2862
3559
  entry.runtime = { status: run.status, turnState: run.turnState, stoppedReason: run.stoppedReason, error: run.error, claimedBy: run.claimedBy, providerSessionId: run.providerSessionId, providerThreadId: run.providerThreadId };
3560
+ // A new provider identity is saved at once: a crash before the run's next write (its first alive mark comes up to
3561
+ // a minute later) would otherwise leave no session to resume.
3562
+ const newIdentity = (run.providerSessionId && run.providerSessionId !== entry.providerSessionId)
3563
+ || (run.providerThreadId && run.providerThreadId !== entry.providerThreadId);
2863
3564
  if (run.providerSessionId) entry.providerSessionId = run.providerSessionId;
2864
3565
  if (run.providerThreadId) entry.providerThreadId = run.providerThreadId;
3566
+ if (newIdentity) persist({ force: true });
2865
3567
  if (message.type === 'sidepanel:run-updated' && message.reason === 'started' && entry.state === 'starting') {
2866
3568
  entry.state = 'running';
2867
3569
  entry.turnState = 'running';
@@ -2883,10 +3585,13 @@ export function createAssistantDispatcher({
2883
3585
  refreshLimits();
2884
3586
  for (const runId of [...queue]) {
2885
3587
  const entry = entries.get(runId);
2886
- if (!entry || entry.state !== 'queued') { queue.splice(queue.indexOf(runId), 1); continue; }
3588
+ // A run a restart cut off and a lowered limit held back (resumeOnBoot) waits at the head of the queue.
3589
+ const resuming = entry?.state === 'resuming';
3590
+ if (!entry || (entry.state !== 'queued' && !resuming)) { queue.splice(queue.indexOf(runId), 1); continue; }
2887
3591
  const spec = { provider: entry.provider, accountId: entry.accountId, workflowId: entry.workflowId, usesComputer: !!entry.usesComputer };
2888
- if (railBlock(spec, entry.assistantSessionId)) continue;
3592
+ if (railBlock(spec, entry.assistantSessionId, resuming ? { except: runId } : {})) continue;
2889
3593
  queue.splice(queue.indexOf(runId), 1);
3594
+ if (resuming) { try { await resumeRun(entry); } catch (error) { notResumed(entry, error?.message || String(error)); } continue; }
2890
3595
  try { await start(entry); emit(entry, 'queue_started'); } catch (error) { entry.notes.push(`queue start failed: ${error?.message || error}`); }
2891
3596
  }
2892
3597
  }
@@ -2959,6 +3664,8 @@ export function createAssistantDispatcher({
2959
3664
  return { ...base, text: text.length > cap ? text.slice(-cap) : text, truncated: text.length > cap };
2960
3665
  }
2961
3666
  if (format === 'files') return { ...base, files: entry.lastResult?.files || [] };
3667
+ // A skill-evaluation run's tool calls and its start catalog (kept while the run lives).
3668
+ if (format === 'tools') return { ...base, calls: liveState?.evalCalls ? [...liveState.evalCalls] : [], init: liveState?.evalInit || null, unavailable: !liveState ? 'tool calls are only kept for live runs' : null };
2962
3669
  if (format === 'events' || format === 'tail') {
2963
3670
  const lines = liveState ? liveState.ring.slice(-Math.max(1, Math.min(500, Number(tail) || 40))) : [];
2964
3671
  const joined = lines.join('\n');
@@ -3068,7 +3775,13 @@ export function createAssistantDispatcher({
3068
3775
  async function shutdown(reason = 'server_shutdown') {
3069
3776
  if (stalledTimer) clearInterval(stalledTimer);
3070
3777
  if (unsubscribeRuntime) { try { unsubscribeRuntime(); } catch {} unsubscribeRuntime = null; }
3778
+ // An exit the graceful shutdown did not run before (a crash, an exit handler): the runs that may continue are
3779
+ // suspended here as well, and their CLIs told to stop (an exit handler cannot wait for them; the next start ends
3780
+ // what is left). A run waiting for its resume stays as it is, for the next start.
3781
+ suspendForShutdown(reason);
3071
3782
  for (const entry of entries.values()) {
3783
+ if (entry.state === 'suspended') { signalWorkerProcess(entry); continue; }
3784
+ if (entry.state === 'resuming') continue;
3072
3785
  if (ACTIVE_STATES.has(entry.state)) {
3073
3786
  pauseWork(entry);
3074
3787
  entry.state = 'interrupted'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
@@ -3083,12 +3796,17 @@ export function createAssistantDispatcher({
3083
3796
 
3084
3797
  loadRegistry();
3085
3798
  attachRuntime();
3086
- stalledTimer = setInterval(() => { attachRuntime(); checkStalled(); }, 60_000);
3799
+ stalledTimer = setInterval(() => { attachRuntime(); checkStalled(); beat(); }, 60_000);
3087
3800
  stalledTimer.unref?.();
3801
+ // After a restart: the cut-off runs are resumed, or said to have ended, once the Assistant listens (server.js makes
3802
+ // its runtime, which subscribes, right after this dispatcher, in the same tick).
3803
+ if (bootResumes.length || bootInterrupts.length) {
3804
+ setTimeout(() => { resumeOnBoot().catch((error) => log(null, 'assistant:resume-error', error?.message || String(error))); }, 0);
3805
+ }
3088
3806
 
3089
3807
  return {
3090
3808
  dispatch, sendTurn, complete, completeClosedTab, stop, killAll, get, peek, list, totals, wait, transcript, pendingPermissions,
3091
- respondPermission, subscribe, shutdown, limits,
3809
+ respondPermission, subscribe, shutdown, limits, suspendForShutdown,
3092
3810
  sessionBudget, checkSession, refreshLimits, providerSpend, coverFollowUp, budget,
3093
3811
  resolveRoute, declineRoute, heldRouteRefusal, escalate,
3094
3812
  markDelivered, deliveryState,