synabun 2.0.9 → 2.0.10

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 (252) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +1 -1
  3. package/THIRD-PARTY-LICENSES.md +46 -4
  4. package/launcher.mjs +9 -3
  5. package/lib/start-launcher.js +6 -1
  6. package/mcp-server/package-lock.json +2 -2
  7. package/mcp-server/package.json +1 -1
  8. package/neural-interface/i18n/en.json +218 -7
  9. package/neural-interface/i18n/pt-BR.json +220 -9
  10. package/neural-interface/lib/account-usage.js +5 -3
  11. package/neural-interface/lib/app-controls-api.js +161 -0
  12. package/neural-interface/lib/assistant-api.js +21 -7
  13. package/neural-interface/lib/assistant-brains/opencode.js +13 -1
  14. package/neural-interface/lib/assistant-brains/opencode2-browser-policy/index.js +44 -0
  15. package/neural-interface/lib/assistant-brains/opencode2-route-gate/index.js +57 -0
  16. package/neural-interface/lib/assistant-catalog.js +17 -2
  17. package/neural-interface/lib/assistant-dispatch.js +3 -0
  18. package/neural-interface/lib/assistant-envelope.js +19 -1
  19. package/neural-interface/lib/assistant-runtime.js +127 -8
  20. package/neural-interface/lib/claude-model-catalog.js +64 -14
  21. package/neural-interface/lib/codex-settings-schema.js +109 -47
  22. package/neural-interface/lib/editor-completion-opencode.js +132 -35
  23. package/neural-interface/lib/exec-loop-command.js +8 -1
  24. package/neural-interface/lib/launch-cache.js +293 -0
  25. package/neural-interface/lib/login-start.js +524 -0
  26. package/neural-interface/lib/native-loop-providers.js +18 -3
  27. package/neural-interface/lib/native-loop-runtime.js +19 -0
  28. package/neural-interface/lib/opencode-db-schema.js +75 -0
  29. package/neural-interface/lib/opencode-history.js +16 -1
  30. package/neural-interface/lib/opencode-protocol.js +173 -0
  31. package/neural-interface/lib/opencode-provider-view.js +81 -0
  32. package/neural-interface/lib/opencode-runtime-config.js +10 -3
  33. package/neural-interface/lib/opencode-serve-records.js +606 -0
  34. package/neural-interface/lib/opencode-v2-client.js +33 -6
  35. package/neural-interface/lib/opencode-v2-ws-requests.js +19 -5
  36. package/neural-interface/lib/opencode-worker-activity.js +229 -0
  37. package/neural-interface/lib/opencode2-client.js +760 -0
  38. package/neural-interface/lib/opencode2-proxy.js +116 -0
  39. package/neural-interface/lib/opencode2-translate.js +1008 -0
  40. package/neural-interface/lib/process-identity.js +361 -0
  41. package/neural-interface/lib/session-cache-builder.js +26 -5
  42. package/neural-interface/lib/session-title-generator.js +18 -0
  43. package/neural-interface/lib/style-guide/api.js +4 -3
  44. package/neural-interface/lib/untrusted-file-headers.js +27 -0
  45. package/neural-interface/package-lock.json +1343 -45
  46. package/neural-interface/package.json +5 -1
  47. package/neural-interface/public/index.html +2 -2
  48. package/neural-interface/public/shared/agent-runtime-options.js +55 -11
  49. package/neural-interface/public/shared/assistant/asst-agents-dock.js +172 -17
  50. package/neural-interface/public/shared/assistant/asst-api.js +11 -3
  51. package/neural-interface/public/shared/assistant/asst-brain-picker.js +195 -80
  52. package/neural-interface/public/shared/assistant/asst-composer.js +1 -1
  53. package/neural-interface/public/shared/assistant/asst-hosts.js +49 -2
  54. package/neural-interface/public/shared/assistant/asst-menu.js +77 -6
  55. package/neural-interface/public/shared/assistant/asst-panel.js +197 -33
  56. package/neural-interface/public/shared/assistant/asst-rack-styles.js +28 -10
  57. package/neural-interface/public/shared/assistant/asst-redact.js +186 -0
  58. package/neural-interface/public/shared/assistant/asst-render.js +382 -36
  59. package/neural-interface/public/shared/assistant/asst-run-stage.js +231 -0
  60. package/neural-interface/public/shared/assistant/asst-sanitize.js +193 -3
  61. package/neural-interface/public/shared/assistant/asst-scene-styles.js +273 -0
  62. package/neural-interface/public/shared/assistant/asst-scenes.js +528 -0
  63. package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +3 -15
  64. package/neural-interface/public/shared/assistant/asst-state.js +14 -0
  65. package/neural-interface/public/shared/assistant/asst-styles.js +43 -0
  66. package/neural-interface/public/shared/assistant/asst-tool-kinds.js +13 -1
  67. package/neural-interface/public/shared/cc-dropdown.js +480 -0
  68. package/neural-interface/public/shared/cdx/cdx-panel.js +13 -1
  69. package/neural-interface/public/shared/cdx/cdx-render.js +15 -3
  70. package/neural-interface/public/shared/cdx/cdx-styles.js +4 -0
  71. package/neural-interface/public/shared/cdx/cdx-tabs.js +149 -35
  72. package/neural-interface/public/shared/cp/cp-markdown.js +4 -57
  73. package/neural-interface/public/shared/data-cache.js +223 -0
  74. package/neural-interface/public/shared/desktop-bridge.js +11 -0
  75. package/neural-interface/public/shared/desktop-titlebar-view.js +78 -1
  76. package/neural-interface/public/shared/desktop-titlebar.js +214 -28
  77. package/neural-interface/public/shared/desktop-update-view.js +15 -5
  78. package/neural-interface/public/shared/file-links.js +73 -8
  79. package/neural-interface/public/shared/html-shell.js +74 -80
  80. package/neural-interface/public/shared/launch-prefetch.js +59 -0
  81. package/neural-interface/public/shared/ocp-v2/ocp-v2-caps.js +3 -2
  82. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +7 -2
  83. package/neural-interface/public/shared/ocp-v2/ocp-v2-modelpicker.js +54 -17
  84. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +93 -2
  85. package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +3 -2
  86. package/neural-interface/public/shared/ocp-v2/ocp-v2-providers.js +11 -9
  87. package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +15 -4
  88. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +1 -1
  89. package/neural-interface/public/shared/ocp-v2/ocp-v2-send-logic.js +401 -0
  90. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +16 -9
  91. package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +60 -0
  92. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +48 -19
  93. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +195 -19
  94. package/neural-interface/public/shared/opencode-providers.js +39 -0
  95. package/neural-interface/public/shared/schedules-view.js +318 -0
  96. package/neural-interface/public/shared/settings/settings-app-pane.js +335 -0
  97. package/neural-interface/public/shared/settings/settings-app.js +243 -0
  98. package/neural-interface/public/shared/settings/settings-data.js +88 -0
  99. package/neural-interface/public/shared/settings/settings-ia.js +17 -0
  100. package/neural-interface/public/shared/sidepanel-session-status.js +49 -0
  101. package/neural-interface/public/shared/styles.css +540 -371
  102. package/neural-interface/public/shared/ui-assistant.js +27 -5
  103. package/neural-interface/public/shared/ui-automation-studio.js +67 -27
  104. package/neural-interface/public/shared/ui-bookmarks.js +2 -1
  105. package/neural-interface/public/shared/ui-claude-panel.js +76 -8
  106. package/neural-interface/public/shared/ui-codex-panel.js +1 -0
  107. package/neural-interface/public/shared/ui-detail.js +6 -6
  108. package/neural-interface/public/shared/ui-file-links.js +1 -0
  109. package/neural-interface/public/shared/ui-image-gallery.js +6 -5
  110. package/neural-interface/public/shared/ui-invite.js +18 -2
  111. package/neural-interface/public/shared/ui-link.js +9 -9
  112. package/neural-interface/public/shared/ui-menubar.js +232 -33
  113. package/neural-interface/public/shared/ui-native-loop-router.js +2 -0
  114. package/neural-interface/public/shared/ui-opencode-panel-v2.js +1 -0
  115. package/neural-interface/public/shared/ui-schedules-studio.js +2099 -869
  116. package/neural-interface/public/shared/ui-sessions.js +15 -4
  117. package/neural-interface/public/shared/ui-settings.js +87 -36
  118. package/neural-interface/public/shared/ui-sidebar.js +4 -4
  119. package/neural-interface/public/shared/ui-sidepanel-session-entry.js +13 -0
  120. package/neural-interface/public/shared/ui-sidepanel-sessions.js +55 -4
  121. package/neural-interface/public/shared/ui-sidepanel-tray.js +84 -8
  122. package/neural-interface/public/shared/ui-skills.js +70 -12
  123. package/neural-interface/public/shared/ui-sync.js +7 -0
  124. package/neural-interface/public/shared/ui-terminal.js +36 -20
  125. package/neural-interface/public/shared/ui-trash.js +8 -1
  126. package/neural-interface/public/shared/ui-tutorial-steps.js +7 -3
  127. package/neural-interface/public/shared/ui-tutorial.js +15 -5
  128. package/neural-interface/public/shared/ui-update.js +83 -14
  129. package/neural-interface/public/shared/ui-whiteboard.js +2 -1
  130. package/neural-interface/public/variant/2d/main.js +4 -1
  131. package/neural-interface/public/variant/3d/main.js +3 -0
  132. package/neural-interface/public/vendor/highlight/LICENSE +29 -0
  133. package/neural-interface/public/vendor/highlight/github-dark-dimmed-11.12.0.min.css +9 -0
  134. package/neural-interface/public/vendor/highlight/highlight-11.12.0.min.js +1262 -0
  135. package/neural-interface/public/vendor/highlight/manifest.json +5 -0
  136. package/neural-interface/public/vendor/html2canvas/LICENSE +22 -0
  137. package/neural-interface/public/vendor/html2canvas/html2canvas-1.4.1.min.js +20 -0
  138. package/neural-interface/public/vendor/html2canvas/manifest.json +4 -0
  139. package/neural-interface/public/vendor/marked/LICENSE.md +44 -0
  140. package/neural-interface/public/vendor/marked/manifest.json +4 -0
  141. package/neural-interface/public/vendor/marked/marked-14.1.4.esm.js +2502 -0
  142. package/neural-interface/public/vendor/mermaid/LICENSE +21 -0
  143. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/abnfDiagram-PAR5IXJI.mjs +1 -0
  144. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/architecture-7GRP2DOG-M7G3SWCO.mjs +1 -0
  145. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/architectureDiagram-2PNWDENW.mjs +36 -0
  146. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/blockDiagram-PUWGUBOA.mjs +129 -0
  147. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/c4Diagram-3NLNHZP5.mjs +38 -0
  148. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-2NMSUPJH.mjs +1 -0
  149. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-32MCUW2P.mjs +206 -0
  150. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-3NPNU5GP.mjs +1 -0
  151. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-3SMGFRGC.mjs +1 -0
  152. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-4CAJECRU.mjs +14 -0
  153. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-4OADUZ5Q.mjs +10 -0
  154. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5IMINLNL.mjs +1 -0
  155. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5IQCK5RY.mjs +3 -0
  156. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5MVFYWFU.mjs +1 -0
  157. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-AQ6EADP3.mjs +1 -0
  158. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-AZZRMDJM.mjs +15 -0
  159. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-CLS4B6BI.mjs +168 -0
  160. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-DZP67EKU.mjs +70 -0
  161. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-EICP7K5X.mjs +1 -0
  162. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-GDY2UAB4.mjs +1 -0
  163. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-H7VHZCWX.mjs +1 -0
  164. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-IJBDOHL6.mjs +1 -0
  165. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JFFVMJIA.mjs +1 -0
  166. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JQRUD6KW.mjs +1 -0
  167. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JZGDI5JK.mjs +1 -0
  168. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-KD6SPIPN.mjs +2 -0
  169. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-NF366YI6.mjs +231 -0
  170. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-NUDPKBEF.mjs +32 -0
  171. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-OJINMHTS.mjs +1 -0
  172. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-Q6NGPIAH.mjs +2 -0
  173. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-QJSWEUOL.mjs +132 -0
  174. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-R7JDMUKV.mjs +1 -0
  175. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-RCXAM2GK.mjs +1 -0
  176. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-RT4MAWR3.mjs +1 -0
  177. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-SFMINX4W.mjs +1 -0
  178. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-SLDQ463B.mjs +88 -0
  179. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-STOV2HOB.mjs +1 -0
  180. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-T5OCTHI4.mjs +1 -0
  181. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-TCEHX7R3.mjs +1 -0
  182. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VQC2EELJ.mjs +1 -0
  183. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VRZKL3EH.mjs +1 -0
  184. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VTHDQ7BV.mjs +1 -0
  185. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VY2OSTJR.mjs +2 -0
  186. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-WUBWJARI.mjs +1 -0
  187. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-YBMQ5JGJ.mjs +2 -0
  188. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-YDMLL4PJ.mjs +1 -0
  189. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-Z6V44ARX.mjs +62 -0
  190. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZJJ7VQJP.mjs +339 -0
  191. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZNLK7HRR.mjs +1 -0
  192. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZQN7UHIL.mjs +1 -0
  193. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZS6VBONO.mjs +174 -0
  194. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZUNWM646.mjs +1 -0
  195. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-FGAMPOII.mjs +1 -0
  196. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-v2-AI5RJEK4.mjs +1 -0
  197. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cose-bilkent-Q72C2OJ3.mjs +1 -0
  198. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cynefin-OW5HDTMX-6OCTWOGG.mjs +1 -0
  199. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cynefinDiagram-2OOWUY4M.mjs +62 -0
  200. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/dagre-MPVFI544.mjs +4 -0
  201. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-AE4NWXFW.mjs +24 -0
  202. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-BUJID5UG.mjs +41 -0
  203. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-C44CZ5NI.mjs +24 -0
  204. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-EYOLEXXS.mjs +30 -0
  205. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-P7B6PWOJ.mjs +3 -0
  206. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ebnfDiagram-FTPZ3PV6.mjs +1 -0
  207. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/erDiagram-H3ERR26V.mjs +99 -0
  208. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/eventmodeling-NTZA5JFV-WKSWQHFS.mjs +1 -0
  209. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/flowDiagram-YHGXBVSY.mjs +1 -0
  210. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ganttDiagram-RWFTB7ED.mjs +292 -0
  211. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/gitGraph-4MIJSDKK-JH2HKYUO.mjs +1 -0
  212. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/gitGraphDiagram-MAR6GKOP.mjs +106 -0
  213. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/info-A6RAGUB7-3TX3OFWW.mjs +1 -0
  214. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/infoDiagram-LV547AFH.mjs +2 -0
  215. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ishikawaDiagram-PUJQGNDS.mjs +70 -0
  216. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/journeyDiagram-EW5QJT4W.mjs +139 -0
  217. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/kanban-definition-JSQERAZB.mjs +89 -0
  218. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/katex-CZ4GXH2S.mjs +257 -0
  219. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/mindmap-definition-VBJCLQLM.mjs +96 -0
  220. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/packet-AYTQ26CC-MKII6P3Y.mjs +1 -0
  221. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pegDiagram-7WILOQTJ.mjs +1 -0
  222. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pie-WAS4IAKB-6B4OPMJ5.mjs +1 -0
  223. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pieDiagram-EFA7CBSB.mjs +39 -0
  224. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/quadrantDiagram-WARVYA7P.mjs +7 -0
  225. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/radar-RG4KPBEZ-UPTR3XUL.mjs +1 -0
  226. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-74A4TZTK-MEFUPFMS.mjs +1 -0
  227. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-abnf-HS5TGJTU-R5YG2S5D.mjs +1 -0
  228. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-ebnf-LZEXJU2U-G5QEX2L6.mjs +1 -0
  229. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-peg-WCYAUIDC-H5I2ZRY2.mjs +1 -0
  230. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroadDiagram-5NIQVNLQ.mjs +1 -0
  231. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/requirementDiagram-SXREGOKZ.mjs +84 -0
  232. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sankeyDiagram-VV74M2VP.mjs +40 -0
  233. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sequenceDiagram-52ZFIFDL.mjs +162 -0
  234. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sizeCapture-FGA5AGYP.mjs +1 -0
  235. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-3QAKPIVI.mjs +1 -0
  236. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-v2-HN2P4RDZ.mjs +1 -0
  237. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/swimlanes-QLVP42QD.mjs +1 -0
  238. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/swimlanesDiagram-JKPDBFCN.mjs +8 -0
  239. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/timeline-definition-GS45XAXW.mjs +120 -0
  240. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/treeView-Q6P3EWNA-ZRJGANNQ.mjs +1 -0
  241. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/treemap-WGGIJYW6-K64VN5AS.mjs +1 -0
  242. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/vennDiagram-QIQU2ZRJ.mjs +34 -0
  243. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/wardley-WFR3VGLG-D67OWCCH.mjs +1 -0
  244. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/wardleyDiagram-U6YNM66N.mjs +78 -0
  245. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/xychartDiagram-XJAZEDNM.mjs +7 -0
  246. package/neural-interface/public/vendor/mermaid/manifest.json +4 -0
  247. package/neural-interface/public/vendor/mermaid/mermaid-11.17.2.esm.min.mjs +14 -0
  248. package/neural-interface/scripts/build-vendor.mjs +84 -3
  249. package/neural-interface/server.js +1099 -286
  250. package/npm-shrinkwrap.json +2 -2
  251. package/package.json +1 -1
  252. package/skills/synabun/modules/schedule.md +1 -1
@@ -125,6 +125,8 @@ import { createCliResolver, spawnArgsFor } from './lib/cli-resolver.js';
125
125
  import { osPath } from './lib/os-path.js';
126
126
  import { createCliInstaller, CLI_INSTALL_GUIDES } from './lib/cli-install.js';
127
127
  import { createCliInstallApi, createCliPathApi, createGitBashApi } from './lib/cli-install-api.js';
128
+ import { createAppControlsApi, createAppSettingsStore } from './lib/app-controls-api.js';
129
+ import { createLoginStart } from './lib/login-start.js';
128
130
  import { createCliPathManager } from './lib/cli-path.js';
129
131
  import { composeTerminalPath, terminalLaunchCommand, terminalArgument } from './lib/terminal-path.js';
