synabun 2.0.10 → 2.2.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 (656) hide show
  1. package/CHANGELOG.md +207 -0
  2. package/README.md +25 -2
  3. package/THIRD-PARTY-LICENSES.md +22 -0
  4. package/bin/synabun.js +71 -24
  5. package/docs/whatsapp.md +13 -13
  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/lib/claude-hooks.js +172 -18
  10. package/lib/client-config-repair.js +30 -17
  11. package/lib/desktop-app.js +7 -1
  12. package/lib/external-tools.js +7 -6
  13. package/lib/first-launch-protection.js +57 -12
  14. package/lib/mcp-dist.js +176 -0
  15. package/lib/release-npm-ci.mjs +138 -0
  16. package/lib/release-publish.mjs +91 -0
  17. package/lib/release-scan.mjs +50 -0
  18. package/lib/server-crash-record.js +123 -0
  19. package/lib/server-exit.js +318 -0
  20. package/lib/server-supervisor.js +508 -0
  21. package/lib/start-launcher.js +37 -4
  22. package/lib/tools-cli.js +45 -0
  23. package/lib/update-snapshot.js +302 -0
  24. package/lib/virustotal.mjs +203 -0
  25. package/lib/windows-crash-event.js +269 -0
  26. package/mcp-server/dist/http.d.ts +20 -1
  27. package/mcp-server/dist/http.d.ts.map +1 -1
  28. package/mcp-server/dist/http.js +59 -12
  29. package/mcp-server/dist/http.js.map +1 -1
  30. package/mcp-server/dist/index.d.ts +33 -27
  31. package/mcp-server/dist/index.d.ts.map +1 -1
  32. package/mcp-server/dist/index.js +128 -143
  33. package/mcp-server/dist/index.js.map +1 -1
  34. package/mcp-server/dist/preload.js +1 -1
  35. package/mcp-server/dist/preload.js.map +1 -1
  36. package/mcp-server/dist/services/budget-judgments.d.ts +1 -1
  37. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -1
  38. package/mcp-server/dist/services/budget-judgments.js +5 -3
  39. package/mcp-server/dist/services/budget-judgments.js.map +1 -1
  40. package/mcp-server/dist/services/codex-config-heal.d.ts +6 -2
  41. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  42. package/mcp-server/dist/services/codex-config-heal.js +81 -28
  43. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  44. package/mcp-server/dist/services/identity.d.ts +2 -2
  45. package/mcp-server/dist/services/identity.js +2 -2
  46. package/mcp-server/dist/services/memory-judge-backfill.d.ts +6 -0
  47. package/mcp-server/dist/services/memory-judge-backfill.d.ts.map +1 -1
  48. package/mcp-server/dist/services/memory-judge-backfill.js +7 -1
  49. package/mcp-server/dist/services/memory-judge-backfill.js.map +1 -1
  50. package/mcp-server/dist/services/memory-sessions.d.ts +1 -1
  51. package/mcp-server/dist/services/memory-sessions.d.ts.map +1 -1
  52. package/mcp-server/dist/services/memory-sessions.js +28 -2
  53. package/mcp-server/dist/services/memory-sessions.js.map +1 -1
  54. package/mcp-server/dist/services/neural-interface.d.ts +18 -11
  55. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  56. package/mcp-server/dist/services/neural-interface.js +67 -11
  57. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  58. package/mcp-server/dist/services/server-instructions.d.ts +7 -2
  59. package/mcp-server/dist/services/server-instructions.d.ts.map +1 -1
  60. package/mcp-server/dist/services/server-instructions.js +22 -63
  61. package/mcp-server/dist/services/server-instructions.js.map +1 -1
  62. package/mcp-server/dist/services/tool-catalog.d.ts +120 -0
  63. package/mcp-server/dist/services/tool-catalog.d.ts.map +1 -0
  64. package/mcp-server/dist/services/tool-catalog.js +339 -0
  65. package/mcp-server/dist/services/tool-catalog.js.map +1 -0
  66. package/mcp-server/dist/services/tool-list-refresh-barrier.d.ts +12 -0
  67. package/mcp-server/dist/services/tool-list-refresh-barrier.d.ts.map +1 -0
  68. package/mcp-server/dist/services/tool-list-refresh-barrier.js +40 -0
  69. package/mcp-server/dist/services/tool-list-refresh-barrier.js.map +1 -0
  70. package/mcp-server/dist/services/tool-list.d.ts +108 -0
  71. package/mcp-server/dist/services/tool-list.d.ts.map +1 -0
  72. package/mcp-server/dist/services/tool-list.js +334 -0
  73. package/mcp-server/dist/services/tool-list.js.map +1 -0
  74. package/mcp-server/dist/services/tool-runtime.d.ts +161 -0
  75. package/mcp-server/dist/services/tool-runtime.d.ts.map +1 -0
  76. package/mcp-server/dist/services/tool-runtime.js +400 -0
  77. package/mcp-server/dist/services/tool-runtime.js.map +1 -0
  78. package/mcp-server/dist/services/typesafe-config.js +1 -1
  79. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  80. package/mcp-server/dist/services/whiteboard-geometry.d.ts +26 -2
  81. package/mcp-server/dist/services/whiteboard-geometry.d.ts.map +1 -1
  82. package/mcp-server/dist/services/whiteboard-geometry.js +91 -8
  83. package/mcp-server/dist/services/whiteboard-geometry.js.map +1 -1
  84. package/mcp-server/dist/tools/agents.d.ts +14 -14
  85. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  86. package/mcp-server/dist/tools/agents.js +29 -19
  87. package/mcp-server/dist/tools/agents.js.map +1 -1
  88. package/mcp-server/dist/tools/browser-batch.d.ts +3 -3
  89. package/mcp-server/dist/tools/choice.d.ts +6 -0
  90. package/mcp-server/dist/tools/choice.d.ts.map +1 -1
  91. package/mcp-server/dist/tools/choice.js +18 -13
  92. package/mcp-server/dist/tools/choice.js.map +1 -1
  93. package/mcp-server/dist/tools/git-tools.js +2 -2
  94. package/mcp-server/dist/tools/git-tools.js.map +1 -1
  95. package/mcp-server/dist/tools/image-tools.d.ts +1 -1
  96. package/mcp-server/dist/tools/image-tools.js +1 -1
  97. package/mcp-server/dist/tools/morelogin.d.ts.map +1 -1
  98. package/mcp-server/dist/tools/morelogin.js +9 -6
  99. package/mcp-server/dist/tools/morelogin.js.map +1 -1
  100. package/mcp-server/dist/tools/skill-tools.d.ts +505 -0
  101. package/mcp-server/dist/tools/skill-tools.d.ts.map +1 -0
  102. package/mcp-server/dist/tools/skill-tools.js +642 -0
  103. package/mcp-server/dist/tools/skill-tools.js.map +1 -0
  104. package/mcp-server/dist/tools/skill.d.ts +7 -0
  105. package/mcp-server/dist/tools/skill.d.ts.map +1 -0
  106. package/mcp-server/dist/tools/skill.js +11 -0
  107. package/mcp-server/dist/tools/skill.js.map +1 -0
  108. package/mcp-server/dist/tools/tool-groups.d.ts +68 -0
  109. package/mcp-server/dist/tools/tool-groups.d.ts.map +1 -0
  110. package/mcp-server/dist/tools/tool-groups.js +147 -0
  111. package/mcp-server/dist/tools/tool-groups.js.map +1 -0
  112. package/mcp-server/dist/tools/whiteboard-format.d.ts.map +1 -1
  113. package/mcp-server/dist/tools/whiteboard-format.js +7 -4
  114. package/mcp-server/dist/tools/whiteboard-format.js.map +1 -1
  115. package/mcp-server/dist/tools/whiteboard-tools.d.ts +64 -13
  116. package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -1
  117. package/mcp-server/dist/tools/whiteboard-tools.js +30 -5
  118. package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -1
  119. package/mcp-server/package-lock.json +2 -2
  120. package/mcp-server/package.json +1 -1
  121. package/mcp-server/src/http.ts +75 -12
  122. package/mcp-server/src/index.ts +147 -154
  123. package/mcp-server/src/preload.ts +12 -12
  124. package/mcp-server/src/services/budget-judgments.ts +5 -3
  125. package/mcp-server/src/services/codex-config-heal.ts +80 -29
  126. package/mcp-server/src/services/identity.ts +2 -2
  127. package/mcp-server/src/services/memory-judge-backfill.ts +7 -1
  128. package/mcp-server/src/services/memory-sessions.ts +24 -3
  129. package/mcp-server/src/services/neural-interface.ts +81 -21
  130. package/mcp-server/src/services/server-instructions.ts +29 -64
  131. package/mcp-server/src/services/tool-catalog.ts +403 -0
  132. package/mcp-server/src/services/tool-list-refresh-barrier.ts +33 -0
  133. package/mcp-server/src/services/tool-list.ts +410 -0
  134. package/mcp-server/src/services/tool-runtime.ts +457 -0
  135. package/mcp-server/src/services/typesafe-config.ts +1 -1
  136. package/mcp-server/src/services/whiteboard-geometry.ts +94 -9
  137. package/mcp-server/src/tools/agents.ts +29 -20
  138. package/mcp-server/src/tools/choice.ts +17 -12
  139. package/mcp-server/src/tools/git-tools.ts +2 -2
  140. package/mcp-server/src/tools/image-tools.ts +1 -1
  141. package/mcp-server/src/tools/morelogin.ts +6 -4
  142. package/mcp-server/src/tools/skill-tools.ts +592 -0
  143. package/mcp-server/src/tools/skill.ts +13 -0
  144. package/mcp-server/src/tools/tool-groups.ts +164 -0
  145. package/mcp-server/src/tools/whiteboard-format.ts +9 -3
  146. package/mcp-server/src/tools/whiteboard-tools.ts +39 -5
  147. package/neural-interface/i18n/en.json +3953 -228
  148. package/neural-interface/i18n/pt-BR.json +3901 -179
  149. package/neural-interface/lib/agent-sandbox-elevation.js +6 -0
  150. package/neural-interface/lib/antigravity/acp-client-host.js +554 -0
  151. package/neural-interface/lib/antigravity/acp-connection.js +299 -0
  152. package/neural-interface/lib/antigravity/bypass.js +100 -0
  153. package/neural-interface/lib/antigravity/catalog.js +273 -0
  154. package/neural-interface/lib/antigravity/home.js +260 -0
  155. package/neural-interface/lib/antigravity/migrate-gemini.js +139 -0
  156. package/neural-interface/lib/antigravity/panel-bridge.js +1058 -0
  157. package/neural-interface/lib/antigravity/policy-hook.mjs +78 -0
  158. package/neural-interface/lib/antigravity/policy.js +74 -0
  159. package/neural-interface/lib/antigravity/tool-call.js +178 -0
  160. package/neural-interface/lib/antigravity/transcripts.js +252 -0
  161. package/neural-interface/lib/antigravity/usage.js +43 -0
  162. package/neural-interface/lib/assistant-api.js +128 -28
  163. package/neural-interface/lib/assistant-brains/antigravity.js +423 -0
  164. package/neural-interface/lib/assistant-brains/claude.js +4 -3
  165. package/neural-interface/lib/assistant-brains/codex-browser-policy-hook.mjs +12 -2
  166. package/neural-interface/lib/assistant-brains/codex-route-gate-hook.mjs +32 -9
  167. package/neural-interface/lib/assistant-brains/codex.js +9 -5
  168. package/neural-interface/lib/assistant-brains/opencode.js +120 -54
  169. package/neural-interface/lib/assistant-brains/opencode2-browser-policy/index.js +28 -13
  170. package/neural-interface/lib/assistant-brains/opencode2-route-gate/index.js +59 -19
  171. package/neural-interface/lib/assistant-budget-planner.js +7 -3
  172. package/neural-interface/lib/assistant-budget-split.js +42 -15
  173. package/neural-interface/lib/assistant-catalog.js +81 -15
  174. package/neural-interface/lib/assistant-config.js +6 -3
  175. package/neural-interface/lib/assistant-dispatch.js +1073 -200
  176. package/neural-interface/lib/assistant-envelope.js +156 -2
  177. package/neural-interface/lib/assistant-evidence.js +80 -0
  178. package/neural-interface/lib/assistant-hidden-models.js +7 -5
  179. package/neural-interface/lib/assistant-persona.js +93 -19
  180. package/neural-interface/lib/assistant-plan-permissions.js +25 -9
  181. package/neural-interface/lib/assistant-route-gate-codex.js +3 -0
  182. package/neural-interface/lib/assistant-route-gate.js +6 -4
  183. package/neural-interface/lib/assistant-router.js +245 -38
  184. package/neural-interface/lib/assistant-runtime.js +214 -30
  185. package/neural-interface/lib/assistant-task-prompt.js +87 -21
  186. package/neural-interface/lib/assistant-tools.js +51 -0
  187. package/neural-interface/lib/assistant-usage.js +29 -0
  188. package/neural-interface/lib/backup-service.js +626 -4
  189. package/neural-interface/lib/backup-zip-reader.js +10 -5
  190. package/neural-interface/lib/backup-zip-writer.js +257 -0
  191. package/neural-interface/lib/browser-execution.js +10 -2
  192. package/neural-interface/lib/browser-guard-cdp.js +84 -0
  193. package/neural-interface/lib/browser-network-guard.js +476 -0
  194. package/neural-interface/lib/browser-policy-roots.js +64 -0
  195. package/neural-interface/lib/browser-tool-policy.js +198 -38
  196. package/neural-interface/lib/claude-agent-bridge.js +11 -2
  197. package/neural-interface/lib/claude-history-cache.js +210 -0
  198. package/neural-interface/lib/claude-hooks-status.js +103 -0
  199. package/neural-interface/lib/claude-model-catalog.js +5 -1
  200. package/neural-interface/lib/claude-temporary.js +5 -11
  201. package/neural-interface/lib/cli-install-api.js +1 -1
  202. package/neural-interface/lib/cli-install.js +163 -154
  203. package/neural-interface/lib/cli-resolver.js +13 -3
  204. package/neural-interface/lib/cli-update-planner.js +52 -41
  205. package/neural-interface/lib/cloudflared/tunnel.js +42 -2
  206. package/neural-interface/lib/codex-mcp-approvals.js +11 -0
  207. package/neural-interface/lib/codex-rollout-reader.js +52 -0
  208. package/neural-interface/lib/codex-worker-app-server.js +191 -0
  209. package/neural-interface/lib/confined-fs.js +30 -3
  210. package/neural-interface/lib/crash-notice-api.js +98 -0
  211. package/neural-interface/lib/editor-completion-opencode.js +68 -68
  212. package/neural-interface/lib/editor-git-api.js +3 -20
  213. package/neural-interface/lib/exec-loop-command.js +13 -10
  214. package/neural-interface/lib/file-explorer-api.js +74 -40
  215. package/neural-interface/lib/file-explorer-watch.js +9 -3
  216. package/neural-interface/lib/git-bash/install.js +52 -6
  217. package/neural-interface/lib/git-bash/manager.js +59 -17
  218. package/neural-interface/lib/git-bash/pins.js +12 -0
  219. package/neural-interface/lib/git-read.js +2 -2
  220. package/neural-interface/lib/git-status-runner.js +390 -0
  221. package/neural-interface/lib/git-status-watchdog.js +40 -0
  222. package/neural-interface/lib/greeting/budgets.js +1 -0
  223. package/neural-interface/lib/greeting/engine.js +2 -2
  224. package/neural-interface/lib/greeting/sources.js +10 -12
  225. package/neural-interface/lib/greeting/writer.js +1 -1
  226. package/neural-interface/lib/http-guards.js +8 -2
  227. package/neural-interface/lib/install-details.js +4 -1
  228. package/neural-interface/lib/mcp-agent-servers.js +67 -0
  229. package/neural-interface/lib/mcp-client-config.js +35 -33
  230. package/neural-interface/lib/mcp-external-servers.js +332 -0
  231. package/neural-interface/lib/mcp-installer.js +2 -1
  232. package/neural-interface/lib/{mcp-profile-refresh-coordinator.js → mcp-tools-refresh-coordinator.js} +4 -4
  233. package/neural-interface/lib/memory-session-capture.js +17 -3
  234. package/neural-interface/lib/morelogin.js +177 -25
  235. package/neural-interface/lib/native-loop-providers.js +965 -66
  236. package/neural-interface/lib/native-loop-runtime.js +290 -42
  237. package/neural-interface/lib/opencode-config.js +557 -0
  238. package/neural-interface/lib/opencode-db-schema.js +17 -34
  239. package/neural-interface/lib/opencode-history.js +48 -218
  240. package/neural-interface/lib/opencode-mcp-ready.js +108 -0
  241. package/neural-interface/lib/opencode-native-boundary.js +53 -0
  242. package/neural-interface/lib/opencode-protocol.js +249 -112
  243. package/neural-interface/lib/opencode-runtime-config.js +32 -22
  244. package/neural-interface/lib/opencode-serve-records.js +39 -21
  245. package/neural-interface/lib/opencode-v2-client.js +48 -153
  246. package/neural-interface/lib/opencode-v2-ws-requests.js +36 -210
  247. package/neural-interface/lib/opencode-worker-data.js +27 -0
  248. package/neural-interface/lib/opencode2-client.js +73 -37
  249. package/neural-interface/lib/opencode2-proxy.js +23 -63
  250. package/neural-interface/lib/opencode2-translate.js +51 -2
  251. package/neural-interface/lib/owner-authority.js +249 -0
  252. package/neural-interface/lib/page-session.js +275 -0
  253. package/neural-interface/lib/path-confine.js +12 -9
  254. package/neural-interface/lib/peer-identity.js +793 -0
  255. package/neural-interface/lib/pty-host/core.js +28 -1
  256. package/neural-interface/lib/pty-host/lifetime.js +12 -0
  257. package/neural-interface/lib/pty-host/manager.js +12 -1
  258. package/neural-interface/lib/remote-policy.js +1 -1
  259. package/neural-interface/lib/request-gate.js +20 -2
  260. package/neural-interface/lib/rulesets/api.js +7 -6
  261. package/neural-interface/lib/rulesets/fs-safe.js +1 -1
  262. package/neural-interface/lib/rulesets/installer.js +180 -36
  263. package/neural-interface/lib/rulesets/json-edit.js +1 -1
  264. package/neural-interface/lib/rulesets/render.js +1 -1
  265. package/neural-interface/lib/session-cache-builder.js +81 -31
  266. package/neural-interface/lib/session-title-generator.js +58 -36
  267. package/neural-interface/lib/skills/api-v2.js +495 -0
  268. package/neural-interface/lib/skills/bundle.js +418 -0
  269. package/neural-interface/lib/skills/catalog.js +298 -0
  270. package/neural-interface/lib/skills/creation-api.js +191 -0
  271. package/neural-interface/lib/skills/creation-brief.js +247 -0
  272. package/neural-interface/lib/skills/creation-sources.js +285 -0
  273. package/neural-interface/lib/skills/creation.js +843 -0
  274. package/neural-interface/lib/skills/deploy.js +569 -0
  275. package/neural-interface/lib/skills/digest.js +119 -0
  276. package/neural-interface/lib/skills/discover.js +111 -0
  277. package/neural-interface/lib/skills/draft-confinement.js +216 -0
  278. package/neural-interface/lib/skills/eval-confinement.js +399 -0
  279. package/neural-interface/lib/skills/eval-sandbox.js +280 -0
  280. package/neural-interface/lib/skills/eval-trace.js +135 -0
  281. package/neural-interface/lib/skills/evals-api.js +212 -0
  282. package/neural-interface/lib/skills/evals.js +1573 -0
  283. package/neural-interface/lib/skills/export.js +203 -0
  284. package/neural-interface/lib/skills/frontmatter.js +64 -0
  285. package/neural-interface/lib/skills/health.js +75 -0
  286. package/neural-interface/lib/skills/json-edit.js +165 -0
  287. package/neural-interface/lib/skills/known-bridge-digests.json +441 -0
  288. package/neural-interface/lib/skills/known-bridges.js +96 -0
  289. package/neural-interface/lib/skills/library.js +823 -0
  290. package/neural-interface/lib/skills/local-store.js +21 -0
  291. package/neural-interface/lib/skills/migration.js +433 -0
  292. package/neural-interface/lib/skills/operations.js +45 -0
  293. package/neural-interface/lib/skills/ownership.js +427 -0
  294. package/neural-interface/lib/skills/packages.js +88 -0
  295. package/neural-interface/lib/skills/reviews.js +212 -0
  296. package/neural-interface/lib/skills/revision.js +80 -0
  297. package/neural-interface/lib/skills/roots.js +269 -0
  298. package/neural-interface/lib/skills/runtime-checks.js +358 -0
  299. package/neural-interface/lib/skills/scan-assets.js +252 -0
  300. package/neural-interface/lib/skills/scan-context.js +26 -0
  301. package/neural-interface/lib/skills/scan-shell.js +324 -0
  302. package/neural-interface/lib/skills/scan.js +364 -0
  303. package/neural-interface/lib/skills/scanners.js +54 -0
  304. package/neural-interface/lib/skills/service.js +530 -0
  305. package/neural-interface/lib/skills/telemetry.js +119 -0
  306. package/neural-interface/lib/sqlite-integrity-worker.js +22 -0
  307. package/neural-interface/lib/synabun-version.js +1 -1
  308. package/neural-interface/lib/system-activity.js +1 -1
  309. package/neural-interface/lib/tool-lists.js +124 -0
  310. package/neural-interface/lib/whatsapp/brain.js +7 -3
  311. package/neural-interface/lib/whatsapp/bridge.js +6 -3
  312. package/neural-interface/lib/whatsapp/cards.js +22 -9
  313. package/neural-interface/lib/whatsapp/config.js +1 -1
  314. package/neural-interface/lib/worker-confinement.js +1268 -0
  315. package/neural-interface/lib/worker-guard-setting.js +56 -0
  316. package/neural-interface/lib/worker-mcp-channel.js +175 -0
  317. package/neural-interface/lib/worker-question.js +74 -0
  318. package/neural-interface/lib/worker-relay-ports.js +227 -0
  319. package/neural-interface/lib/worker-resume.js +238 -0
  320. package/neural-interface/lib/worker-sandbox.js +329 -0
  321. package/neural-interface/package-lock.json +34 -12
  322. package/neural-interface/package.json +3 -2
  323. package/neural-interface/public/index.html +3 -1
  324. package/neural-interface/public/index2d.html +19 -11
  325. package/neural-interface/public/onboarding.html +3 -3
  326. package/neural-interface/public/shared/account-menu.js +8 -5
  327. package/neural-interface/public/shared/agent-runtime-options.js +125 -31
  328. package/neural-interface/public/shared/agy/agy-bypass.js +47 -0
  329. package/neural-interface/public/shared/agy/agy-client.js +266 -0
  330. package/neural-interface/public/shared/agy/agy-commands.js +120 -0
  331. package/neural-interface/public/shared/agy/agy-copy.js +326 -0
  332. package/neural-interface/public/shared/agy/agy-diff.js +161 -0
  333. package/neural-interface/public/shared/agy/agy-events.js +469 -0
  334. package/neural-interface/public/shared/agy/agy-greeting.js +26 -0
  335. package/neural-interface/public/shared/agy/agy-options.js +232 -0
  336. package/neural-interface/public/shared/agy/agy-panel.js +2543 -0
  337. package/neural-interface/public/shared/agy/agy-render.js +392 -0
  338. package/neural-interface/public/shared/agy/agy-setup.js +149 -0
  339. package/neural-interface/public/shared/agy/agy-styles.js +604 -0
  340. package/neural-interface/public/shared/agy/agy-tabs-logic.js +181 -0
  341. package/neural-interface/public/shared/api.js +1527 -1336
  342. package/neural-interface/public/shared/app-frame.js +192 -0
  343. package/neural-interface/public/shared/assistant/asst-agents-dock.js +36 -6
  344. package/neural-interface/public/shared/assistant/asst-api.js +1 -1
  345. package/neural-interface/public/shared/assistant/asst-ask-styles.js +270 -0
  346. package/neural-interface/public/shared/assistant/asst-ask-tail.js +118 -0
  347. package/neural-interface/public/shared/assistant/asst-ask.js +397 -0
  348. package/neural-interface/public/shared/assistant/asst-brain-picker.js +105 -44
  349. package/neural-interface/public/shared/assistant/asst-budget.js +5 -4
  350. package/neural-interface/public/shared/assistant/asst-control.js +205 -26
  351. package/neural-interface/public/shared/assistant/asst-followups-card.js +383 -0
  352. package/neural-interface/public/shared/assistant/asst-followups.js +417 -0
  353. package/neural-interface/public/shared/assistant/asst-hosts.js +16 -7
  354. package/neural-interface/public/shared/assistant/asst-limit-dock.js +45 -0
  355. package/neural-interface/public/shared/assistant/asst-limit-wait.js +327 -0
  356. package/neural-interface/public/shared/assistant/asst-limit.js +168 -0
  357. package/neural-interface/public/shared/assistant/asst-mascot.js +49 -12
  358. package/neural-interface/public/shared/assistant/asst-menu.js +7 -6
  359. package/neural-interface/public/shared/assistant/asst-panel.js +151 -19
  360. package/neural-interface/public/shared/assistant/asst-rack-styles.js +66 -49
  361. package/neural-interface/public/shared/assistant/asst-render.js +445 -82
  362. package/neural-interface/public/shared/assistant/asst-route.js +304 -53
  363. package/neural-interface/public/shared/assistant/asst-routes-editor.js +9 -8
  364. package/neural-interface/public/shared/assistant/asst-scenes.js +56 -1
  365. package/neural-interface/public/shared/assistant/asst-sidepanel.js +117 -10
  366. package/neural-interface/public/shared/assistant/asst-slash.js +17 -9
  367. package/neural-interface/public/shared/assistant/asst-state.js +87 -24
  368. package/neural-interface/public/shared/assistant/asst-stop.js +127 -0
  369. package/neural-interface/public/shared/assistant/asst-styles.js +93 -21
  370. package/neural-interface/public/shared/assistant/asst-tool-kinds.js +190 -23
  371. package/neural-interface/public/shared/assistant/asst-usage.js +2 -1
  372. package/neural-interface/public/shared/automation-context.js +5 -2
  373. package/neural-interface/public/shared/cc-dropdown.js +89 -19
  374. package/neural-interface/public/shared/cdx/cdx-context-menu.js +10 -9
  375. package/neural-interface/public/shared/cdx/cdx-copy.js +16 -0
  376. package/neural-interface/public/shared/cdx/cdx-output-model.js +22 -1
  377. package/neural-interface/public/shared/cdx/cdx-output.js +34 -10
  378. package/neural-interface/public/shared/cdx/cdx-panel.js +59 -151
  379. package/neural-interface/public/shared/cdx/cdx-preview.js +12 -0
  380. package/neural-interface/public/shared/cdx/cdx-protocol.js +39 -11
  381. package/neural-interface/public/shared/cdx/cdx-render.js +57 -38
  382. package/neural-interface/public/shared/cdx/cdx-requests.js +14 -4
  383. package/neural-interface/public/shared/cdx/cdx-skill-hints.js +54 -0
  384. package/neural-interface/public/shared/cdx/cdx-styles.js +15 -3
  385. package/neural-interface/public/shared/cdx/cdx-tabs.js +127 -44
  386. package/neural-interface/public/shared/cdx/cdx-ws.js +0 -1
  387. package/neural-interface/public/shared/cli-install-controller.js +218 -0
  388. package/neural-interface/public/shared/cli-install-view.js +11 -12
  389. package/neural-interface/public/shared/cli-status.js +11 -10
  390. package/neural-interface/public/shared/constants.js +5 -4
  391. package/neural-interface/public/shared/context-menu-areas.js +34 -0
  392. package/neural-interface/public/shared/context-menu-logic.js +243 -0
  393. package/neural-interface/public/shared/context-menu-model.js +367 -0
  394. package/neural-interface/public/shared/context-menu.js +372 -0
  395. package/neural-interface/public/shared/cp/cp-context-menu.js +10 -9
  396. package/neural-interface/public/shared/cp/cp-restore.js +162 -0
  397. package/neural-interface/public/shared/cp/cp-tool-cards.js +10 -3
  398. package/neural-interface/public/shared/crash-notice-view.js +168 -0
  399. package/neural-interface/public/shared/desktop-bridge.js +17 -0
  400. package/neural-interface/public/shared/desktop-titlebar-view.js +12 -0
  401. package/neural-interface/public/shared/desktop-titlebar.js +13 -4
  402. package/neural-interface/public/shared/desktop-update-view.js +21 -0
  403. package/neural-interface/public/shared/editor/editor-panel-logic.js +2 -2
  404. package/neural-interface/public/shared/explorer/fx-api.js +4 -4
  405. package/neural-interface/public/shared/explorer/fx-links.js +3 -3
  406. package/neural-interface/public/shared/explorer/fx-logic.js +1 -1
  407. package/neural-interface/public/shared/explorer/fx-main.js +48 -16
  408. package/neural-interface/public/shared/explorer/fx-tree.js +1 -0
  409. package/neural-interface/public/shared/greeting/greeting-card.js +2 -2
  410. package/neural-interface/public/shared/greeting/greeting-view.js +1 -0
  411. package/neural-interface/public/shared/html-shell.js +30 -15
  412. package/neural-interface/public/shared/i18n.js +2 -0
  413. package/neural-interface/public/shared/ocp-v2/ocp-v2-approvals.js +7 -7
  414. package/neural-interface/public/shared/ocp-v2/ocp-v2-binding.js +4 -4
  415. package/neural-interface/public/shared/ocp-v2/ocp-v2-changes-logic.js +3 -101
  416. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +2 -0
  417. package/neural-interface/public/shared/ocp-v2/ocp-v2-compact-button.js +1 -3
  418. package/neural-interface/public/shared/ocp-v2/ocp-v2-composer-logic.js +2 -6
  419. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-menu.js +11 -11
  420. package/neural-interface/public/shared/ocp-v2/ocp-v2-context-model.js +0 -3
  421. package/neural-interface/public/shared/ocp-v2/ocp-v2-events.js +8 -4
  422. package/neural-interface/public/shared/ocp-v2/ocp-v2-manager.js +7 -7
  423. package/neural-interface/public/shared/ocp-v2/ocp-v2-outcome-notifications.js +16 -0
  424. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +74 -259
  425. package/neural-interface/public/shared/ocp-v2/ocp-v2-plan.js +1 -2
  426. package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +95 -202
  427. package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +13 -4
  428. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +13 -22
  429. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +4 -4
  430. package/neural-interface/public/shared/ocp-v2/ocp-v2-session-actions.js +8 -61
  431. package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +2 -91
  432. package/neural-interface/public/shared/ocp-v2/ocp-v2-slash-hints.js +16 -15
  433. package/neural-interface/public/shared/ocp-v2/ocp-v2-state.js +8 -21
  434. package/neural-interface/public/shared/ocp-v2/ocp-v2-status-logic.js +1 -99
  435. package/neural-interface/public/shared/ocp-v2/ocp-v2-status.js +5 -35
  436. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +17 -21
  437. package/neural-interface/public/shared/ocp-v2/{ocp-v2-profile-inheritance.js → ocp-v2-tools-inheritance.js} +16 -16
  438. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +60 -50
  439. package/neural-interface/public/shared/page-session.js +96 -0
  440. package/neural-interface/public/shared/provider-icons.js +4 -2
  441. package/neural-interface/public/shared/registry.js +5 -3
  442. package/neural-interface/public/shared/retired-2d.js +3 -0
  443. package/neural-interface/public/shared/schedules-view.js +195 -61
  444. package/neural-interface/public/shared/settings/settings-claude-hooks.js +135 -0
  445. package/neural-interface/public/shared/settings/settings-data.js +3 -1
  446. package/neural-interface/public/shared/settings/settings-ia.js +6 -6
  447. package/neural-interface/public/shared/settings/settings-opencode.js +89 -0
  448. package/neural-interface/public/shared/settings/settings-rules.js +2 -2
  449. package/neural-interface/public/shared/settings/settings-skill-folders-pane.js +265 -0
  450. package/neural-interface/public/shared/settings/settings-skill-folders.js +130 -0
  451. package/neural-interface/public/shared/settings/settings-worker-guard.js +72 -0
  452. package/neural-interface/public/shared/showcase-privacy.js +6 -1
  453. package/neural-interface/public/shared/sidepanel-session-status.js +3 -3
  454. package/neural-interface/public/shared/skills/ss-args.js +96 -0
  455. package/neural-interface/public/shared/skills/ss-creation.js +222 -0
  456. package/neural-interface/public/shared/skills/ss-dialog.js +133 -0
  457. package/neural-interface/public/shared/skills/ss-doc.js +173 -0
  458. package/neural-interface/public/shared/skills/ss-eval-adapters.js +269 -0
  459. package/neural-interface/public/shared/skills/ss-eval-grade.js +359 -0
  460. package/neural-interface/public/shared/skills/ss-eval-optimise.js +153 -0
  461. package/neural-interface/public/shared/skills/ss-eval-plan.js +315 -0
  462. package/neural-interface/public/shared/skills/ss-evals.js +417 -0
  463. package/neural-interface/public/shared/skills/ss-form.js +159 -0
  464. package/neural-interface/public/shared/skills/ss-frontmatter.js +376 -0
  465. package/neural-interface/public/shared/skills/ss-ids.js +32 -0
  466. package/neural-interface/public/shared/skills/ss-lint.js +149 -0
  467. package/neural-interface/public/shared/skills/ss-presets.js +117 -0
  468. package/neural-interface/public/shared/skills/ss-profiles.js +513 -0
  469. package/neural-interface/public/shared/skills/ss-resources.js +165 -0
  470. package/neural-interface/public/shared/skills/ss-review-view.js +190 -0
  471. package/neural-interface/public/shared/skills/ss-tabs.js +79 -0
  472. package/neural-interface/public/shared/skills/ss-unicode.js +135 -0
  473. package/neural-interface/public/shared/skills/studio/sx-actions.js +222 -0
  474. package/neural-interface/public/shared/skills/studio/sx-cm.js +101 -0
  475. package/neural-interface/public/shared/skills/studio/sx-create-ai-view.js +749 -0
  476. package/neural-interface/public/shared/skills/studio/sx-create-ai.js +237 -0
  477. package/neural-interface/public/shared/skills/studio/sx-create-view.js +254 -0
  478. package/neural-interface/public/shared/skills/studio/sx-create.js +126 -0
  479. package/neural-interface/public/shared/skills/studio/sx-diff-view.js +43 -0
  480. package/neural-interface/public/shared/skills/studio/sx-diff.js +133 -0
  481. package/neural-interface/public/shared/skills/studio/sx-docs.js +480 -0
  482. package/neural-interface/public/shared/skills/studio/sx-dom.js +230 -0
  483. package/neural-interface/public/shared/skills/studio/sx-edit-view.js +848 -0
  484. package/neural-interface/public/shared/skills/studio/sx-editor-model.js +193 -0
  485. package/neural-interface/public/shared/skills/studio/sx-errors.js +22 -0
  486. package/neural-interface/public/shared/skills/studio/sx-fixes-view.js +359 -0
  487. package/neural-interface/public/shared/skills/studio/sx-fixes.js +327 -0
  488. package/neural-interface/public/shared/skills/studio/sx-geometry.js +82 -0
  489. package/neural-interface/public/shared/skills/studio/sx-import.js +262 -0
  490. package/neural-interface/public/shared/skills/studio/sx-inspector.js +308 -0
  491. package/neural-interface/public/shared/skills/studio/sx-install-flow.js +282 -0
  492. package/neural-interface/public/shared/skills/studio/sx-install.js +313 -0
  493. package/neural-interface/public/shared/skills/studio/sx-library-view.js +748 -0
  494. package/neural-interface/public/shared/skills/studio/sx-library.js +479 -0
  495. package/neural-interface/public/shared/skills/studio/sx-main.js +690 -0
  496. package/neural-interface/public/shared/skills/studio/sx-operations-view.js +98 -0
  497. package/neural-interface/public/shared/skills/studio/sx-operations.js +11 -0
  498. package/neural-interface/public/shared/skills/studio/sx-plan.js +158 -0
  499. package/neural-interface/public/shared/skills/studio/sx-publish-view.js +621 -0
  500. package/neural-interface/public/shared/skills/studio/sx-review-view.js +369 -0
  501. package/neural-interface/public/shared/skills/studio/sx-review.js +148 -0
  502. package/neural-interface/public/shared/skills/studio/sx-scan.js +35 -0
  503. package/neural-interface/public/shared/skills/studio/sx-snippets.js +96 -0
  504. package/neural-interface/public/shared/skills/studio/sx-subject.js +249 -0
  505. package/neural-interface/public/shared/skills/studio/sx-test-view.js +994 -0
  506. package/neural-interface/public/shared/skills/studio/sx-test.js +328 -0
  507. package/neural-interface/public/shared/skills/studio/sx-versions-view.js +241 -0
  508. package/neural-interface/public/shared/skills/studio/sx-workspace-view.js +426 -0
  509. package/neural-interface/public/shared/state.js +1 -1
  510. package/neural-interface/public/shared/streaming-markdown.js +58 -0
  511. package/neural-interface/public/shared/styleguide/sg-panel.js +4 -2
  512. package/neural-interface/public/shared/styles.css +1822 -1756
  513. package/neural-interface/public/shared/synabun-mascot.js +1115 -9
  514. package/neural-interface/public/shared/text-cache.js +29 -0
  515. package/neural-interface/public/shared/tool-list-picker.js +55 -0
  516. package/neural-interface/public/shared/tool-list-storage.js +24 -0
  517. package/neural-interface/public/shared/tool-list-view.js +112 -0
  518. package/neural-interface/public/shared/topright-overflow-logic.js +54 -0
  519. package/neural-interface/public/shared/tour/tour-actor.js +268 -0
  520. package/neural-interface/public/shared/tour/tour-beats.js +336 -0
  521. package/neural-interface/public/shared/tour/tour-engine.js +884 -0
  522. package/neural-interface/public/shared/tour/tour-ink.js +390 -0
  523. package/neural-interface/public/shared/tour/tour-stage.js +440 -0
  524. package/neural-interface/public/shared/tour/tour-state.js +40 -0
  525. package/neural-interface/public/shared/tour/tour-tips.js +99 -0
  526. package/neural-interface/public/shared/tour/tour.css +144 -0
  527. package/neural-interface/public/shared/ui-antigravity-panel.js +19 -0
  528. package/neural-interface/public/shared/ui-assistant.js +2 -0
  529. package/neural-interface/public/shared/ui-automation-studio.js +111 -140
  530. package/neural-interface/public/shared/ui-claude-panel.js +376 -82
  531. package/neural-interface/public/shared/ui-cli-banner.js +23 -167
  532. package/neural-interface/public/shared/ui-command-runner.js +6 -3
  533. package/neural-interface/public/shared/ui-context-menu.js +295 -0
  534. package/neural-interface/public/shared/ui-cost-widget.js +57 -59
  535. package/neural-interface/public/shared/ui-crash-notice.js +187 -0
  536. package/neural-interface/public/shared/ui-detail.js +15 -3
  537. package/neural-interface/public/shared/ui-file-explorer.js +48 -6
  538. package/neural-interface/public/shared/ui-file-links.js +2 -2
  539. package/neural-interface/public/shared/ui-games.js +2 -2
  540. package/neural-interface/public/shared/ui-image-gallery.js +50 -47
  541. package/neural-interface/public/shared/ui-judgments.js +1 -1
  542. package/neural-interface/public/shared/ui-keybinds.js +33 -2
  543. package/neural-interface/public/shared/ui-link.js +3 -3
  544. package/neural-interface/public/shared/ui-menubar.js +10 -20
  545. package/neural-interface/public/shared/ui-native-loop-router-state.js +5 -0
  546. package/neural-interface/public/shared/ui-native-loop-router.js +11 -1
  547. package/neural-interface/public/shared/ui-navbar.js +49 -24
  548. package/neural-interface/public/shared/ui-notifications.js +1 -0
  549. package/neural-interface/public/shared/ui-panels.js +96 -65
  550. package/neural-interface/public/shared/ui-resume.js +9 -7
  551. package/neural-interface/public/shared/ui-schedules-studio.js +546 -431
  552. package/neural-interface/public/shared/ui-scroll-follow.js +16 -5
  553. package/neural-interface/public/shared/ui-sessions.js +27 -15
  554. package/neural-interface/public/shared/ui-settings.js +683 -526
  555. package/neural-interface/public/shared/ui-sidepanel-session-entry.js +9 -2
  556. package/neural-interface/public/shared/ui-sidepanel-sessions.js +4 -4
  557. package/neural-interface/public/shared/ui-sidepanel-state.js +23 -12
  558. package/neural-interface/public/shared/ui-sidepanel-tray.js +48 -7
  559. package/neural-interface/public/shared/ui-sidepanel-windows.js +18 -13
  560. package/neural-interface/public/shared/ui-skills.js +33 -2830
  561. package/neural-interface/public/shared/ui-styleguide.js +5 -2
  562. package/neural-interface/public/shared/ui-sync.js +15 -5
  563. package/neural-interface/public/shared/ui-terminal.js +270 -190
  564. package/neural-interface/public/shared/ui-tool-updates.js +6 -4
  565. package/neural-interface/public/shared/ui-tooltip.js +81 -51
  566. package/neural-interface/public/shared/ui-topright-overflow.js +233 -0
  567. package/neural-interface/public/shared/ui-trash.js +6 -4
  568. package/neural-interface/public/shared/ui-tutorial-layout.js +3 -117
  569. package/neural-interface/public/shared/ui-tutorial-preview.js +103 -0
  570. package/neural-interface/public/shared/ui-tutorial-steps.js +3 -65
  571. package/neural-interface/public/shared/ui-tutorial.css +47 -100
  572. package/neural-interface/public/shared/ui-tutorial.js +285 -509
  573. package/neural-interface/public/shared/ui-update.js +38 -2
  574. package/neural-interface/public/shared/ui-whiteboard.js +312 -59
  575. package/neural-interface/public/shared/ui-worker-end.js +5 -1
  576. package/neural-interface/public/shared/ui-workspaces.js +3 -2
  577. package/neural-interface/public/shared/view-variant.js +66 -0
  578. package/neural-interface/public/shared/whatsapp/wa-view.js +3 -3
  579. package/neural-interface/public/shared/whiteboard-list.js +172 -0
  580. package/neural-interface/public/shared/worker-end.js +10 -3
  581. package/neural-interface/public/shared/worker-questions.js +34 -0
  582. package/neural-interface/public/shared/worker-restart.js +12 -0
  583. package/neural-interface/public/sidepanel-session.html +2 -2
  584. package/neural-interface/public/variant/2d/background.js +2 -1
  585. package/neural-interface/public/variant/2d/context-menu.js +8 -2
  586. package/neural-interface/public/variant/2d/graph.js +42 -20
  587. package/neural-interface/public/variant/2d/hulls.js +14 -9
  588. package/neural-interface/public/variant/2d/lasso.js +11 -7
  589. package/neural-interface/public/variant/2d/main.js +32 -1
  590. package/neural-interface/public/variant/2d/minimap.js +11 -13
  591. package/neural-interface/public/variant/3d/main.js +69 -6
  592. package/neural-interface/public/variant/3d/map-controls.js +24 -9
  593. package/neural-interface/public/vendor/caveat/OFL.txt +93 -0
  594. package/neural-interface/public/vendor/caveat/caveat-latin-400-normal.woff2 +0 -0
  595. package/neural-interface/public/vendor/caveat/caveat-latin-700-normal.woff2 +0 -0
  596. package/neural-interface/public/vendor/caveat/caveat.css +17 -0
  597. package/neural-interface/public/vendor/caveat/manifest.json +12 -0
  598. package/neural-interface/public/vendor/yaml/LICENSE +13 -0
  599. package/neural-interface/public/vendor/yaml/manifest.json +4 -0
  600. package/neural-interface/public/vendor/yaml/yaml-2.9.1.min.js +137 -0
  601. package/neural-interface/scripts/build-vendor.mjs +32 -0
  602. package/neural-interface/server.js +4553 -2855
  603. package/neural-interface/templates/rulesets/README.md +1 -1
  604. package/neural-interface/templates/rulesets/core.md +1 -1
  605. package/neural-interface/templates/rulesets/legacy-hashes.json +23 -5
  606. package/neural-interface/templates/rulesets/manifest.json +8 -8
  607. package/npm-shrinkwrap.json +34 -12
  608. package/package.json +17 -3
  609. package/postinstall.js +9 -4
  610. package/preuninstall.js +1 -1
  611. package/setup.js +97 -322
  612. package/skills/skill-forge/SKILL.md +177 -0
  613. package/skills/skill-forge/evals/evals.json +54 -0
  614. package/skills/skill-forge/references/package.md +105 -0
  615. package/skills/skill-forge/references/review-and-recovery.md +63 -0
  616. package/skills/skill-forge/references/runtime-profiles.md +20 -0
  617. package/skills/skill-forge/references/sources-and-capabilities.md +51 -0
  618. package/skills/synabun/SKILL.md +10 -7
  619. package/skills/synabun/modules/audit.md +1 -5
  620. package/skills/synabun/modules/changelog.md +1 -5
  621. package/skills/synabun/modules/idea.md +1 -1
  622. package/skills/synabun/modules/memorize.md +6 -9
  623. package/skills/synabun/modules/schedule.md +6 -6
  624. package/mcp-server/dist/services/profiles.d.ts +0 -120
  625. package/mcp-server/dist/services/profiles.d.ts.map +0 -1
  626. package/mcp-server/dist/services/profiles.js +0 -387
  627. package/mcp-server/dist/services/profiles.js.map +0 -1
  628. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +0 -97
  629. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +0 -1
  630. package/mcp-server/dist/tools/leonardo-browser-tools.js +0 -228
  631. package/mcp-server/dist/tools/leonardo-browser-tools.js.map +0 -1
  632. package/mcp-server/dist/tools/leonardo.d.ts +0 -13
  633. package/mcp-server/dist/tools/leonardo.d.ts.map +0 -1
  634. package/mcp-server/dist/tools/leonardo.js +0 -21
  635. package/mcp-server/dist/tools/leonardo.js.map +0 -1
  636. package/mcp-server/dist/tools/profile.d.ts +0 -17
  637. package/mcp-server/dist/tools/profile.d.ts.map +0 -1
  638. package/mcp-server/dist/tools/profile.js +0 -114
  639. package/mcp-server/dist/tools/profile.js.map +0 -1
  640. package/mcp-server/src/services/profiles.ts +0 -428
  641. package/mcp-server/src/tools/leonardo-browser-tools.ts +0 -291
  642. package/mcp-server/src/tools/leonardo.ts +0 -30
  643. package/mcp-server/src/tools/profile.ts +0 -122
  644. package/neural-interface/lib/assistant-brains/opencode-browser-policy.js +0 -47
  645. package/neural-interface/lib/assistant-brains/opencode-route-gate.js +0 -62
  646. package/neural-interface/lib/mcp-profile-servers.js +0 -120
  647. package/neural-interface/public/shared/ui-tutorial-draw.js +0 -311
  648. package/skills/leonardo/SKILL.md +0 -168
  649. package/skills/leonardo/icon.svg +0 -1
  650. package/skills/leonardo/modules/browser-guide.md +0 -378
  651. package/skills/leonardo/modules/image-prompter.md +0 -283
  652. package/skills/leonardo/modules/model-advisor.md +0 -100
  653. package/skills/leonardo/modules/prompt-library.md +0 -110
  654. package/skills/leonardo/modules/style-guide.md +0 -186
  655. package/skills/leonardo/modules/video-prompter.md +0 -317
  656. /package/neural-interface/templates/rulesets/{gemini.md → antigravity.md} +0 -0