130
132
  import {
@@ -145,6 +147,11 @@ import {
145
147
  } from './lib/backup-zip-reader.js';
146
148
  import { planRestoreWrites, removeRestoreUpload, saveRestoreUpload } from './lib/system-restore.js';
147
149
  import * as opencodeV2Client from './lib/opencode-v2-client.js';
150
+ import { basicAuthHeader, checkOpencodeServer, opencodeProblemText, protocolForVersion, serveSpecFor } from './lib/opencode-protocol.js';
151
+ import { SERVE_TOKEN_ENV } from './lib/process-identity.js';
152
+ import { createServeRecords } from './lib/opencode-serve-records.js';
153
+ import { opencode2Proxy } from './lib/opencode2-proxy.js';
154
+ import { opencodeDbFlavor, sessionTablesFor } from './lib/opencode-db-schema.js';
148
155
  import {
149
156
  handleOpencodeV2Request, opencodeV2WsCapabilities, opencodeSdkVersion, buildOpencodeRuntimeMcpConfig, shouldRelayOpencodeV2Message,
150
157
  buildSessionListQuery, mapSessionListRow, OPENCODE_V2_WS_MUTATION_TYPES, guardOpencodeV2Request,
@@ -160,6 +167,7 @@ import {
160
167
  createCodexNativeLoopAdapter,
161
168
  createOpenCodeNativeLoopAdapter,
162
169
  } from './lib/native-loop-providers.js';
170
+ import { createOpenCodeActivityRelay } from './lib/opencode-worker-activity.js';
163
171
  import {
164
172
  mergeCodexModelContextCapabilities,
165
173
  resolveCodexExtendedContext,
@@ -238,15 +246,20 @@ import { generateSessionTitle } from './lib/session-title-generator.js';
238
246
  import {
239
247
  CLAUDE_EFFORT_LEVELS,
240
248
  CLAUDE_FALLBACK_MODELS,
249
+ binaryIdentity,
241
250
  checkClaudeCliSkew,
242
251
  discoverClaudeModels,
252
+ markClaudeModelsStale,
243
253
  onClaudeModelsChanged,
254
+ setClaudeModelStore,
244
255
  shellCommand,
245
256
  } from './lib/claude-model-catalog.js';
257
+ import { createLaunchCache, createLimiter, createSnapshotStore, launchCacheDir } from './lib/launch-cache.js';
258
+ import { compactProviderCatalog, isProviderCatalog, providerCatalogSignature } from './lib/opencode-provider-view.js';
246
259
  import { resolveClaudeSdkExecutable } from './lib/claude-executable.js';
247
260
  import { collectInstallDetails } from './lib/install-details.js';
248
261
  import { withPathEnv, pathEnvValue, getAugmentedPath, rememberedOsPath, rememberPackagedPath, rememberedPackagedPath, withPackagedPathLast } from './lib/augmented-path.js';
249
- import { readCodexSettingsSchema, readCodexFeatureSupport, validateCodexSettings } from './lib/codex-settings-schema.js';
262
+ import { cachedCodexSettingsSchema, readCodexSettingsSchema, readCodexFeatureSupport, setCodexSettingsStore, validateCodexSettings } from './lib/codex-settings-schema.js';
250
263
  import { semverNewer, synabunReleaseChannel } from './lib/synabun-version.js';
251
264
  import { selectSynabunUpdate, buildSynabunInstallPlan, refuseStaleUpdateClick } from './lib/synabun-update-plan.js';
252
265
  import { collectActivity, countWhere, recentCliSessions } from './lib/system-activity.js';
@@ -277,7 +290,7 @@ import {
277
290
  import { ensureProjectCategories as ensureProjectCategoriesInFile, ensureCategories as ensureCategoriesInFile, FEATURE_CATEGORIES } from '../hooks/claude-code/shared.mjs';
278
291
  import { getDataHome, getDataHomeDiagnostics, isGlobalInstall, pathIsInside } from '../lib/paths.js';
279
292
  import { launcherLogPath } from '../lib/first-launch-protection.js';
280
- import { registerStartLauncher, startLauncherState } from '../lib/start-launcher.js';
293
+ import { launcherRegistrationAllowed, registerStartLauncher, startLauncherState } from '../lib/start-launcher.js';
281
294
  import { effectiveNpmPrefix } from '../lib/child-path.js';
282
295
  import { checkRuntimeDependencies, fallbackInstallArgs, fallbackInstallEnv, findInstalled, mcpCompiler, npmCommand, npmInvocation } from '../lib/runtime-dependencies.js';
283
296
  import { redactDeep, redactSecrets } from '../lib/log-redact.js';
@@ -307,6 +320,7 @@ import {
307
320
  clearOpenCodeHistoryInWorker,
308
321
  getOpenCodeHistoryStats,
309
322
  OPENCODE_HISTORY_CONFIRMATION,
323
+ OPENCODE2_HISTORY_REFUSAL,
310
324
  } from './lib/opencode-history.js';
311
325
  import {
312
326
  clearProjectStorageInWorker,
@@ -461,6 +475,8 @@ import {
461
475
  import { createOpencodeCompletionEngine } from './lib/editor-completion-opencode.js';
462
476
  import { createEndpointCompletionEngine } from './lib/editor-completion-endpoint.js';
463
477
  import { ownerRouteGuard } from './lib/http-guards.js';
478
+ import { setUntrustedFileHeaders } from './lib/untrusted-file-headers.js';
479
+ import { BUNDLED_LOGO_FILES, UPLOADED_IMAGE_PATH } from './lib/request-gate.js';
464
480
  import { createCloudflaredManager } from './lib/cloudflared/manager.js';
465
481
  import { createQuickTunnel } from './lib/cloudflared/tunnel.js';
466
482
  import { createGitBashManager } from './lib/git-bash/manager.js';
@@ -471,6 +487,9 @@ const PORT = process.env.NEURAL_PORT || 3344;
471
487
  const EMBEDDING_DIMS = getEmbeddingDims();
472
488
  const PACKAGE_ROOT = resolve(__dirname, '..');
473
489
  const DATA_HOME = getDataHome();
490
+ // Where the slow lists windows and menus wait for (model lists, OpenCode's providers, Codex probes) are kept
491
+ // across restarts (lib/launch-cache.js): disposable, so outside data/ (no backup carries them) and outside the package.
492
+ const LAUNCH_CACHE_DIR = launchCacheDir(DATA_HOME);
474
493
  registerCodexPlanRoutes(app, resolve(DATA_HOME, 'data', 'codex-plans'));
475
494
  const DATA_HOME_DIAGNOSTICS = getDataHomeDiagnostics(DATA_HOME, PACKAGE_ROOT);
476
495
  const CODEX_SIDEPANEL_PERMISSIONS_PATH = resolve(DATA_HOME, 'data', 'codex-sidepanel-permissions.json');
@@ -833,11 +852,19 @@ app.use('/skins', express.static(join(__dirname, 'skins'), {
833
852
  lastModified: true,
834
853
  setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
835
854
  }));
855
+ // The user's own file icons (POST /api/file-icons, SVG among them): an <img> shows them, opened by their address
856
+ // they run nothing and an SVG is a download (lib/untrusted-file-headers.js).
836
857
  app.use('/custom-icons', express.static(CUSTOM_ICONS_DIR, {
837
858
  etag: true,
838
859
  lastModified: true,
839
- setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
860
+ setHeaders: (res, path) => { res.setHeader('Cache-Control', 'no-cache'); setUntrustedFileHeaders(res, path); },
840
861
  }));
862
+ // A category's uploaded logo sits beside the app's own files ("/logo-<name>.<ext>"): it gets the same headers.
863
+ app.use(function uploadedLogoHeaders(req, res, next) {
864
+ const path = String(req.path || '').toLowerCase();
865
+ if (UPLOADED_IMAGE_PATH.test(path) && (!BUNDLED_LOGO_FILES.includes(path) || isCategoryUpload(path))) setUntrustedFileHeaders(res, path);
866
+ next();
867
+ });
841
868
  app.use(express.static(join(__dirname, 'public'), {
842
869
  etag: true,
843
870
  lastModified: true,
@@ -3473,22 +3500,60 @@ function getNativeCodexBin() {
3473
3500
  // its full path; on Windows the opencode.exe found, started directly with the
3474
3501
  // PATH a new terminal has (a command file that leads nowhere: through cmd.exe,
3475
3502
  // every word escaped). A command line in Settings is run as written.
3476
- function opencodeServeLaunch(port, { env = process.env } = {}) {
3503
+ // `protocol` is the OpenCode major it is (detectOpencodeProtocol): OpenCode 2
3504
+ // also gets its loopback host and a fresh password (lib/opencode-protocol.js
3505
+ // serveSpecFor), which the answer carries as `password`.
3506
+ function opencodeServeLaunch(port, { env = process.env, protocol = 1, password = null } = {}) {
3477
3507
  const found = cliResolver.launcher('opencode');
3478
3508
  _noteCliLookup('opencode', found, 'OpenCode');
3479
3509
  const line = configuredCommandLine('opencode');
3510
+ const spec = serveSpecFor(protocol, port, { env, password });
3511
+ const words = spec.args.join(' ');
3480
3512
  if (IS_WIN) {
3481
- const spawnEnv = withPathEnv(env, getAugmentedPath());
3513
+ const spawnEnv = withPathEnv(spec.env, getAugmentedPath());
3482
3514
  if (!line && found.spec) {
3483
- const launch = spawnArgsFor(found.spec, ['serve', '--port', String(port)], { env: spawnEnv });
3484
- return { command: launch.command, args: launch.args, options: { ...launch.options, env: spawnEnv, windowsHide: true }, label: `${found.launchPath} serve --port ${port}` };
3515
+ const launch = spawnArgsFor(found.spec, spec.args, { env: spawnEnv });
3516
+ return { command: launch.command, args: launch.args, options: { ...launch.options, env: spawnEnv, windowsHide: true }, label: `${found.launchPath} ${words}`, password: spec.password };
3485
3517
  }
3486
- const text = `${line || 'opencode'} serve --port ${port}`;
3487
- return { command: DEFAULT_SHELL, args: ['/d', '/s', '/c', text], options: { env: spawnEnv, windowsHide: true }, label: text };
3518
+ const text = `${line || 'opencode'} ${words}`;
3519
+ return { command: DEFAULT_SHELL, args: ['/d', '/s', '/c', text], options: { env: spawnEnv, windowsHide: true }, label: text, password: spec.password };
3488
3520
  }
3489
3521
  const cmd = line || (found.path ? shellSingleQuote(found.path) : 'opencode');
3490
3522
  // A packaged application's runtime and npm after the user's own (the login shell adds the user's profile on top).
3491
- return { command: DEFAULT_SHELL, args: ['-lc', `exec ${cmd} serve --port ${port}`], options: { env: withPackagedPathLast(env) }, label: `${cmd} serve --port ${port}` };
3523
+ return { command: DEFAULT_SHELL, args: ['-lc', `exec ${cmd} ${words}`], options: { env: withPackagedPathLast(spec.env) }, label: `${cmd} ${words}`, password: spec.password };
3524
+ }
3525
+
3526
+ // Which OpenCode major the binary SynaBun starts is: the resolver's `--version`
3527
+ // of it (cached by the file, so an upgrade in place is read again), then
3528
+ // lib/opencode-protocol.js protocolForVersion. `{ protocol: 1 | 2 | null,
3529
+ // major, version, supported, reason, path }`; `protocol` null with `supported`
3530
+ // false and a `major` is an OpenCode SynaBun does not speak (3 and later). A
3531
+ // command line in Settings has no version to read (`protocol` null, `major`
3532
+ // null): its serve is told apart by what answers (checkOpencodeServer).
3533
+ async function detectOpencodeProtocol({ force = false } = {}) {
3534
+ let found = cliResolver.launcher('opencode');
3535
+ if (configuredCommandLine('opencode')) {
3536
+ return { protocol: null, major: null, version: null, supported: true, reason: null, path: null, commandLine: true };
3537
+ }
3538
+ let version = found.version || null;
3539
+ if (!version || force || found.state === 'unverified') {
3540
+ try {
3541
+ const status = await cliResolver.resolve('opencode', { force });
3542
+ version = status?.version || version;
3543
+ found = cliResolver.launcher('opencode');
3544
+ version = found.version || version;
3545
+ } catch { /* the launch below says what is wrong */ }
3546
+ }
3547
+ if (!found.path) return { protocol: null, major: null, version: null, supported: false, reason: 'OpenCode is not installed.', path: null };
3548
+ return { ...protocolForVersion(version), path: found.path };
3549
+ }
3550
+
3551
+ // The OpenCode major as far as it is known now, without starting anything (for
3552
+ // a command line built at once): the resolver's cached --version, else the
3553
+ // major the shared serve answered with, else 1.
3554
+ function knownOpencodeMajor() {
3555
+ const fromVersion = protocolForVersion(cliResolver.launcher('opencode').version).protocol;
3556
+ return fromVersion || _ocpProtocol || 1;
3492
3557
  }
3493
3558
 
3494
3559
  function extractCodexConfigValues(result) {
@@ -3784,6 +3849,8 @@ function _codexAccounts() {
3784
3849
  broadcastSync({ type: 'codex:accounts-changed', accountId: event.accountId || null, removed: !!event.removed, created: !!event.created });
3785
3850
  broadcastSync({ type: 'assistant:accounts-changed', provider: 'codex', accountId: event.accountId || null, removed: !!event.removed });
3786
3851
  _assistantCatalog?.invalidate?.();
3852
+ // The account's model list is read again (another sign-in is another key; a removed account's is dropped).
3853
+ codexModelListsChanged(event.accountId || null, { removed: !!event.removed });
3787
3854
  } catch {}
3788
3855
  });
3789
3856
  }
@@ -4036,7 +4103,7 @@ CRITICAL — When you need clarification during planning:
4036
4103
  codexSessionSettings.set(activeAccountId, threadId, settings);
4037
4104
  }
4038
4105
  if (['skills_config_write', 'experimental_features_set'].includes(type)) {
4039
- if (type === 'experimental_features_set') codexContextModels = [];
4106
+ if (type === 'experimental_features_set') { codexContextModels = []; codexModelListsChanged(null); }
4040
4107
  sendToClient({ type: 'catalogs_changed', catalogs: type === 'skills_config_write' ? ['skills'] : ['models', 'skills', 'apps', 'mcp', 'features'] });
4041
4108
  }
4042
4109
  }
@@ -4547,9 +4614,10 @@ CRITICAL — When you need clarification during planning:
4547
4614
  }
4548
4615
 
4549
4616
  async function readSettingsMetadata() {
4550
- const schema = readCodexSettingsSchema(getCodexBin());
4617
+ // Both probes start Codex itself, off the event loop, once per binary (kept across restarts).
4618
+ const schema = await readCodexSettingsSchema(getCodexBin());
4551
4619
  const features = await listCodexPages(request, 'experimentalFeature/list', { limit: 100 });
4552
- const featureSupport = readCodexFeatureSupport(getCodexBin(), features.data);
4620
+ const featureSupport = await readCodexFeatureSupport(getCodexBin(), features.data);
4553
4621
  // The catalog's enabled field is a process-start snapshot. config/read is
4554
4622
  // authoritative for saved defaults after reloadUserConfig writes.
4555
4623
  return { ...schema, features: features.data.map(feature => ({ ...feature,
@@ -5540,12 +5608,12 @@ CRITICAL — When you need clarification during planning:
5540
5608
  // Sync tool approvals only after the base transport is known to be valid.
5541
5609
  syncCodexToolPermissions([..._codexPermittedTools], activeCodexHome);
5542
5610
  const codexBin = getCodexBin();
5611
+ // The version the resolver read (its `--version`, cached per binary and asked off the request): a
5612
+ // blocking spawn here froze the whole server for up to 5 s. Before the first probe of a binary it is
5613
+ // null, and the app-server's own userAgent fills it in at initialize (below).
5543
5614
  let cliVersion = null;
5544
5615
  let sdkVersion = null;
5545
- try {
5546
- const versionResult = spawnSync(codexBin, ['--version'], { encoding: 'utf-8', timeout: 5000, windowsHide: true });
5547
- cliVersion = parseCodexVersion(versionResult.stdout);
5548
- } catch {}
5616
+ try { cliVersion = parseCodexVersion(cliResolver.launcher('codex').version); } catch {}
5549
5617
  sdkVersion = findInstalled(__dirname, '@openai/codex-sdk', { stopAt: PACKAGE_ROOT })?.version || null;
5550
5618
  codexCapabilities.reset({ codexBin, codexBinSource: _codexBinSource, cliVersion, sdkVersion });
5551
5619
  startChild();
@@ -6433,11 +6501,18 @@ CRITICAL — When you need clarification during planning:
6433
6501
  }
6434
6502
  return;
6435
6503
  }
6504
+ // The sandbox check below is synchronous: the schema it validates against is read first (asynchronously,
6505
+ // once per Codex binary), so the check itself never starts Codex.
6506
+ if (msg.type === 'thread_settings_update' && Object.prototype.hasOwnProperty.call(msg, 'sandboxMode') && msg.sandboxMode !== null) {
6507
+ await readCodexSettingsSchema(getCodexBin()).catch(() => null);
6508
+ }
6436
6509
  if (await handleCodexCompatMessage(msg, {
6437
6510
  activeThreadId, activeTurnId, cwd: PACKAGE_ROOT,
6438
6511
  role: codexRole, getActiveThreadId: () => activeThreadId,
6439
6512
  sandboxPolicyForMode: (mode, cwd) => {
6440
- validateCodexSettings({ sandbox_mode: mode }, readCodexSettingsSchema(getCodexBin()));
6513
+ const schema = cachedCodexSettingsSchema(getCodexBin());
6514
+ if (!schema) throw new Error('Codex could not report its settings schema');
6515
+ validateCodexSettings({ sandbox_mode: mode }, schema);
6441
6516
  return buildCodexSandboxPolicy(cwd, mode);
6442
6517
  },
6443
6518
  limitAppCall: message => appBoundary.consume(activeThreadId, message.originCallId),
@@ -6784,6 +6859,8 @@ CRITICAL — When you need clarification during planning:
6784
6859
  await request('config/batchWrite', { edits, reloadUserConfig: true }, 10000);
6785
6860
  const config = await readConfigState(msg.cwd || PACKAGE_ROOT);
6786
6861
  codexContextModels = [];
6862
+ // config.toml is every account's (shared): their kept model lists are read again.
6863
+ codexModelListsChanged(null);
6787
6864
  sendToClient({ type: 'config_saved', requestId: msg.requestId || null, config });
6788
6865
  sendToClient({ type: 'catalogs_changed', catalogs: ['models', 'skills', 'apps', 'mcp', 'features', 'config'] });
6789
6866
  } catch (err) {
@@ -7434,8 +7511,63 @@ CRITICAL — When you need clarification during planning:
7434
7511
  ws.on('close', handleCodexSocketClose);
7435
7512
  }
7436
7513
 
7437
- const _codexModelListCache = new Map();
7438
- const CODEX_MODEL_LIST_CACHE_MS = 60_000;
7514
+ // The Codex model list of each account (GET /api/codex/models, the Studios' model check, the Assistant's
7515
+ // catalog through the route): read from the account's app-server, answered at once from the last good list
7516
+ // (lib/launch-cache.js, kept across restarts) and read again in the background once it is old. Keyed on what
7517
+ // it depends on: the account and its home, the Codex binary (an update is another binary) and the account's
7518
+ // sign-in, so one account's list is never given for another. An empty list is what a failure leaves (Codex
7519
+ // missing, signed out): never kept.
7520
+ const CODEX_MODEL_LIST_CACHE_MS = 10 * 60_000;
7521
+
7522
+ function codexModelListKey(accountId) {
7523
+ const id = String(accountId || 'default');
7524
+ const account = findCodexAccount(id);
7525
+ let signIn = '';
7526
+ try {
7527
+ const row = _codexAccounts().describe(id);
7528
+ signIn = createHash('sha256').update(`${row?.mode || ''}\n${row?.identity || ''}\n${row?.signedIn}`).digest('hex').slice(0, 16);
7529
+ } catch { /* unreadable: keyed on the rest */ }
7530
+ return JSON.stringify([id, account?.home || CODEX_DEFAULT_HOME, binaryIdentity(configuredCommandLine('codex') || getCodexBin()), signIn]);
7531
+ }
7532
+
7533
+ const _codexModelListCache = createLaunchCache({
7534
+ name: 'codex-models',
7535
+ dir: LAUNCH_CACHE_DIR,
7536
+ ttlMs: CODEX_MODEL_LIST_CACHE_MS,
7537
+ maxEntries: 24,
7538
+ load: async (key) => {
7539
+ const [accountId] = JSON.parse(key);
7540
+ const account = findCodexAccount(accountId);
7541
+ if (!account) throw new Error(`Codex account not found: ${accountId}`);
7542
+ const codexHome = account.home || CODEX_DEFAULT_HOME;
7543
+ return mergeCodexModelContextCapabilities(
7544
+ await queryCodexAppServerModels(codexHome, _codexAccounts().launchFor(account.id)),
7545
+ readCodexRuntimeModelCatalog(codexHome),
7546
+ );
7547
+ },
7548
+ accept: (models) => Array.isArray(models) && models.length > 0,
7549
+ // The Assistant's catalog is built from the default account's list: when that list changes, its copy is
7550
+ // dropped and rebuilt (which tells the open windows).
7551
+ onChange: (key) => {
7552
+ try { if (JSON.parse(key)[0] === 'default') _assistantCatalog?.refresh?.().catch(() => {}); } catch {}
7553
+ },
7554
+ log: (message) => console.warn(message),
7555
+ });
7556
+
7557
+ /**
7558
+ * A Codex account changed (sign-in, mode, removal) or its config was saved (`accountId` null: every
7559
+ * account): its kept lists are asked for again in the background, a removed account's are dropped.
7560
+ */
7561
+ function codexModelListsChanged(accountId = null, { removed = false } = {}) {
7562
+ for (const key of _codexModelListCache.keys()) {
7563
+ let id = null;
7564
+ try { id = JSON.parse(key)[0]; } catch { continue; }
7565
+ if (accountId && id !== accountId) continue;
7566
+ if (removed) _codexModelListCache.invalidate(key);
7567
+ else _codexModelListCache.markStale(key);
7568
+ }
7569
+ if (!removed) scheduleLaunchRefresh('codex-models');
7570
+ }
7439
7571
 
7440
7572
  // `launch` (lib/codex-accounts.js launchFor()): the list is read under the account's own credential,
7441
7573
  // with no inherited one, as its sessions would run (in API-key mode, the key it holds over stdin).
@@ -7563,22 +7695,11 @@ app.get('/api/codex/models', async (req, res) => {
7563
7695
  const accountId = String(req.query?.accountId || 'default');
7564
7696
  const account = findCodexAccount(accountId);
7565
7697
  if (!account) return res.status(404).json({ ok: false, error: `Codex account not found: ${accountId}`, models: [] });
7566
- const codexHome = account.home || CODEX_DEFAULT_HOME;
7698
+ // The kept list at once (read again in the background when old); a first read, another sign-in or
7699
+ // binary, or ?refresh=1 waits for the account's app-server.
7567
7700
  const force = req.query?.refresh === '1';
7568
- const cached = _codexModelListCache.get(codexHome);
7569
- if (!force && cached?.models.length && Date.now() - cached.at < CODEX_MODEL_LIST_CACHE_MS) {
7570
- return res.json({ ok: true, source: 'cache', models: cached.models });
7571
- }
7572
- const models = mergeCodexModelContextCapabilities(
7573
- await queryCodexAppServerModels(codexHome, _codexAccounts().launchFor(account.id)),
7574
- readCodexRuntimeModelCatalog(codexHome),
7575
- );
7576
- // The Assistant's catalog is built from the default account's list: when that list
7577
- // changes, its copy is dropped and rebuilt (which tells the open windows).
7578
- const listChanged = codexHome === CODEX_DEFAULT_HOME && !!cached && JSON.stringify(cached.models) !== JSON.stringify(models);
7579
- _codexModelListCache.set(codexHome, { at: Date.now(), models });
7580
- if (listChanged) _assistantCatalog?.refresh?.().catch(() => {});
7581
- res.json({ ok: true, source: 'codex-app-server', models });
7701
+ const { value: models, cached } = await _codexModelListCache.read(codexModelListKey(account.id), { refresh: force });
7702
+ res.json({ ok: true, source: cached ? 'cache' : 'codex-app-server', models: Array.isArray(models) ? models : [] });
7582
7703
  } catch (err) {
7583
7704
  res.json({ ok: false, source: 'fallback', error: err.message, models: [] });
7584
7705
  }
@@ -7614,6 +7735,12 @@ let _ocpBaseUrl = () => `http://127.0.0.1:${_ocpPort}`;
7614
7735
  let _ocpStarting = false;
7615
7736
  let _ocpReady = false;
7616
7737
  let _ocpVersion = null;
7738
+ // The OpenCode major the shared serve speaks (1 or 2; null until one answered),
7739
+ // the password SynaBun gave the OpenCode 2 serve it started, and the sentence
7740
+ // that says why OpenCode cannot be used (shown by the panel and the tray).
7741
+ let _ocpProtocol = null;
7742
+ let _ocpPassword = null;
7743
+ let _ocpProblem = null;
7617
7744
  let _ocpHistoryClearInProgress = false;
7618
7745
 
7619
7746
  // ── OpenCode V2 (clean-slate sidepanel) — client set & bridge ──
@@ -7621,9 +7748,18 @@ const _ocpV2WsClients = new Set(); // all connected /ws/opencode-v2 clients
7621
7748
  let _ocpV2ListenerInstalled = false;
7622
7749
  let _ocpV2ClientSeq = 0;
7623
7750
  const OCP_MANAGED_PID_PATH = resolve(DATA_HOME, 'data', 'opencode-managed.pid');
7751
+ // Every OpenCode serve SynaBun starts, on record while it runs (lib/opencode-serve-records.js): what a crash left
7752
+ // running is found by these records alone, and stopped only when it is provably SynaBun's and nothing uses it.
7753
+ // Process state of this machine, so outside data/ (no backup or restore carries it).
7754
+ const opencodeServeRecords = createServeRecords({
7755
+ dir: resolve(DATA_HOME, 'runtime', 'opencode-serves'),
7756
+ log: (message) => console.log(`[opencode] ${message}`),
7757
+ });
7758
+ migrateOpencodeManagedPid();
7624
7759
  let _ocpBootReconcileTried = false;
7625
7760
  let _ocpLastBootAt = 0; // Date.now() when serve process last became ready
7626
7761
  let _ocpAuthReconciling = false; // prevent concurrent reconciliation
7762
+ let _ocpAuthStaleNoted = 0; // the boot whose server SynaBun said it would not restart (once per boot)
7627
7763
 
7628
7764
  // ── Verbose trace logging (opt-in: OCP_VERBOSE=1 to enable) ──
7629
7765
  const OCP_VERBOSE = process.env.OCP_VERBOSE === '1';
@@ -7688,24 +7824,95 @@ function persistMcpToOpencodeConfig(name, mcpConfig) {
7688
7824
  console.log(`[opencode] Persisted MCP server "${name}" to ${configPath}`);
7689
7825
  }
7690
7826
 
7691
- function readOpencodeManagedPid() {
7692
- try {
7693
- const pid = Number(readFileSync(OCP_MANAGED_PID_PATH, 'utf8').trim());
7694
- if (Number.isFinite(pid) && pid > 0) return pid;
7695
- } catch {}
7696
- return null;
7827
+ // opencode-managed.pid is where an older SynaBun kept its shared serve: a bare pid, or a record without the token,
7828
+ // machine, user, boot and owner every record holds now. Neither can ever prove which process it names, so neither
7829
+ // is ever acted on: the file goes, and a serve it named that still runs is left running.
7830
+ function migrateOpencodeManagedPid() {
7831
+ try { unlinkSync(OCP_MANAGED_PID_PATH); } catch {}
7832
+ }
7833
+
7834
+ // Every serve SynaBun starts is on record from its spawn until its process has ended (lib/opencode-serve-records.js):
7835
+ // the process SynaBun spawned at once, under the token SynaBun put in its environment (SERVE_TOKEN_ENV); and, once
7836
+ // something answers on its port, the process that answers when the spawned one started it (proved through the
7837
+ // parent chain). No other process is ever put on record. `answering()` says whether the serve that answers on the
7838
+ // port is this start's: 'ours', 'foreign' (another server took the port: a start collision, which is left running and
7839
+ // never recorded) or 'unknown' (the OS could not say who listens: only the spawned process stays on record). The
7840
+ // spawned process's record goes with its exit, which Node has seen; a process it started that outlives it stays on
7841
+ // record until it has provably ended. Every serve SynaBun starts comes through here: the shared serve, the isolated
7842
+ // serves, the completion serve.
7843
+ function trackOpencodeServe(proc, { kind, id = null, port, token }) {
7844
+ const pid = Number(proc?.pid) || null;
7845
+ const exited = () => !proc || proc.exitCode != null || proc.signalCode != null;
7846
+ const spawned = pid ? opencodeServeRecords.record({ kind, id, port, pid, token }).catch(() => null) : Promise.resolve(null);
7847
+ let started = Promise.resolve(null);
7848
+ const forget = () => {
7849
+ spawned.then((record) => { if (record) opencodeServeRecords.remove(record); }).catch(() => {});
7850
+ started.then((record) => { if (record && opencodeServeRecords.verifySync(record) === 'gone') opencodeServeRecords.remove(record); }).catch(() => {});
7851
+ };
7852
+ if (exited()) forget();
7853
+ else proc.once?.('exit', forget);
7854
+ return {
7855
+ spawned,
7856
+ async answering() {
7857
+ await spawned;
7858
+ const who = await opencodeServeRecords.answering({ port, pid, exited });
7859
+ if (who.state !== 'ours') return who.state;
7860
+ // Read again now that it answers (its login shell has exec'd OpenCode by now), and the process that answers
7861
+ // when the spawned one started it.
7862
+ if (!exited()) await opencodeServeRecords.record({ kind, id, port, pid, token }).catch(() => null);
7863
+ if (who.pid !== pid) started = opencodeServeRecords.record({ kind, id, port, pid: who.pid, token }).catch(() => null);
7864
+ await started;
7865
+ return 'ours';
7866
+ },
7867
+ };
7697
7868
  }
7698
7869
 
7699
- function writeOpencodeManagedPid(pid) {
7700
- if (!Number.isFinite(pid) || pid <= 0) return;
7701
- try {
7702
- mkdirSync(dirname(OCP_MANAGED_PID_PATH), { recursive: true });
7703
- writeFileSync(OCP_MANAGED_PID_PATH, `${pid}\n`, 'utf8');
7704
- } catch {}
7870
+ // Look for the OpenCode serves an earlier SynaBun left running (lib/opencode-serve-records.js), when SynaBun
7871
+ // starts and before a port is picked for a serve: one look at a time, shared by everyone who waits for it.
7872
+ function sweepLeftoverOpencodeServes() {
7873
+ return opencodeServeRecords.sweep().then((result) => {
7874
+ if (result.stopped.length) console.log(`[opencode] stopped ${result.stopped.length} serve(s) an earlier SynaBun left running: ${result.stopped.map((r) => `${r.kind} on port ${r.port} (pid ${r.pid})`).join(', ')}`);
7875
+ return result;
7876
+ }, () => null);
7705
7877
  }
7706
7878
 
7707
- function clearOpencodeManagedPid() {
7708
- try { if (existsSync(OCP_MANAGED_PID_PATH)) unlinkSync(OCP_MANAGED_PID_PATH); } catch {}
7879
+ // SynaBun's shared serve, on record (kind 'shared'), on `port` when one is named, the newest first: only a record
7880
+ // this server may act on (lib/opencode-serve-records.js claimable): written on this machine, for this user, in this
7881
+ // boot, and its own or one whose SynaBun has positively ended. Never another live SynaBun's (a data home two servers
7882
+ // share), one whose owner was never recorded or cannot be read now, or one of another machine or user: their serves
7883
+ // are servers SynaBun did not start, as far as this server is concerned.
7884
+ function readOpencodeManagedRecord({ port = null } = {}) {
7885
+ return opencodeServeRecords.list({ kind: 'shared', port }).find((record) => opencodeServeRecords.claimable(record)) || null;
7886
+ }
7887
+
7888
+ // A record goes once its process has provably ended (or its pid is another process's by now); one that cannot be
7889
+ // read now may still run, and stays.
7890
+ function clearOpencodeManagedPid(record = readOpencodeManagedRecord()) {
7891
+ if (record && opencodeServeRecords.verifySync(record) === 'gone') opencodeServeRecords.remove(record);
7892
+ }
7893
+
7894
+ // The recorded serves this server may act on while the OS still reports each as that very process: the same pid
7895
+ // and start time, the user's own, the token of its spawn in its environment (and, given a port, while it listens
7896
+ // there). Only such a process, or SynaBun's own child, is ever stopped.
7897
+ function verifiedOpencodeManagedProcesses({ port = null } = {}) {
7898
+ return opencodeServeRecords.list({ kind: 'shared', port }).filter((record) => record.pid !== process.pid
7899
+ && opencodeServeRecords.claimable(record)
7900
+ && opencodeServeRecords.verifySync(record) === 'same'
7901
+ && (port == null || process.platform === 'win32' || listPortListenerPids(port).includes(record.pid)));
7902
+ }
7903
+
7904
+ function verifiedOpencodeManagedProcess({ port = null } = {}) {
7905
+ return verifiedOpencodeManagedProcesses({ port })[0] || null;
7906
+ }
7907
+
7908
+ // Signal SynaBun's recorded serve, checked once more right before: a pid that is another process by now, or one
7909
+ // that cannot be read, is left alone. Windows ends the serve's tree with taskkill.
7910
+ function signalOwnedOpencodeProcess(record, signal) {
7911
+ if (!record || opencodeServeRecords.verifySync(record) !== 'same') return false;
7912
+ if (process.platform === 'win32') {
7913
+ try { spawn('taskkill', ['/pid', String(record.pid), '/T', '/F'], { shell: true, windowsHide: true, stdio: 'ignore' }); return true; } catch { return false; }
7914
+ }
7915
+ return signalPids([record.pid], signal).length > 0;
7709
7916
  }
7710
7917
 
7711
7918
  function parseOpencodeLogTimestamp(fileName) {
@@ -7748,7 +7955,9 @@ function shouldReconcileOpencodeForStoredAuth() {
7748
7955
  return false;
7749
7956
  }
7750
7957
 
7751
- // Runtime auth reconciliation: restart serve if auth.json changed since last boot
7958
+ // Runtime auth reconciliation: restart serve if auth.json changed since last boot. Automatic, so only a serve
7959
+ // SynaBun can prove is its own is restarted (its child, or its recorded serve that is still that process); a
7960
+ // server someone else runs is used as it is, and said once.
7752
7961
  async function reconcileAuthIfStale() {
7753
7962
  if (_ocpAuthReconciling || !_ocpReady || !_ocpLastBootAt) return false;
7754
7963
  try {
@@ -7756,6 +7965,13 @@ async function reconcileAuthIfStale() {
7756
7965
  if (!existsSync(authPath)) return false;
7757
7966
  const authMtime = statSync(authPath).mtimeMs;
7758
7967
  if (authMtime <= _ocpLastBootAt) return false;
7968
+ if (!_ocpProc && !verifiedOpencodeManagedProcess({ port: _ocpPort })) {
7969
+ if (_ocpAuthStaleNoted !== _ocpLastBootAt) {
7970
+ _ocpAuthStaleNoted = _ocpLastBootAt;
7971
+ console.warn(`[opencode] auth.json changed, but the OpenCode server on port ${_ocpPort} was not started by SynaBun: it is not restarted (it shows the new credentials once whoever runs it restarts it)`);
7972
+ }
7973
+ return false;
7974
+ }
7759
7975
  _ocpAuthReconciling = true;
7760
7976
  console.log('[opencode] auth.json modified after last boot — restarting to pick up new credentials');
7761
7977
  await takeoverOpencodeServer();
@@ -7779,15 +7995,7 @@ function listPortListenerPids(port) {
7779
7995
  return [];
7780
7996
  }
7781
7997
 
7782
- function listOpencodeServePids() {
7783
- if (process.platform === 'win32') return [];
7784
- try {
7785
- const out = execSync('pgrep -f "opencode.*serve"', { encoding: 'utf8' }).trim();
7786
- return out.split('\n').filter(Boolean).map((pid) => Number(pid)).filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid);
7787
- } catch {}
7788
- return [];
7789
- }
7790
-
7998
+ // The raw signal. Only signalOwnedOpencodeProcess calls it, for a process it has just checked is SynaBun's.
7791
7999
  function signalPids(pids, signal) {
7792
8000
  const targeted = [];
7793
8001
  for (const pid of pids) {
@@ -7799,19 +8007,6 @@ function signalPids(pids, signal) {
7799
8007
  return targeted;
7800
8008
  }
7801
8009
 
7802
- async function waitForOpencodeShutdown(timeoutMs = 6000) {
7803
- const deadline = Date.now() + timeoutMs;
7804
- while (Date.now() < deadline) {
7805
- const listenerPids = listPortListenerPids(_ocpPort);
7806
- if (!listenerPids.length) {
7807
- const healthy = await checkOpencodeHealth();
7808
- if (!healthy) return true;
7809
- }
7810
- await new Promise((resolve) => setTimeout(resolve, 250));
7811
- }
7812
- return false;
7813
- }
7814
-
7815
8010
  // ─────────────────────────────────────────────────────────────────────────────
7816
8011
  // OpenCode V2 — clean-slate broadcast bridge & WS handler
7817
8012
  // Pass-through: forwards opencodeV2Client envelopes verbatim to /ws/opencode-v2
@@ -7846,7 +8041,15 @@ const OPENCODE_HISTORY_WRITE_TYPES = new Set([
7846
8041
  ...OPENCODE_V2_WS_MUTATION_TYPES,
7847
8042
  ]);
7848
8043
 
8044
+ // Set while the provider cache announces its own change (it is not a reason to read the list again).
8045
+ let _announcingOpencodeProviders = false;
8046
+
7849
8047
  function broadcastToOpencodeV2Clients(data, { nativeRunId = null } = {}) {
8048
+ // The kept provider list follows what the panels are told: a credential change, the serve becoming
8049
+ // ready, a catalog event (the same ones the panel re-reads on, ocp-v2-status-logic.js).
8050
+ if (data?.type === 'providers:changed' && !_announcingOpencodeProviders) noteOpencodeProvidersChanged({ announce: true });
8051
+ else if (data?.type === 'server:status' && data.status === 'ready') noteOpencodeProvidersChanged();
8052
+ else if (data?.type === 'event' && /^(?:catalog\.updated|models-dev\.refreshed|integration\.)/.test(String(data.eventType || ''))) noteOpencodeProvidersChanged();
7850
8053
  // OpenCode 1.18 mirrors every bus event as a `sync` envelope; the panel acts
7851
8054
  // on the bus event, so relaying the mirror only doubles the traffic.
7852
8055
  if (!shouldRelayOpencodeV2Message(data)) return;
@@ -7888,6 +8091,7 @@ function installOpencodeV2Listener() {
7888
8091
  type: 'server:status',
7889
8092
  status: event?.status || 'unknown',
7890
8093
  version: _ocpVersion,
8094
+ protocol: _ocpProtocol,
7891
8095
  port: event?.port || _ocpPort,
7892
8096
  managed: !!_ocpProc,
7893
8097
  });
@@ -7924,6 +8128,78 @@ function allocOpencodeIsoPort() {
7924
8128
  return null;
7925
8129
  }
7926
8130
 
8131
+ // OpenCode 2 cannot open its database while another serve of the same data
8132
+ // folder is opening it ("database is locked", and the serve exits): SynaBun
8133
+ // starts its OpenCode 2 serves one at a time, each until it answers. Every
8134
+ // start goes through here: the shared serve (ensureOpencodeServer), the
8135
+ // isolated serves of panel sessions, loops, workers and brains
8136
+ // (ensureIsolatedServe) and the editor's completion serve
8137
+ // (lib/editor-completion-opencode.js `startOneAtATime`). A start holds its turn
8138
+ // from the spawn until its serve answers, or until a failed start's process is
8139
+ // gone. OpenCode 1 serves start as they always did, side by side.
8140
+ let _ocpStartChain = Promise.resolve();
8141
+ function oneOpencodeStartAtATime(protocol, run) {
8142
+ if (protocol === 1) return run();
8143
+ const next = _ocpStartChain.then(run, run);
8144
+ _ocpStartChain = next.then(() => {}, () => {});
8145
+ return next;
8146
+ }
8147
+
8148
+ // Resolves once a child process has exited (true), or after `timeoutMs` (false): a failed start keeps its turn
8149
+ // until its process has let go of the database.
8150
+ function opencodeChildExited(proc, timeoutMs = 3000) {
8151
+ if (!proc || proc.exitCode != null || proc.signalCode != null) return Promise.resolve(true);
8152
+ return new Promise((done) => {
8153
+ const timer = setTimeout(() => done(false), timeoutMs);
8154
+ timer.unref?.();
8155
+ proc.once?.('exit', () => { clearTimeout(timer); done(true); });
8156
+ });
8157
+ }
8158
+
8159
+ // A start that failed ends its own process before its turn is over. It was sent SIGTERM (taskkill /T /F on Windows);
8160
+ // one still running after `timeoutMs` gets SIGKILL through its handle, which only ever reaches SynaBun's own child
8161
+ // (Node stops signalling a child once it has seen its exit, so never a pid another program has by now), and is
8162
+ // waited for once more. True once its exit is seen. Its record stays until then (trackOpencodeServe removes it on
8163
+ // that exit): one that outlives even SIGKILL stays on record for the look after this server has ended.
8164
+ async function endOpencodeChild(proc, timeoutMs = 3000) {
8165
+ if (await opencodeChildExited(proc, timeoutMs)) return true;
8166
+ console.warn(`[opencode] serve process ${proc.pid} did not end within ${Math.round(timeoutMs / 1000)} s of SIGTERM: SIGKILL`);
8167
+ try {
8168
+ if (IS_WIN && proc.pid) spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' }).on?.('error', () => {});
8169
+ else proc.kill('SIGKILL');
8170
+ } catch {}
8171
+ if (await opencodeChildExited(proc, timeoutMs)) return true;
8172
+ console.warn(`[opencode] serve process ${proc.pid} still runs after SIGKILL: it stays on record`);
8173
+ return false;
8174
+ }
8175
+
8176
+ function killIsolatedServeProcess(proc) {
8177
+ try {
8178
+ if (proc && !proc.killed && proc.exitCode == null) {
8179
+ if (IS_WIN && proc.pid) {
8180
+ spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], {
8181
+ shell: true,
8182
+ windowsHide: true,
8183
+ stdio: 'ignore',
8184
+ });
8185
+ } else {
8186
+ proc.kill('SIGTERM');
8187
+ }
8188
+ }
8189
+ } catch {}
8190
+ }
8191
+
8192
+ // An isolated start that is no longer wanted ends what it started. While its entry is still the one under its
8193
+ // term id, that is a stop like any other; a start that was cancelled (stopIsolatedServe) or replaced (a scoped
8194
+ // restart under the same term id) meanwhile ends only its own process and client: the replacement keeps its
8195
+ // bindings and its config folder, which has the same path.
8196
+ function endIsolatedStart(termId, entry) {
8197
+ if (_ocpIsoServes.get(termId) === entry) { stopIsolatedServe(termId); return; }
8198
+ try { entry.unsub?.(); } catch {}
8199
+ try { entry.client?.stop(); } catch {}
8200
+ killIsolatedServeProcess(entry.proc);
8201
+ }
8202
+
7927
8203
  // Sidepanel variant of setupOpencodeLoopConfig: inject the terminal identity and
7928
8204
  // pinned profile into the MCP env (no browser pins → the child acquires its own
7929
8205
  // tab) and do not add the loop permission bypass — the panel is user-supervised.
@@ -8040,13 +8316,18 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
8040
8316
  for (let i = 0; i < 60 && !entry.ready; i++) await new Promise(r => setTimeout(r, 250));
8041
8317
  return entry.ready ? entry : null;
8042
8318
  }
8043
- const port = allocOpencodeIsoPort();
8044
- if (port == null) { console.warn('[ocp-iso] no free port for isolated serve'); return null; }
8045
8319
  let xdgRoot;
8046
8320
  try { xdgRoot = requestedXdgRoot || setupOpencodeSidepanelConfig(termId, requestedProfile); }
8047
8321
  catch (err) { console.warn(`[ocp-iso] config setup failed: ${err.message}`); return null; }
8048
- entry = { termId, port, xdgRoot, mcpProfile: requestedProfile, mcpProfileRefreshFailed: false, proc: null, client: null, ready: false, starting: true, keepAlive: !!keepAlive, unsub: null, sessions: new Set(), lastUsed: Date.now() };
8322
+ // Registered before anything is waited for: a second start for the same term id waits for this one.
8323
+ entry = { termId, port: null, xdgRoot, mcpProfile: requestedProfile, mcpProfileRefreshFailed: false, proc: null, client: null, ready: false, starting: true, keepAlive: !!keepAlive, unsub: null, sessions: new Set(), lastUsed: Date.now() };
8049
8324
  _ocpIsoServes.set(termId, entry);
8325
+ // What an earlier SynaBun left running goes first (its ports with it); then the port is picked.
8326
+ await sweepLeftoverOpencodeServes();
8327
+ if (_ocpIsoServes.get(termId) !== entry) return null;
8328
+ let port = allocOpencodeIsoPort();
8329
+ if (port == null) { console.warn('[ocp-iso] no free port for isolated serve'); endIsolatedStart(termId, entry); return null; }
8330
+ entry.port = port;
8050
8331
  try {
8051
8332
  const isolatedEnv = {
8052
8333
  ...process.env,
@@ -8065,33 +8346,85 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
8065
8346
  };
8066
8347
  delete isolatedEnv.CLAUDECODE;
8067
8348
  if (!brainCredential) delete isolatedEnv.SYNABUN_BRAIN_TOKEN;
8068
- const launch = opencodeServeLaunch(port, { env: isolatedEnv });
8069
- const proc = spawn(launch.command, launch.args, {
8070
- stdio: ['ignore', 'pipe', 'pipe'],
8071
- ...launch.options,
8072
- detached: false, shell: false,
8073
- });
8074
- entry.proc = proc;
8349
+ // The OpenCode major decides how the serve starts (OpenCode 2: a password of
8350
+ // its own) and how it is spoken to; one SynaBun does not speak never starts.
8351
+ const detected = await detectOpencodeProtocol();
8075
8352
  const tag = termId.slice(-8);
8076
- proc.stdout?.on('data', (d) => console.log(`[ocp-iso:${tag}:out] ${String(d).trim()}`));
8077
- proc.stderr?.on('data', (d) => console.log(`[ocp-iso:${tag}:err] ${String(d).trim()}`));
8078
- proc.on('exit', (code) => {
8079
- console.log(`[ocp-iso] serve ${tag} exited code=${code}`);
8080
- if (_ocpIsoServes.get(termId)?.proc === proc) stopIsolatedServe(termId);
8081
- });
8353
+ if (detected.major != null && !detected.supported) { console.warn(`[ocp-iso] serve ${tag}: ${detected.reason}`); endIsolatedStart(termId, entry); return null; }
8354
+ // This start is wanted while its entry is the one under its term id: a stop cancels it, a scoped restart replaces it.
8355
+ const wanted = () => _ocpIsoServes.get(termId) === entry;
8356
+ let check = null;
8357
+ let started = 'cancelled';
8358
+ // Another server that takes the port while this serve boots is a start collision: it is left running (and never
8359
+ // put on record), this start's own process is ended, and the start tries the next free port (three at most).
8360
+ for (let attempt = 1; attempt <= 3; attempt++) {
8361
+ started = await oneOpencodeStartAtATime(detected.protocol, async () => {
8362
+ // Its turn may come after it was cancelled or replaced: then nothing is started for it.
8363
+ if (!wanted()) return 'cancelled';
8364
+ // A token of this spawn in its environment: what tells its processes from any other (lib/opencode-serve-records.js).
8365
+ const token = opencodeServeRecords.newToken();
8366
+ const launch = opencodeServeLaunch(port, { env: { ...isolatedEnv, [SERVE_TOKEN_ENV]: token }, protocol: detected.protocol });
8367
+ const proc = spawn(launch.command, launch.args, {
8368
+ stdio: ['ignore', 'pipe', 'pipe'],
8369
+ ...launch.options,
8370
+ detached: false, shell: false,
8371
+ });
8372
+ entry.proc = proc;
8373
+ entry.password = launch.password;
8374
+ // On record from its spawn: a crash of SynaBun from here on does not leave it running for good.
8375
+ const tracked = trackOpencodeServe(proc, { kind: 'isolated', id: termId, port, token });
8376
+ // While it boots, its start sees an exit itself (another server on the port is a collision); afterwards
8377
+ // its exit ends the serve.
8378
+ let booting = true;
8379
+ proc.stdout?.on('data', (d) => console.log(`[ocp-iso:${tag}:out] ${String(d).trim()}`));
8380
+ proc.stderr?.on('data', (d) => console.log(`[ocp-iso:${tag}:err] ${String(d).trim()}`));
8381
+ proc.on('exit', (code) => {
8382
+ console.log(`[ocp-iso] serve ${tag} exited code=${code}`);
8383
+ if (!booting && _ocpIsoServes.get(termId)?.proc === proc) stopIsolatedServe(termId);
8384
+ });
8082
8385
 
8083
- let healthy = false;
8084
- for (let i = 0; i < 60; i++) {
8085
- await new Promise(r => setTimeout(r, 250));
8086
- if (!_ocpIsoServes.has(termId)) return null; // torn down mid-boot
8087
- try {
8088
- const resp = await fetch(`http://127.0.0.1:${port}/global/health`, { signal: AbortSignal.timeout(3000) });
8089
- if (resp.ok) { healthy = true; break; }
8090
- } catch { /* not up yet */ }
8386
+ // Healthy only when the right API answers JSON (an OpenCode 2 web page never is), and only from this start's
8387
+ // own process (the spawned one, or one it started).
8388
+ let outcome = 'unhealthy';
8389
+ for (let i = 0; i < 60; i++) {
8390
+ await new Promise(r => setTimeout(r, 250));
8391
+ if (!wanted()) { outcome = 'cancelled'; break; } // cancelled or replaced mid-boot
8392
+ check = await checkOpencodeServer({ port, protocol: detected.protocol, password: launch.password, timeoutMs: 3000 });
8393
+ if (check.ok) {
8394
+ if (await tracked.answering() !== 'foreign') { booting = false; return 'ready'; }
8395
+ outcome = 'collision';
8396
+ break;
8397
+ }
8398
+ if (proc.exitCode != null || proc.signalCode != null) { outcome = 'exited'; break; }
8399
+ if (['mismatch', 'html', 'locked'].includes(check.state) && i >= 3) break;
8400
+ }
8401
+ // Not started, no longer wanted, or another server's port: this start's own process is gone before the next
8402
+ // start may open the database (SIGTERM, then SIGKILL), and on record until its exit is seen. Its exit no
8403
+ // longer speaks for the entry.
8404
+ if (entry.proc === proc) entry.proc = null;
8405
+ killIsolatedServeProcess(proc);
8406
+ await endOpencodeChild(proc);
8407
+ return outcome;
8408
+ });
8409
+ if (started !== 'collision' || !wanted() || attempt === 3) break;
8410
+ const taken = port;
8411
+ port = allocOpencodeIsoPort();
8412
+ if (port == null) { console.warn(`[ocp-iso] serve ${tag}: another server answers on port ${taken}, and no other port is free`); break; }
8413
+ entry.port = port;
8414
+ console.warn(`[ocp-iso] serve ${tag}: another server answers on port ${taken}; it is left running, and this serve starts on port ${port}`);
8415
+ }
8416
+ if (started !== 'ready') {
8417
+ if (started === 'unhealthy') console.warn(`[ocp-iso] serve ${tag} not healthy within 15s${check && check.state !== 'down' ? `: ${opencodeProblemText({ check, port })}` : ''}`);
8418
+ if (started === 'exited') console.warn(`[ocp-iso] serve ${tag} exited before it answered`);
8419
+ if (started === 'collision') console.warn(`[ocp-iso] serve ${tag}: another server took every port it tried`);
8420
+ endIsolatedStart(termId, entry);
8421
+ return null;
8091
8422
  }
8092
- if (!healthy) { console.warn(`[ocp-iso] serve ${tag} not healthy within 15s`); stopIsolatedServe(termId); return null; }
8423
+ if (!wanted()) { endIsolatedStart(termId, entry); return null; }
8424
+ entry.protocol = check.protocol;
8425
+ entry.version = check.version || null;
8093
8426
 
8094
- const client = opencodeV2Client.createClientInstance({ port });
8427
+ const client = opencodeV2Client.createClientInstance({ port, protocol: entry.protocol, password: entry.password, version: entry.version || '' });
8095
8428
  entry.client = client;
8096
8429
  entry.unsub = client.onEvent((envelope) => {
8097
8430
  if (!envelope) return;
@@ -8119,6 +8452,7 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
8119
8452
  });
8120
8453
  client.start();
8121
8454
  await client.waitUntilConnected?.(10_000);
8455
+ if (!wanted()) { endIsolatedStart(termId, entry); return null; }
8122
8456
  entry.ready = true;
8123
8457
  entry.starting = false;
8124
8458
  entry.lastUsed = Date.now();
@@ -8126,7 +8460,7 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
8126
8460
  return entry;
8127
8461
  } catch (err) {
8128
8462
  console.warn(`[ocp-iso] spawn failed: ${err.message}`);
8129
- stopIsolatedServe(termId);
8463
+ endIsolatedStart(termId, entry);
8130
8464
  return null;
8131
8465
  }
8132
8466
  }
@@ -8145,19 +8479,7 @@ function stopIsolatedServe(termId, { preserveBindings = false } = {}) {
8145
8479
  }
8146
8480
  try { entry.unsub?.(); } catch {}
8147
8481
  try { entry.client?.stop(); } catch {}
8148
- try {
8149
- if (entry.proc && !entry.proc.killed) {
8150
- if (IS_WIN && entry.proc.pid) {
8151
- spawn('taskkill', ['/pid', String(entry.proc.pid), '/T', '/F'], {
8152
- shell: true,
8153
- windowsHide: true,
8154
- stdio: 'ignore',
8155
- });
8156
- } else {
8157
- entry.proc.kill('SIGTERM');
8158
- }
8159
- }
8160
- } catch {}
8482
+ killIsolatedServeProcess(entry.proc);
8161
8483
  try { if (entry.xdgRoot && existsSync(entry.xdgRoot)) rmSync(entry.xdgRoot, { recursive: true, force: true }); } catch {}
8162
8484
  }
8163
8485
 
@@ -8380,7 +8702,7 @@ function handleOpencodeV2Ws(ws) {
8380
8702
  }
8381
8703
  if (type === 'identify') {
8382
8704
  // Sent on every (re)connect: the panel gates features on this list.
8383
- send({ type: 'capabilities', capabilities: opencodeV2WsCapabilities() });
8705
+ send({ type: 'capabilities', capabilities: opencodeV2WsCapabilities({ protocol: _ocpProtocol }) });
8384
8706
  return;
8385
8707
  }
8386
8708
  // Canonical session id first (msg.sessionId is rewritten in place), then
@@ -8406,11 +8728,14 @@ function handleOpencodeV2Ws(ws) {
8406
8728
  id,
8407
8729
  ready,
8408
8730
  version: _ocpVersion,
8731
+ // 1 or 2: the OpenCode API the shared serve speaks; `problem` why OpenCode cannot be used.
8732
+ protocol: _ocpProtocol,
8733
+ ...(ready ? {} : { problem: _ocpProblem || null }),
8409
8734
  sdkVersion: opencodeSdkVersion(),
8410
8735
  port: _ocpPort,
8411
8736
  managed: !!_ocpProc,
8412
8737
  connected: opencodeV2Client.isConnected(),
8413
- capabilities: opencodeV2WsCapabilities(),
8738
+ capabilities: opencodeV2WsCapabilities({ protocol: _ocpProtocol }),
8414
8739
  });
8415
8740
  break;
8416
8741
  }
@@ -8421,11 +8746,14 @@ function handleOpencodeV2Ws(ws) {
8421
8746
  // registered project. Reading the DB directly returns ALL sessions
8422
8747
  // so the sidepanel resume dropdown and boot tab-restore can
8423
8748
  // surface sessions outside SynaBun (every other registered project).
8749
+ // OpenCode 2 keeps them in other tables of the same file
8750
+ // (lib/opencode-db-schema.js); neither set of tables: the API.
8424
8751
  const db = getOpencodeDb();
8425
- if (db) {
8752
+ const flavor = db ? opencodeDbFlavor(db, _ocpProtocol) : null;
8753
+ if (db && flavor) {
8426
8754
  try {
8427
8755
  // msg.options: search / archived / roots / limit (see buildSessionListQuery).
8428
- const query = buildSessionListQuery(msg.options || {});
8756
+ const query = buildSessionListQuery(msg.options || {}, { flavor });
8429
8757
  const rows = db.prepare(query.sql).all(...query.params);
8430
8758
  const sessions = rows.filter((row) => {
8431
8759
  const run = activeNativeOpenCodeRunForSession(row.id);
@@ -8648,30 +8976,37 @@ function handleOpencodeV2Ws(ws) {
8648
8976
  });
8649
8977
  }
8650
8978
 
8979
+ // Healthy only when the API of the shared serve's major answers JSON
8980
+ // (lib/opencode-protocol.js checkOpencodeServer): OpenCode 2 answers every
8981
+ // OpenCode 1 path with its web app and status 200, which is never healthy.
8982
+ // `_ocpLastCheck` keeps the whole answer (what answered, and why it is not usable).
8983
+ let _ocpLastCheck = null;
8651
8984
  async function checkOpencodeHealth() {
8652
- try {
8653
- const resp = await fetch(`${_ocpBaseUrl()}/global/health`, { signal: AbortSignal.timeout(3000) });
8654
- if (resp.ok) {
8655
- const body = await resp.json().catch(() => null);
8656
- _ocpVersion = body?.version || null;
8657
- return true;
8658
- }
8659
- } catch {}
8660
- return false;
8985
+ const check = await checkOpencodeServer({ port: _ocpPort, protocol: _ocpProtocol, password: _ocpPassword, timeoutMs: 3000 });
8986
+ _ocpLastCheck = check;
8987
+ if (check.ok) {
8988
+ _ocpVersion = check.version || null;
8989
+ if (check.protocol) _ocpProtocol = check.protocol;
8990
+ }
8991
+ return check.ok;
8661
8992
  }
8662
8993
 
8994
+ // At the first start: a server already on the port that is SynaBun's own serve of an earlier run (its recorded
8995
+ // process, still that process) is taken over, and so is one SynaBun owns whose credentials are older than
8996
+ // auth.json. Automatic, so a server someone else runs is never stopped: it is used as it is.
8663
8997
  async function reconcileOpencodeServerIfStale() {
8664
- const listenerPids = listPortListenerPids(_ocpPort);
8665
- const managedPid = readOpencodeManagedPid();
8666
- const shouldReconcile = !_ocpBootReconcileTried && (
8667
- (managedPid && listenerPids.includes(managedPid)) ||
8668
- shouldReconcileOpencodeForStoredAuth()
8669
- );
8670
- if (!shouldReconcile) return false;
8998
+ if (_ocpBootReconcileTried) return false;
8999
+ const owned = verifiedOpencodeManagedProcess({ port: _ocpPort });
9000
+ const staleAuth = shouldReconcileOpencodeForStoredAuth();
9001
+ if (!owned && !staleAuth) return false;
8671
9002
  _ocpBootReconcileTried = true;
9003
+ if (!owned) {
9004
+ console.warn(`[opencode] The OpenCode server on port ${_ocpPort} was not started by SynaBun: it is used as it is, not restarted (it may not have the newest provider credentials until whoever runs it restarts it)`);
9005
+ return false;
9006
+ }
8672
9007
  _ocpReady = false;
8673
9008
  _ocpStarting = false;
8674
- console.log(`[opencode] Existing server on port ${_ocpPort} is stale for current auth; taking over`);
9009
+ console.log(`[opencode] SynaBun's own earlier server (pid ${owned.pid}) holds port ${_ocpPort}; taking it over`);
8675
9010
  await takeoverOpencodeServer();
8676
9011
  return true;
8677
9012
  }
@@ -8703,6 +9038,65 @@ async function prewarmOpencodeMcpServers() {
8703
9038
  }
8704
9039
  }
8705
9040
 
9041
+ // Is something listening on 127.0.0.1:port? A TCP connect, so it answers on Windows too (no lsof there).
9042
+ function opencodePortAnswers(port, timeoutMs = 500) {
9043
+ return new Promise((resolveAnswer) => {
9044
+ const socket = netConnect({ host: '127.0.0.1', port });
9045
+ const done = (value) => { try { socket.destroy(); } catch {} resolveAnswer(value); };
9046
+ socket.setTimeout(timeoutMs, () => done(false));
9047
+ socket.once('connect', () => done(true));
9048
+ socket.once('error', () => done(false));
9049
+ });
9050
+ }
9051
+
9052
+ // Stop SynaBun's own recorded serve (verifiedOpencodeManagedProcess) on a port: SIGTERM, then SIGKILL while it
9053
+ // is still there, each checked against the record right before (signalOwnedOpencodeProcess). True once the
9054
+ // port is free.
9055
+ async function stopOwnedOpencodeServe(owned, port) {
9056
+ signalOwnedOpencodeProcess(owned, 'SIGTERM');
9057
+ for (let i = 0; i < 24 && listPortListenerPids(port).length; i++) await new Promise((r) => setTimeout(r, 250));
9058
+ if (listPortListenerPids(port).includes(owned.pid)) signalOwnedOpencodeProcess(owned, 'SIGKILL');
9059
+ for (let i = 0; i < 12 && listPortListenerPids(port).length; i++) await new Promise((r) => setTimeout(r, 250));
9060
+ // Its record goes once it has provably ended; one that still runs, or cannot be read, stays on record for the next look.
9061
+ clearOpencodeManagedPid(owned);
9062
+ return !listPortListenerPids(port).length && !(await opencodePortAnswers(port));
9063
+ }
9064
+
9065
+ // The shared serve moves to one of the next free ports, never into the isolated serves' range (4120–4400):
9066
+ // from 4096, 4097 to 4119. `why` is said with the move. False when no port could be had.
9067
+ async function moveSharedOpencodePort(why) {
9068
+ for (let port = _ocpPort + 1; port <= _ocpPort + 23; port++) {
9069
+ if ((port >= 4120 && port <= 4400) || port > 65535) continue;
9070
+ if (listPortListenerPids(port).length || await opencodePortAnswers(port)) continue;
9071
+ console.warn(`[opencode] ${why} The shared OpenCode serve uses port ${port} instead.`);
9072
+ _ocpPort = port;
9073
+ return true;
9074
+ }
9075
+ return false;
9076
+ }
9077
+
9078
+ // The shared serve's port is held by a server SynaBun cannot use: an OpenCode
9079
+ // 2 whose password SynaBun does not hold, a web page, something else. SynaBun's
9080
+ // own serve from an earlier run (its recorded process, while the OS still
9081
+ // reports that very process) is stopped and the port reused; anything else,
9082
+ // and a recorded pid that is another process by now, is left running, and the
9083
+ // shared serve moves to another port.
9084
+ async function freeSharedOpencodePortIfHeld(check) {
9085
+ const owned = verifiedOpencodeManagedProcess({ port: _ocpPort });
9086
+ if (owned) {
9087
+ console.log(`[opencode] SynaBun's earlier serve (pid ${owned.pid}) holds port ${_ocpPort} and cannot be used (${check?.error || check?.state}); stopping it`);
9088
+ if (await stopOwnedOpencodeServe(owned, _ocpPort)) return true;
9089
+ }
9090
+ return moveSharedOpencodePort(opencodeProblemText({ check, port: _ocpPort }));
9091
+ }
9092
+
9093
+ // Tell every OpenCode panel that OpenCode cannot be used, and why.
9094
+ function reportOpencodeProblem(problem) {
9095
+ _ocpProblem = problem || 'OpenCode could not be started.';
9096
+ console.warn(`[opencode] ${_ocpProblem}`);
9097
+ broadcastToOpencodeV2Clients({ type: 'server:status', status: 'error', error: _ocpProblem, version: _ocpVersion, protocol: _ocpProtocol, port: _ocpPort, managed: !!_ocpProc });
9098
+ }
9099
+
8706
9100
  async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
8707
9101
  if (_ocpHistoryClearInProgress && !allowDuringHistoryClear) return false;
8708
9102
  if (_ocpReady) return true;
@@ -8715,6 +9109,23 @@ async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
8715
9109
  }
8716
9110
  _ocpStarting = true;
8717
9111
 
9112
+ // 0. Which OpenCode is installed: its major decides how the serve starts and
9113
+ // how SynaBun speaks to it. A major SynaBun does not know is refused here, with
9114
+ // a sentence the panel shows, instead of failing call by call later.
9115
+ const detected = await detectOpencodeProtocol();
9116
+ if (detected.major != null && !detected.supported) {
9117
+ _ocpStarting = false;
9118
+ reportOpencodeProblem(detected.reason);
9119
+ return false;
9120
+ }
9121
+ _ocpProblem = null;
9122
+ // A serve already running is adopted whatever is installed: an OpenCode 1
9123
+ // answers SynaBun as it is, an OpenCode 2 only to the password SynaBun gave it.
9124
+ if (!_ocpProc) _ocpProtocol = null;
9125
+ // A serve an earlier SynaBun left running (on this port or any other it used) goes before anything here is
9126
+ // adopted, and its port with it.
9127
+ await sweepLeftoverOpencodeServes();
9128
+
8718
9129
  // 1. Check if already running externally
8719
9130
  if (await checkOpencodeHealth()) {
8720
9131
  if (await reconcileOpencodeServerIfStale()) return true;
@@ -8725,66 +9136,111 @@ async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
8725
9136
  // One event stream on this serve: the v2 client's. MCP prewarm runs on
8726
9137
  // the v2 client too.
8727
9138
  installOpencodeV2Listener();
8728
- await opencodeV2Client.connect({ port: _ocpPort });
8729
- broadcastToOpencodeV2Clients({ type: 'server:status', status: 'ready', version: _ocpVersion, port: _ocpPort, managed: false });
8730
- console.log(`[opencode] External server detected on port ${_ocpPort} (v${_ocpVersion || '?'})`);
9139
+ await opencodeV2Client.connect({ port: _ocpPort, protocol: _ocpProtocol, password: _ocpPassword, version: _ocpVersion });
9140
+ broadcastToOpencodeV2Clients({ type: 'server:status', status: 'ready', version: _ocpVersion, protocol: _ocpProtocol, port: _ocpPort, managed: false });
9141
+ console.log(`[opencode] External server detected on port ${_ocpPort} (v${_ocpVersion || '?'}, OpenCode ${_ocpProtocol || '?'} API)`);
8731
9142
  prewarmOpencodeMcpServers();
8732
9143
  return true;
8733
9144
  }
9145
+ if (_ocpLastCheck && _ocpLastCheck.state !== 'down' && !(await freeSharedOpencodePortIfHeld(_ocpLastCheck))) {
9146
+ _ocpStarting = false;
9147
+ reportOpencodeProblem(opencodeProblemText({ check: _ocpLastCheck, port: _ocpPort }));
9148
+ return false;
9149
+ }
8734
9150
 
8735
- // 2. Spawn opencode serve
8736
- try {
8737
- const launch = opencodeServeLaunch(_ocpPort, { env: { ...process.env, FORCE_COLOR: '0', OPENCODE_DISABLE_CLAUDE_CODE: '1' } });
8738
- console.log(`[opencode] Spawning: ${launch.label}`);
8739
- _ocpProc = spawn(launch.command, launch.args, {
8740
- stdio: ['ignore', 'pipe', 'pipe'],
8741
- ...launch.options,
8742
- detached: false,
8743
- shell: false,
8744
- });
8745
- _ocpProc.stdout?.on('data', (d) => console.log(`[opencode:stdout] ${d.toString().trim()}`));
8746
- _ocpProc.stderr?.on('data', (d) => console.log(`[opencode:stderr] ${d.toString().trim()}`));
8747
- _ocpProc.on('exit', (code) => {
8748
- console.log(`[opencode] Process exited with code ${code}`);
8749
- _ocpProc = null;
8750
- _ocpReady = false;
8751
- clearOpencodeManagedPid();
8752
- opencodeV2Client.disconnect().catch(() => {});
8753
- broadcastToOpencodeV2Clients({ type: 'server:status', status: 'offline', port: _ocpPort });
9151
+ // 2. Spawn opencode serve, and 3. poll until ready (max 15s): one start at a time with every other serve
9152
+ // SynaBun starts (oneOpencodeStartAtATime), its turn held until the serve answers or a failed start's process
9153
+ // is gone. null for a command line in Settings: what answers says which major it is.
9154
+ // Another server that takes the port while SynaBun's serve starts is a start collision: it is left running (and
9155
+ // never put on record), SynaBun's own process is ended, and the shared serve tries the next free port (three at most).
9156
+ let outcome = null;
9157
+ for (let attempt = 1; attempt <= 3; attempt++) {
9158
+ _ocpProtocol = detected.protocol;
9159
+ outcome = await oneOpencodeStartAtATime(detected.protocol, async () => {
9160
+ let proc = null;
9161
+ let tracked = null;
9162
+ try {
9163
+ // A token of this spawn in its environment: what tells its processes from any other (lib/opencode-serve-records.js).
9164
+ const token = opencodeServeRecords.newToken();
9165
+ const launch = opencodeServeLaunch(_ocpPort, { env: { ...process.env, FORCE_COLOR: '0', OPENCODE_DISABLE_CLAUDE_CODE: '1', [SERVE_TOKEN_ENV]: token }, protocol: detected.protocol });
9166
+ _ocpPassword = launch.password;
9167
+ console.log(`[opencode] Spawning: ${launch.label}`);
9168
+ proc = spawn(launch.command, launch.args, {
9169
+ stdio: ['ignore', 'pipe', 'pipe'],
9170
+ ...launch.options,
9171
+ detached: false,
9172
+ shell: false,
9173
+ });
9174
+ _ocpProc = proc;
9175
+ // On record from its spawn: a crash of SynaBun from here on does not leave it running for good.
9176
+ tracked = trackOpencodeServe(proc, { kind: 'shared', id: 'shared', port: _ocpPort, token });
9177
+ proc.stdout?.on('data', (d) => console.log(`[opencode:stdout] ${d.toString().trim()}`));
9178
+ proc.stderr?.on('data', (d) => console.log(`[opencode:stderr] ${d.toString().trim()}`));
9179
+ proc.on('exit', (code) => {
9180
+ console.log(`[opencode] Process exited with code ${code}`);
9181
+ // A serve stopped or replaced meanwhile says nothing about the one running now. (Its record goes with
9182
+ // its process either way: trackOpencodeServe.)
9183
+ if (_ocpProc !== proc) return;
9184
+ _ocpProc = null;
9185
+ _ocpReady = false;
9186
+ opencodeV2Client.disconnect().catch(() => {});
9187
+ broadcastToOpencodeV2Clients({ type: 'server:status', status: 'offline', port: _ocpPort });
9188
+ });
9189
+ } catch (err) {
9190
+ console.error(`[opencode] Failed to spawn: ${err.message}`);
9191
+ return 'not-spawned';
9192
+ }
9193
+ for (let i = 0; i < 30; i++) {
9194
+ await new Promise(r => setTimeout(r, 500));
9195
+ if (await checkOpencodeHealth()) {
9196
+ // Only SynaBun's own process counts (the spawned one, or one it started): another server that took the
9197
+ // port meanwhile answers for itself.
9198
+ if (await tracked.answering() !== 'foreign') return 'ready';
9199
+ if (_ocpProc === proc) _ocpProc = null;
9200
+ killIsolatedServeProcess(proc);
9201
+ await endOpencodeChild(proc);
9202
+ return 'collision';
9203
+ }
9204
+ // Something answers, but not the API SynaBun expects (another major, a web page): no use waiting.
9205
+ if (_ocpLastCheck && ['mismatch', 'html', 'locked'].includes(_ocpLastCheck.state) && i >= 3) break;
9206
+ }
9207
+ stopOpencodeServer();
9208
+ await endOpencodeChild(proc);
9209
+ return 'unhealthy';
8754
9210
  });
8755
- } catch (err) {
8756
- console.error(`[opencode] Failed to spawn: ${err.message}`);
9211
+ if (outcome !== 'collision' || attempt === 3) break;
9212
+ if (!(await moveSharedOpencodePort(`Another server took port ${_ocpPort} while SynaBun's OpenCode serve was starting: it is left running.`))) break;
9213
+ }
9214
+ if (outcome === 'not-spawned') {
8757
9215
  _ocpStarting = false;
8758
9216
  return false;
8759
9217
  }
8760
-
8761
- // 3. Poll until ready (max 15s)
8762
- for (let i = 0; i < 30; i++) {
8763
- await new Promise(r => setTimeout(r, 500));
8764
- if (await checkOpencodeHealth()) {
8765
- _ocpReady = true;
8766
- _ocpStarting = false;
8767
- _ocpBootReconcileTried = true;
8768
- _ocpLastBootAt = Date.now();
8769
- writeOpencodeManagedPid(listPortListenerPids(_ocpPort)[0] || _ocpProc?.pid);
8770
- installOpencodeV2Listener();
8771
- await opencodeV2Client.connect({ port: _ocpPort });
8772
- broadcastToOpencodeV2Clients({ type: 'server:status', status: 'ready', version: _ocpVersion, port: _ocpPort, managed: true });
8773
- console.log(`[opencode] Server ready on port ${_ocpPort} (v${_ocpVersion || '?'})`);
8774
- prewarmOpencodeMcpServers();
8775
- return true;
8776
- }
9218
+ if (outcome === 'ready') {
9219
+ _ocpReady = true;
9220
+ _ocpStarting = false;
9221
+ _ocpBootReconcileTried = true;
9222
+ _ocpLastBootAt = Date.now();
9223
+ installOpencodeV2Listener();
9224
+ await opencodeV2Client.connect({ port: _ocpPort, protocol: _ocpProtocol, password: _ocpPassword, version: _ocpVersion });
9225
+ broadcastToOpencodeV2Clients({ type: 'server:status', status: 'ready', version: _ocpVersion, protocol: _ocpProtocol, port: _ocpPort, managed: true });
9226
+ console.log(`[opencode] Server ready on port ${_ocpPort} (v${_ocpVersion || '?'}, OpenCode ${_ocpProtocol || '?'} API)`);
9227
+ prewarmOpencodeMcpServers();
9228
+ return true;
8777
9229
  }
8778
9230
 
8779
- console.error('[opencode] Server did not become healthy within 15s');
8780
- stopOpencodeServer();
9231
+ const why = outcome === 'collision'
9232
+ ? `Another server took port ${_ocpPort} while SynaBun's OpenCode serve was starting, on every port it tried; it is left running.`
9233
+ : _ocpLastCheck && _ocpLastCheck.state !== 'down'
9234
+ ? opencodeProblemText({ check: _ocpLastCheck, port: _ocpPort })
9235
+ : 'OpenCode did not start within 15 seconds.';
9236
+ console.error(`[opencode] Server did not become healthy: ${why}`);
8781
9237
  _ocpStarting = false;
9238
+ reportOpencodeProblem(why);
8782
9239
  return false;
8783
9240
  }
8784
9241
 
8785
9242
  function stopOpencodeServer({ stopManagedOllama = true } = {}) {
8786
9243
  opencodeV2Client.disconnect().catch(() => {});
8787
- const managedPid = readOpencodeManagedPid();
8788
9244
  const procPid = _ocpProc?.pid || null;
8789
9245
  const isWin = process.platform === 'win32';
8790
9246
 
@@ -8810,14 +9266,16 @@ function stopOpencodeServer({ stopManagedOllama = true } = {}) {
8810
9266
  }
8811
9267
  _ocpProc = null;
8812
9268
  }
8813
- if (managedPid && managedPid !== procPid) {
8814
- if (isWin) {
8815
- winKill(managedPid);
8816
- } else {
8817
- signalPids([managedPid], 'SIGTERM');
8818
- }
8819
- }
8820
- clearOpencodeManagedPid();
9269
+ // SynaBun's recorded serves (a process its child started that answers on the port; one of an earlier run whose
9270
+ // SynaBun has ended), only while the OS still reports each as that very process: a pid another program has by now,
9271
+ // or one that cannot be read, is never signalled (signalOwnedOpencodeProcess checks once more right before;
9272
+ // taskkill on Windows).
9273
+ const owned = verifiedOpencodeManagedProcesses();
9274
+ for (const record of owned) if (record.pid !== procPid) signalOwnedOpencodeProcess(record, 'SIGTERM');
9275
+ // A record whose process has provably ended goes. One whose serve was just signalled stays until that serve has
9276
+ // ended (its exit removes it, or the next look for leftovers does): a crash meanwhile does not leave it running
9277
+ // unrecorded.
9278
+ if (!owned.length) clearOpencodeManagedPid();
8821
9279
  _ocpReady = false;
8822
9280
  _ocpStarting = false;
8823
9281
  broadcastToOpencodeV2Clients({ type: 'server:status', status: 'offline', port: _ocpPort });
@@ -8827,7 +9285,13 @@ function stopOpencodeServer({ stopManagedOllama = true } = {}) {
8827
9285
  }
8828
9286
  }
8829
9287
 
8830
- // Takeover: kill external server (or restart managed), then spawn managed
9288
+ // Takeover: a fresh shared serve that boots from the updated auth/config files. A serve is stopped only when it
9289
+ // is SynaBun's own: its child (a plain restart), or its recorded serve of an earlier run while the OS still
9290
+ // reports that very process, checked again before each signal. No other process is ever signalled: not every
9291
+ // `opencode … serve` of the machine (the user's own, OpenCode 2's background service, SynaBun's isolated serves),
9292
+ // not a recorded pid another program has by now. A server SynaBun did not start is left running, and SynaBun
9293
+ // starts its own on another port. The automatic callers (reconcileOpencodeServerIfStale, reconcileAuthIfStale)
9294
+ // call this only for a serve that is SynaBun's.
8831
9295
  async function takeoverOpencodeServer() {
8832
9296
  if (_ocpHistoryClearInProgress) throw new Error('OpenCode history is being cleared');
8833
9297
  if (_ocpProc) {
@@ -8836,32 +9300,23 @@ async function takeoverOpencodeServer() {
8836
9300
  await new Promise(r => setTimeout(r, 1000));
8837
9301
  return ensureOpencodeServer();
8838
9302
  }
8839
- // External server — terminate the actual port listener so we can take over with
8840
- // a managed process that will boot from the updated auth/config files.
8841
9303
  _ocpReady = false;
8842
9304
  _ocpStarting = false;
9305
+ const owned = verifiedOpencodeManagedProcess({ port: _ocpPort });
9306
+ if (!owned) {
9307
+ await opencodeV2Client.disconnect().catch(() => {});
9308
+ if (!(await moveSharedOpencodePort(`The OpenCode server on port ${_ocpPort} was not started by SynaBun: it is left running.`))) {
9309
+ throw new Error(`The OpenCode server on port ${_ocpPort} was not started by SynaBun and is left running; no other port is free for SynaBun's own`);
9310
+ }
9311
+ return ensureOpencodeServer();
9312
+ }
8843
9313
  try {
8844
9314
  await ocpProxy('POST', '/global/dispose');
8845
9315
  } catch {}
8846
- const initialPortPids = listPortListenerPids(_ocpPort);
8847
- const commandPids = listOpencodeServePids();
8848
- const candidatePids = [...new Set([...initialPortPids, ...commandPids])];
8849
- if (candidatePids.length) {
8850
- const terminated = signalPids(candidatePids, 'SIGTERM');
8851
- console.log(`[opencode] Sent SIGTERM to external server PIDs: ${terminated.join(', ') || 'none'}`);
8852
- } else {
8853
- console.log(`[opencode] No external listener PIDs found on port ${_ocpPort}; waiting for shutdown`);
8854
- }
8855
- if (!(await waitForOpencodeShutdown())) {
8856
- const stubbornPids = [...new Set([...listPortListenerPids(_ocpPort), ...listOpencodeServePids()])];
8857
- if (stubbornPids.length) {
8858
- const killed = signalPids(stubbornPids, 'SIGKILL');
8859
- console.log(`[opencode] Sent SIGKILL to stubborn external PIDs: ${killed.join(', ') || 'none'}`);
8860
- }
8861
- await waitForOpencodeShutdown(4000);
8862
- }
8863
- if (await checkOpencodeHealth()) {
8864
- throw new Error(`External OpenCode server on port ${_ocpPort} survived takeover`);
9316
+ await opencodeV2Client.disconnect().catch(() => {});
9317
+ console.log(`[opencode] Stopping SynaBun's earlier server (pid ${owned.pid}) on port ${_ocpPort}`);
9318
+ if (!(await stopOwnedOpencodeServe(owned, _ocpPort)) && await checkOpencodeHealth()) {
9319
+ throw new Error(`SynaBun's OpenCode server on port ${_ocpPort} survived takeover`);
8865
9320
  }
8866
9321
  await new Promise(r => setTimeout(r, 500));
8867
9322
  return ensureOpencodeServer();
@@ -8897,7 +9352,43 @@ function getOpencodeDb() {
8897
9352
 
8898
9353
  // ── Proxy helper: forward REST calls to opencode serve ──
8899
9354
 
9355
+ // The OpenCode 2 serve behind a base URL (the shared one or an isolated one):
9356
+ // `{ client, password }`, or null for an OpenCode 1 serve.
9357
+ function opencode2ServeFor(baseUrl) {
9358
+ let port = null;
9359
+ try { port = Number(new URL(baseUrl).port) || null; } catch { return null; }
9360
+ if (port === _ocpPort && _ocpProtocol === 2) {
9361
+ const client = opencodeV2Client.getClient();
9362
+ return client ? { client, password: _ocpPassword } : null;
9363
+ }
9364
+ for (const entry of _ocpIsoServes.values()) {
9365
+ if (entry.port === port && entry.protocol === 2 && entry.client) return { client: entry.client.getClient(), password: entry.password };
9366
+ }
9367
+ return null;
9368
+ }
9369
+
8900
9370
  async function ocpProxy(method, path, body = null, timeoutMs = 30000, baseUrl = _ocpBaseUrl()) {
9371
+ // OpenCode 2 answers these OpenCode 1 paths with its web app: each is
9372
+ // translated onto its API instead (lib/opencode2-proxy.js), /api/* passes
9373
+ // through with the serve's password.
9374
+ const v2 = opencode2ServeFor(baseUrl);
9375
+ if (v2?.client) {
9376
+ ocpTrace('proxy:req', { tid: _ocpTraceId(), method, path, opencode: 2, timeoutMs });
9377
+ return opencode2Proxy(v2.client, method, path, body, {
9378
+ rawFetch: async (apiPath, init = {}) => {
9379
+ const resp = await fetch(`${baseUrl}${apiPath}`, {
9380
+ method: init.method || method,
9381
+ headers: { 'Content-Type': 'application/json', authorization: basicAuthHeader(v2.password || '') },
9382
+ ...(init.body != null && (init.method || method) !== 'GET' ? { body: JSON.stringify(init.body) } : {}),
9383
+ signal: AbortSignal.timeout(timeoutMs),
9384
+ });
9385
+ const text = await resp.text();
9386
+ let data;
9387
+ try { data = JSON.parse(text); } catch { data = text; }
9388
+ return { status: resp.status, data };
9389
+ },
9390
+ });
9391
+ }
8901
9392
  const url = `${baseUrl}${path}`;
8902
9393
  const opts = {
8903
9394
  method,
@@ -9188,40 +9679,29 @@ async function opencodePortIsAlive(port) {
9188
9679
  }
9189
9680
  }
9190
9681
 
9191
- function opencodeProcessIsAlive(pid) {
9192
- if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
9193
- try {
9194
- process.kill(pid, 0);
9195
- return true;
9196
- } catch {
9197
- return false;
9198
- }
9199
- }
9200
-
9201
- async function waitForOpencodeProcessesToStop(ports, pids, timeoutMs = 8000) {
9682
+ async function waitForOpencodeProcessesToStop(ports, stopped, timeoutMs = 8000) {
9202
9683
  const uniquePorts = [...new Set(ports.filter((port) => Number.isFinite(port) && port > 0))];
9203
- const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid))];
9204
9684
  const deadline = Date.now() + timeoutMs;
9205
9685
  while (Date.now() < deadline) {
9206
9686
  const alive = await Promise.all(uniquePorts.map(opencodePortIsAlive));
9207
- if (!alive.some(Boolean) && !uniquePids.some(opencodeProcessIsAlive)) return true;
9687
+ if (!alive.some(Boolean) && stopped()) return true;
9208
9688
  await new Promise((resolveWait) => setTimeout(resolveWait, 250));
9209
9689
  }
9210
9690
  return false;
9211
9691
  }
9212
9692
 
9693
+ // Only what SynaBun started is stopped for a history clear: its children, by their handles (which never signal a
9694
+ // process that has exited), and its recorded serve while the OS still reports that very process. A server someone
9695
+ // else runs on one of these ports is left running, and the clear then says so instead of killing it.
9213
9696
  async function stopOpenCodeForHistoryClear() {
9214
- const isolated = [..._ocpIsoServes.values()].map((entry) => ({
9215
- port: entry.port,
9216
- pid: entry.proc?.pid || null,
9217
- }));
9697
+ const isolated = [..._ocpIsoServes.values()].map((entry) => ({ port: entry.port, proc: entry.proc || null }));
9218
9698
  const ports = [_ocpPort, ...isolated.map((entry) => entry.port)];
9219
- const exactPids = new Set([
9220
- _ocpProc?.pid,
9221
- readOpencodeManagedPid(),
9222
- ...isolated.map((entry) => entry.pid),
9223
- ...ports.flatMap((port) => listPortListenerPids(port)),
9224
- ].filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid));
9699
+ const children = [_ocpProc, ...isolated.map((entry) => entry.proc)].filter(Boolean);
9700
+ const owned = verifiedOpencodeManagedProcess();
9701
+ const gone = (child) => child.exitCode != null || child.signalCode != null;
9702
+ // Still running until it has provably ended: one that cannot be read now counts as running.
9703
+ const ownedRuns = () => !!owned && opencodeServeRecords.verifySync(owned) !== 'gone';
9704
+ const stopped = () => children.every(gone) && !ownedRuns();
9225
9705
 
9226
9706
  await Promise.allSettled(isolated.map(({ port }) => fetch(
9227
9707
  `http://127.0.0.1:${port}/global/dispose`,
@@ -9231,21 +9711,23 @@ async function stopOpenCodeForHistoryClear() {
9231
9711
  stopAllIsolatedServes();
9232
9712
  stopOpencodeServer({ stopManagedOllama: false });
9233
9713
 
9234
- if (await waitForOpencodeProcessesToStop(ports, [...exactPids])) return;
9235
- if (process.platform !== 'win32' && exactPids.size) {
9236
- signalPids([...exactPids], 'SIGKILL');
9237
- await waitForOpencodeProcessesToStop(ports, [...exactPids], 4000);
9714
+ if (await waitForOpencodeProcessesToStop(ports, stopped)) return;
9715
+ if (process.platform !== 'win32') {
9716
+ for (const child of children) if (!gone(child)) { try { child.kill('SIGKILL'); } catch {} }
9717
+ if (owned) signalOwnedOpencodeProcess(owned, 'SIGKILL');
9718
+ await waitForOpencodeProcessesToStop(ports, stopped, 4000);
9238
9719
  }
9239
9720
  const stillAlive = (await Promise.all(ports.map(async (port) => (
9240
9721
  (await opencodePortIsAlive(port)) ? port : null
9241
9722
  )))).filter(Boolean);
9242
- const stillRunningPids = [...exactPids].filter(opencodeProcessIsAlive);
9723
+ const stillRunningPids = [...children.filter((child) => !gone(child)).map((child) => child.pid), ...(ownedRuns() ? [owned.pid] : [])];
9243
9724
  if (stillAlive.length || stillRunningPids.length) {
9244
9725
  const details = [
9245
9726
  ...stillAlive.map((port) => `port ${port}`),
9246
9727
  ...stillRunningPids.map((pid) => `process ${pid}`),
9247
9728
  ];
9248
- throw new Error(`OpenCode did not release ${details.join(', ')}`);
9729
+ const someoneElse = stillAlive.length && !stillRunningPids.length ? ' (an OpenCode server SynaBun did not start answers there: stop it, then clear again)' : '';
9730
+ throw new Error(`OpenCode did not release ${details.join(', ')}${someoneElse}`);
9249
9731
  }
9250
9732
  }
9251
9733
 
@@ -9288,6 +9770,12 @@ app.delete('/api/opencode/history', async (req, res) => {
9288
9770
  blockers,
9289
9771
  });
9290
9772
  }
9773
+ // OpenCode 2's database is not cleared here (lib/opencode-history.js): refused before anything is stopped.
9774
+ let historyStats = null;
9775
+ try { historyStats = getOpenCodeHistoryStats({ dbPath: getOpencodeDbPath(), dataRoot: dirname(getOpencodeDbPath()) }); } catch { /* the clear itself says */ }
9776
+ if (historyStats?.opencode2 || _ocpProtocol === 2) {
9777
+ return res.status(409).json({ ok: false, opencode2: true, error: historyStats?.reason || OPENCODE2_HISTORY_REFUSAL });
9778
+ }
9291
9779
 
9292
9780
  wasRunning = await checkOpencodeHealth();
9293
9781
  await stopOpenCodeForHistoryClear();
@@ -9363,7 +9851,7 @@ app.get('/api/opencode/status', async (req, res) => {
9363
9851
  } else if (!running && _ocpReady) {
9364
9852
  _ocpReady = false;
9365
9853
  }
9366
- res.json({ ok: true, running, port: _ocpPort, version: _ocpVersion, managed: !!_ocpProc });
9854
+ res.json({ ok: true, running, port: _ocpPort, version: _ocpVersion, protocol: _ocpProtocol, managed: !!_ocpProc, ...(running ? {} : { problem: _ocpProblem || (_ocpLastCheck && _ocpLastCheck.state !== 'down' ? opencodeProblemText({ check: _ocpLastCheck, port: _ocpPort }) : null) }) });
9367
9855
  } catch (err) {
9368
9856
  res.status(500).json({ ok: false, error: err.message });
9369
9857
  }
@@ -9420,14 +9908,108 @@ app.post('/api/opencode/snapshot', (req, res) => {
9420
9908
  }
9421
9909
  });
9422
9910
 
9911
+ // ── OpenCode's provider list, kept (lib/launch-cache.js) ──
9912
+ // The shared serve's /provider is the whole models.dev catalog (about 7 MB, 231 providers on a typical
9913
+ // install) and took 1.3-1.5 s on every call. It is read once and kept: answered at once, read again in the
9914
+ // background when it is old, when the serve restarts and when a catalog event says it changed. Keyed on what
9915
+ // it depends on: the OpenCode major, the binary (an update), its credentials file and its config files (a
9916
+ // provider connected or removed rewrites auth.json: another key, so the next read waits for the new list).
9917
+ // Only a provider list is kept: a web page (an OpenCode 2 serve answering an OpenCode 1 path) or an error
9918
+ // never is. ?view=compact (the connected providers, lib/opencode-provider-view.js) is what the pickers read,
9919
+ // and the only part kept across restarts: the whole catalog is megabytes.
9920
+ const OPENCODE_PROVIDERS_TTL_MS = 5 * 60_000;
9921
+
9922
+ function opencodeFileStamp(file) {
9923
+ try { const stat = statSync(file); return `${Math.round(stat.mtimeMs)}:${stat.size}`; } catch { return '-'; }
9924
+ }
9925
+
9926
+ function opencodeProvidersKey() {
9927
+ const configDir = dirname(getOpencodeConfigPath());
9928
+ return [
9929
+ knownOpencodeMajor(),
9930
+ configuredCommandLine('opencode') || binaryIdentity(cliResolver.launcher('opencode').path || ''),
9931
+ opencodeFileStamp(getOpencodeAuthPath()),
9932
+ opencodeFileStamp(getOpencodeConfigPath()),
9933
+ opencodeFileStamp(join(configDir, 'opencode.json')),
9934
+ opencodeFileStamp(join(configDir, 'opencode.jsonc')),
9935
+ ].join('|');
9936
+ }
9937
+
9938
+ const _opencodeProviders = createLaunchCache({
9939
+ name: 'opencode-providers',
9940
+ dir: LAUNCH_CACHE_DIR,
9941
+ ttlMs: OPENCODE_PROVIDERS_TTL_MS,
9942
+ maxEntries: 2,
9943
+ load: async () => {
9944
+ if (!_ocpReady) throw new Error('OpenCode server not running');
9945
+ await reconcileAuthIfStale();
9946
+ const r = await ocpProxy('GET', '/provider');
9947
+ if (r.status !== 200 || !isProviderCatalog(r.data)) {
9948
+ throw Object.assign(new Error(r.status === 200 ? 'OpenCode did not answer with a provider list' : `OpenCode answered ${r.status}`), { status: r.status === 200 ? 502 : r.status, data: r.data });
9949
+ }
9950
+ return { full: r.data, compact: compactProviderCatalog(r.data), signature: providerCatalogSignature(r.data) };
9951
+ },
9952
+ accept: (value) => !!value && (isProviderCatalog(value.full) || isProviderCatalog(value.compact)),
9953
+ equals: (a, b) => !!a?.signature && a.signature === b?.signature && !!a.full === !!b.full,
9954
+ persistValue: (value) => (value?.compact ? { compact: value.compact, signature: value.signature } : undefined),
9955
+ // A refresh found another list than the kept one: every window's pickers read it again.
9956
+ onChange: (_key, value, previous) => { if (value?.signature !== previous?.signature) announceOpencodeProvidersChanged(); },
9957
+ log: (message) => console.warn(message),
9958
+ });
9959
+
9960
+ // The serialized answer of each view, made once per list (the whole catalog is megabytes of JSON), with an
9961
+ // ETag of its own so Express does not hash megabytes for every answer.
9962
+ const _opencodeProviderTexts = new WeakMap();
9963
+ function sendOpencodeProviders(res, value, view) {
9964
+ let texts = _opencodeProviderTexts.get(value);
9965
+ if (!texts) { texts = {}; _opencodeProviderTexts.set(value, texts); }
9966
+ if (!texts[view]) {
9967
+ const text = `{"ok":true,"data":${JSON.stringify(view === 'compact' ? value.compact : value.full)}}`;
9968
+ texts[view] = { text, etag: `W/"ocp-${view}-${createHash('sha1').update(text).digest('hex').slice(0, 20)}"` };
9969
+ }
9970
+ res.set('ETag', texts[view].etag);
9971
+ res.type('json').send(texts[view].text);
9972
+ }
9973
+
9974
+ /**
9975
+ * The provider list for a view ('full' | 'compact'): the kept one at once, else the serve's (one read for
9976
+ * every caller). `refresh` waits for a new read. The whole catalog is never kept across a restart, so a
9977
+ * restored entry answers 'compact' only and 'full' reads the serve.
9978
+ */
9979
+ async function readOpencodeProviders(view = 'full', { refresh = false } = {}) {
9980
+ const key = opencodeProvidersKey();
9981
+ let { value } = await _opencodeProviders.read(key, { refresh });
9982
+ if (view === 'full' && !value?.full) ({ value } = await _opencodeProviders.read(key, { refresh: true }));
9983
+ if (!value || !(view === 'full' ? value.full : value.compact)) throw new Error('OpenCode did not answer with a provider list');
9984
+ return value;
9985
+ }
9986
+
9987
+ // Every window learns the list changed (an OpenCode panel's own socket says so only where one is open).
9988
+ function announceOpencodeProvidersChanged() {
9989
+ try { broadcastSync({ type: 'opencode:providers-changed' }); } catch {}
9990
+ _announcingOpencodeProviders = true;
9991
+ try { broadcastToOpencodeV2Clients({ type: 'providers:changed' }); } finally { _announcingOpencodeProviders = false; }
9992
+ }
9993
+
9994
+ // The serve restarted, a credential changed or OpenCode said its catalog changed: the kept list is read
9995
+ // again in the background (still answered meanwhile; a credential change is another key anyway). `announce`:
9996
+ // the OpenCode panels were told already; every other window is told too.
9997
+ function noteOpencodeProvidersChanged({ announce = false } = {}) {
9998
+ try {
9999
+ _opencodeProviders.markStale();
10000
+ scheduleLaunchRefresh('opencode-providers');
10001
+ if (announce) broadcastSync({ type: 'opencode:providers-changed' });
10002
+ } catch { /* before the cache exists (start-up): nothing is kept yet */ }
10003
+ }
10004
+
9423
10005
  app.get('/api/opencode/providers', async (req, res) => {
9424
10006
  try {
9425
10007
  if (!_ocpReady) return res.json({ ok: false, error: 'OpenCode server not running', data: [] });
9426
- await reconcileAuthIfStale();
9427
- const r = await ocpProxy('GET', '/provider');
9428
- res.status(r.status).json({ ok: r.status === 200, data: r.data });
10008
+ const value = await readOpencodeProviders('full', { refresh: req.query?.refresh === '1' });
10009
+ sendOpencodeProviders(res, value, 'full');
9429
10010
  } catch (err) {
9430
- res.status(500).json({ ok: false, error: err.message });
10011
+ if (err?.status && err.status !== 502) return res.status(err.status).json({ ok: false, data: err.data });
10012
+ res.status(err?.status || 500).json({ ok: false, error: err.message });
9431
10013
  }
9432
10014
  });
9433
10015
 
@@ -9467,8 +10049,10 @@ app.get('/api/opencode-v2/status', async (req, res) => {
9467
10049
  running,
9468
10050
  port: _ocpPort,
9469
10051
  version: _ocpVersion,
10052
+ protocol: _ocpProtocol,
9470
10053
  managed: !!_ocpProc,
9471
10054
  connected: opencodeV2Client.isConnected(),
10055
+ ...(running ? {} : { problem: _ocpProblem || (_ocpLastCheck && _ocpLastCheck.state !== 'down' ? opencodeProblemText({ check: _ocpLastCheck, port: _ocpPort }) : null) }),
9472
10056
  });
9473
10057
  } catch (err) {
9474
10058
  res.status(500).json({ ok: false, error: err.message });
@@ -9500,15 +10084,17 @@ app.post('/api/opencode-v2/serve/stop', (req, res) => {
9500
10084
  // Models disabled in Settings → OpenCode (GET / PUT, broadcasts opencode:hidden-models-changed)
9501
10085
  app.use('/api/opencode/hidden-models', createOpencodeHiddenModelsRouter({ store: opencodeHiddenModels, isGuestRequest, broadcastSync }));
9502
10086
 
9503
- // Full provider data including connected[] array and auth methods
10087
+ // Full provider data including connected[] array and auth methods. ?view=compact: only the connected
10088
+ // providers, with what the pickers read (lib/opencode-provider-view.js); ?refresh=1 reads the serve again.
9504
10089
  app.get('/api/opencode/providers/full', async (req, res) => {
9505
10090
  try {
9506
10091
  if (!_ocpReady) return res.json({ ok: false, error: 'OpenCode server not running', data: {} });
9507
- await reconcileAuthIfStale();
9508
- const r = await ocpProxy('GET', '/provider');
9509
- res.status(r.status).json({ ok: r.status === 200, data: r.data });
10092
+ const view = req.query?.view === 'compact' ? 'compact' : 'full';
10093
+ const value = await readOpencodeProviders(view, { refresh: req.query?.refresh === '1' });
10094
+ sendOpencodeProviders(res, value, view);
9510
10095
  } catch (err) {
9511
- res.status(500).json({ ok: false, error: err.message });
10096
+ if (err?.status && err.status !== 502) return res.status(err.status).json({ ok: false, data: err.data });
10097
+ res.status(err?.status || 500).json({ ok: false, error: err.message });
9512
10098
  }
9513
10099
  });
9514
10100
 
@@ -9860,6 +10446,8 @@ function _panelClaudeAccounts() {
9860
10446
  // The Assistant's account list and catalog listen to their own event.
9861
10447
  broadcastSync({ type: 'assistant:accounts-changed', provider: 'claude-code', accountId: event.accountId || null, removed: !!event.removed });
9862
10448
  _assistantCatalog?.invalidate?.();
10449
+ // The model list is read under the default account: asked again (another sign-in is another key).
10450
+ if (!event.accountId || event.accountId === 'default') { markClaudeModelsStale(); scheduleLaunchRefresh('claude-models'); }
9863
10451
  } catch {}
9864
10452
  });
9865
10453
  }
@@ -10060,6 +10648,31 @@ function handleClaudeSkinWebSocket(ws) {
10060
10648
 
10061
10649
  // Ask the installed CLI which models this account can run. Falls back to a small
10062
10650
  // static list so the picker still renders when the CLI is missing or errors.
10651
+ // The sign-in the CLI reads its model list under (the default account: discovery runs with no
10652
+ // CLAUDE_CONFIG_DIR of its own), as a hash: another account can be entitled to other models, so the
10653
+ // kept list is keyed on it and never given for another account.
10654
+ function claudeModelAccountKey() {
10655
+ try {
10656
+ const row = _panelClaudeAccounts().describe('default');
10657
+ if (!row) return '';
10658
+ return createHash('sha256').update(`${row.mode || ''}\n${row.identity || ''}\n${row.signedIn}`).digest('hex').slice(0, 16);
10659
+ } catch { return ''; }
10660
+ }
10661
+
10662
+ // The last verified Claude list survives a restart (DATA_HOME/cache/launch/claude-models.json).
10663
+ setClaudeModelStore(createSnapshotStore({ dir: LAUNCH_CACHE_DIR, name: 'claude-models', log: (message) => console.warn(message) }));
10664
+
10665
+ // What Codex said about its settings schema and feature switches (lib/codex-settings-schema.js), per binary
10666
+ // (path, mtime, size: an update is another binary), kept across restarts: asking costs a Codex start per value.
10667
+ const _codexSettingsProbes = createLaunchCache({
10668
+ name: 'codex-settings',
10669
+ dir: LAUNCH_CACHE_DIR,
10670
+ ttlMs: Number.MAX_SAFE_INTEGER,
10671
+ maxEntries: 8,
10672
+ load: async () => { throw new Error('asked by lib/codex-settings-schema.js'); },
10673
+ });
10674
+ setCodexSettingsStore({ get: (key) => _codexSettingsProbes.peek(key), set: (key, value) => _codexSettingsProbes.set(key, value) });
10675
+
10063
10676
  async function getClaudeModelsForClient(force = false) {
10064
10677
  try {
10065
10678
  const bin = getClaudeBin();
@@ -10068,7 +10681,9 @@ async function getClaudeModelsForClient(force = false) {
10068
10681
  // installation is worth one line in the log. Cached per binary (a spawn only
10069
10682
  // when the CLI changes).
10070
10683
  checkClaudeCliSkew(bin).catch(() => {});
10071
- const result = await discoverClaudeModels(bin, { force, cwd: PACKAGE_ROOT });
10684
+ // Answered from the kept list at once (asked again in the background when it is old);
10685
+ // only a first read, another binary or account, or ?refresh=1 waits for the CLI.
10686
+ const result = await discoverClaudeModels(bin, { force, cwd: PACKAGE_ROOT, account: claudeModelAccountKey() });
10072
10687
  if (result?.models?.length) return result;
10073
10688
  } catch (err) {
10074
10689
  console.warn('[claude-models] discovery failed:', err.message);
@@ -10833,11 +11448,15 @@ app.get('/api/images', (req, res) => {
10833
11448
  }
10834
11449
  });
10835
11450
 
10836
- // GET /api/images/file/:filename — serve an image file
11451
+ // GET /api/images/file/:filename — serve an image file. These are the user's files (screenshots, pastes,
11452
+ // attachments, whiteboard images), and an SVG is a document: opened as a page it runs its own script in this origin.
11453
+ // So every answer runs nothing (a CSP with no script source, a sandbox) and is read only as the type it is sent as,
11454
+ // and an SVG is a download when it is opened as a page. An <img> in the app ignores all three and shows the image.
10837
11455
  app.get('/api/images/file/:filename', (req, res) => {
10838
11456
  const filename = basename(req.params.filename);
10839
11457
  const fp = join(IMAGES_DIR, filename);
10840
11458
  if (!existsSync(fp)) return res.status(404).json({ error: 'Not found' });
11459
+ setUntrustedFileHeaders(res, filename);
10841
11460
  res.sendFile(fp);
10842
11461
  });
10843
11462
 
@@ -12676,15 +13295,17 @@ function unknownAutomationContextMode(mode) {
12676
13295
  return mode != null && mode !== '' && !['default', 'extended', '1m'].includes(mode);
12677
13296
  }
12678
13297
 
13298
+ // The model list of the account that lives in `codexHome` (Default when none does): the one the
13299
+ // Codex panel's selector reads, from the same cache as GET /api/codex/models.
12679
13300
  async function codexAutomationModels(codexHome) {
12680
13301
  const home = codexHome || CODEX_DEFAULT_HOME;
12681
- const cached = _codexModelListCache.get(home);
12682
- if (cached?.models.length && Date.now() - cached.at < CODEX_MODEL_LIST_CACHE_MS) return cached.models;
12683
- const models = mergeCodexModelContextCapabilities(
12684
- await queryCodexAppServerModels(home), readCodexRuntimeModelCatalog(home),
12685
- );
12686
- _codexModelListCache.set(home, { at: Date.now(), models });
12687
- return models;
13302
+ let accountId = 'default';
13303
+ try {
13304
+ const owner = _codexAccounts().list().find((row) => row?.home && samePath(row.home, home));
13305
+ if (owner?.id) accountId = owner.id;
13306
+ } catch { /* the default account */ }
13307
+ const models = await _codexModelListCache.get(codexModelListKey(accountId));
13308
+ return Array.isArray(models) ? models : [];
12688
13309
  }
12689
13310
 
12690
13311
  async function resolveAutomationLaunchChoice(profile, model, contextMode, codexHome) {
@@ -16660,18 +17281,52 @@ async function checkSynabunUpdate() {
16660
17281
  }
16661
17282
 
16662
17283
  app.get('/api/system/version', async (req, res) => {
16663
- const force = req.query.force === '1';
16664
- if (force || !_synabunUpdateCache.checkedAt) {
17284
+ // Settings > App: with automatic updates off the server looks only when the user asks (?force=1). A page's own
17285
+ // timer or a drawer that refreshes itself says `passive=1`, and never makes it look then.
17286
+ const auto = automaticUpdateCheck();
17287
+ const force = req.query.force === '1' && (auto || req.query.passive !== '1');
17288
+ if (force || (auto && !_synabunUpdateCache.checkedAt)) {
16665
17289
  try { await checkSynabunUpdate(); } catch {}
16666
- } else {
17290
+ } else if (auto) {
16667
17291
  const age = Date.now() - new Date(_synabunUpdateCache.checkedAt).getTime();
16668
17292
  if (age > 60 * 60 * 1000) {
16669
17293
  checkSynabunUpdate().catch(() => {});
16670
17294
  }
16671
17295
  }
16672
- res.json(_synabunUpdateCache);
17296
+ res.json({ ..._synabunUpdateCache, current: _synabunUpdateCache.current || installedSynabunVersion(), autoCheck: auto });
16673
17297
  });
16674
17298
 
17299
+ // ── Settings > App (lib/app-controls-api.js): automatic update checks, start at login, the data and logs folders ──
17300
+ // The desktop app keeps its own (packaging/desktop/src/app-prefs.mjs): there this only says so.
17301
+ const appSettings = createAppSettingsStore({ path: join(DATA_HOME, 'data', 'app-settings.json') });
17302
+ /** Does the server look for updates by itself (at start, and when the page asks without ?force=1)? */
17303
+ function automaticUpdateCheck() {
17304
+ if (PACKAGED_RUNTIME?.shell) return true;
17305
+ try { return appSettings.get().autoUpdateCheck !== false; } catch { return true; }
17306
+ }
17307
+ function installedSynabunVersion() {
17308
+ try { return JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf-8')).version || null; } catch { return null; }
17309
+ }
17310
+ const loginStart = createLoginStart({
17311
+ packageRoot: PACKAGE_ROOT,
17312
+ home: os.homedir(),
17313
+ dataHome: DATA_HOME,
17314
+ entry: PACKAGED_RUNTIME && !PACKAGED_RUNTIME.shell ? durableEntry(PACKAGED_RUNTIME) : null,
17315
+ desktopShell: !!PACKAGED_RUNTIME?.shell,
17316
+ // Never from a test run or a throwaway data folder (the same rule as the synabun:// handler).
17317
+ allowed: () => launcherRegistrationAllowed({ env: process.env, dataHome: DATA_HOME }),
17318
+ log: (line) => console.log(`[app] ${line}`),
17319
+ });
17320
+ app.use('/api/system/app', createAppControlsApi({
17321
+ store: appSettings,
17322
+ loginStart,
17323
+ folders: { data: DATA_HOME, logs: join(DATA_HOME, 'data') },
17324
+ install: PACKAGED_RUNTIME?.shell ? 'desktop' : INSTALL_KIND,
17325
+ isGuestRequest,
17326
+ port: () => PORT,
17327
+ log: (line) => console.warn(line),
17328
+ }));
17329
+
16675
17330
  // GET /api/system/about — Settings → About (lib/system-about.js): the app, the runtime, the project's links and
16676
17331
  // every dependency of the product's packages with the version installed, read from the files on disk on the first
16677
17332
  // request and kept for the life of the process. No environment, no token, no setting. Owner only (/api/system is
@@ -17490,6 +18145,8 @@ const editorConfigStore = createEditorConfigStore({
17490
18145
  // the first request, stopped after ten idle minutes and at shutdown, never the shared serve), or a
17491
18146
  // user-configured endpoint is, whose key is SYNABUN_EDITOR_AI_KEY in the data home's .env.
17492
18147
  const editorEndpointKey = createEndpointKeyStore({ envPath: ENV_PATH, parseEnvFile, writeEnvFile });
18148
+ // The completion serve's process → its record (trackOpencodeServe), from its spawn until it has ended.
18149
+ const editorCompletionServeTracks = new WeakMap();
17493
18150
  const editorCompletionServe = createOpencodeCompletionEngine({
17494
18151
  dataDir: resolve(DATA_HOME, 'data', 'editor-complete'),
17495
18152
  system: COMPLETION_SYSTEM,
@@ -17499,18 +18156,30 @@ const editorCompletionServe = createOpencodeCompletionEngine({
17499
18156
  },
17500
18157
  // A provider's {file:./key} is relative to that configuration: it is written whole for the serve's own folder.
17501
18158
  userConfigDir: () => dirname(getOpencodeConfigPath()),
17502
- allocPort: allocOpencodeIsoPort,
18159
+ // What an earlier SynaBun left running goes first (lib/opencode-serve-records.js), then a port is picked.
18160
+ allocPort: async () => { await sweepLeftoverOpencodeServes(); return allocOpencodeIsoPort(); },
18161
+ // On record from its spawn (spawnServe), its record gone with its process: a crash of SynaBun does not leave it
18162
+ // running. Once something answers on its port: is it this start's own serve ('foreign': another server took the
18163
+ // port, which the engine leaves running while it tries another)?
18164
+ trackServe: ({ proc }) => editorCompletionServeTracks.get(proc)?.answering(),
17503
18165
  // As the isolated serves are started (opencodeServeLaunch): the CLI the resolver found, through the login shell.
17504
- spawnServe: ({ port, env, cwd }) => {
17505
- const launch = opencodeServeLaunch(port, { env });
18166
+ // OpenCode 2 gets the password the engine made for this start, and its start waits its turn among every serve
18167
+ // SynaBun starts (one OpenCode 2 database cannot be opened by two serves at once).
18168
+ detectProtocol: () => detectOpencodeProtocol(),
18169
+ startOneAtATime: oneOpencodeStartAtATime,
18170
+ spawnServe: ({ port, env, cwd, protocol = 1, password = null }) => {
18171
+ const token = opencodeServeRecords.newToken();
18172
+ const launch = opencodeServeLaunch(port, { env: { ...env, [SERVE_TOKEN_ENV]: token }, protocol, password });
17506
18173
  const proc = spawn(launch.command, launch.args, { stdio: ['ignore', 'ignore', 'pipe'], ...launch.options, cwd, detached: false, shell: false, windowsHide: true });
18174
+ editorCompletionServeTracks.set(proc, trackOpencodeServe(proc, { kind: 'completion', id: 'editor-complete', port, token }));
17507
18175
  proc.stderr?.on('data', (d) => console.log(`[editor-complete:err] ${String(d).trim().slice(0, 500)}`));
17508
18176
  return proc;
17509
18177
  },
17510
- killServe: (proc) => {
18178
+ // SIGTERM to stop it; SIGKILL, through its handle, for a failed start's process that did not end (Windows: taskkill /T /F).
18179
+ killServe: (proc, signal = 'SIGTERM') => {
17511
18180
  try {
17512
18181
  if (IS_WIN && proc.pid) spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { shell: true, windowsHide: true, stdio: 'ignore' });
17513
- else proc.kill('SIGTERM');
18182
+ else proc.kill(signal);
17514
18183
  } catch { /* already gone */ }
17515
18184
  },
17516
18185
  createClient: opencodeV2Client.createClientInstance,
@@ -17523,8 +18192,8 @@ const editorCompletion = createEditorCompletionService({
17523
18192
  // The model list comes from the shared serve while it runs: listing models starts nothing.
17524
18193
  sharedProviders: async () => {
17525
18194
  if (!_ocpReady) return null;
17526
- const r = await ocpProxy('GET', '/provider', null, 8000);
17527
- return r.status === 200 ? r.data : null;
18195
+ // The connected providers, from the kept list (the same read as the pickers').
18196
+ try { return (await readOpencodeProviders('compact')).compact; } catch { return null; }
17528
18197
  },
17529
18198
  isHiddenModel: (id) => opencodeHiddenModels.has(id),
17530
18199
  usage: createUsageStore({
@@ -20077,11 +20746,13 @@ app.get('/api/opencode/sessions', (req, res) => {
20077
20746
 
20078
20747
  let rows;
20079
20748
  try {
20749
+ // OpenCode 1's tables, or OpenCode 2's (lib/opencode-db-schema.js).
20750
+ const tables = sessionTablesFor(opencodeDbFlavor(db, _ocpProtocol));
20080
20751
  const stmt = db.prepare(`
20081
20752
  SELECT s.id, s.title, s.directory, s.time_created, s.time_updated,
20082
20753
  p.worktree as project_worktree,
20083
- (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) as message_count
20084
- FROM session s
20754
+ ${tables.messageCount} as message_count
20755
+ FROM ${tables.session} s
20085
20756
  LEFT JOIN project p ON s.project_id = p.id
20086
20757
  WHERE s.time_archived IS NULL
20087
20758
  ORDER BY s.time_updated DESC
@@ -22386,6 +23057,8 @@ app.get('/api/skills-studio/artifact/:id/icon', (req, res) => {
22386
23057
  const mimeMap = { '.png': 'image/png', '.svg': 'image/svg+xml', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
22387
23058
  res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream');
22388
23059
  res.setHeader('Cache-Control', 'no-cache');
23060
+ // A skill's icon is whatever file the skill folder holds: opened by its address it runs nothing.
23061
+ setUntrustedFileHeaders(res, iconPath, mimeMap[ext]);
22389
23062
  res.send(readConfinedFile(resolveArtifactFile(id).realBase, iconPath, { maxBytes: ICON_MAX_BYTES }));
22390
23063
  } catch (err) {
22391
23064
  res.status(skillsStudioStatus(err)).json({ error: err.message });
@@ -24358,7 +25031,8 @@ function getTerminalProfile(profileId, opts = {}) {
24358
25031
  'claude-code': (m) => `${cmd} --model ${m}`,
24359
25032
  'codex': (m) => `${cmd} --model ${m}`,
24360
25033
  'gemini': (m) => `${cmd} --model ${m}`,
24361
- 'opencode': (m) => `${cmd} --model ${m}`,
25034
+ // OpenCode 2's terminal app has no --model (it prints its help and exits): the model is picked in it.
25035
+ 'opencode': (m) => (knownOpencodeMajor() >= 2 ? cmd : `${cmd} --model ${m}`),
24362
25036
  };
24363
25037
  const builder = modelMap[profileId];
24364
25038
  if (builder) cmd = builder(quotedModel);
@@ -25213,6 +25887,9 @@ async function releaseNativeLoopResources(run) {
25213
25887
  reconcileKeepAwake();
25214
25888
  }
25215
25889
 
25890
+ // An OpenCode worker's tool calls, for the Assistant's run stages and tray: a compact copy, at a bounded rate.
25891
+ const openCodeWorkerActivity = createOpenCodeActivityRelay({ send: (message) => broadcastSync(message) });
25892
+
25216
25893
  _nativeLoopRuntime = new NativeLoopRuntime({
25217
25894
  stateDir: LOOP_DIR,
25218
25895
  ledgerPath: resolve(LOOP_DATA_DIR, 'native-loop-runs.json'),
@@ -25223,11 +25900,10 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25223
25900
  judgeGoal: createLoopGoalJudge({ judgeLoopGoal, surfaceConfig: typesafeSurfaceConfig }),
25224
25901
  log: (runId, tag, message, meta) => loopLog(runId, tag, message, meta),
25225
25902
  onEvent: (message) => {
25226
- // OpenCode already streams from its isolated serve into the V2 panel. Claude
25227
- // and Codex SDK events need the shared sync socket to reach their renderers.
25228
- if (message.type !== 'sidepanel:provider-event' || message.event?.provider !== 'opencode') {
25229
- broadcastSync(message);
25230
- }
25903
+ // OpenCode already streams from its isolated serve into the V2 panel: only its
25904
+ // tool calls go to the sync socket, compact (lib/opencode-worker-activity.js).
25905
+ // Claude and Codex SDK events need the shared sync socket to reach their renderers.
25906
+ if (!openCodeWorkerActivity.observe(message)) broadcastSync(message);
25231
25907
  },
25232
25908
  onFinish: releaseNativeLoopResources,
25233
25909
  providerFactories: {
@@ -25366,6 +26042,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25366
26042
  return codexAccountAuth().rateLimits(id, opts);
25367
26043
  } });
25368
26044
  _assistantAccountUsage = accountUsage;
26045
+ // The quotas the account menus show, read once now that they can be (the launch warm-up may have run first).
26046
+ scheduleLaunchRefresh('account-usage', 5000);
25369
26047
  claudeAccounts.subscribe(({ accountId }) => accountUsage.invalidate('claude-code', accountId));
25370
26048
  _codexAccounts().subscribe(({ accountId }) => accountUsage.invalidate('codex', accountId));
25371
26049
  // A Claude Code worker whose dispatch names no account runs under this one (Assistant → accounts).
@@ -25433,6 +26111,12 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25433
26111
  });
25434
26112
  const assistantUsage = createUsageLedger({ dataDir: resolve(DATA_HOME, 'data'), log: assistantLog, judgments });
25435
26113
  const catalogFetch = async (path) => {
26114
+ // OpenCode's providers are read in process, the connected ones only (the whole models.dev catalog is
26115
+ // megabytes and the catalog lists connected providers alone): what the route answers for ?view=compact.
26116
+ if (path === '/api/opencode/providers/full') {
26117
+ if (!_ocpReady) return { ok: false, error: 'OpenCode server not running', data: {} };
26118
+ try { return { ok: true, data: (await readOpencodeProviders('compact')).compact }; } catch { return null; }
26119
+ }
25436
26120
  try {
25437
26121
  const response = await fetch(`http://127.0.0.1:${PORT}${path}`, { signal: AbortSignal.timeout(4000) });
25438
26122
  return response.ok ? await response.json() : null;
@@ -25460,9 +26144,13 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25460
26144
  reconcileHidden: (args) => assistantHiddenModels.reconcile(args),
25461
26145
  // A provider lists other models than before: every open window reloads its menus.
25462
26146
  onChange: ({ providers }) => broadcastSync({ type: 'assistant:catalog-changed', providers }),
26147
+ // An old build answers at once while the next is made (its lists are kept by their own caches).
26148
+ staleWhileRevalidate: true,
25463
26149
  log: (tag, message) => assistantLog(tag, message),
25464
26150
  });
25465
26151
  _assistantCatalog = assistantCatalog;
26152
+ // Built once the provider lists are warm (the launch warm-up may have run before the Assistant existed).
26153
+ scheduleLaunchRefresh('assistant-catalog', 8000);
25466
26154
  // A Models toggle or a Settings → OpenCode checkbox reaches routing on the next call.
25467
26155
  assistantHiddenModels.onChange((change) => {
25468
26156
  // What a catalog build settled about a hidden alias is already in that build. When it
@@ -25632,7 +26320,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25632
26320
  app.use('/api/assistant/hidden-models', createAssistantHiddenModelsRouter({
25633
26321
  hiddenModels: assistantHiddenModels, catalog: assistantCatalog, opencodeStore: opencodeHiddenModels, isGuestRequest, broadcastSync,
25634
26322
  }));
25635
- const buildCatalog = async (opts = {}) => { await accountUsage.refresh(); return { ...(await assistantCatalog.get(opts)), accounts: accountUsage.list() }; };
26323
+ // Quotas as last read (read again in the background): a window's menu never waits for the quota probe.
26324
+ const buildCatalog = async (opts = {}) => { accountUsage.refresh().catch(() => {}); return { ...(await assistantCatalog.get(opts)), accounts: accountUsage.list({ stale: true }) }; };
25636
26325
  // Clarification: a materially ambiguous request gets 1-3 questions before it
25637
26326
  // is routed; the user's words and answers become the brief every worker of
25638
26327
  // that request receives. Knobs: assistant-config.json `clarify` (read live).
@@ -26145,6 +26834,7 @@ function prepareExecWrapperFile(terminalSessionId, loopState, taskFile, sentinel
26145
26834
  mcpEnvOverrides,
26146
26835
  // The Admin sandbox setting the loop started with (a loop file from before it: the current one).
26147
26836
  codexConfigArgs: codexWindowsSandboxArgs(typeof loopState.adminSandbox === 'boolean' ? loopState.adminSandbox : adminSandboxOn()),
26837
+ ...(profile === 'opencode' ? { opencodeMajor: knownOpencodeMajor() } : {}),
26148
26838
  });
26149
26839
  const wrapperFile = join(
26150
26840
  os.tmpdir(),
@@ -27028,6 +27718,7 @@ const IS_WINDOWS = process.platform === 'win32';
27028
27718
  let _gitMissingLogged = false;
27029
27719
  let _gitExePath = null; // Cached absolute path — preferring .exe on Windows
27030
27720
  let _gitResolved = false;
27721
+ let _gitResolving = null; // the lookup resolveGitPathInBackground() runs, while it runs
27031
27722
 
27032
27723
  // Resolve git's absolute path at first use. On Windows, `where git` lists all matches;
27033
27724
  // prefer .exe so spawnSync works without cmd.exe quoting issues. On Unix, use `command -v`.
@@ -27093,15 +27784,39 @@ function logGitFailure(where, err) {
27093
27784
  console.warn(`[git] '${where}' failed:`, msg.trim().split('\n')[0]);
27094
27785
  }
27095
27786
 
27096
- // Startup probe — always logs git status so Windows users can see whether git works at all.
27787
+ // resolveGitPath()'s lookup without blocking: `where git` (Windows) or `command -v git`, run once in the
27788
+ // background at start-up, so the first git call of a request finds the answer kept instead of running the
27789
+ // lookup itself (a synchronous spawn through cmd.exe holds every request on a Windows PC without git).
27790
+ function resolveGitPathInBackground() {
27791
+ if (_gitResolved) return Promise.resolve(_gitExePath || gitBash.gitExecutable());
27792
+ if (_gitResolving) return _gitResolving;
27793
+ _gitResolving = new Promise((done) => {
27794
+ const [command, args] = IS_WINDOWS ? ['where', ['git']] : ['/bin/sh', ['-c', 'command -v git']];
27795
+ execFile(command, args, { encoding: 'utf8', timeout: 3000, windowsHide: true }, (error, stdout) => {
27796
+ if (!_gitResolved) {
27797
+ _gitResolved = true;
27798
+ const lines = error ? [] : String(stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
27799
+ _gitExePath = (IS_WINDOWS ? lines.find((line) => /\.exe$/i.test(line)) : null) || lines[0] || null;
27800
+ if (_gitExePath) console.log(`[git] resolved: ${_gitExePath}`);
27801
+ else console.warn('[git] resolution failed:', error?.message || 'resolver returned no paths');
27802
+ }
27803
+ done(_gitExePath || gitBash.gitExecutable());
27804
+ });
27805
+ }).finally(() => { _gitResolving = null; });
27806
+ return _gitResolving;
27807
+ }
27808
+
27809
+ // Startup probe — always logs git status so Windows users can see whether git works at all. Asynchronous:
27810
+ // after the lookup above, one `git --version` (no folder, no repository's configuration) off the event loop.
27097
27811
  setImmediate(() => {
27098
- const probe = spawnGit(['--version'], { timeout: 3000 });
27099
- if (probe.status === 0) {
27100
- console.log(`[git] ${probe.stdout.toString().trim()} — OK`);
27101
- } else {
27102
- const msg = probe.stderr?.toString?.()?.trim() || probe.error?.message || 'unknown error';
27103
- console.warn(`[git] startup probe FAILED: ${msg}. File explorer git UI will be hidden.`);
27104
- }
27812
+ resolveGitPathInBackground().then((gitPath) => new Promise((done) => {
27813
+ const shell = IS_WINDOWS && (!gitPath || /\.(cmd|bat)$/i.test(gitPath));
27814
+ execFile(gitPath || 'git', ['--version'], { encoding: 'utf8', timeout: 3000, windowsHide: true, shell }, (error, stdout, stderr) => {
27815
+ if (!error) console.log(`[git] ${String(stdout || '').trim()} — OK`);
27816
+ else console.warn(`[git] startup probe FAILED: ${String(stderr || '').trim() || error.message || 'unknown error'}. File explorer git UI will be hidden.`);
27817
+ done();
27818
+ });
27819
+ })).catch(() => {});
27105
27820
  });
27106
27821
 
27107
27822
  // Git "dubious ownership" detection — CVE-2022-24765 safety check refuses to operate on
@@ -33477,6 +34192,83 @@ app.get('/api/browser/detect-browsers', (req, res) => {
33477
34192
  res.json({ browsers });
33478
34193
  });
33479
34194
 
34195
+ // --- Launch warm-up ---
34196
+ // After the server listens (never before it, and never in front of the first page), the slow lists the
34197
+ // windows, menus and Settings wait for are read once in the background: one step at a time, a pause between
34198
+ // two, so a first click finds them kept (lib/launch-cache.js) and nothing competes with the page. Every step
34199
+ // is asynchronous: a CLI runs as a child process, never through a synchronous spawn. A step that is not ready
34200
+ // yet (the Assistant still starting, OpenCode's serve not up) is skipped: its own ready event runs it again.
34201
+ // scheduleLaunchRefresh(name) runs one step again after an event (an account switch, a provider connected,
34202
+ // a serve restart), debounced, behind the same one-at-a-time queue.
34203
+ const _launchQueue = createLimiter(1);
34204
+ const _launchTimers = new Map();
34205
+ const LAUNCH_STEP_GAP_MS = 250;
34206
+ const LAUNCH_TASKS = {
34207
+ // Where git is: the lookup a first git call would otherwise run synchronously.
34208
+ 'git-path': () => resolveGitPathInBackground(),
34209
+ // Claude Code's model list (one CLI start; the kept list answers meanwhile). Only where Claude Code is found.
34210
+ 'claude-models': async () => { if (cliFoundForWarmup('claude-code')) await getClaudeModelsForClient(true); },
34211
+ // OpenCode's providers, once its serve answers (the serve's ready event runs this step again).
34212
+ 'opencode-providers': async () => { if (_ocpReady) await readOpencodeProviders('compact', { refresh: true }); },
34213
+ // Every signed-in Codex account's model list (one app-server each, one after another).
34214
+ 'codex-models': async () => {
34215
+ // Only where Codex is found: listing prepares the account's config.toml, which a machine without Codex never gets.
34216
+ if (!cliFoundForWarmup('codex')) return;
34217
+ let rows = [];
34218
+ try { rows = _codexAccounts().list().filter((row) => row?.signedIn !== false); } catch { rows = [{ id: 'default' }]; }
34219
+ for (const row of rows) {
34220
+ try { await _codexModelListCache.read(codexModelListKey(row.id), { refresh: true }); } catch { /* that account's list stays as kept */ }
34221
+ }
34222
+ },
34223
+ // The Assistant's catalog, from the lists above (every view the windows ask for is made from it).
34224
+ 'assistant-catalog': async () => { await _assistantCatalog?.full?.({ force: true }); },
34225
+ // Subscription quotas the account menus show.
34226
+ 'account-usage': async () => { await _assistantAccountUsage?.refresh?.(); },
34227
+ // What the Codex panel's settings and cog menu validate against (a Codex start per setting value, once per version).
34228
+ 'codex-settings': async () => {
34229
+ const found = cliResolver.launcher('codex');
34230
+ const bin = found.launchPath || found.path;
34231
+ // A file the resolver checked (a bare command name or a command line has no binary identity to key on).
34232
+ if (found.state === 'installed' && bin && /[\\/]/.test(bin)) await readCodexSettingsSchema(bin).catch(() => null);
34233
+ },
34234
+ };
34235
+
34236
+ // A CLI the resolver found (probed or not yet): the warm-up starts nothing for a tool that is not installed.
34237
+ function cliFoundForWarmup(toolKey) {
34238
+ try {
34239
+ const found = cliResolver.launcher(toolKey);
34240
+ return !!found.path && found.state !== 'broken' && found.state !== 'missing';
34241
+ } catch { return false; }
34242
+ }
34243
+
34244
+ function runLaunchStep(name) {
34245
+ const task = LAUNCH_TASKS[name];
34246
+ if (!task) return Promise.resolve();
34247
+ return _launchQueue(async () => {
34248
+ const started = Date.now();
34249
+ try { await task(); } catch (error) { console.warn(`[launch-cache] ${name}: ${error?.message || error}`); }
34250
+ const took = Date.now() - started;
34251
+ if (took > 2000) console.log(`[launch-cache] ${name} warmed in ${(took / 1000).toFixed(1)} s`);
34252
+ await new Promise((done) => { const timer = setTimeout(done, LAUNCH_STEP_GAP_MS); timer.unref?.(); });
34253
+ });
34254
+ }
34255
+
34256
+ /** Run one warm-up step again soon (an event changed what it reads); bursts of events run it once. */
34257
+ function scheduleLaunchRefresh(name, delayMs = 600) {
34258
+ if (!LAUNCH_TASKS[name] || _launchTimers.has(name)) return;
34259
+ const timer = setTimeout(() => { _launchTimers.delete(name); runLaunchStep(name); }, delayMs);
34260
+ timer.unref?.();
34261
+ _launchTimers.set(name, timer);
34262
+ }
34263
+
34264
+ function startLaunchWarmup() {
34265
+ if (process.env.SYNABUN_LAUNCH_WARMUP === 'off') return;
34266
+ const timer = setTimeout(() => {
34267
+ for (const name of Object.keys(LAUNCH_TASKS)) runLaunchStep(name);
34268
+ }, 3000);
34269
+ timer.unref?.();
34270
+ }
34271
+
33480
34272
  // --- Start ---
33481
34273
  reloadConfig();
33482
34274
 
@@ -33509,6 +34301,15 @@ const httpServer = app.listen(PORT, async () => {
33509
34301
  else if (!launcher.ok) console.warn(`[server] Start Server launcher not registered: ${launcher.errors[0]}`);
33510
34302
  } catch {}
33511
34303
 
34304
+ // Start at login (Settings > App): while it is on, its entry starts this install (another Node, a moved
34305
+ // install). One that is off, or that the user turned off in the system, is left as it is.
34306
+ try {
34307
+ const healed = loginStart.heal();
34308
+ if (healed.changed) console.log('[server] start at login: the entry now starts this install');
34309
+ } catch (err) {
34310
+ console.warn(`[server] start at login: ${err.message}`);
34311
+ }
34312
+
33512
34313
  // Auto-heal legacy synabun-symlink plugin state and reinstall via CC CLI
33513
34314
  try {
33514
34315
  const repair = pluginInstaller.repairSynabunPlugins();
@@ -33556,11 +34357,19 @@ const httpServer = app.listen(PORT, async () => {
33556
34357
  // opencode-<sid> node_modules that never got their per-loop cleanup.
33557
34358
  setTimeout(() => { try { cleanupStaleLoopConfigs(); } catch {} }, 1500);
33558
34359
 
34360
+ // OpenCode serves an earlier SynaBun left running (it crashed, or was killed): found by their records alone and
34361
+ // stopped only when they are provably its own and nothing uses them (lib/opencode-serve-records.js). An
34362
+ // OpenCode start that comes earlier makes the same look first (and this one then finds nothing left).
34363
+ setTimeout(() => { sweepLeftoverOpencodeServes(); }, 1000);
34364
+
33559
34365
  // Compare the installed Claude CLI with the release the Agent SDK was built
33560
34366
  // against, once, so an older installation is named in the log before the
33561
34367
  // first session runs on it (checkClaudeCliSkew in lib/claude-model-catalog.js).
33562
34368
  setTimeout(() => { checkClaudeCliSkew(getClaudeBin()).catch(() => {}); }, 1000);
33563
34369
 
34370
+ // The lists windows, menus and Settings wait for, read once in the background (see LAUNCH_TASKS above).
34371
+ startLaunchWarmup();
34372
+
33564
34373
  // Windows: the Git Bash every Claude Code gets (lib/git-bash/). Looked for now (the settings of the user's own
33565
34374
  // claude follow it); a PC with none, and an app that carries none, gets SynaBun's copy, fetched in the background.
33566
34375
  setTimeout(() => {
@@ -33595,7 +34404,7 @@ const httpServer = app.listen(PORT, async () => {
33595
34404
  },
33596
34405
  () => {
33597
34406
  try {
33598
- const oc = rebuildOpencodeCache({ getOpencodeDb });
34407
+ const oc = rebuildOpencodeCache({ getOpencodeDb, protocol: _ocpProtocol });
33599
34408
  if (oc.updated > 0) console.log(` Session cache (opencode): indexed ${oc.updated}/${oc.sessions}`);
33600
34409
  } catch (err) { console.warn(' Session cache (opencode) error:', err.message); }
33601
34410
  },
@@ -33680,8 +34489,12 @@ const httpServer = app.listen(PORT, async () => {
33680
34489
  }
33681
34490
  } catch { /* ok */ }
33682
34491
 
33683
- // Check for SynaBun updates (npm + github releases)
33684
- try { await checkSynabunUpdate(); } catch {}
34492
+ // Check for SynaBun updates (npm + github releases), unless Settings > App turned the automatic check off
34493
+ if (automaticUpdateCheck()) {
34494
+ try { await checkSynabunUpdate(); } catch {}
34495
+ } else {
34496
+ console.log(' Updates: automatic check off (Settings > App)');
34497
+ }
33685
34498
 
33686
34499
  // Prune any external CLI tool left in our node_modules before the version
33687
34500
  // check runs — defends against `npm install` not auto-pruning packages that