@@ -1,9 +1,11 @@
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
  // ═══════════════════════════════════════════
4
6
  //
5
7
  // A "dispatch" is one worker run launched by the central assistant: a single
6
- // provider conversation (Claude / Codex / OpenCode) that executes one task,
8
+ // provider conversation (Claude / Codex / OpenCode / Antigravity) that executes one task,
7
9
  // then stays warm for follow-up turns until the assistant completes it or the
8
10
  // idle timeout fires. It rides on NativeLoopRuntime (ledger, claims, sidepanel
9
11
  // attach) through the per-launch buildPrompt/wrapAdapter overrides, so the
@@ -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';
@@ -33,32 +43,41 @@ import { loadPlaybook } from './assistant-playbooks.js';
33
43
  import { loadStyleGuideForRun } from './style-guide/store.js';
34
44
  import { claimCheckWaived, normalizeTestDensity, testingApplies } from './assistant-test-density.js';
35
45
  import { normalizeEffort } from './effort-levels.js';
36
- import { createTurnEvidence, recordProviderEvent, evidenceSnapshot, claimEvidenceLine } from './assistant-evidence.js';
46
+ import { acpCommandOf, createTurnEvidence, recordProviderEvent, evidenceSnapshot, claimEvidenceLine } from './assistant-evidence.js';
37
47
  import { BUDGET_MAX_USD, codexUsageCostUsd, floorCents } from './assistant-budget.js';
38
- import { MIN_SPEND_USD, PROVIDER_LABELS, isFreePrice } from './assistant-budget-split.js';
48
+ import { MIN_SPEND_USD, PROVIDER_LABELS, UNPRICED_PROVIDER_REASON, isFreePrice, isUnpricedProvider } from './assistant-budget-split.js';
39
49
  import { createBudgetPlanner } from './assistant-budget-planner.js';
40
- import { CLAUDE_ESTIMATE_SOURCE, CODEX_USAGE_KEYS, TOKEN_KEYS, addTokens, codexUsageTokens, createClaudeMeter, createCodexMeter, createOpenCodeMeter, totalTokens, withTotal, zeroTokens } from './assistant-usage.js';
50
+ import { CLAUDE_ESTIMATE_SOURCE, CODEX_USAGE_KEYS, TOKEN_KEYS, addTokens, antigravityUsageTokens, 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 { validateWorkerReply } from './worker-question.js';
54
+ import { limitFromText } from '../public/shared/assistant/asst-limit.js';
43
55
 
44
- export const NATIVE_PROVIDERS = new Set(['claude-code', 'codex', 'opencode']);
56
+ // Antigravity (Google's `agy`, over ACP: lib/native-loop-providers.js createAntigravityNativeLoopAdapter) is a worker
57
+ // provider only: never a brain, no accounts, its usage reported in tokens and never priced.
58
+ export const NATIVE_PROVIDERS = new Set(['claude-code', 'codex', 'opencode', 'antigravity']);
45
59
  // awaiting_route: an un-routed dispatch held until the user picks a model on
46
60
  // 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']);
61
+ // suspended: a native worker the server suspended as it went down (its CLI
62
+ // ended); resuming: the next start continues it (resumeRun). Neither is an end.
63
+ export const ACTIVE_STATES = new Set(['queued', 'awaiting_route', 'starting', 'running', 'idle', 'awaiting_permission', 'suspended', 'resuming']);
48
64
  export const TERMINAL_STATES = new Set(['completed', 'failed', 'stopped', 'interrupted']);
49
65
  const WAITING_STATES = new Set(['queued', 'awaiting_route']);
66
+ // What a run can be suspended from at a shutdown: its worker is up, in a turn or between turns.
67
+ const SUSPENDABLE_STATES = new Set(['starting', 'running', 'idle', 'awaiting_permission']);
50
68
 
51
69
  /**
52
70
  * The run status a view reports beside its state (the agent_status contract:
53
71
  * 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.
72
+ * worker between turns (idle, awaiting_permission) is still running, and so is
73
+ * one the server suspended while it restarts; a run held for the user's model
74
+ * choice is still queued; one being resumed after a restart is starting.
56
75
  */
57
76
  export function runStatusFromState(state) {
58
77
  const value = String(state || '');
59
78
  if (TERMINAL_STATES.has(value)) return value;
60
79
  if (WAITING_STATES.has(value)) return 'queued';
61
- if (value === 'starting') return 'starting';
80
+ if (value === 'starting' || value === 'resuming') return 'starting';
62
81
  return ACTIVE_STATES.has(value) ? 'running' : null;
63
82
  }
64
83
 
@@ -69,7 +88,7 @@ export function runStatusFromState(state) {
69
88
  export const MIN_MAX_MINUTES = 20;
70
89
 
71
90
  export const DEFAULT_LIMITS = Object.freeze({
72
- perProvider: Object.freeze({ 'claude-code': 3, codex: 4, opencode: 3 }),
91
+ perProvider: Object.freeze({ 'claude-code': 3, codex: 4, opencode: 3, antigravity: 3 }),
73
92
  perCodexAccount: 2,
74
93
  perSession: 6,
75
94
  queuedPerSession: 20,
@@ -105,47 +124,13 @@ export const DEFAULT_LIMITS = Object.freeze({
105
124
  mediaRunBytes: MAX_RUN_MEDIA_BYTES,
106
125
  });
107
126
 
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
- */
127
+ /** Class defaults; the shared resolver owns automatic groups. */
118
128
  export const CLASS_DEFAULTS = Object.freeze({
119
- design: Object.freeze({ capability: 'workspace', usesBrowser: true, mcpProfile: 'browser', maxMinutes: 60 }),
129
+ design: Object.freeze({ capability: 'workspace', usesBrowser: true, maxMinutes: 60 }),
120
130
  });
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;
131
+ export function browserSettingsConflict(spec) {
132
+ try { return browserToolsConflict(spec); }
133
+ catch (error) { throw new DispatchError(error.code, error.message, { status: error.status || 400, problems: error.problems }); }
149
134
  }
150
135
  /** `spec` with its class's defaults filled in where it leaves a field out. */
151
136
  export function withClassDefaults(spec, taskClass) {
@@ -160,7 +145,6 @@ export function withClassDefaults(spec, taskClass) {
160
145
  if (!defaults) return out;
161
146
  for (const [key, value] of Object.entries(defaults)) {
162
147
  if (!(out[key] === undefined || out[key] === null || out[key] === '')) continue;
163
- if (key === 'mcpProfile' && out.usesBrowser === false) continue;
164
148
  out[key] = value;
165
149
  }
166
150
  return out;
@@ -218,6 +202,43 @@ function clip(value, max = 160) {
218
202
  }
219
203
  function isDirectory(path) { try { return statSync(path).isDirectory(); } catch { return false; } }
220
204
 
205
+ /** Internal Skills workers own their runtime selection and confinement. */
206
+ function isSkillsRun(entry) { return ['skill-eval', 'skill-draft'].includes(entry?.confinement?.kind); }
207
+
208
+ function requireConfinementProvider(entry) {
209
+ if (entry.confinement && entry.confinement.provider !== entry.provider) {
210
+ 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 });
211
+ }
212
+ }
213
+
214
+ /** A skill-evaluation run: its own prompt, result, memory and escalation rules. */
215
+ export function isEvalRun(entry) { return entry?.confinement?.kind === EVAL_CONFINEMENT; }
216
+
217
+ /**
218
+ * Keep an evaluation run's tool calls (one entry per call id: a later update of the same call replaces its earlier
219
+ * one, so a command's "done" with its exit code wins over its "started") and the catalog its init named. Bounded.
220
+ */
221
+ export function recordEvalCalls(liveState, payload = {}) {
222
+ const init = evalInitOf(payload);
223
+ if (init && !liveState.evalInit) liveState.evalInit = init;
224
+ const calls = evalToolCallsOf(payload);
225
+ if (!calls.length) return;
226
+ if (!liveState.evalCalls) { liveState.evalCalls = []; liveState.evalCallIndex = new Map(); }
227
+ for (const call of calls) {
228
+ const key = call.id ? `${call.tool}:${call.id}` : null;
229
+ const at = key ? liveState.evalCallIndex.get(key) : undefined;
230
+ if (at !== undefined) {
231
+ const prev = liveState.evalCalls[at];
232
+ // An update without the input (an OpenCode part's later state) keeps the input seen first.
233
+ liveState.evalCalls[at] = { ...prev, ...call, input: call.input && Object.keys(call.input).length ? call.input : prev.input };
234
+ continue;
235
+ }
236
+ if (liveState.evalCalls.length >= 300) return;
237
+ if (key) liveState.evalCallIndex.set(key, liveState.evalCalls.length);
238
+ liveState.evalCalls.push(call);
239
+ }
240
+ }
241
+
221
242
  /** One-line human summary of a provider stream event (used for transcript tails). */
222
243
  export function summarizeProviderEvent(payload = {}) {
223
244
  const event = payload.event || {};
@@ -226,6 +247,7 @@ export function summarizeProviderEvent(payload = {}) {
226
247
  if (eventType === 'synabun.permission_request') return `permission requested: ${event.toolName || event.kind || 'tool'} ${clip(JSON.stringify(event.input || {}), 120)}`;
227
248
  if (eventType === 'synabun.permission_resolved') return `permission ${event.behavior || 'resolved'}: ${event.toolName || ''}`;
228
249
  if (eventType === 'synabun.error') return `error: ${clip(event.message, 200)}`;
250
+ if (eventType === 'synabun.confinement') return describeConfinementEvent(event);
229
251
  if (payload.provider === 'claude-code') {
230
252
  if (event.type === 'assistant') {
231
253
  const blocks = Array.isArray(event.message?.content) ? event.message.content : [];
@@ -279,9 +301,55 @@ export function summarizeProviderEvent(payload = {}) {
279
301
  if (/question\.asked/i.test(eventType)) return `question asked: ${clip((event.questions || []).map((q) => q.question).join(' | '), 200)}`;
280
302
  return null;
281
303
  }
304
+ if (payload.provider === 'antigravity') {
305
+ // eventType is the ACP session/update kind; event the update as the agent sent it.
306
+ if (eventType === 'agent_message_chunk') {
307
+ const text = event.content?.type === 'text' ? event.content.text : '';
308
+ return text && String(text).trim() ? `assistant: ${clip(text, 200)}` : null;
309
+ }
310
+ if (eventType === 'tool_call') return `${acpToolNoun(event)} started: ${acpToolDetail(event)}`;
311
+ if (eventType === 'tool_call_update') {
312
+ // Only the end of a call: its start was the tool_call line, its progress says nothing new.
313
+ if (event.status !== 'completed' && event.status !== 'failed') return null;
314
+ return `${acpToolNoun(event)} ${event.status === 'failed' ? 'failed' : 'done'}: ${acpToolDetail(event)}`;
315
+ }
316
+ if (eventType === 'plan') {
317
+ const entries = Array.isArray(event.entries) ? event.entries : [];
318
+ if (!entries.length) return null;
319
+ const done = entries.filter((step) => step?.status === 'completed').length;
320
+ const current = entries.find((step) => step?.status === 'in_progress');
321
+ return `plan: ${done}/${entries.length} done${current?.content ? `, now ${clip(current.content, 120)}` : ''}`;
322
+ }
323
+ if (eventType === 'synabun.provider_exit') {
324
+ const how = event.signal ? ` (${event.signal})` : event.code !== null && event.code !== undefined ? ` (code ${event.code})` : '';
325
+ return `error: Antigravity exited${how}${event.error ? `: ${clip(event.error, 160)}` : ''}`;
326
+ }
327
+ return null;
328
+ }
282
329
  return null;
283
330
  }
284
331
 
332
+ /** What an ACP tool call is, for a transcript line: command, file_change or tool (Codex's words). */
333
+ function acpToolNoun(call = {}) {
334
+ const kind = String(call.kind || '');
335
+ // agy names an MCP call call_mcp_tool, kind `execute`: it is a tool, never a command.
336
+ if (call.name === 'call_mcp_tool' || (call.rawInput && typeof call.rawInput === 'object' && call.rawInput.ServerName && call.rawInput.ToolName)) return 'tool';
337
+ if (kind === 'execute') return 'command';
338
+ if (kind === 'edit' || kind === 'delete' || kind === 'move') return 'file_change';
339
+ return 'tool';
340
+ }
341
+ /** The command, the paths or the title of an ACP tool call; its id when an update carries none of them. */
342
+ function acpToolDetail(call = {}) {
343
+ const input = call.rawInput && typeof call.rawInput === 'object' ? call.rawInput : {};
344
+ if (input.ServerName && input.ToolName) return clip(`${input.ServerName}_${input.ToolName}`, 140);
345
+ const command = call.kind === 'execute' ? acpCommandOf(call) : '';
346
+ if (command) return clip(command, 140);
347
+ const paths = (Array.isArray(call.locations) ? call.locations : []).map((location) => location?.path).filter((path) => typeof path === 'string' && path);
348
+ if (paths.length && call.kind !== 'execute' && call.kind !== 'fetch') return clip(paths.join(', '), 160);
349
+ if (call.title) return clip(call.title, 140);
350
+ return call.toolCallId ? `call ${String(call.toolCallId).slice(0, 12)}` : (call.kind || 'tool');
351
+ }
352
+
285
353
  // The old five-cap keys a config file may still hold: never limits (the budget planner owns the money).
286
354
  const LEGACY_BUDGET_LIMITS = new Set(['defaultBudgetUsd', 'maxBudgetUsd', 'sessionWarnUsd', 'sessionSoftBudgetUsd', 'sessionHardBudgetUsd', 'brainBudgetUsd', 'budgetVersion', 'budgetUpdatedAt']);
287
355
  function mergeLimits(overrides = {}) {
@@ -330,9 +398,12 @@ function parseJsonObject(text) {
330
398
  */
331
399
  export function createAssistantDispatcher({
332
400
  getRuntime,
401
+ resolveExternalTools = () => ({ names: [], disabled: [] }),
333
402
  dataDir,
334
403
  loopDir = null,
335
404
  broadcastSync = () => {},
405
+ observeSkillEvent = () => {},
406
+ completeSkillUsage = () => {},
336
407
  log = () => {},
337
408
  PACKAGE_ROOT = process.cwd(),
338
409
  findCodexAccount = () => null,
@@ -345,9 +416,13 @@ export function createAssistantDispatcher({
345
416
  codexAccounts = null,
346
417
  accountUsage = null,
347
418
  codexWorkerAccount = () => null,
348
- isValidMcpProfileValue = () => true,
349
- normalizeMcpProfileName = (value) => value,
350
- readActiveMcpProfile = () => 'full',
419
+ // The Protect workers setting (lib/worker-guard-setting.js) as a run starts: 'enforce' | 'warn' | 'off'.
420
+ workerGuard = () => WORKER_GUARD_DEFAULT,
421
+ // SynaBun's own port: a guarded worker's shell may not call it (lib/worker-confinement.js).
422
+ serverPort = null,
423
+ // The ports SynaBun keeps for its OpenCode serves ({ from, to }): a guarded worker's shell calls none of them.
424
+ localPortRange = null,
425
+ toolListOptions = () => ({}),
351
426
  acquireLoopBrowserAndTab = null,
352
427
  releaseSharedBrowserTab = async () => {},
353
428
  reconcileKeepAwake = () => {},
@@ -398,6 +473,9 @@ export function createAssistantDispatcher({
398
473
  // The session's Test density (assistant-test-density.js): (assistantSessionId) => level | null.
399
474
  // Read when a prompt is composed; null or an unknown value is the default level.
400
475
  testDensity = () => null,
476
+ // After a restart, resolves once this server answers its workers (a resumed CLI connects to SynaBun's MCP as it
477
+ // starts): runs are resumed only then. null: at once.
478
+ serverReady = null,
401
479
  } = {}) {
402
480
  if (typeof getRuntime !== 'function') throw new Error('createAssistantDispatcher requires getRuntime()');
403
481
  if (!dataDir) throw new Error('createAssistantDispatcher requires dataDir');
@@ -419,6 +497,8 @@ export function createAssistantDispatcher({
419
497
  const registryPath = resolve(dataDir, 'assistant-dispatches.json');
420
498
  // What image / video creation runs generated, and design runs' screenshots: <dataDir>/media/<runId>/<n>-<name>.
421
499
  const mediaRoot = resolve(dataDir, 'media');
500
+ // A guarded worker's own scratch folder (lib/worker-confinement.js): <dataDir>/scratch/<runId>, removed when empty.
501
+ const scratchRoot = resolve(dataDir, 'scratch');
422
502
  /** A known task class, else null. */
423
503
  const knownClass = (value) => (Object.prototype.hasOwnProperty.call(TASK_CLASS_META, String(value || '')) ? String(value) : null);
424
504
  const entries = new Map(); // runId → entry (persisted shape)
@@ -438,29 +518,95 @@ export function createAssistantDispatcher({
438
518
  let persistTimer = null;
439
519
  let stalledTimer = null;
440
520
  let unsubscribeRuntime = null;
521
+ // A server restart: the runs this start resumes (resumeOnBoot), and the ones it ends whose end is said once the
522
+ // Assistant listens (both filled by loadRegistry). Follow-ups sent to a run while it is away wait here for it.
523
+ const bootResumes = [];
524
+ const bootInterrupts = [];
525
+ const awaitingResume = new Map(); // runId → follow-up items (sendTurn) for a suspended or resuming run
526
+ const resumeInProgress = new Set(); // runIds resumeRun works on (they hold their slot; the others waiting do not)
527
+ // The server is going down (suspendForShutdown ran): a resume under way stops where it is, for the next start.
528
+ let closing = false;
441
529
 
442
530
  // ── persistence ────────────────────────────────────────────────────────────
531
+ /** Why the original provider account of a run cannot be used now ('' when it can, or the provider has no accounts). */
532
+ function providerAccountUnusable(accountId, provider = 'claude-code', entry = null) {
533
+ // OpenCode and Antigravity sign in inside their own CLI: SynaBun keeps no account of theirs.
534
+ if (provider !== 'codex' && provider !== 'claude-code') return '';
535
+ if (provider === 'codex') {
536
+ const home = codexAccounts?.homeFor?.(accountId);
537
+ if (home && entry?.codexHome && resolve(home) !== resolve(entry.codexHome)) return 'its CODEX_HOME changed';
538
+ return codexAccounts?.unusableReason?.(accountId) || '';
539
+ }
540
+ try { return typeof claudeAccounts?.unusableReason === 'function' ? String(claudeAccounts.unusableReason(accountId || 'default') || '') : ''; } catch (error) { return error?.message || 'it could not be read'; }
541
+ }
443
542
  function loadRegistry() {
444
543
  const rows = readJson(registryPath, []);
445
544
  if (!Array.isArray(rows)) return;
446
545
  for (const row of rows) {
447
546
  if (!row?.runId) continue;
448
- const entry = { ...row };
547
+ let entry = { ...row };
548
+ try {
549
+ entry = readStoredToolListRecord(row, toolListOptions());
550
+ entry.toolsRequested = row.toolsRequested !== undefined ? row.toolsRequested : storedToolList(row, toolListOptions());
551
+ dispatchTools({ tools: entry.toolsRequested ?? undefined });
552
+ if (row.rawSpec) entry.rawSpec = toolListRecord(row.rawSpec, toolListOptions());
553
+ if (!entry.tools) entry.tools = launchTools(dispatchTools({ ...entry, tools: entry.toolsRequested ?? undefined }));
554
+ } catch (error) {
555
+ entry.tools = []; entry.toolsRequested = [];
556
+ entry.toolListError = { code: error.code, message: error.message, original: row.tools ?? row.mcpProfile };
557
+ delete entry.mcpProfile; delete entry.rawSpec;
558
+ entry.state = 'failed'; entry.completionReason = 'tools_invalid';
559
+ }
449
560
  // A failure cause being judged when the server went down is never coming.
450
561
  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).
562
+ // When the server was last seen with the run live: its last update, or the last beat() after it.
563
+ const lastSeen = Math.max(Date.parse(entry.updatedAt || '') || 0, Math.min(now(), Date.parse(entry.aliveAt || '') || 0));
564
+ delete entry.aliveAt;
565
+ // A turn cut off by the restart worked until then (as far as anything shows).
460
566
  if (entry.workingSince) {
461
- entry.workMs = workUsedMs(entry, Math.max(Date.parse(entry.workingSince) || 0, Date.parse(entry.updatedAt || '') || 0));
567
+ entry.workMs = workUsedMs(entry, Math.max(Date.parse(entry.workingSince) || 0, lastSeen));
462
568
  entry.workingSince = null;
463
569
  }
570
+ // A run the last shutdown could not suspend ended then (suspendForShutdown): it is said now, as a refusal here is.
571
+ if (entry.refusedAtShutdown) {
572
+ delete entry.refusedAtShutdown;
573
+ if (entry.resumeRefused && TERMINAL_STATES.has(entry.state)) bootInterrupts.push(entry.runId);
574
+ }
575
+ if (ACTIVE_STATES.has(entry.state)) {
576
+ // What the restart cut off: a run the last shutdown suspended, one a crash left working (no shutdown ran), or
577
+ // one a restart caught while it resumed. A native worker that may continue is resumed (resumeOnBoot);
578
+ // everything else ends as interrupted, as every cut-off run did before, with why in its notes.
579
+ const candidate = entry.state === 'suspended' || entry.state === 'resuming' || SUSPENDABLE_STATES.has(entry.state);
580
+ // A crash suspended nothing: such a run was left when the server was last seen with it.
581
+ const crashed = candidate && entry.state !== 'suspended' && entry.state !== 'resuming';
582
+ const leftAt = crashed ? iso(lastSeen || now()) : null;
583
+ const why = candidate
584
+ ? resumeRefusal(crashed ? { ...entry, suspendedAt: leftAt } : entry, { now: now(), workLeftMs: workLeftMs(entry), isDirectory, accountUnusable: providerAccountUnusable })
585
+ : null;
586
+ if (candidate && !why) {
587
+ if (crashed) {
588
+ entry.suspendedAt = leftAt;
589
+ entry.suspendReason = 'server_restart';
590
+ entry.crashed = true;
591
+ }
592
+ entry.state = 'resuming';
593
+ entry.turnState = 'resuming';
594
+ entry.pending = [];
595
+ bootResumes.push(entry.runId);
596
+ } else {
597
+ entry.state = 'interrupted';
598
+ entry.completionReason = entry.completionReason || 'server_restart';
599
+ entry.finishedAt = entry.finishedAt || iso(now());
600
+ entry.pending = [];
601
+ entry.turnState = 'terminal';
602
+ if (why) {
603
+ entry.notes = [...(Array.isArray(entry.notes) ? entry.notes : []), `not resumed: ${why}`];
604
+ entry.resumeRefused = why;
605
+ bootInterrupts.push(entry.runId);
606
+ }
607
+ closeUsageState(entry);
608
+ }
609
+ }
464
610
  entries.set(entry.runId, entry);
465
611
  }
466
612
  }
@@ -480,6 +626,21 @@ export function createAssistantDispatcher({
480
626
  persistTimer = setTimeout(() => { persistTimer = null; write(); }, 200);
481
627
  persistTimer.unref?.();
482
628
  }
629
+ /**
630
+ * Every minute, on each live run (a worker up or about to be): the server is alive with it now (aliveAt, saved, not an
631
+ * update). After a crash loadRegistry measures from it, which can be long after the run's last update (a warm worker
632
+ * idle for a follow-up, a long silent turn).
633
+ */
634
+ function beat() {
635
+ const at = iso(now());
636
+ let alive = 0;
637
+ for (const entry of entries.values()) {
638
+ if (!ACTIVE_STATES.has(entry.state) || WAITING_STATES.has(entry.state)) continue;
639
+ entry.aliveAt = at;
640
+ alive += 1;
641
+ }
642
+ if (alive) persist();
643
+ }
483
644
 
484
645
  // ── views + events ─────────────────────────────────────────────────────────
485
646
  /** 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 +669,13 @@ export function createAssistantDispatcher({
508
669
  const startedAt = entry.startedAt || entry.createdAt;
509
670
  const end = entry.finishedAt ? new Date(entry.finishedAt).getTime() : at;
510
671
  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;
672
+ // The brief goes to the worker's prompt; views name it only. The meters' state stays inside, and so does what a
673
+ // resume after a restart needs (resumeInfo: the CLI's pid, the turn in progress); `resumes` counts them.
674
+ const { rawSpec: _rawSpec, brief: _brief, usageState: _usageState, resumeInfo: _resumeInfo, crashed: _crashed, refusedAtShutdown: _refusedAtShutdown, aliveAt: _aliveAt, ...rest } = entry;
513
675
  return {
514
676
  ...rest,
677
+ tools: toolsView({ ...entry, tools: runtimeRun?.tools ?? entry.tools }),
678
+ resumes: Number(entry.resumes) || 0,
515
679
  briefId: entry.brief?.briefId || null,
516
680
  taskId: entry.taskId || null,
517
681
  // The task of the turn in progress (or the last one started): a follow-up may work for a
@@ -545,9 +709,11 @@ export function createAssistantDispatcher({
545
709
  status: runtimeRun.status, turnState: runtimeRun.turnState, stoppedReason: runtimeRun.stoppedReason,
546
710
  error: runtimeRun.error, claimedBy: runtimeRun.claimedBy, providerSessionId: runtimeRun.providerSessionId,
547
711
  providerThreadId: runtimeRun.providerThreadId,
712
+ ...((runtimeRun.antigravityHome || entry.resumeInfo?.antigravityHome || entry.antigravityHome) ? { antigravityHome: runtimeRun.antigravityHome || entry.resumeInfo?.antigravityHome || entry.antigravityHome } : {}),
548
713
  } : (entry.runtime || null),
549
714
  providerSessionId: runtimeRun?.providerSessionId || entry.providerSessionId || null,
550
715
  providerThreadId: runtimeRun?.providerThreadId || entry.providerThreadId || null,
716
+ ...((runtimeRun?.antigravityHome || entry.resumeInfo?.antigravityHome || entry.antigravityHome) ? { antigravityHome: runtimeRun?.antigravityHome || entry.resumeInfo?.antigravityHome || entry.antigravityHome } : {}),
551
717
  };
552
718
  }
553
719
  function safeRuntime() { try { return getRuntime(); } catch { return null; } }
@@ -594,6 +760,7 @@ export function createAssistantDispatcher({
594
760
  touch(entry);
595
761
  persist({ force: TERMINAL_STATES.has(entry.state) });
596
762
  const payload = { type: 'assistant:dispatch', reason, run: view(entry), ...extra };
763
+ if (['turn_completed', 'idle', 'failed', 'stopped', 'completed'].includes(reason)) { try { completeSkillUsage(entry); } catch {} }
597
764
  try { broadcastSync(payload); } catch {}
598
765
  for (const listener of listeners) { try { listener(payload); } catch {} }
599
766
  notifyWaiters(entry.runId, reason);
@@ -657,7 +824,7 @@ export function createAssistantDispatcher({
657
824
  }
658
825
 
659
826
  /** `taskClass`: the run's class (its route's, else the spec's); image / video creation needs a model that makes the medium. */
660
- function normalizeSpec(spec = {}, { catalogValue = null, codexAskFallback = false, taskClass = null } = {}) {
827
+ function normalizeSpec(spec = {}, { catalogValue = null, taskClass = null } = {}) {
661
828
  const provider = String(spec.provider || '').trim().toLowerCase();
662
829
  if (!NATIVE_PROVIDERS.has(provider)) throw new DispatchError('INVALID_PROVIDER', `provider must be one of ${[...NATIVE_PROVIDERS].join(', ')}`);
663
830
  const task = String(spec.task || '').trim();
@@ -666,14 +833,8 @@ export function createAssistantDispatcher({
666
833
  const cwd = String(spec.cwd || spec.project || PACKAGE_ROOT).trim();
667
834
  if (!cwd || !isDirectory(cwd)) throw new DispatchError('CWD_INVALID', `cwd is not a directory: ${cwd}`);
668
835
  let permissionPolicy = normalizePermissionPolicy(spec.permissionPolicy);
669
- const capability = normalizeCapability(spec.capability || 'full');
836
+ let capability = normalizeCapability(spec.capability || 'full');
670
837
  const notes = [];
671
- if (provider === 'codex' && permissionPolicy === 'ask') {
672
- // A route card may move an 'ask' dispatch onto Codex: degrade instead of failing.
673
- if (!codexAskFallback) throw new DispatchError('PERMISSION_POLICY_UNSUPPORTED', 'Codex dispatches cannot use permissionPolicy "ask" (the Codex SDK has no approval channel). Use "restricted" for a workspace-write sandbox or "auto".', { suggested: 'restricted' });
674
- permissionPolicy = 'restricted';
675
- notes.push('permission policy "ask" became "restricted" (Codex has no approval channel)');
676
- }
677
838
  // "auto" (or nothing) = let routing pick / use the provider default.
678
839
  let model = spec.model && String(spec.model).trim() && String(spec.model).trim().toLowerCase() !== 'auto' ? String(spec.model).trim() : null;
679
840
  let modelInfo = null;
@@ -687,6 +848,7 @@ export function createAssistantDispatcher({
687
848
  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
849
  }
689
850
  if (!model && catalogValue && defaultIsHidden(catalogValue, provider)) {
851
+ if (isSkillsRun(spec)) throw new DispatchError('MODEL_DISABLED', `The ${provider} default model is disabled; the Skills run cannot substitute another model.`, { status: 400 });
690
852
  // No model = the provider default, which the user hid: pin the first visible model.
691
853
  const pinned = defaultModelRow(catalogValue, provider);
692
854
  if (pinned) {
@@ -721,16 +883,46 @@ export function createAssistantDispatcher({
721
883
  // an unsupported level becomes the nearest one it runs, recorded on the run.
722
884
  const checked = normalizeEffort({ provider, model, effort: spec.effort, catalog: catalogValue });
723
885
  const effortCorrection = checked.corrected || null;
886
+ 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
887
  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
888
  // The run's own browser settings must agree on the provider it gets, whatever its class.
726
889
  const conflict = browserSettingsConflict(spec, provider);
727
890
  if (conflict) throw new DispatchError('BROWSER_SETTINGS_CONFLICT', conflict, { status: 400 });
728
- const usesBrowser = !!spec.usesBrowser || turnsBrowserOn(spec.mcpProfile);
891
+ const selection = dispatchTools({ ...spec, taskClass, provider });
892
+ const externalServers = resolveExternalTools({ ...spec, provider }, selection.servers);
893
+ const usesBrowser = spec.usesBrowser === true || selection.groups.some(isBrowserGroup);
729
894
  const usesComputer = spec.usesComputer === true || spec.usesComputer === 'true';
730
895
  const tags = Array.isArray(spec.tags) ? spec.tags.map((tag) => String(tag).trim()).filter(Boolean).slice(0, 10) : [];
731
896
  if ((usesBrowser || usesComputer) && capability === 'full' && !tags.includes('user-authorized-full')) {
732
897
  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
898
  }
899
+ // A skill-drafting run (lib/skills/creation.js): held to its folder by lib/skills/draft-confinement.js, each
900
+ // provider the way it can (draftConfinementSupport); one that cannot is refused, never started unconfined.
901
+ // It reads no page, drives no computer and is never asked anything.
902
+ let confinement = null;
903
+ if (spec.confinement !== undefined && spec.confinement !== null) {
904
+ if (spec.confinement.provider) requireConfinementProvider({ provider, confinement: spec.confinement });
905
+ if (spec.confinement?.kind === EVAL_CONFINEMENT) {
906
+ // A skill-evaluation run (lib/skills/evals.js): held to its job folder by lib/skills/eval-confinement.js, the
907
+ // same way on every provider that can hold it; its task is sent as it is (the case's own words), and it has
908
+ // no Result contract, no Jev reading, no result retry and no escalation.
909
+ const support = evalConfinementSupport(provider);
910
+ if (!support.ok) throw new DispatchError('CONFINEMENT_UNSUPPORTED', `A skill-evaluation run cannot run on ${provider}: ${support.reason}`, { status: 400 });
911
+ if (usesBrowser || usesComputer) throw new DispatchError('CONFINEMENT_UNSUPPORTED', 'A skill-evaluation run reads no page and drives no computer.', { status: 400 });
912
+ const role = EVAL_ROLES.includes(spec.confinement.role) ? spec.confinement.role : null;
913
+ if (!role) throw new DispatchError('CONFINEMENT_INVALID', 'Unknown evaluation role.', { status: 400 });
914
+ const skill = typeof spec.confinement.skill === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(spec.confinement.skill) ? spec.confinement.skill : null;
915
+ confinement = { kind: EVAL_CONFINEMENT, provider, role, shell: role === 'trial' && spec.confinement.shell === true, skill };
916
+ } else {
917
+ if (spec.confinement?.kind !== 'skill-draft') throw new DispatchError('CONFINEMENT_INVALID', 'Unknown confinement.', { status: 400 });
918
+ const support = draftConfinementSupport(provider);
919
+ if (!support.ok) throw new DispatchError('CONFINEMENT_UNSUPPORTED', `A skill-drafting run cannot run on ${provider}: ${support.reason}`, { status: 400 });
920
+ if (usesBrowser || usesComputer) throw new DispatchError('CONFINEMENT_UNSUPPORTED', 'A skill-drafting run reads no page and drives no computer.', { status: 400 });
921
+ confinement = { kind: 'skill-draft', provider };
922
+ }
923
+ capability = 'workspace';
924
+ permissionPolicy = 'auto';
925
+ }
734
926
  if (usesComputer) {
735
927
  if (!desktop || !desktop.isSupported?.()) throw new DispatchError('COMPUTER_UNAVAILABLE', 'Computer use is not available on this machine (macOS only).', { status: 409 });
736
928
  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 +942,6 @@ export function createAssistantDispatcher({
750
942
  const sightRefusal = designRefusal(catalogValue, provider, model, taskClass);
751
943
  if (sightRefusal) throw sightRefusal;
752
944
  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
945
  let codexAccount = null;
761
946
  let claudeAccount = null;
762
947
  const accountId = spec.accountId || spec.codexAccountId || spec.claudeAccountId || null;
@@ -809,7 +994,7 @@ export function createAssistantDispatcher({
809
994
  model, modelContextWindow, pricingModel,
810
995
  effort: checked.effort || null, effortCorrection,
811
996
  agent, modelInfo, notes,
812
- mcpProfile, permissionPolicy, capability, usesBrowser, usesComputer,
997
+ toolsRequested: selection.requested, tools: launchTools(selection), externalMcpVersion: 2, externalServers, permissionPolicy, capability, usesBrowser, usesComputer, confinement,
813
998
  codexAccountId: codexAccount?.id || null,
814
999
  codexHome: codexAccount ? (codexAccount.home || CODEX_DEFAULT_HOME) : null,
815
1000
  claudeAccountId: claudeAccount?.id || (provider === 'claude-code' ? 'default' : null),
@@ -826,21 +1011,39 @@ export function createAssistantDispatcher({
826
1011
  }
827
1012
 
828
1013
  // ── rails ──────────────────────────────────────────────────────────────────
1014
+ // A suspended run holds no slot (its worker is gone with the server); one being resumed holds its own again.
829
1015
  function activeEntries(filter = () => true) {
830
- return [...entries.values()].filter((entry) => ACTIVE_STATES.has(entry.state) && !WAITING_STATES.has(entry.state) && filter(entry));
1016
+ return [...entries.values()].filter((entry) => ACTIVE_STATES.has(entry.state) && !WAITING_STATES.has(entry.state) && entry.state !== 'suspended' && filter(entry));
1017
+ }
1018
+ /**
1019
+ * The runs that hold a provider's slots, as a queued note says them: every Assistant session's runs
1020
+ * count (a warm worker waiting for a follow-up too; never a brain, which is no run), so a slot taken
1021
+ * in another session is named as such.
1022
+ */
1023
+ function slotHolders(holders, assistantSessionId) {
1024
+ 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)' : ''}`);
1025
+ return `held by ${named.join(', ')}`;
831
1026
  }
832
- function railBlock(spec, assistantSessionId) {
1027
+ /**
1028
+ * `except`: a run being resumed, which counts the others but not itself, nor the runs still waiting for their resume
1029
+ * (resumeRun has not taken them up): runs that wait for a slot never hold each other back. A new dispatch counts them,
1030
+ * so it waits behind them.
1031
+ */
1032
+ function railBlock(spec, assistantSessionId, { except = null } = {}) {
1033
+ const others = (filter) => activeEntries((e) => e.runId !== except && (!except || e.state !== 'resuming' || resumeInProgress.has(e.runId)) && filter(e));
833
1034
  // 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)';
1035
+ if (spec.usesComputer && others((e) => e.usesComputer).length >= 1) return 'another computer-use run is active (one at a time)';
835
1036
  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})`;
1037
+ const providerRuns = others((e) => e.provider === spec.provider);
1038
+ if (providerRuns.length >= perProvider) return `provider ${spec.provider} at its concurrency limit (${perProvider}), ${slotHolders(providerRuns, assistantSessionId)}`;
1039
+ const accountRuns = spec.provider === 'codex' ? others((e) => e.provider === 'codex' && e.accountId === spec.accountId) : [];
1040
+ if (spec.provider === 'codex' && accountRuns.length >= limits.perCodexAccount) {
1041
+ return `Codex account ${spec.accountId} at its concurrency limit (${limits.perCodexAccount}), ${slotHolders(accountRuns, assistantSessionId)}`;
839
1042
  }
840
- if (assistantSessionId && activeEntries((e) => e.assistantSessionId === assistantSessionId).length >= limits.perSession) {
1043
+ if (assistantSessionId && others((e) => e.assistantSessionId === assistantSessionId).length >= limits.perSession) {
841
1044
  return `assistant session at its concurrency limit (${limits.perSession})`;
842
1045
  }
843
- if (spec.workflowId && activeEntries((e) => e.workflowId === spec.workflowId).length >= limits.maxFanOut) {
1046
+ if (spec.workflowId && others((e) => e.workflowId === spec.workflowId).length >= limits.maxFanOut) {
844
1047
  return `workflow ${spec.workflowId} at its fan-out limit (${limits.maxFanOut})`;
845
1048
  }
846
1049
  return null;
@@ -914,10 +1117,11 @@ export function createAssistantDispatcher({
914
1117
  /**
915
1118
  * Why a run's spend could not be held to the budget (no price for its model), else null. Enforced
916
1119
  * only with a budget and a price source (`pricing`): Claude reports cost, Codex needs a list price,
917
- * OpenCode a catalog price (a free model is fine).
1120
+ * OpenCode a catalog price (a free model is fine), Antigravity never has one (tokens, not prices).
918
1121
  */
919
1122
  function unpricedReason(entry) {
920
1123
  if (!pricing || entry.provider === 'claude-code' || !limitedNow()) return null;
1124
+ if (isUnpricedProvider(entry.provider)) return UNPRICED_PROVIDER_REASON;
921
1125
  const model = entry.pricingModel || entry.model || null;
922
1126
  if (entry.provider === 'codex') {
923
1127
  let price = null;
@@ -929,7 +1133,10 @@ export function createAssistantDispatcher({
929
1133
  function refuseUnpriced(entry) {
930
1134
  const why = unpricedReason(entry);
931
1135
  if (!why) return;
932
- throw new DispatchError('BUDGET_UNPRICED', `Not dispatched: ${why}, so the session budget could not hold its spend. Pick a priced model or a free one (agent_catalog shows prices).`, { status: 409, provider: entry.provider, model: entry.pricingModel || entry.model || null });
1136
+ const next = isUnpricedProvider(entry.provider)
1137
+ ? `Dispatch it to another provider (agent_catalog shows prices), or ask the user to switch the session budget to No limit (Assistant → Budget) to run it on ${providerName(entry.provider)}.`
1138
+ : 'Pick a priced model or a free one (agent_catalog shows prices).';
1139
+ throw new DispatchError('BUDGET_UNPRICED', `Not dispatched: ${why}, so the session budget could not hold its spend. ${next}`, { status: 409, provider: entry.provider, model: entry.pricingModel || entry.model || null });
933
1140
  }
934
1141
  const providerName = (provider) => PROVIDER_LABELS[provider] || provider;
935
1142
  /** The session budget's words for a provider whose share is used up ("codex share used up: $4.00 of $4.00 spent"). */
@@ -989,16 +1196,29 @@ export function createAssistantDispatcher({
989
1196
  /**
990
1197
  * The Claude CLI's own stop (--max-budget-usd) for a Claude worker under a budget: the run's ceiling,
991
1198
  * 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).
1199
+ * provider's share between model calls. null under No limit (no CLI cap at all). `spentUsd`: what the run
1200
+ * spent already (a resume after a restart): its ceiling less that is what it may still spend.
993
1201
  */
994
- function claudeBackstop(entry) {
1202
+ function claudeBackstop(entry, { spentUsd = 0 } = {}) {
995
1203
  if (!limitedNow()) return null;
996
1204
  const cap = Number(entry.budgetUsd) || 0;
1205
+ const capLeft = cap > 0 ? Math.max(0, cap - Math.max(0, Number(spentUsd) || 0)) : Infinity;
997
1206
  const left = entry.assistantSessionId ? (budget.coverage(entry.assistantSessionId)?.leftUsd ?? null) : null;
998
- const value = Math.min(cap > 0 ? cap : Infinity, left === null ? Infinity : left);
1207
+ const value = Math.min(capLeft, left === null ? Infinity : left);
999
1208
  if (!Number.isFinite(value)) return cap > 0 ? cap : null;
1000
1209
  return Math.max(MIN_SPEND_USD, floorCents(value) / 100);
1001
1210
  }
1211
+ /**
1212
+ * The Claude CLI's stop for a run: claudeBackstop() under a budget; a skill-drafting run (confined) also stops at its
1213
+ * own ceiling whatever the session's mode, No limit included (its budget_usd, or what No limit set aside).
1214
+ */
1215
+ function claudeRunCap(entry, options = {}) {
1216
+ const backstop = claudeBackstop(entry, options);
1217
+ if (!entry.confinement) return backstop;
1218
+ const own = Number(entry.budgetUsd) || Number(entry.budgetIgnoredUsd) || 0;
1219
+ const caps = [backstop, own > 0 ? own : null].filter((v) => Number(v) > 0);
1220
+ return caps.length ? Math.min(...caps) : null;
1221
+ }
1002
1222
  /**
1003
1223
  * After money moved in a session: one warning at 80 % of the budget (per budget amount), and at
1004
1224
  * the budget every live, queued and held run of the session stops. Nothing under No limit.
@@ -1058,8 +1278,9 @@ export function createAssistantDispatcher({
1058
1278
  return {
1059
1279
  provider: spec.provider, model: spec.model, effort: spec.effort, effortCorrection: spec.effortCorrection || null, agent: spec.agent || null, accountId: spec.accountId,
1060
1280
  codexAccountId: spec.codexAccountId, codexHome: spec.codexHome, claudeAccountId: spec.claudeAccountId, claudeConfigDir: spec.claudeConfigDir,
1061
- cwd: spec.cwd, project: spec.project, mcpProfile: spec.mcpProfile,
1281
+ cwd: spec.cwd, project: spec.project, toolsRequested: spec.toolsRequested, tools: spec.tools, externalMcpVersion: 2, externalServers: spec.externalServers,
1062
1282
  permissionPolicy: spec.permissionPolicy, capability: spec.capability, usesBrowser: spec.usesBrowser, usesComputer: !!spec.usesComputer,
1283
+ confinement: spec.confinement || null,
1063
1284
  title: spec.title, task: spec.task, context: spec.context, tags: spec.tags,
1064
1285
  maxMinutes: spec.maxMinutes, budgetUsd: spec.budgetUsd ?? null, freeModel: spec.freeModel === true, idleTimeoutMs: spec.idleTimeoutMs, maxTurns: spec.maxTurns,
1065
1286
  outputSchema: spec.outputSchema, focus: spec.focus, workflowId: spec.workflowId, parentRunId: spec.parentRunId,
@@ -1163,7 +1384,8 @@ export function createAssistantDispatcher({
1163
1384
  const catalogValue = await catalogSnapshot();
1164
1385
  // Routing (ask modes may hold the run until the user picks a model).
1165
1386
  let routed = null;
1166
- if (router && origin !== 'ui' && sessionId) {
1387
+ // Skills owns the runtime selection and its provider-specific workspace. Never bind Assistant routes.
1388
+ if (router && !isSkillsRun(rawSpec) && origin !== 'ui' && sessionId) {
1167
1389
  routed = await router.resolveDispatch({ sessionId, spec: rawSpec, origin });
1168
1390
  // A hold names an open card, but an answer that was already in flight (it waited on the same
1169
1391
  // catalog read) can decide that card before this continuation runs. Approved for this
@@ -1330,6 +1552,12 @@ export function createAssistantDispatcher({
1330
1552
  };
1331
1553
  const heldClass = knownClass(routed.route?.taskClass) || knownClass(input.taskClass);
1332
1554
  if (heldClass) entry.taskClass = heldClass;
1555
+ const selection = dispatchTools({ ...input, taskClass: heldClass });
1556
+ entry.externalServers = resolveExternalTools({ ...entry, ...input }, selection.servers);
1557
+ entry.externalMcpVersion = 2;
1558
+ entry.toolsRequested = selection.requested;
1559
+ entry.tools = launchTools(selection);
1560
+ entry.usesBrowser = input.usesBrowser === true || selection.groups.some(isBrowserGroup);
1333
1561
  if (brief) entry.brief = brief;
1334
1562
  // A held run belongs to the task that asked for it, whenever the user picks its model.
1335
1563
  entry.taskId = taskAtDispatch(sessionId);
@@ -1345,7 +1573,7 @@ export function createAssistantDispatcher({
1345
1573
  emit(entry, 'route_pending', { routeId: routed.routeId });
1346
1574
  return {
1347
1575
  ok: true, queued: true, awaitingRoute: true, routeId: routed.routeId, run: view(entry),
1348
- next: 'This run is waiting for the user to pick its model on the route card. Do not dispatch it again. It starts automatically after the pick; the decision arrives as a [SynaBun Mailbox] route_decided event.',
1576
+ next: 'This run is waiting for the user to pick its model on the route card. Do not dispatch it again. It starts automatically after the pick; the decision arrives as a [SynaBun Mailbox] route_decided event. If the user picks "here" instead, this run never starts and that event asks you to do the task yourself.',
1349
1577
  };
1350
1578
  }
1351
1579
 
@@ -1374,11 +1602,11 @@ export function createAssistantDispatcher({
1374
1602
  const entry = entries.get(runId);
1375
1603
  if (!entry || entry.state !== 'awaiting_route' || !target?.provider) return { ok: false, code: 'NOT_HELD' };
1376
1604
  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 } : {}) };
1605
+ let input = { ...(entry.rawSpec || {}), provider: target.provider, model: target.model || null, effort: target.effort ?? null, accountId: target.accountId || null, codexAccountId: null, claudeAccountId: null };
1378
1606
  let spec;
1379
1607
  // The picked provider may need another clamp (Codex under strict worker approvals), or the level fell meanwhile.
1380
1608
  const remoteNotes = [];
1381
- try { input = clampRemote(input, entry.assistantSessionId, remoteNotes); spec = normalizeSpec(input, { catalogValue, codexAskFallback: true, taskClass: entry.taskClass || null }); }
1609
+ try { input = clampRemote(input, entry.assistantSessionId, remoteNotes); spec = normalizeSpec(input, { catalogValue, taskClass: entry.taskClass || null }); }
1382
1610
  catch (error) {
1383
1611
  entry.state = 'failed'; entry.completionReason = 'route_invalid'; entry.finishedAt = iso(now()); entry.turnState = 'terminal';
1384
1612
  entry.error = error?.message || String(error);
@@ -1404,7 +1632,10 @@ export function createAssistantDispatcher({
1404
1632
  const entry = entries.get(runId);
1405
1633
  if (!entry || entry.state !== 'awaiting_route') return false;
1406
1634
  entry.state = 'stopped'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
1407
- entry.route = { ...(entry.route || {}), status: reason === 'route_expired' ? 'expired' : 'declined', decidedBy: reason === 'route_expired' ? 'timeout' : 'user', decidedAt: iso(now()) };
1635
+ // route_here: the user picked "here" on its card, so the assistant does the task itself and this worker never starts.
1636
+ const status = reason === 'route_expired' ? 'expired' : reason === 'route_here' ? 'here' : 'declined';
1637
+ entry.route = { ...(entry.route || {}), status, decidedBy: reason === 'route_expired' ? 'timeout' : 'user', decidedAt: iso(now()) };
1638
+ if (reason === 'route_here') entry.notes.push('the user chose to do this task here, in the conversation: this worker was not started');
1408
1639
  delete entry.rawSpec;
1409
1640
  emit(entry, 'stopped');
1410
1641
  return true;
@@ -1413,6 +1644,7 @@ export function createAssistantDispatcher({
1413
1644
  /** Re-dispatch a failed/blocked run one tier up, or on the same model after a transient failure (the user pressed Escalate / Retry). */
1414
1645
  async function escalate(runId, { target = null } = {}) {
1415
1646
  const entry = requireEntry(runId);
1647
+ if (isSkillsRun(entry)) throw new DispatchError('NO_ESCALATION', 'Skills runs cannot change runtime or model through escalation.', { status: 409 });
1416
1648
  const to = target?.provider ? target : entry.escalation?.to;
1417
1649
  if (!to?.provider) throw new DispatchError('NO_ESCALATION', `Run ${runId} has no escalation target`, { status: 409 });
1418
1650
  // A transient failure (timeout, rate limit, outage) is retried as it was, not escalated.
@@ -1426,21 +1658,55 @@ export function createAssistantDispatcher({
1426
1658
  provider: to.provider, model: to.model || null, effort: to.effort || null, task: entry.task, cwd: entry.cwd, context,
1427
1659
  accountId: to.accountId || (to.provider === entry.provider ? entry.accountId : null),
1428
1660
  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,
1430
- permissionPolicy: to.provider === 'codex' && entry.permissionPolicy === 'ask' ? 'restricted' : entry.permissionPolicy,
1661
+ tools: entry.tools?.some(item => item.startsWith('mcp:')) ? retainExternalRefs(entry.toolsRequested ?? dispatchTools({}).items, entry.tools) : entry.toolsRequested ?? undefined, capability: entry.capability,
1662
+ permissionPolicy: entry.permissionPolicy,
1431
1663
  usesBrowser: entry.usesBrowser, usesComputer: entry.usesComputer, maxMinutes: entry.maxMinutes, budgetUsd: entry.budgetUsd,
1432
1664
  outputSchema: entry.outputSchema, taskClass: entry.taskClass || null,
1433
1665
  }, { assistantSessionId: entry.assistantSessionId, origin: 'ui', brief: entry.brief || null });
1434
1666
  const child = entries.get(result?.run?.runId);
1435
1667
  if (child) { child.escalationDepth = (Number(entry.escalationDepth) || 0) + 1; child.escalatedFrom = runId; }
1436
1668
  entry.notes.push(`${retry ? 'retried on' : 'escalated to'} ${to.provider}/${to.model || 'default'} as ${result?.run?.runId || '?'}`);
1669
+ if (entry.limit && typeof entry.limit === 'object') entry.limit = { ...entry.limit, retriedAt: iso(now()) };
1437
1670
  emit(entry, 'escalated', { childRunId: result?.run?.runId || null });
1438
1671
  return result;
1439
1672
  }
1440
1673
  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; }
1674
+ if (isSkillsRun(entry) || !router?.escalationFor) return null;
1675
+ let found = null;
1676
+ try { found = router.escalationFor({ run: stateOverride ? { ...entry, state: stateOverride } : entry, depth: Number(entry.escalationDepth) || 0 }) || null; }
1443
1677
  catch { return null; }
1678
+ return found;
1679
+ }
1680
+ /** The Protect workers setting now, or the default when the host cannot say. */
1681
+ function safeWorkerGuard() {
1682
+ try { return workerGuard?.() ?? WORKER_GUARD_DEFAULT; } catch { return WORKER_GUARD_DEFAULT; }
1683
+ }
1684
+ /**
1685
+ * A run a provider's usage limit stopped (failed, or a turn that ended blocked or without a result):
1686
+ * entry.limit = { provider, accountId, resetsAt (ISO | null), source, detectedAt } for the panel's
1687
+ * waiting card and the brain's mailbox line, else null. The reset is the account's own usage window
1688
+ * when SynaBun reads one (account-usage.js), else the time the worker's message names (asst-limit.js).
1689
+ */
1690
+ function noteLimit(entry) {
1691
+ const prev = entry.limit && typeof entry.limit === 'object' ? entry.limit : null;
1692
+ const text = [entry.error, entry.lastResult?.summary, entry.lastResult?.question, lastParagraph(entry.lastText), ...(entry.notes || []).slice(-3)]
1693
+ .filter(Boolean).map((line) => String(line)).join('\n');
1694
+ const read = limitFromText(text, { now: now() });
1695
+ let usage = null;
1696
+ try { usage = accountUsage?.peek?.(entry.provider, entry.accountId || 'default', { stale: true }) || null; } catch { usage = null; }
1697
+ const limited = usage?.limited === true;
1698
+ if (!read && !limited) { entry.limit = null; return null; }
1699
+ const accountReset = Date.parse(usage?.resetsAt || '');
1700
+ const fromAccount = Number.isFinite(accountReset) && accountReset > now() && (limited || read?.resetsAt == null);
1701
+ const resetsAt = fromAccount ? accountReset : read?.resetsAt ?? null;
1702
+ entry.limit = {
1703
+ provider: entry.provider || null,
1704
+ accountId: entry.accountId || 'default',
1705
+ resetsAt: Number.isFinite(resetsAt) ? new Date(resetsAt).toISOString() : null,
1706
+ source: fromAccount ? 'account' : resetsAt != null ? 'message' : null,
1707
+ detectedAt: prev?.detectedAt || iso(now()),
1708
+ };
1709
+ return entry.limit;
1444
1710
  }
1445
1711
 
1446
1712
  // ── Jev (worker-outcome, rider worker-claim) ────────────────────────────────
@@ -1518,8 +1784,8 @@ export function createAssistantDispatcher({
1518
1784
  * for it — at most the surface timeout. Without Jev it is emitted at once.
1519
1785
  */
1520
1786
  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(); }
1787
+ const done = () => { delete entry.judging; noteLimit(entry); entry.escalation = computeEscalation(entry); emit(entry, 'failed'); };
1788
+ if (!judge?.workerOutcome || budgetStopReason(entry.completionReason) || isEvalRun(entry)) { done(); return Promise.resolve(); }
1523
1789
  entry.judging = true;
1524
1790
  const evidence = evidenceSnapshot(evidenceAcc);
1525
1791
  const error = [entry.error, ...(entry.notes || []).slice(-3)].filter(Boolean).map((line) => String(line)).join('\n');
@@ -1541,6 +1807,7 @@ export function createAssistantDispatcher({
1541
1807
  }
1542
1808
  } catch { /* the failure stays unjudged */ }
1543
1809
  delete entry.judging;
1810
+ noteLimit(entry);
1544
1811
  entry.escalation = computeEscalation(entry);
1545
1812
  if (judged?.logId) {
1546
1813
  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 +1817,12 @@ export function createAssistantDispatcher({
1550
1817
  }
1551
1818
 
1552
1819
  async function start(entry) {
1820
+ try { requireConfinementProvider(entry); } catch (error) {
1821
+ entry.state = 'failed'; entry.error = error.message; entry.completionReason = 'confinement_provider_mismatch';
1822
+ entry.finishedAt = iso(now()); entry.turnState = 'terminal';
1823
+ emit(entry, 'failed');
1824
+ throw error;
1825
+ }
1553
1826
  const runtime = safeRuntime();
1554
1827
  if (!runtime) throw new DispatchError('RUNTIME_UNAVAILABLE', 'Native loop runtime is not available', { status: 503 });
1555
1828
  // A queued or held run starts under the budget as it is now (its provider's share included).
@@ -1577,50 +1850,19 @@ export function createAssistantDispatcher({
1577
1850
  }
1578
1851
  entry.browserSessionId = browserSessionId;
1579
1852
  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
- };
1853
+ const liveState = newLiveState(entry);
1586
1854
  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
- };
1623
- if (entry.permissionPolicy === 'ask') state.permissionBroker = createBroker(entry.runId);
1855
+ // The guard a read-only / workspace run starts under (never for full, never for a drafting run): the setting
1856
+ // now, and a scratch folder of its own. Recorded on the run so the panel and the brain can see it.
1857
+ entry.guardMode = normalizeWorkerGuardMode(safeWorkerGuard());
1858
+ if (entry.provider === 'opencode' && !entry.confinement) resumeInfoOf(entry).persistentOpenCode = true;
1859
+ if ((!entry.confinement || isEvalRun(entry)) && entry.guardMode !== 'off' && (entry.capability === 'read-only' || entry.capability === 'workspace')) {
1860
+ const scratch = resolve(scratchRoot, entry.runId);
1861
+ try { mkdirSync(scratch, { recursive: true }); entry.scratchDir = scratch; } catch (error) { entry.scratchDir = null; entry.notes.push(`scratch folder not made: ${error?.message || error}`); }
1862
+ } else entry.scratchDir = null;
1863
+ const desktopGrant = mintDesktopGrant(entry);
1864
+ const state = runState(entry, { browserSessionId, browserTabId, desktopGrant, startedAt: iso(now()) });
1865
+ state.permissionBroker = createBroker(entry.runId);
1624
1866
  // A class's rules (design), read now so an edited override applies without a restart;
1625
1867
  // they go to the prompt only, not into the loop state file.
1626
1868
  const playbook = playbookFor(entry);
@@ -1637,7 +1879,9 @@ export function createAssistantDispatcher({
1637
1879
  source: 'assistant',
1638
1880
  focus: entry.focus === true,
1639
1881
  title: entry.title,
1640
- buildPrompt: () => buildTaskPrompt({ ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}), testDensity: briefTestDensity(entry) }),
1882
+ // A skill-evaluation run gets its task as written (a case's request, a grader's brief): the worker preamble and
1883
+ // the Result contract would change what is measured.
1884
+ buildPrompt: () => (isEvalRun(entry) ? entry.task : buildTaskPrompt({ ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}), testDensity: briefTestDensity(entry) })),
1641
1885
  wrapAdapter: (adapter, ctx) => createTaskAdapter(adapter, ctx, liveState, state),
1642
1886
  });
1643
1887
  entry.runtime = { status: descriptor.status };
@@ -1654,6 +1898,104 @@ export function createAssistantDispatcher({
1654
1898
  throw error;
1655
1899
  }
1656
1900
  }
1901
+ /** What a run's worker keeps in memory while it lives (never saved): its turn loop, transcript tail, waits. */
1902
+ function newLiveState(entry) {
1903
+ return {
1904
+ entry, ctx: null, ring: [], ringChars: 0, followUps: [], waitResolve: null, idleTimer: null,
1905
+ pendingResolvers: new Map(), dead: false, lastEventAt: now(), stalledFlagged: false, turnCounter: 0, abortReason: null,
1906
+ evidence: null, // the current turn's commands / tool results / edited files (assistant-evidence.js)
1907
+ completed: false, // its turn loop ended on a completion (agent_complete, a closed finished tab, a removal)
1908
+ };
1909
+ }
1910
+ /**
1911
+ * Computer use: an unforgeable per-run grant unlocks the `computer` MCP group for this worker only. Passed as a
1912
+ * function so the loop runtime's JSON state file never contains it. null for a run without computer use.
1913
+ */
1914
+ function mintDesktopGrant(entry) {
1915
+ if (!entry.usesComputer || !desktop?.mintGrant) return null;
1916
+ try { return desktop.mintGrant({ kind: 'run', assistantSessionId: entry.assistantSessionId, runId: entry.runId, provider: entry.provider, model: entry.model, vision: entry.modelInfo?.vision ?? null }) || null; }
1917
+ catch (error) { entry.notes.push(`desktop grant failed: ${error?.message || error}`); return null; }
1918
+ }
1919
+ /**
1920
+ * The loop state a run starts (or resumes, with `extra`) with: what its provider factory and its prompt read. Its
1921
+ * functions (notes, the CLI's process, the desktop grant) stay in memory: the runtime writes the rest to the run's
1922
+ * state file.
1923
+ */
1924
+ function runState(entry, { browserSessionId = null, browserTabId = null, desktopGrant = null, startedAt = iso(now()), extra = {} } = {}) {
1925
+ const extraEnv = {};
1926
+ if (entry.claudeConfigDir) extraEnv.CLAUDE_CONFIG_DIR = entry.claudeConfigDir;
1927
+ return {
1928
+ active: true, task: entry.task, context: entry.context, totalIterations: 1, currentIteration: 0,
1929
+ maxMinutes: entry.maxMinutes, startedAt, lastIterationAt: null, retries: 0, pending: false,
1930
+ terminalSessionId: entry.runId, usesBrowser: entry.usesBrowser, usesComputer: !!entry.usesComputer, browserSessionId, browserTabId,
1931
+ profile: entry.provider, provider: entry.provider, agent: entry.agent || null,
1932
+ model: entry.model, modelContextWindow: entry.modelContextWindow || null, effort: entry.effort, tools: entry.tools,
1933
+ codexAccountId: entry.codexAccountId, codexHome: entry.provider === 'codex' ? (entryCodexHome(entry)) : null,
1934
+ claudeAccountId: entry.claudeAccountId, claudeConfigDir: entry.claudeConfigDir,
1935
+ driverType: 'native', runtimeType: 'native', surface: 'sidepanel', runId: entry.runId, cwd: entry.cwd,
1936
+ runMode: 'task', assistantSessionId: entry.assistantSessionId, permissionPolicy: entry.permissionPolicy,
1937
+ // A Claude worker's CLI stop (--max-budget-usd): a backstop under a budget, none under No limit.
1938
+ capability: entry.capability, budgetUsd: entry.budgetUsd, maxBudgetUsd: entry.provider === 'claude-code' ? claudeRunCap(entry) : null,
1939
+ // A skill-drafting run's confinement (lib/skills/draft-confinement.js): its adapter enforces it, or refuses to start.
1940
+ confinement: entry.confinement || null,
1941
+ // The worker confinement (lib/worker-confinement.js): the Protect workers setting as the run starts, kept for
1942
+ // its life, and the run's own scratch folder (writable beside the working directory and the temp folder).
1943
+ persistentOpenCode: !!entry.resumeInfo?.persistentOpenCode,
1944
+ sandboxPlan: entry.resumeInfo?.sandboxPlan || null,
1945
+ providerTurnBase: Number(entry.resumeInfo?.providerTurn) || 0,
1946
+ adminSandbox: entry.resumeInfo?.adminSandbox === true,
1947
+ onCodexSandbox: (value) => { resumeInfoOf(entry).adminSandbox = value === true; persist({ force: true }); },
1948
+ onSandboxPlan: (plan) => { resumeInfoOf(entry).sandboxPlan = plan; persist({ force: true }); },
1949
+ // Antigravity counts tokens per session (ACP usage): what its session had counted, kept for a resume after a
1950
+ // restart, so the resumed run's first turn books only its own.
1951
+ sessionUsageBase: entry.provider === 'antigravity' ? entry.resumeInfo?.acpSessionUsage || null : null,
1952
+ onSessionUsage: (value) => { if (entry.provider === 'antigravity' && value && typeof value === 'object') resumeInfoOf(entry).acpSessionUsage = value; },
1953
+ guardMode: entry.guardMode, scratchDir: entry.scratchDir || null, serverPort: Number(serverPort) > 0 ? Number(serverPort) : null,
1954
+ localPortRange: localPortRange && Number(localPortRange.from) > 0 && Number(localPortRange.to) >= Number(localPortRange.from) ? { from: Number(localPortRange.from), to: Number(localPortRange.to) } : null,
1955
+ questionTimeoutMs: limits.questionTimeoutMs,
1956
+ idleTimeoutMs: entry.idleTimeoutMs, maxTurns: entry.maxTurns, outputSchema: entry.outputSchema, project: entry.project,
1957
+ title: entry.title, tags: entry.tags, workflowId: entry.workflowId, parentRunId: entry.parentRunId,
1958
+ // The user's brief after a clarification (assistant-task-prompt.js briefBlock).
1959
+ brief: entry.brief || null,
1960
+ // Image / video creation: the prompt's media block and what the adapter collects ('image' | 'video');
1961
+ // design collects its screenshots and images the same way ('image').
1962
+ taskClass: entry.taskClass || null, collectMedia: collectedMedia(entry.taskClass),
1963
+ // extraEnv is a plain object, so it IS written to the loop state file;
1964
+ // keep secrets out of it. The desktop grant rides as a function instead.
1965
+ extraEnv,
1966
+ desktopGrantProvider: desktopGrant ? () => desktopGrant : null,
1967
+ // What the adapter reports while it starts (a Codex worker whose browser-policy hook is not trusted).
1968
+ onNote: (text) => { const note = String(text || '').trim(); if (note && !entry.notes.includes(note)) entry.notes.push(note); },
1969
+ // A Claude Code worker's CLI (lib/native-loop-providers.js onProcess): saved with the run at once, so the next
1970
+ // start can end it should it outlive this server (a crash ends no process) before the session is resumed.
1971
+ onProcess: (info) => {
1972
+ const pid = Number(info?.pid);
1973
+ if (!Number.isInteger(pid) || pid <= 0) return;
1974
+ resumeInfoOf(entry).pid = pid;
1975
+ entry.resumeInfo.pidStartedAt = info.startedAt || iso(now());
1976
+ // An Antigravity run's own home (lib/antigravity/home.js): where its conversation is, for a resume.
1977
+ if (typeof info.home === 'string' && info.home) { entry.resumeInfo.antigravityHome = info.home; entry.antigravityHome = info.home; }
1978
+ persist({ force: true });
1979
+ },
1980
+ ...extra,
1981
+ };
1982
+ }
1983
+ /** What a resume after a restart needs that is saved with the run (the CLI's pid, the turn in progress). */
1984
+ function resumeInfoOf(entry) {
1985
+ if (!entry.resumeInfo || typeof entry.resumeInfo !== 'object') entry.resumeInfo = {};
1986
+ return entry.resumeInfo;
1987
+ }
1988
+ /** The number of the run's last recorded turn (0 before its first): a resumed run's turns go on from it. */
1989
+ function lastTurnNumber(entry) {
1990
+ return (Array.isArray(entry.turns) ? entry.turns : []).reduce((max, turn) => Math.max(max, Number(turn?.n) || 0), 0);
1991
+ }
1992
+ /**
1993
+ * The turns a run away for a restart will have had when a follow-up reaches it (as turnCounter counts a turn in
1994
+ * progress): its recorded turns, the one the restart cut off (its resume continues it), or its first, not yet run.
1995
+ */
1996
+ function awayTurns(entry) {
1997
+ return Math.max(lastTurnNumber(entry), Number(entry.resumeInfo?.inFlight?.turn) || 0, 1);
1998
+ }
1657
1999
  /**
1658
2000
  * What the run's prompt says about its project's Style Guide (lib/style-guide/store.js): the
1659
2001
  * summary for the run's class, DESIGN.md and the token files. null when the project has no saved
@@ -1709,36 +2051,38 @@ export function createAssistantDispatcher({
1709
2051
  return account?.home || CODEX_DEFAULT_HOME || null;
1710
2052
  }
1711
2053
 
1712
- // ── permission broker ('ask' policy) ──────────────────────────────────────
2054
+ // ── request broker (questions in every policy; tool approvals under 'ask') ──────────────────────────────────────
1713
2055
  function createBroker(runId) {
1714
2056
  return {
1715
2057
  request: (request = {}) => new Promise((resolvePermission) => {
1716
2058
  const entry = entries.get(runId);
1717
2059
  const liveState = live.get(runId);
1718
- if (!entry || !liveState) { resolvePermission({ behavior: 'deny', message: 'Run is no longer tracked.' }); return; }
2060
+ if (!entry || !liveState || liveState.dead || liveState.suspending || TERMINAL_STATES.has(entry.state) || request.signal?.aborted) { resolvePermission({ behavior: 'deny', message: 'Run is no longer tracked.' }); return; }
1719
2061
  const requestId = `perm-${randomBytes(6).toString('hex')}`;
1720
2062
  const kind = request.kind === 'ask' ? 'question' : 'permission';
1721
2063
  const timeoutMs = kind === 'question' ? limits.questionTimeoutMs : limits.permissionTimeoutMs;
1722
2064
  const pending = {
1723
2065
  requestId, kind, toolName: request.toolName || null, input: request.input ?? null,
2066
+ protocol: request.protocol || null,
1724
2067
  questions: Array.isArray(request.questions) ? request.questions : [], createdAt: iso(now()), expiresAt: iso(now() + timeoutMs),
1725
2068
  };
1726
2069
  entry.pending = [...(entry.pending || []), pending];
1727
2070
  entry.state = 'awaiting_permission';
1728
2071
  entry.turnState = 'awaiting_permission';
2072
+ // Install the resolver before publishing: a synchronous listener can answer at once.
2073
+ const timer = setTimeout(() => finishPending(runId, requestId, { behavior: 'deny', message: `No decision within ${Math.round(timeoutMs / 60000)} minutes.`, resolvedBy: 'timeout' }), timeoutMs);
2074
+ timer.unref?.();
2075
+ const abortSignal = request.signal;
2076
+ const onAbort = () => finishPending(runId, requestId, { behavior: 'deny', message: 'Run aborted.', resolvedBy: 'abort' });
2077
+ liveState.pendingResolvers.set(requestId, { resolve: resolvePermission, timer, cleanup: () => abortSignal?.removeEventListener?.('abort', onAbort) });
2078
+ abortSignal?.addEventListener?.('abort', onAbort, { once: true });
1729
2079
  // Waiting on the answer is not work: the request's own timeout bounds it.
1730
2080
  pauseWork(entry, liveState);
2081
+ liveState.ctx?.setPendingRequests?.(entry.pending);
1731
2082
  liveState.ctx?.setTurnState?.('awaiting_permission');
1732
2083
  liveState.ctx?.emit?.({ type: 'synabun.permission_request', requestId, kind, toolName: pending.toolName, input: pending.input, questions: pending.questions });
1733
- const timer = setTimeout(() => finishPending(runId, requestId, { behavior: 'deny', message: `No decision within ${Math.round(timeoutMs / 60000)} minutes.`, resolvedBy: 'timeout' }), timeoutMs);
1734
- timer.unref?.();
1735
- liveState.pendingResolvers.set(requestId, { resolve: resolvePermission, timer });
1736
2084
  try { broadcastSync({ type: 'assistant:permission-request', runId, assistantSessionId: entry.assistantSessionId, provider: entry.provider, request_id: requestId, request: pending }); } catch {}
1737
2085
  emit(entry, kind === 'question' ? 'needs_input' : 'permission_request', { request: pending });
1738
- const abortSignal = request.signal;
1739
- if (abortSignal && typeof abortSignal.addEventListener === 'function') {
1740
- abortSignal.addEventListener('abort', () => finishPending(runId, requestId, { behavior: 'deny', message: 'Run aborted.', resolvedBy: 'abort' }), { once: true });
1741
- }
1742
2086
  }),
1743
2087
  };
1744
2088
  }
@@ -1749,10 +2093,12 @@ export function createAssistantDispatcher({
1749
2093
  const resolver = liveState.pendingResolvers.get(requestId);
1750
2094
  if (!resolver) return false;
1751
2095
  clearTimeout(resolver.timer);
2096
+ resolver.cleanup?.();
1752
2097
  liveState.pendingResolvers.delete(requestId);
1753
2098
  const request = (entry.pending || []).find((item) => item.requestId === requestId) || null;
1754
2099
  entry.pending = (entry.pending || []).filter((item) => item.requestId !== requestId);
1755
- if (entry.state === 'awaiting_permission' && entry.pending.length === 0) {
2100
+ liveState.ctx?.setPendingRequests?.(entry.pending);
2101
+ if (entry.state === 'awaiting_permission' && entry.pending.length === 0 && !liveState.dead && !liveState.suspending && !liveState.abortReason) {
1756
2102
  entry.state = 'running';
1757
2103
  entry.turnState = 'running';
1758
2104
  startWork(entry, liveState);
@@ -1775,9 +2121,14 @@ export function createAssistantDispatcher({
1775
2121
  throw new DispatchError(known ? 'PERMISSION_ALREADY_RESOLVED' : 'NO_PENDING_REQUEST', known ? `Request ${requestId} was already resolved` : `No pending request ${requestId} on run ${runId}`, { status: 409 });
1776
2122
  }
1777
2123
  const behavior = response.behavior === 'allow' || response.decision === 'allow' || response.decision === 'answer' ? 'allow' : 'deny';
2124
+ // Antigravity saves an "always" answer for every later session of the user's, outside the run: a worker's
2125
+ // answer is for this request only.
2126
+ const always = entry.provider === 'antigravity' ? false : !!response.always;
2127
+ try { validateWorkerReply((entry.pending || []).find((p) => p.requestId === requestId), { ...response, behavior }); }
2128
+ catch (error) { throw new DispatchError('INVALID_QUESTION_ANSWER', error.message); }
1778
2129
  finishPending(runId, requestId, {
1779
- behavior, updatedInput: response.updatedInput, message: response.message || response.note, always: !!response.always,
1780
- answers: response.answers, resolvedBy: origin,
2130
+ behavior, updatedInput: response.updatedInput, message: response.message || response.note, always,
2131
+ answers: response.answers ?? response.updatedInput?.answers, result: response.result, content: response.content, action: response.action, error: response.error, resolvedBy: origin,
1781
2132
  }, { origin });
1782
2133
  return view(entry);
1783
2134
  }
@@ -1805,6 +2156,8 @@ export function createAssistantDispatcher({
1805
2156
  // Saved with the run: a result that arrives after a restart belongs to the turn that was
1806
2157
  // started, whether or not it was ever recorded.
1807
2158
  usageStateOf(entry).turn = { n: turnNumber, taskId };
2159
+ // So is the turn in progress: a restart in it is resumed with the continuation prompt (resumeRun).
2160
+ resumeInfoOf(entry).inFlight = { turn: turnNumber, taskId, origin, startedAt };
1808
2161
  liveState.turnUsage = null;
1809
2162
  liveState.turnTokens = zeroTokens();
1810
2163
  liveState.turnOpen = true;
@@ -1815,21 +2168,32 @@ export function createAssistantDispatcher({
1815
2168
  startWork(entry, liveState);
1816
2169
  ctx.setTurnState('running');
1817
2170
  emit(entry, 'turn_started', { turn: turnNumber });
2171
+ persist({ force: true });
1818
2172
  // What this turn runs (commands, tool results, edited files): the evidence behind a "done".
1819
2173
  liveState.evidence = createTurnEvidence();
1820
2174
  // Money booked while this turn runs (OpenCode steps, Codex rollout records).
1821
2175
  liveState.turnMeteredUsd = 0;
1822
2176
  let raw = await inner.runTurn(prompt, { ...meta, turn: turnNumber, outputSchema: entry.outputSchema || undefined });
1823
2177
  if (entry.provider === 'codex') settleCodexTurn(entry, liveState, raw);
2178
+ else if (entry.provider === 'antigravity') settleAntigravityTurn(entry, liveState, raw);
1824
2179
  let text = typeof raw?.text === 'string' ? raw.text : '';
1825
- let parsed = parseResultContract(text);
2180
+ const evalRun = isEvalRun(entry);
2181
+ let parsed = evalRun ? { found: false } : parseResultContract(text);
1826
2182
  parsed.source = parsed.found ? 'contract' : null;
1827
2183
  // A run with an output schema answers with bare JSON: that object is its result, no retry turn.
1828
2184
  if (!parsed.found) parsed = structuredResult(entry, raw, text) || parsed;
2185
+ // A skill-evaluation run's answer is what is measured: it is its result as it is (no contract to find).
2186
+ 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' };
2187
+ // The server is going down (suspendForShutdown) as the turn ended without its ## Result block: no retry turn can
2188
+ // run now. The turn stays in flight, so the resumed worker is asked to finish it; what it spent is booked.
2189
+ if (liveState.suspending && !parsed.found) {
2190
+ accumulateCost(entry, raw);
2191
+ throw Object.assign(new Error('suspended for a server restart'), { name: 'AbortError' });
2192
+ }
1829
2193
  const noBlock = !parsed.found && strictResult && !!text.trim() && !liveState.dead;
1830
2194
  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 });
2195
+ // One Jev request before any retry turn (null without Jev: today's behaviour). Never for an evaluation run.
2196
+ const judged = evalRun ? null : await judgeTurn(entry, { text, parsed, noBlock, evidence, followUp });
1833
2197
  let retried = false;
1834
2198
  let retryCost = 0;
1835
2199
  if (judged?.status) {
@@ -1849,12 +2213,15 @@ export function createAssistantDispatcher({
1849
2213
  if (retryParsed.found) { parsed = { ...retryParsed, source: 'retry' }; text = `${text}\n\n${retryText}`; }
1850
2214
  retryCost = accumulateCost(entry, retry);
1851
2215
  if (entry.provider === 'codex') settleCodexTurn(entry, liveState, retry);
2216
+ else if (entry.provider === 'antigravity') settleAntigravityTurn(entry, liveState, retry);
1852
2217
  } catch (error) {
1853
2218
  // Stopped during the retry: the run ends here (handleTurnError books the retry), with no
1854
2219
  // 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; }
2220
+ // Suspended for a server restart during it: the turn stays in flight for the resume.
2221
+ if (liveState.abortReason || liveState.suspending) { accumulateCost(entry, raw); throw error; }
1856
2222
  retryCost = accumulateCost(entry, error);
1857
2223
  if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
2224
+ else if (entry.provider === 'antigravity') settleAntigravityTurn(entry, liveState, error);
1858
2225
  entry.notes.push(`result retry failed: ${error?.message || error}`);
1859
2226
  }
1860
2227
  }
@@ -1880,7 +2247,8 @@ export function createAssistantDispatcher({
1880
2247
  liveState.turnOpen = false;
1881
2248
  stopUsagePolling(liveState);
1882
2249
  let turnUsage = raw?.usage || null;
1883
- if (entry.provider === 'codex') turnUsage = liveState.turnUsage || null;
2250
+ // Codex and Antigravity: what their provider turns (the retry included) reported, in provider keys.
2251
+ if (entry.provider === 'codex' || entry.provider === 'antigravity') turnUsage = liveState.turnUsage || null;
1884
2252
  else if (entry.provider === 'opencode') turnUsage = (await settleOpenCodeTurn(entry, liveState)) || turnUsage;
1885
2253
  // A reported cost below an earlier turn's estimate can make this negative: a turn never reads below $0.
1886
2254
  const turnCost = Math.max(0, accumulateCost(entry, raw) + retryCost + (Number(liveState.turnMeteredUsd) || 0));
@@ -1893,6 +2261,7 @@ export function createAssistantDispatcher({
1893
2261
  // Rows that settle from here on (a late rollout record) go to the record itself.
1894
2262
  liveState.turnTokens = null;
1895
2263
  entry.turns = [...(entry.turns || []), turn].slice(-50);
2264
+ if (entry.resumeInfo) delete entry.resumeInfo.inFlight;
1896
2265
  entry.lastResult = parsed;
1897
2266
  // The run's own descriptor (lib/native-loop-runtime.js) carries it too, for the surfaces that read that one
1898
2267
  // (the OpenCode panel's pill): a warm worker whose turn ended asking waits on the user, it does not rest.
@@ -1904,6 +2273,8 @@ export function createAssistantDispatcher({
1904
2273
  // itself stays warm for follow-ups; the brain or the user decides). A
1905
2274
  // status Jev read from the message is a usable result.
1906
2275
  entry.escalation = parsed.status === 'blocked' || (!parsed.found && parsed.source !== 'jev') ? computeEscalation(entry, 'idle') : null;
2276
+ // A provider's usage limit that stopped it: when it resets (the panel's waiting card counts to it).
2277
+ if (parsed.status === 'blocked' || !parsed.found) noteLimit(entry); else entry.limit = null;
1907
2278
  if (judged?.logId) {
1908
2279
  try {
1909
2280
  judge.annotate?.(judged.logId, {
@@ -1927,13 +2298,24 @@ export function createAssistantDispatcher({
1927
2298
  isAlive: () => !liveState.dead && (typeof inner.isAlive === 'function' ? inner.isAlive() : true),
1928
2299
  describe: () => ({ ...(typeof inner.describe === 'function' ? inner.describe() : {}), runMode: 'task' }),
1929
2300
  async runTurn(prompt, meta = {}) {
1930
- try {
1931
- await runInnerTurn(prompt, meta, { origin: 'dispatch' });
1932
- } catch (error) {
1933
- pauseWork(entry, liveState);
1934
- return handleTurnError(error, liveState, entry, ctx);
2301
+ // A run resumed after a server restart (resumeRun): one that was in a turn sends the continuation prompt (the
2302
+ // runtime's buildPrompt for it) as a turn of its own; one that waited for a follow-up waits again, with a fresh
2303
+ // idle timer; one that never got its first turn going sends its task as a new run would.
2304
+ const resume = liveState.resume || null;
2305
+ liveState.resume = null;
2306
+ if (resume?.mode !== 'idle') {
2307
+ try {
2308
+ await runInnerTurn(prompt, meta, resume?.mode === 'mid_turn'
2309
+ ? { origin: 'resume', label: 'resumed after a server restart', taskId: resume.taskId || entry.taskId || null }
2310
+ : { origin: 'dispatch' });
2311
+ } catch (error) {
2312
+ pauseWork(entry, liveState);
2313
+ return handleTurnError(error, liveState, entry, ctx);
2314
+ }
1935
2315
  }
1936
2316
  for (;;) {
2317
+ // Suspended for a server shutdown (suspendForShutdown): the next start resumes it; nothing ends here.
2318
+ if (liveState.suspending) return { providerSessionId: null };
1937
2319
  // Stopped while the turn ran (a stop that did not fail it): never idle, never take a queued follow-up.
1938
2320
  if (liveState.abortReason) { entry.completionReason = entry.completionReason || liveState.abortReason; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1939
2321
  // Out of money (its own ceiling, the session's budget, unpriced usage): stop instead of idling
@@ -1946,6 +2328,7 @@ export function createAssistantDispatcher({
1946
2328
  ctx.setTurnState('idle');
1947
2329
  emit(entry, 'idle');
1948
2330
  const next = await waitForFollowUp();
2331
+ if (liveState.suspending) return { providerSessionId: null };
1949
2332
  if (next.kind === 'abort') { entry.completionReason = entry.completionReason || liveState.abortReason || 'aborted'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1950
2333
  if (next.kind === 'complete') { entry.completionReason = next.reason || 'complete'; liveState.completed = true; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
1951
2334
  if (next.kind === 'timeout') { entry.completionReason = 'idle_timeout'; return { providerSessionId: inner.identity?.()?.providerSessionId }; }
@@ -2057,6 +2440,14 @@ export function createAssistantDispatcher({
2057
2440
  }
2058
2441
 
2059
2442
  function handleTurnError(error, liveState, entry, ctx) {
2443
+ // Suspended for a server shutdown (suspendForShutdown): the turn the shutdown cut off is resumed on the next start
2444
+ // (its in-flight mark stays), and what it spent was settled when it was suspended. Nothing ends here.
2445
+ if (liveState.suspending) {
2446
+ liveState.turnOpen = false;
2447
+ stopUsagePolling(liveState);
2448
+ return { providerSessionId: null };
2449
+ }
2450
+ if (entry.resumeInfo) delete entry.resumeInfo.inFlight;
2060
2451
  // A failed turn was still paid for (a budget-capped run used to read $0).
2061
2452
  liveState.turnOpen = false;
2062
2453
  stopUsagePolling(liveState);
@@ -2066,6 +2457,8 @@ export function createAssistantDispatcher({
2066
2457
  if (entry.provider === 'codex') settleCodexTurn(entry, liveState, error);
2067
2458
  // OpenCode: what streamed before the failure, as it stands (its serve may be gone: no reconcile).
2068
2459
  else if (entry.provider === 'opencode') bookOpenCodeTurn(entry, liveState, { reconciled: false });
2460
+ // Antigravity: a stopped turn's answer still carried its tokens (the adapter puts them on the abort).
2461
+ else if (entry.provider === 'antigravity') settleAntigravityTurn(entry, liveState, error);
2069
2462
  announceUnpriced(entry);
2070
2463
  checkSession(entry.assistantSessionId, { source: entry });
2071
2464
  if (budgetStopReason(entry.completionReason)) return { providerSessionId: null };
@@ -2148,7 +2541,8 @@ export function createAssistantDispatcher({
2148
2541
  if (!raw) return 0;
2149
2542
  const cost = Number(raw.costUsd);
2150
2543
  const added = bookCost(entry, cost, raw.basis || 'reported');
2151
- if (entry.provider !== 'codex') addLegacyUsage(entry, raw.usage);
2544
+ // Codex and Antigravity usage reaches entry.usage when it is settled (settleCodexTurn, settleAntigravityTurn).
2545
+ if (entry.provider !== 'codex' && entry.provider !== 'antigravity') addLegacyUsage(entry, raw.usage);
2152
2546
  return added;
2153
2547
  }
2154
2548
 
@@ -2435,11 +2829,43 @@ export function createAssistantDispatcher({
2435
2829
  persist({ force: true });
2436
2830
  }
2437
2831
 
2832
+ // Antigravity: each provider turn's answer carries that turn's own ACP usage (the adapter takes it off the
2833
+ // session's running totals; a failed or stopped turn has none, so the next answer carries it). Tokens only:
2834
+ // Antigravity is billed by its Google sign-in and reports no price, so every row is unpriced, never $0.
2835
+ /**
2836
+ * Book one Antigravity provider turn: its five token classes into the ledger (exact when the agent's own total
2837
+ * agrees with one reading of its classes, else partial: 'antigravity-total-mismatch'), its usage in provider keys
2838
+ * into the run's and the turn's legacy totals, and the run marked unpriced. Nothing without usage.
2839
+ */
2840
+ function settleAntigravityTurn(entry, liveState, raw) {
2841
+ if (entry.usageState?.closed) return;
2842
+ const usage = raw?.usage && typeof raw.usage === 'object' ? raw.usage : null;
2843
+ if (!usage) return;
2844
+ const { tokens, mismatch } = antigravityUsageTokens(usage);
2845
+ if (totalTokens(tokens) === 0) return;
2846
+ const used = { input_tokens: tokens.input, output_tokens: tokens.output, reasoning_tokens: tokens.reasoning, cache_read_input_tokens: tokens.cacheRead, cache_write_input_tokens: tokens.cacheWrite };
2847
+ addLegacyUsage(entry, used);
2848
+ if (liveState) {
2849
+ const prev = liveState.turnUsage || {};
2850
+ liveState.turnUsage = Object.fromEntries(Object.entries(used).map(([key, value]) => [key, (Number(prev[key]) || 0) + value]));
2851
+ }
2852
+ markUnpriced(entry, UNPRICED_PROVIDER_REASON);
2853
+ // No model named: the one the session runs (the adapter reads it off the session's config options).
2854
+ let model = entry.model || null;
2855
+ if (!model) { try { model = liveState?.inner?.describe?.()?.model || null; } catch { model = null; } }
2856
+ settleUsage(entry, {
2857
+ model, part: 'main', tokens, costUsd: null, costBasis: 'unpriced',
2858
+ fidelity: mismatch ? 'partial' : 'exact', reason: mismatch ? 'antigravity-total-mismatch' : null, source: 'antigravity-acp-usage',
2859
+ });
2860
+ // At once, not debounced: a kill must not leave the saved run behind the ledger.
2861
+ persist({ force: true });
2862
+ }
2863
+
2438
2864
  // OpenCode: every session of the run's own serve (the root and the `task` tool's child sessions).
2439
2865
  function openCodeMeterOf(entry) {
2440
2866
  const held = metersOf(entry);
2441
- // Its state is not saved with the run: a run does not outlive the process that holds its serve.
2442
- if (!held.opencode) held.opencode = createOpenCodeMeter();
2867
+ // Message and mark snapshots survive with the session; replayed history must not be charged again.
2868
+ if (!held.opencode) held.opencode = createOpenCodeMeter({ state: entry.usageState?.opencode });
2443
2869
  return held.opencode;
2444
2870
  }
2445
2871
  /** The run's own session id, for a message that names none. */
@@ -2497,7 +2923,7 @@ export function createAssistantDispatcher({
2497
2923
  function bookOpenCodeMessage(entry, liveState, info) {
2498
2924
  // A finished run keeps no message book: what comes late is dropped, not booked from zero.
2499
2925
  if (entry.usageState?.closed) return;
2500
- const book = liveState.meter || (liveState.meter = new Map());
2926
+ const book = liveState.meter || (liveState.meter = new Map(Object.entries(entry.usageState?.opencodeMessages || {})));
2501
2927
  const key = `${info.sessionID || ''}:${info.id}`;
2502
2928
  const prev = book.get(key) || { cost: 0, usage: null, completed: false, estimate: 0 };
2503
2929
  const reported = Number(info.cost);
@@ -2528,6 +2954,8 @@ export function createAssistantDispatcher({
2528
2954
  } else markUnpriced(entry, `OpenCode reported no cost for ${model || 'its default model'} and it has no list price`);
2529
2955
  }
2530
2956
  book.set(key, { cost, usage: used, completed, estimate });
2957
+ usageStateOf(entry).opencodeMessages = Object.fromEntries(book);
2958
+ usageStateOf(entry).opencode = openCodeMeterOf(entry).state();
2531
2959
  if (delta > 0 || completed) enforceMidTurn(entry, liveState);
2532
2960
  }
2533
2961
  /**
@@ -2539,6 +2967,7 @@ export function createAssistantDispatcher({
2539
2967
  if (entry.usageState?.closed) return null;
2540
2968
  const meter = openCodeMeterOf(entry);
2541
2969
  const delta = meter.markTurn();
2970
+ usageStateOf(entry).opencode = meter.state();
2542
2971
  const fidelity = reconciled ? 'exact' : 'partial';
2543
2972
  const reason = reconciled ? null : 'opencode-not-reconciled';
2544
2973
  const source = reconciled ? 'opencode-messages' : 'opencode-events';
@@ -2687,6 +3116,336 @@ export function createAssistantDispatcher({
2687
3116
  return true;
2688
3117
  }
2689
3118
 
3119
+ // ── server restart: suspend, then resume ───────────────────────────────────
3120
+ // Every restart ends the provider processes of its workers (they are its children). A native worker that may
3121
+ // continue (lib/worker-resume.js resumeRefusal) is suspended as the server goes down and resumed on the next start
3122
+ // as the same run: its runId, provider conversation and sidepanel tab. See docs/assistant.md.
3123
+
3124
+ /**
3125
+ * The server is going down (`reason`: the shutdown's): every eligible native worker is suspended
3126
+ * (state 'suspended', no end, no end card), and its runId returned for the runtime to keep (native-loop-runtime.js
3127
+ * shutdown's `keep`: its CLI is ended, its record kept). Called first by the graceful shutdown (server.js), and by
3128
+ * shutdown() for an exit that skipped it. Idempotent.
3129
+ */
3130
+ function suspendForShutdown(reason = 'server_shutdown') {
3131
+ closing = true;
3132
+ const kept = [];
3133
+ for (const entry of entries.values()) {
3134
+ if (!SUSPENDABLE_STATES.has(entry.state)) continue;
3135
+ const liveState = live.get(entry.runId) || null;
3136
+ // Only a run whose worker is up, or whose resume is starting it (its session is known): one still starting for
3137
+ // the first time has no session to resume yet.
3138
+ if (!liveState || liveState.dead || (!liveState.inner && !liveState.resuming)) continue;
3139
+ // Skill evaluation and drafting runs end as before (the runtime stops them).
3140
+ if (entry.confinement) continue;
3141
+ const refused = resumeRefusal(entry, { atShutdown: true, workLeftMs: workLeftMs(entry) });
3142
+ if (refused) {
3143
+ if (entry.refusedAtShutdown) continue;
3144
+ // A Claude Code worker that may not continue (resumed too often, out of work time) ends interrupted with why
3145
+ // when the runtime stops it (finalize), and is said again once the server is back (loadRegistry): the brain's
3146
+ // mailbox goes down with this server.
3147
+ entry.resumeRefused = refused;
3148
+ entry.refusedAtShutdown = true;
3149
+ entry.completionReason = entry.completionReason || 'server_restart';
3150
+ entry.error = `Not resumed after the server restart: ${refused}`;
3151
+ entry.notes.push(`not resumed: ${refused}`);
3152
+ continue;
3153
+ }
3154
+ // A resume still starting keeps what it was resuming: the turn the earlier restart cut off (resumeInfo.inFlight)
3155
+ // or the wait for a follow-up.
3156
+ const midTurn = entry.state !== 'idle' && liveState.resume?.mode !== 'idle';
3157
+ pauseWork(entry, liveState);
3158
+ liveState.suspending = true;
3159
+ if (liveState.idleTimer) { clearTimeout(liveState.idleTimer); liveState.idleTimer = null; }
3160
+ entry.state = 'suspended';
3161
+ entry.turnState = 'suspended';
3162
+ entry.suspendedAt = iso(now());
3163
+ entry.suspendReason = String(reason || 'server_shutdown');
3164
+ delete entry.crashed;
3165
+ // An open permission request goes with the process: the resumed worker asks again.
3166
+ for (const requestId of [...liveState.pendingResolvers.keys()]) finishPending(entry.runId, requestId, { behavior: 'deny', message: 'Server restarting; ask again after resume.', resolvedBy: 'server_restart' });
3167
+ entry.pending = [];
3168
+ // What the cut-off turn spent so far is booked now (an estimate per call the stream showed: no result will
3169
+ // count them); the meter takes the resumed CLI's counters as its new baseline (rebaseClaudeUsage).
3170
+ try { suspendUsage(entry, liveState); } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
3171
+ 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`);
3172
+ emit(entry, 'suspended', { text: 'suspended: SynaBun is restarting; this run resumes as soon as the server is back (no action needed)' });
3173
+ kept.push(entry.runId);
3174
+ }
3175
+ if (kept.length) persist({ force: true });
3176
+ return kept;
3177
+ }
3178
+ /** Book pending provider usage and save the meter baseline before the process ends. */
3179
+ function suspendUsage(entry, liveState) {
3180
+ if (entry.provider === 'codex') pollCodex(entry, liveState, { discover: true });
3181
+ if (entry.provider === 'opencode' && liveState) bookOpenCodeTurn(entry, liveState);
3182
+ stopUsagePolling(liveState);
3183
+ if (liveState) liveState.turnOpen = false;
3184
+ const claude = meters.get(entry.runId)?.claude;
3185
+ if (claude && typeof claude.settlePending === 'function') {
3186
+ const rows = claude.settlePending({ reason: 'no-result' });
3187
+ if (rows.length) settleClaudeRows(entry, rows);
3188
+ usageStateOf(entry).claude = claude.state();
3189
+ }
3190
+ if (ledger && entry.assistantSessionId) { try { ledger.clearPending(entry.assistantSessionId, entry.runId); } catch { /* nothing pending */ } }
3191
+ }
3192
+ /**
3193
+ * The process of a suspended run's CLI, if it still runs: told to stop (an exit handler cannot wait for it). Not
3194
+ * when the runtime is ending it already (its record is suspended): a second signal could cut its clean exit short.
3195
+ * Windows has no SIGTERM (a signal ends the CLI alone and leaves its tool's processes): the tree is ended with
3196
+ * taskkill, which returns once it is done.
3197
+ */
3198
+ function signalWorkerProcess(entry) {
3199
+ try { if (safeRuntime()?.get?.(entry.runId)?.status === 'suspended') return; } catch { /* no runtime: signal it */ }
3200
+ let pid = null;
3201
+ try { pid = live.get(entry.runId)?.inner?.processInfo?.()?.pid || null; } catch { pid = null; }
3202
+ if (!pid) return;
3203
+ if (process.platform === 'win32') { try { endProcessTree(pid); } catch { /* gone, or not allowed */ } return; }
3204
+ try { process.kill(pid, 'SIGTERM'); } catch { /* gone */ }
3205
+ }
3206
+
3207
+ /**
3208
+ * The resumed CLI starts its counters where its transcript's last cost-state left them (lib/worker-resume.js
3209
+ * readCostState): the Claude meter takes that as its baseline and the adapter its cost counter, so the first turn
3210
+ * after the resume is charged for itself only. What the cut-off turn spent was booked when the run was suspended; a
3211
+ * crash booked nothing, so what the CLI counted after the run's last settled result is settled now, from its own
3212
+ * counts (one result made of the cost-state). Returns the cost base for the adapter (costBaseUsd).
3213
+ */
3214
+ function rebaseClaudeUsage(entry) {
3215
+ let costState = null;
3216
+ try {
3217
+ const transcript = findClaudeTranscript({ sessionId: entry.providerSessionId, configDir: entry.claudeConfigDir || null });
3218
+ costState = transcript ? readCostState(transcript, entry.providerSessionId) : null;
3219
+ } catch { costState = null; }
3220
+ const state = usageStateOf(entry);
3221
+ if (entry.crashed && costState?.modelUsage) {
3222
+ try {
3223
+ const meter = claudeMeterOf(entry);
3224
+ const before = meter.state().snapshot;
3225
+ if (!before || countersCover(costState.modelUsage, before)) {
3226
+ const { settled } = meter.onEvent({ type: 'result', subtype: 'success', uuid: `resume:${entry.runId}:${Number(entry.resumes) || 0}`, session_id: entry.providerSessionId, modelUsage: costState.modelUsage, usage: {} });
3227
+ if (settled) { settleClaudeRows(entry, settled.rows, settled); bookCost(entry, Number(settled.costUsd) || 0, 'reported'); }
3228
+ }
3229
+ } catch (error) { log(entry.runId, 'assistant:meter-error', error?.message || String(error)); }
3230
+ }
3231
+ const held = metersOf(entry);
3232
+ const saved = held.claude ? held.claude.state() : (state.claude || {});
3233
+ state.claude = { ...saved, snapshot: costState?.modelUsage || null, unbaselined: null, history: false };
3234
+ held.claude = null;
3235
+ delete entry.crashed;
3236
+ persist({ force: true });
3237
+ return costState ? costState.totalCostUsd : 0;
3238
+ }
3239
+ /** Whether per-model counters `now` are at or above `before` for every model and counter (the same process went on). */
3240
+ function countersCover(nowUsage, before) {
3241
+ const keys = ['inputTokens', 'outputTokens', 'cacheReadInputTokens', 'cacheCreationInputTokens'];
3242
+ return Object.entries(before || {}).every(([model, counters]) => {
3243
+ const current = nowUsage?.[model];
3244
+ return !!current && keys.every((key) => (Number(current[key]) || 0) >= (Number(counters?.[key]) || 0));
3245
+ });
3246
+ }
3247
+
3248
+ /**
3249
+ * After a restart (loadRegistry filled bootResumes / bootInterrupts): the runs that will not resume are said
3250
+ * ('interrupted', with why) and their runtime records ended; the others are resumed one at a time, in the order they
3251
+ * were dispatched, ahead of every queued dispatch. Runs a lowered concurrency limit holds back wait at the head of
3252
+ * the queue (processQueue resumes them).
3253
+ */
3254
+ async function resumeOnBoot() {
3255
+ // The limits as the user set them (assistant-config.json), not the defaults.
3256
+ refreshLimits();
3257
+ const runtime = safeRuntime();
3258
+ const ids = bootResumes.splice(0)
3259
+ .map((runId) => entries.get(runId)).filter((entry) => entry?.state === 'resuming')
3260
+ .sort((a, b) => String(a.createdAt || '').localeCompare(String(b.createdAt || '')))
3261
+ .map((entry) => entry.runId);
3262
+ // Held for this dispatcher from here on: the runtime's own hold no longer ends them.
3263
+ for (const runId of ids) { try { runtime?.keepSuspended?.(runId); } catch { /* ended meanwhile */ } }
3264
+ for (const runId of bootInterrupts.splice(0)) {
3265
+ const entry = entries.get(runId);
3266
+ if (!entry) continue;
3267
+ // A CLI a crash left behind goes with the run.
3268
+ await endOldProcess(entry);
3269
+ try { await runtime?.endSuspended?.(runId, 'server_restart', { error: entry.resumeRefused ? `not resumed: ${entry.resumeRefused}` : null }); } catch { /* not held */ }
3270
+ emit(entry, 'interrupted', { text: `not resumed after the server restart: ${entry.resumeRefused || 'it could not continue'}` });
3271
+ }
3272
+ // A resumed CLI connects to SynaBun's MCP as it starts: not before this server answers.
3273
+ if (ids.length && typeof serverReady === 'function') { try { await serverReady(); } catch { /* resume anyway */ } }
3274
+ const held = [];
3275
+ for (const runId of ids) {
3276
+ const entry = entries.get(runId);
3277
+ if (!entry || entry.state !== 'resuming') continue;
3278
+ const spec = { provider: entry.provider, accountId: entry.accountId, workflowId: entry.workflowId, usesComputer: !!entry.usesComputer };
3279
+ const block = railBlock(spec, entry.assistantSessionId, { except: runId });
3280
+ if (block) { entry.notes.push(`resume queued: ${block}`); held.push(runId); persist(); continue; }
3281
+ // What it threw (a folder that cannot be made, a full disk) ends it, with why: nothing else would.
3282
+ try { await resumeRun(entry); } catch (error) { log(runId, 'assistant:resume-error', error?.message || String(error)); notResumed(entry, error?.message || String(error)); }
3283
+ }
3284
+ if (held.length) { queue.unshift(...held); processQueue().catch(() => {}); }
3285
+ }
3286
+
3287
+ /**
3288
+ * Continue a run a server restart cut off (state 'resuming'): what start() does for a new run (the budget as it is
3289
+ * now, a browser tab, the desktop grant, the permission broker), then the runtime resumes the run's record with its
3290
+ * Claude session (resumeSessionId). A run that was in a turn gets the continuation prompt (buildResumePrompt) as its
3291
+ * next turn; one that waited for a follow-up waits again; one that never got its first turn going is sent its task.
3292
+ * A run that cannot be resumed ends as interrupted, with why. It holds its provider's slot from the call on (the
3293
+ * callers check the rails just before it, in the same tick).
3294
+ */
3295
+ async function resumeRun(entry) {
3296
+ if (entry.state !== 'resuming') return null;
3297
+ resumeInProgress.add(entry.runId);
3298
+ try { return await continueRun(entry); } finally { resumeInProgress.delete(entry.runId); }
3299
+ }
3300
+ async function continueRun(entry) {
3301
+ if (closing) return null;
3302
+ const runtime = safeRuntime();
3303
+ if (!runtime?.resume) return notResumed(entry, 'the native loop runtime is not available');
3304
+ refreshLimits();
3305
+ const refusal = resumeRefusal(entry, { now: now(), workLeftMs: workLeftMs(entry), isDirectory, accountUnusable: providerAccountUnusable });
3306
+ if (refusal) return notResumed(entry, refusal);
3307
+ try { await fitBudget(entry, { routed: true }); } catch (error) { return notResumed(entry, `the budget does not allow it (${error?.message || error})`); }
3308
+ if (entry.state !== 'resuming' || closing) return null; // stopped meanwhile, or the server is going down again
3309
+ // Its own ceiling (budget_usd), with what its cut-off turn was booked at the suspension (fitBudget checks the
3310
+ // session and the share only): a run out of money is not resumed, as a turn that ends out of money does not idle.
3311
+ const spentOut = budgetBlock(entry);
3312
+ if (spentOut && spentOut.code !== 'BUDGET_SHARE_EXHAUSTED') {
3313
+ entry.completionReason = spentOut.reason;
3314
+ return notResumed(entry, `the budget does not allow it (${spentOut.message})`);
3315
+ }
3316
+ if (!runtime.isSuspended?.(entry.runId)) return notResumed(entry, 'its runtime record is gone');
3317
+ // A crash ended no process: the CLI the run had may still be there, finishing the cut-off tool call. It is ended
3318
+ // first; one that cannot be checked keeps the run from resuming (two processes on one session would both write its
3319
+ // transcript).
3320
+ const orphan = await endOldProcess(entry);
3321
+ if (orphan.state === 'unknown' || orphan.state === 'still_running') {
3322
+ 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'}`);
3323
+ }
3324
+ if (entry.state !== 'resuming' || closing) return null;
3325
+ const costBaseUsd = entry.provider === 'claude-code' ? rebaseClaudeUsage(entry) : 0;
3326
+ if (loopDir && !existsSync(loopDir)) mkdirSync(loopDir, { recursive: true });
3327
+ // Its browser session went with the server: a new one (the resume prompt names it).
3328
+ let browserSessionId = null;
3329
+ let browserTabId = null;
3330
+ if (entry.usesBrowser && typeof acquireLoopBrowserAndTab === 'function') {
3331
+ const acq = await acquireLoopBrowserAndTab({ requestedBrowserSessionId: entry.requestedBrowserSessionId, terminalSessionId: entry.runId, logTag: 'assistant:browser' }).catch((error) => ({ error: error?.message || String(error) }));
3332
+ if (acq?.error) return notResumed(entry, `no browser tab: ${acq.error}`);
3333
+ browserSessionId = acq.browserSessionId || null;
3334
+ browserTabId = acq.browserTabId || null;
3335
+ }
3336
+ if (entry.state !== 'resuming' || closing) {
3337
+ if (browserSessionId) await releaseSharedBrowserTab(browserSessionId, entry.runId).catch(() => {});
3338
+ return null;
3339
+ }
3340
+ entry.browserSessionId = browserSessionId;
3341
+ entry.browserTabId = browserTabId;
3342
+ if (entry.scratchDir) { try { mkdirSync(entry.scratchDir, { recursive: true }); } catch { /* the guard says so when it needs it */ } }
3343
+ const inFlight = entry.resumeInfo?.inFlight || null;
3344
+ const turned = lastTurnNumber(entry);
3345
+ const mode = inFlight ? 'mid_turn' : turned > 0 ? 'idle' : 'fresh';
3346
+ const liveState = newLiveState(entry);
3347
+ liveState.turnCounter = turned;
3348
+ liveState.resume = { mode, taskId: inFlight?.taskId || null };
3349
+ // Follow-ups sent while it was away wait for it like any queued follow-up.
3350
+ const waiting = awaitingResume.get(entry.runId) || [];
3351
+ awaitingResume.delete(entry.runId);
3352
+ liveState.followUps.push(...waiting);
3353
+ live.set(entry.runId, liveState);
3354
+ const desktopGrant = mintDesktopGrant(entry);
3355
+ const state = runState(entry, {
3356
+ browserSessionId, browserTabId, desktopGrant,
3357
+ startedAt: entry.startedAt || iso(now()),
3358
+ extra: { resumeSessionId: entry.providerSessionId, resumeThreadId: entry.providerThreadId, costBaseUsd, resumingWorker: true, antigravityHome: entry.resumeInfo?.antigravityHome || null },
3359
+ });
3360
+ // The CLI's spend stop (--max-budget-usd) counts from the total the resumed CLI restores (costBaseUsd): what the run
3361
+ // may still spend (its ceiling less what it spent, never more than the session has left) goes on top of that. A
3362
+ // backstop only: the dispatcher holds the run to its ceiling and its share between calls.
3363
+ const allowanceUsd = claudeRunCap(entry, { spentUsd: entry.costUsd });
3364
+ if (Number(allowanceUsd) > 0) state.maxBudgetUsd = Number((Number(allowanceUsd) + Math.max(0, Number(costBaseUsd) || 0)).toFixed(2));
3365
+ state.permissionBroker = createBroker(entry.runId);
3366
+ const at = clockTime(entry.suspendedAt);
3367
+ const selfRestarts = isInside(entry.cwd, PACKAGE_ROOT);
3368
+ const playbook = mode === 'fresh' ? playbookFor(entry) : null;
3369
+ const styleGuide = mode === 'fresh' ? styleGuideFor(entry) : null;
3370
+ const prompt = () => (mode === 'mid_turn'
3371
+ ? buildResumePrompt(state, { at, selfRestarts })
3372
+ : buildTaskPrompt({ ...state, ...(playbook ? { playbook } : {}), ...(styleGuide ? { styleGuide } : {}), testDensity: briefTestDensity(entry) }));
3373
+ entry.resumes = (Number(entry.resumes) || 0) + 1;
3374
+ entry.resumedAt = iso(now());
3375
+ entry.state = 'starting';
3376
+ entry.turnState = 'starting';
3377
+ 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'}`);
3378
+ // A shutdown while its CLI starts suspends it again (suspendForShutdown): the next start resumes it.
3379
+ liveState.resuming = true;
3380
+ try {
3381
+ const descriptor = await runtime.resume({
3382
+ runId: entry.runId,
3383
+ state,
3384
+ buildPrompt: prompt,
3385
+ wrapAdapter: (adapter, ctx) => createTaskAdapter(adapter, ctx, liveState, state),
3386
+ });
3387
+ entry.runtime = { status: descriptor.status };
3388
+ } catch (error) {
3389
+ // Suspended again by a shutdown while its CLI started: the start was cancelled, nothing ended.
3390
+ if (entry.state === 'suspended') return null;
3391
+ // The runtime ended its record (resume_failed): its run-stopped event ended the run here too.
3392
+ const why = error?.message || String(error);
3393
+ if (browserSessionId) await releaseSharedBrowserTab(browserSessionId, entry.runId).catch(() => {});
3394
+ if (!TERMINAL_STATES.has(entry.state)) return notResumed(entry, why);
3395
+ entry.notes.push(`not resumed: ${why}`);
3396
+ persist();
3397
+ return null;
3398
+ }
3399
+ if (entry.state === 'suspended') return null;
3400
+ delete entry.suspendedAt;
3401
+ delete entry.suspendReason;
3402
+ emit(entry, 'resumed', {
3403
+ resumes: entry.resumes,
3404
+ 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.`,
3405
+ });
3406
+ reconcileKeepAwake();
3407
+ return view(entry);
3408
+ }
3409
+ /**
3410
+ * The CLI a run had before a crash may still be there, finishing its cut-off tool call (a crash ends no process): it
3411
+ * is ended once its command line shows it is this run's (lib/worker-resume.js endOrphanProcess), before a resume and
3412
+ * when a run a restart cut off ends without one. → { state, pid } ('gone' without a saved pid).
3413
+ */
3414
+ async function endOldProcess(entry) {
3415
+ const pid = Number(entry?.resumeInfo?.pid) || null;
3416
+ if (!pid) return { state: 'gone', pid: null };
3417
+ let orphan = null;
3418
+ try { orphan = await endOrphanProcess({ pid, runId: entry.runId }); } catch { orphan = { state: 'unknown' }; }
3419
+ 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}`);
3420
+ if (orphan.state !== 'unknown' && orphan.state !== 'still_running' && entry.resumeInfo) {
3421
+ delete entry.resumeInfo.pid;
3422
+ delete entry.resumeInfo.pidStartedAt;
3423
+ }
3424
+ return { ...orphan, pid };
3425
+ }
3426
+ /** A run that could not be resumed after a restart ends as interrupted, with why (its notes, its error, its event). */
3427
+ function notResumed(entry, why) {
3428
+ if (TERMINAL_STATES.has(entry.state)) return null;
3429
+ const index = queue.indexOf(entry.runId);
3430
+ if (index !== -1) queue.splice(index, 1);
3431
+ // Follow-ups accepted for it while it was away (agent_send) reach nobody: its end says so, with the first of them.
3432
+ const waiting = awaitingResume.get(entry.runId) || [];
3433
+ awaitingResume.delete(entry.runId);
3434
+ 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)}")` : '';
3435
+ // Nothing of it may go on: a CLI a crash left behind is ended too.
3436
+ endOldProcess(entry).then(() => persist()).catch(() => {});
3437
+ entry.notes.push(`not resumed: ${why}${undelivered}`);
3438
+ entry.resumeRefused = why;
3439
+ entry.completionReason = entry.completionReason || 'server_restart';
3440
+ const error = `Not resumed after the server restart: ${why}${undelivered}`;
3441
+ let ended = false;
3442
+ try { ended = !!safeRuntime()?.isSuspended?.(entry.runId); } catch { ended = false; }
3443
+ // The runtime's run-stopped event finalizes the run here (onRuntimeMessage); without a record it is done here.
3444
+ if (ended) safeRuntime().endSuspended(entry.runId, 'server_restart', { error }).catch(() => {});
3445
+ if (!TERMINAL_STATES.has(entry.state)) finalize(entry, { status: 'interrupted', stoppedReason: 'server_restart', error });
3446
+ return null;
3447
+ }
3448
+
2690
3449
  // ── follow-ups / lifecycle controls ────────────────────────────────────────
2691
3450
  function requireEntry(runId) {
2692
3451
  const entry = entries.get(runId);
@@ -2700,9 +3459,12 @@ export function createAssistantDispatcher({
2700
3459
  if (message.length > 20_000) throw new DispatchError('MESSAGE_TOO_LONG', 'text must be at most 20000 characters');
2701
3460
  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 });
2702
3461
  if (entry.state === 'queued') throw new DispatchError('RUN_NOT_STARTED', `Run ${runId} is still queued`, { status: 409 });
2703
- const liveState = live.get(runId);
2704
- if (!liveState) throw new DispatchError('RUN_NOT_ACTIVE', `Run ${runId} has no live worker`, { status: 409 });
2705
- if (liveState.turnCounter >= entry.maxTurns) throw new DispatchError('MAX_TURNS', `Run ${runId} reached its max turns (${entry.maxTurns})`, { status: 409 });
3462
+ // Away for a server restart (suspended, or being resumed): the follow-up waits for the run and goes to it after
3463
+ // its resume, like a follow-up queued behind a turn. Should the run not resume, it ends and says why.
3464
+ const away = entry.state === 'suspended' || entry.state === 'resuming';
3465
+ const liveState = away ? null : live.get(runId);
3466
+ if (!away && !liveState) throw new DispatchError('RUN_NOT_ACTIVE', `Run ${runId} has no live worker`, { status: 409 });
3467
+ if ((away ? awayTurns(entry) : liveState.turnCounter) >= entry.maxTurns) throw new DispatchError('MAX_TURNS', `Run ${runId} reached its max turns (${entry.maxTurns})`, { status: 409 });
2706
3468
  refreshLimits();
2707
3469
  const blocked = budgetBlock(entry);
2708
3470
  if (blocked) {
@@ -2720,6 +3482,14 @@ export function createAssistantDispatcher({
2720
3482
  // A follow-up works for the task in progress when it is sent, which may be a later one than the run's.
2721
3483
  const item = { kind: 'follow-up', text: message, origin, at: iso(now()), taskId: taskNow(entry.assistantSessionId) || entry.taskId || null };
2722
3484
  if (origin === 'user') emit(entry, 'user_intervened', { text: clip(message, 200) });
3485
+ if (away) {
3486
+ 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 });
3487
+ const waiting = awaitingResume.get(runId) || [];
3488
+ waiting.push(item);
3489
+ awaitingResume.set(runId, waiting);
3490
+ emit(entry, 'follow_up_queued', { queued: waiting.length });
3491
+ 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.' };
3492
+ }
2723
3493
  if (liveState.waitResolve) { liveState.waitResolve(item); return { ok: true, queued: false, turn: liveState.turnCounter + 1, run: view(entry) }; }
2724
3494
  if (!allowQueue) throw new DispatchError('TURN_ACTIVE', `Run ${runId} is busy; retry when idle or pass queue:true`, { status: 409 });
2725
3495
  liveState.followUps.push(item);
@@ -2777,6 +3547,15 @@ export function createAssistantDispatcher({
2777
3547
  }
2778
3548
  if (TERMINAL_STATES.has(entry.state)) return { ok: true, run: view(entry), alreadyTerminal: true };
2779
3549
  entry.completionReason = entry.completionReason || reason;
3550
+ // A run waiting for its resume after a restart leaves the queue and drops the follow-ups that waited for it; its
3551
+ // runtime record (suspended) ends as stopped.
3552
+ if (entry.state === 'resuming' || entry.state === 'suspended') {
3553
+ const index = queue.indexOf(runId);
3554
+ if (index !== -1) queue.splice(index, 1);
3555
+ awaitingResume.delete(runId);
3556
+ // A CLI a crash left behind (this start has not ended it yet) goes with the run.
3557
+ if (entry.state === 'resuming') await endOldProcess(entry);
3558
+ }
2780
3559
  const runtime = safeRuntime();
2781
3560
  const stopped = runtime ? await runtime.stop(runId, reason) : false;
2782
3561
  if (!stopped) finalize(entry, { status: 'stopped', stoppedReason: reason });
@@ -2795,11 +3574,15 @@ export function createAssistantDispatcher({
2795
3574
  function finalize(entry, runtimeRun) {
2796
3575
  if (TERMINAL_STATES.has(entry.state)) return;
2797
3576
  const liveState = live.get(entry.runId);
3577
+ // Follow-ups that waited for its resume after a restart go with it.
3578
+ awaitingResume.delete(entry.runId);
2798
3579
  pauseWork(entry, liveState);
2799
3580
  let status = runtimeRun?.status || 'completed';
2800
3581
  // A completed worker whose loop file was swept meanwhile (a stale-file cleanup) reaches the
2801
3582
  // runtime's next check as "stopped": its turn loop had already ended on the completion.
2802
3583
  if (status === 'stopped' && liveState?.completed && /^state_(removed|inactive)$/.test(String(runtimeRun?.stoppedReason || ''))) status = 'completed';
3584
+ // A Claude Code worker the shutdown could not suspend (suspendForShutdown) ends interrupted, with why.
3585
+ if (status === 'stopped' && entry.refusedAtShutdown) status = 'interrupted';
2803
3586
  entry.state = TERMINAL_STATES.has(status) ? status : 'completed';
2804
3587
  entry.turnState = 'terminal';
2805
3588
  entry.finishedAt = iso(now());
@@ -2814,18 +3597,21 @@ export function createAssistantDispatcher({
2814
3597
  }
2815
3598
  entry.pending = [];
2816
3599
  releaseDesktop(entry);
3600
+ // The run's scratch folder goes when it left nothing there (what it wrote is the user's to look at).
3601
+ if (entry.scratchDir) { try { rmdirSync(entry.scratchDir); } catch { /* not empty, or already gone */ } }
2817
3602
  // A run that ended gives back what it did not spend: the session's split is made again (debounced).
2818
3603
  if (entry.assistantSessionId) { try { budget.onRunEnded(entry.assistantSessionId); } catch { /* the split stays as it is */ } }
2819
- if (entry.state === 'failed' && !budgetStopReason(entry.completionReason) && judge?.workerOutcome) {
3604
+ if (entry.state === 'failed' && !budgetStopReason(entry.completionReason) && judge?.workerOutcome && !isEvalRun(entry)) {
2820
3605
  // State, queue and desktop move on now; the 'failed' event waits for its cause.
2821
3606
  emitFailed(entry, liveState?.evidence || null);
2822
3607
  processQueue().catch(() => {});
2823
3608
  return;
2824
3609
  }
2825
- if (entry.state === 'failed' || entry.lastResult?.status === 'blocked') entry.escalation = computeEscalation(entry);
3610
+ if (entry.state === 'failed' || entry.lastResult?.status === 'blocked') { noteLimit(entry); entry.escalation = computeEscalation(entry); }
2826
3611
  emit(entry, entry.state);
2827
3612
  // A memory already stored for the run (by the worker or the brain) is the run's memory.
2828
- if (memory?.rememberDispatch && !syncMemory(entry, { announce: true }) && (entry.state === 'completed' || (entry.state === 'stopped' && entry.lastResult))) {
3613
+ // An evaluation run (lib/skills/evals.js) is a measurement, not work to remember: it stores no memory.
3614
+ if (memory?.rememberDispatch && !isEvalRun(entry) && !syncMemory(entry, { announce: true }) && (entry.state === 'completed' || (entry.state === 'stopped' && entry.lastResult))) {
2829
3615
  Promise.resolve(memory.rememberDispatch(view(entry))).then((memoryId) => {
2830
3616
  if (!memoryId) return;
2831
3617
  entry.memoryId = memoryId;
@@ -2835,11 +3621,64 @@ export function createAssistantDispatcher({
2835
3621
  }
2836
3622
  processQueue().catch(() => {});
2837
3623
  }
3624
+ /** One transcript line into a live run's ring (bounded by lines and characters). */
3625
+ function pushRingLine(liveState, line) {
3626
+ liveState.ring.push(line);
3627
+ liveState.ringChars += line.length;
3628
+ // The message line still growing is no longer the last one.
3629
+ liveState.messageLine = null;
3630
+ while (liveState.ring.length > limits.ringSize || liveState.ringChars > limits.ringChars) {
3631
+ const removed = liveState.ring.shift();
3632
+ liveState.ringChars -= removed?.length || 0;
3633
+ }
3634
+ }
3635
+ /**
3636
+ * An Antigravity message chunk (ACP agent_message_chunk): the text joins the message's line when that line is
3637
+ * still the ring's last one (same messageId), else it starts a new "assistant:" line. Clipped as every line is.
3638
+ */
3639
+ function growMessageLine(liveState, update = {}) {
3640
+ const text = update?.content?.type === 'text' ? String(update.content.text || '') : '';
3641
+ if (!text) return;
3642
+ const id = update.messageId ?? null;
3643
+ const open = liveState.messageLine;
3644
+ if (open && open.id === id && liveState.ring.length && liveState.ring[liveState.ring.length - 1] === open.line) {
3645
+ open.text += text;
3646
+ const line = `${open.stamp} assistant: ${clip(open.text, 200)}`;
3647
+ if (line !== open.line) {
3648
+ liveState.ring[liveState.ring.length - 1] = line;
3649
+ liveState.ringChars += line.length - open.line.length;
3650
+ open.line = line;
3651
+ }
3652
+ return;
3653
+ }
3654
+ if (!text.trim()) return;
3655
+ const stamp = iso(now()).slice(11, 19);
3656
+ const line = `${stamp} assistant: ${clip(text, 200)}`;
3657
+ pushRingLine(liveState, line);
3658
+ liveState.messageLine = { id, text, stamp, line };
3659
+ }
3660
+ /**
3661
+ * An ACP tool_call_update carries only what changed: the call it updates, as the run has seen it so far, so its
3662
+ * end is said with the call's kind, command and title. Other payloads pass as they are. Bounded per run.
3663
+ */
3664
+ function withAcpCall(liveState, payload) {
3665
+ if ((payload.eventType !== 'tool_call' && payload.eventType !== 'tool_call_update') || !payload.event?.toolCallId) return payload;
3666
+ const calls = liveState.acpCalls || (liveState.acpCalls = new Map());
3667
+ const id = String(payload.event.toolCallId);
3668
+ const call = { ...(calls.get(id) || {}) };
3669
+ for (const [key, value] of Object.entries(payload.event)) if (value !== undefined && value !== null) call[key] = value;
3670
+ calls.delete(id);
3671
+ calls.set(id, call);
3672
+ if (calls.size > 200) calls.delete(calls.keys().next().value);
3673
+ return { ...payload, event: call };
3674
+ }
2838
3675
  function onRuntimeMessage(message) {
2839
3676
  const runId = message?.run?.runId;
2840
3677
  if (!runId || !entries.has(runId)) return;
2841
3678
  const entry = entries.get(runId);
2842
3679
  if (message.type === 'sidepanel:provider-event') {
3680
+ if (!isEvalRun(entry)) { try { observeSkillEvent(entry, message.event || {}); } catch {} }
3681
+ if (entry.provider === 'codex' && Number(message.event?.providerTurn) > 0) resumeInfoOf(entry).providerTurn = Number(message.event.providerTurn);
2843
3682
  const liveState = live.get(runId);
2844
3683
  // Not tied to the live turn: a result that arrives after an abort is still settled.
2845
3684
  if (entry.provider === 'claude-code') { try { meterClaude(entry, message.event?.event); } catch (error) { log(runId, 'assistant:meter-error', error?.message || String(error)); } }
@@ -2847,24 +3686,42 @@ export function createAssistantDispatcher({
2847
3686
  liveState.lastEventAt = now();
2848
3687
  liveState.stalledFlagged = false;
2849
3688
  if (liveState.evidence) { try { recordProviderEvent(liveState.evidence, message.event); } catch {} }
3689
+ // A skill-evaluation run: its tool calls (one entry per call id) and the catalog it started with, for its
3690
+ // trigger reading (lib/skills/eval-trace.js; read with transcript format 'tools').
3691
+ if (isEvalRun(entry)) { try { recordEvalCalls(liveState, message.event || {}); } catch {} }
2850
3692
  if (entry.provider === 'opencode') { try { meterOpenCode(entry, liveState, message.event || {}); } catch (error) { log(runId, 'assistant:meter-error', error?.message || String(error)); } }
2851
- const summary = summarizeProviderEvent(message.event || {});
2852
- if (summary) {
2853
- const line = `${iso(now()).slice(11, 19)} ${summary}`;
2854
- liveState.ring.push(line);
2855
- liveState.ringChars += line.length;
2856
- while (liveState.ring.length > limits.ringSize || liveState.ringChars > limits.ringChars) {
2857
- const removed = liveState.ring.shift();
2858
- liveState.ringChars -= removed?.length || 0;
2859
- }
3693
+ // Antigravity streams each message in small chunks: one transcript line per message, grown as it streams.
3694
+ if (entry.provider === 'antigravity' && message.event?.eventType === 'agent_message_chunk') { growMessageLine(liveState, message.event.event); return; }
3695
+ const summary = summarizeProviderEvent(entry.provider === 'antigravity' ? withAcpCall(liveState, message.event || {}) : message.event || {});
3696
+ // A guard decision (lib/worker-confinement.js) is also a note of the run: what was refused or warned about,
3697
+ // with its reason, so the brain and the panel see it without the transcript. The first twenty.
3698
+ if ((message.event?.eventType || message.event?.event?.type) === 'synabun.confinement') {
3699
+ const count = (liveState.guardEvents = (liveState.guardEvents || 0) + 1);
3700
+ if (count <= 20) entry.notes.push(`guard ${message.event.event?.action || 'refused'}: ${clip(summary, 300)}`);
3701
+ else if (count === 21) entry.notes.push('guard: more decisions follow in the transcript');
2860
3702
  }
3703
+ if (summary) pushRingLine(liveState, `${iso(now()).slice(11, 19)} ${summary}`);
2861
3704
  }
2862
3705
  return;
2863
3706
  }
2864
3707
  const run = message.run;
2865
- entry.runtime = { status: run.status, turnState: run.turnState, stoppedReason: run.stoppedReason, error: run.error, claimedBy: run.claimedBy, providerSessionId: run.providerSessionId, providerThreadId: run.providerThreadId };
3708
+ if (message.reason === 'mcp-tools' && Array.isArray(run.tools)) {
3709
+ entry.tools = storedToolList({ tools: retainExternalRefs(run.tools, entry.tools) }, toolListOptions());
3710
+ // Keep the original request for escalation, and the current selection for restart.
3711
+ persist({ force: true });
3712
+ emit(entry, 'tools_changed');
3713
+ }
3714
+ const runHome = run.antigravityHome || entry.resumeInfo?.antigravityHome || entry.antigravityHome || null;
3715
+ entry.runtime = { status: run.status, turnState: run.turnState, stoppedReason: run.stoppedReason, error: run.error, claimedBy: run.claimedBy, providerSessionId: run.providerSessionId, providerThreadId: run.providerThreadId, ...(runHome ? { antigravityHome: runHome } : {}) };
3716
+ // A new provider identity is saved at once: a crash before the run's next write (its first alive mark comes up to
3717
+ // a minute later) would otherwise leave no session to resume.
3718
+ const newIdentity = (run.providerSessionId && run.providerSessionId !== entry.providerSessionId)
3719
+ || (run.providerThreadId && run.providerThreadId !== entry.providerThreadId)
3720
+ || (run.antigravityHome && run.antigravityHome !== entry.antigravityHome);
2866
3721
  if (run.providerSessionId) entry.providerSessionId = run.providerSessionId;
2867
3722
  if (run.providerThreadId) entry.providerThreadId = run.providerThreadId;
3723
+ if (run.antigravityHome) entry.antigravityHome = run.antigravityHome;
3724
+ if (newIdentity) persist({ force: true });
2868
3725
  if (message.type === 'sidepanel:run-updated' && message.reason === 'started' && entry.state === 'starting') {
2869
3726
  entry.state = 'running';
2870
3727
  entry.turnState = 'running';
@@ -2886,10 +3743,13 @@ export function createAssistantDispatcher({
2886
3743
  refreshLimits();
2887
3744
  for (const runId of [...queue]) {
2888
3745
  const entry = entries.get(runId);
2889
- if (!entry || entry.state !== 'queued') { queue.splice(queue.indexOf(runId), 1); continue; }
3746
+ // A run a restart cut off and a lowered limit held back (resumeOnBoot) waits at the head of the queue.
3747
+ const resuming = entry?.state === 'resuming';
3748
+ if (!entry || (entry.state !== 'queued' && !resuming)) { queue.splice(queue.indexOf(runId), 1); continue; }
2890
3749
  const spec = { provider: entry.provider, accountId: entry.accountId, workflowId: entry.workflowId, usesComputer: !!entry.usesComputer };
2891
- if (railBlock(spec, entry.assistantSessionId)) continue;
3750
+ if (railBlock(spec, entry.assistantSessionId, resuming ? { except: runId } : {})) continue;
2892
3751
  queue.splice(queue.indexOf(runId), 1);
3752
+ if (resuming) { try { await resumeRun(entry); } catch (error) { notResumed(entry, error?.message || String(error)); } continue; }
2893
3753
  try { await start(entry); emit(entry, 'queue_started'); } catch (error) { entry.notes.push(`queue start failed: ${error?.message || error}`); }
2894
3754
  }
2895
3755
  }
@@ -2962,6 +3822,8 @@ export function createAssistantDispatcher({
2962
3822
  return { ...base, text: text.length > cap ? text.slice(-cap) : text, truncated: text.length > cap };
2963
3823
  }
2964
3824
  if (format === 'files') return { ...base, files: entry.lastResult?.files || [] };
3825
+ // A skill-evaluation run's tool calls and its start catalog (kept while the run lives).
3826
+ 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 };
2965
3827
  if (format === 'events' || format === 'tail') {
2966
3828
  const lines = liveState ? liveState.ring.slice(-Math.max(1, Math.min(500, Number(tail) || 40))) : [];
2967
3829
  const joined = lines.join('\n');
@@ -3071,7 +3933,13 @@ export function createAssistantDispatcher({
3071
3933
  async function shutdown(reason = 'server_shutdown') {
3072
3934
  if (stalledTimer) clearInterval(stalledTimer);
3073
3935
  if (unsubscribeRuntime) { try { unsubscribeRuntime(); } catch {} unsubscribeRuntime = null; }
3936
+ // An exit the graceful shutdown did not run before (a crash, an exit handler): the runs that may continue are
3937
+ // suspended here as well, and their CLIs told to stop (an exit handler cannot wait for them; the next start ends
3938
+ // what is left). A run waiting for its resume stays as it is, for the next start.
3939
+ suspendForShutdown(reason);
3074
3940
  for (const entry of entries.values()) {
3941
+ if (entry.state === 'suspended') { signalWorkerProcess(entry); continue; }
3942
+ if (entry.state === 'resuming') continue;
3075
3943
  if (ACTIVE_STATES.has(entry.state)) {
3076
3944
  pauseWork(entry);
3077
3945
  entry.state = 'interrupted'; entry.completionReason = reason; entry.finishedAt = iso(now()); entry.turnState = 'terminal'; entry.pending = [];
@@ -3086,12 +3954,17 @@ export function createAssistantDispatcher({
3086
3954
 
3087
3955
  loadRegistry();
3088
3956
  attachRuntime();
3089
- stalledTimer = setInterval(() => { attachRuntime(); checkStalled(); }, 60_000);
3957
+ stalledTimer = setInterval(() => { attachRuntime(); checkStalled(); beat(); }, 60_000);
3090
3958
  stalledTimer.unref?.();
3959
+ // After a restart: the cut-off runs are resumed, or said to have ended, once the Assistant listens (server.js makes
3960
+ // its runtime, which subscribes, right after this dispatcher, in the same tick).
3961
+ if (bootResumes.length || bootInterrupts.length) {
3962
+ setTimeout(() => { resumeOnBoot().catch((error) => log(null, 'assistant:resume-error', error?.message || String(error))); }, 0);
3963
+ }
3091
3964
 
3092
3965
  return {
3093
3966
  dispatch, sendTurn, complete, completeClosedTab, stop, killAll, get, peek, list, totals, wait, transcript, pendingPermissions,
3094
- respondPermission, subscribe, shutdown, limits,
3967
+ respondPermission, subscribe, shutdown, limits, suspendForShutdown,
3095
3968
  sessionBudget, checkSession, refreshLimits, providerSpend, coverFollowUp, budget,
3096
3969
  resolveRoute, declineRoute, heldRouteRefusal, escalate,
3097
3970
  markDelivered, deliveryState,