synabun 2.0.7 → 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 (565) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +1 -1
  3. package/THIRD-PARTY-LICENSES.md +46 -4
  4. package/hooks/claude-code/post-plan.mjs +6 -30
  5. package/hooks/claude-code/session-start.mjs +1 -4
  6. package/hooks/claude-code/shared.mjs +106 -72
  7. package/launcher.mjs +9 -3
  8. package/lib/package-processes.js +670 -0
  9. package/lib/runtime-dependencies.js +225 -0
  10. package/lib/start-launcher.js +6 -1
  11. package/mcp-server/dist/services/browser-bench.d.ts +1 -1
  12. package/mcp-server/dist/services/browser-bench.d.ts.map +1 -1
  13. package/mcp-server/dist/services/browser-bench.js +1 -1
  14. package/mcp-server/dist/services/browser-bench.js.map +1 -1
  15. package/mcp-server/dist/services/categories.d.ts +35 -2
  16. package/mcp-server/dist/services/categories.d.ts.map +1 -1
  17. package/mcp-server/dist/services/categories.js +77 -33
  18. package/mcp-server/dist/services/categories.js.map +1 -1
  19. package/mcp-server/dist/services/desktop-bench.d.ts +7 -7
  20. package/mcp-server/dist/services/desktop-bench.d.ts.map +1 -1
  21. package/mcp-server/dist/services/desktop-bench.js +1 -1
  22. package/mcp-server/dist/services/desktop-bench.js.map +1 -1
  23. package/mcp-server/dist/tools/agents.d.ts +1 -1
  24. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  25. package/mcp-server/dist/tools/agents.js +1 -1
  26. package/mcp-server/dist/tools/agents.js.map +1 -1
  27. package/mcp-server/dist/tools/bluesky.js +1 -1
  28. package/mcp-server/dist/tools/bluesky.js.map +1 -1
  29. package/mcp-server/dist/tools/browser-advanced.d.ts +1 -1
  30. package/mcp-server/dist/tools/browser-advanced.d.ts.map +1 -1
  31. package/mcp-server/dist/tools/browser-advanced.js +1 -1
  32. package/mcp-server/dist/tools/browser-advanced.js.map +1 -1
  33. package/mcp-server/dist/tools/browser-batch.d.ts +1 -1
  34. package/mcp-server/dist/tools/browser-batch.d.ts.map +1 -1
  35. package/mcp-server/dist/tools/browser-batch.js +1 -1
  36. package/mcp-server/dist/tools/browser-batch.js.map +1 -1
  37. package/mcp-server/dist/tools/browser-cheatsheet.d.ts +1 -1
  38. package/mcp-server/dist/tools/browser-cheatsheet.d.ts.map +1 -1
  39. package/mcp-server/dist/tools/browser-cheatsheet.js +1 -1
  40. package/mcp-server/dist/tools/browser-cheatsheet.js.map +1 -1
  41. package/mcp-server/dist/tools/browser-interact.d.ts +1 -1
  42. package/mcp-server/dist/tools/browser-interact.d.ts.map +1 -1
  43. package/mcp-server/dist/tools/browser-interact.js +1 -1
  44. package/mcp-server/dist/tools/browser-interact.js.map +1 -1
  45. package/mcp-server/dist/tools/browser-navigate.d.ts +1 -1
  46. package/mcp-server/dist/tools/browser-navigate.d.ts.map +1 -1
  47. package/mcp-server/dist/tools/browser-navigate.js +1 -1
  48. package/mcp-server/dist/tools/browser-navigate.js.map +1 -1
  49. package/mcp-server/dist/tools/browser-observe.d.ts +1 -1
  50. package/mcp-server/dist/tools/browser-observe.d.ts.map +1 -1
  51. package/mcp-server/dist/tools/browser-observe.js +1 -1
  52. package/mcp-server/dist/tools/browser-observe.js.map +1 -1
  53. package/mcp-server/dist/tools/card-tools.d.ts +1 -1
  54. package/mcp-server/dist/tools/card-tools.d.ts.map +1 -1
  55. package/mcp-server/dist/tools/card-tools.js +1 -1
  56. package/mcp-server/dist/tools/card-tools.js.map +1 -1
  57. package/mcp-server/dist/tools/category.d.ts +1 -1
  58. package/mcp-server/dist/tools/category.d.ts.map +1 -1
  59. package/mcp-server/dist/tools/category.js +5 -1
  60. package/mcp-server/dist/tools/category.js.map +1 -1
  61. package/mcp-server/dist/tools/choice.d.ts +1 -1
  62. package/mcp-server/dist/tools/choice.d.ts.map +1 -1
  63. package/mcp-server/dist/tools/choice.js +1 -1
  64. package/mcp-server/dist/tools/choice.js.map +1 -1
  65. package/mcp-server/dist/tools/computer.d.ts +1 -1
  66. package/mcp-server/dist/tools/computer.d.ts.map +1 -1
  67. package/mcp-server/dist/tools/computer.js +1 -1
  68. package/mcp-server/dist/tools/computer.js.map +1 -1
  69. package/mcp-server/dist/tools/discord-channel.d.ts +1 -1
  70. package/mcp-server/dist/tools/discord-channel.d.ts.map +1 -1
  71. package/mcp-server/dist/tools/discord-channel.js +1 -1
  72. package/mcp-server/dist/tools/discord-channel.js.map +1 -1
  73. package/mcp-server/dist/tools/discord-guild.d.ts +1 -1
  74. package/mcp-server/dist/tools/discord-guild.d.ts.map +1 -1
  75. package/mcp-server/dist/tools/discord-guild.js +1 -1
  76. package/mcp-server/dist/tools/discord-guild.js.map +1 -1
  77. package/mcp-server/dist/tools/discord-member.d.ts +1 -1
  78. package/mcp-server/dist/tools/discord-member.d.ts.map +1 -1
  79. package/mcp-server/dist/tools/discord-member.js +1 -1
  80. package/mcp-server/dist/tools/discord-member.js.map +1 -1
  81. package/mcp-server/dist/tools/discord-message.d.ts +1 -1
  82. package/mcp-server/dist/tools/discord-message.d.ts.map +1 -1
  83. package/mcp-server/dist/tools/discord-message.js +1 -1
  84. package/mcp-server/dist/tools/discord-message.js.map +1 -1
  85. package/mcp-server/dist/tools/discord-onboarding.d.ts +1 -1
  86. package/mcp-server/dist/tools/discord-onboarding.d.ts.map +1 -1
  87. package/mcp-server/dist/tools/discord-onboarding.js +1 -1
  88. package/mcp-server/dist/tools/discord-onboarding.js.map +1 -1
  89. package/mcp-server/dist/tools/discord-role.d.ts +1 -1
  90. package/mcp-server/dist/tools/discord-role.d.ts.map +1 -1
  91. package/mcp-server/dist/tools/discord-role.js +1 -1
  92. package/mcp-server/dist/tools/discord-role.js.map +1 -1
  93. package/mcp-server/dist/tools/discord-thread.d.ts +1 -1
  94. package/mcp-server/dist/tools/discord-thread.d.ts.map +1 -1
  95. package/mcp-server/dist/tools/discord-thread.js +1 -1
  96. package/mcp-server/dist/tools/discord-thread.js.map +1 -1
  97. package/mcp-server/dist/tools/discord-webhook.d.ts +1 -1
  98. package/mcp-server/dist/tools/discord-webhook.d.ts.map +1 -1
  99. package/mcp-server/dist/tools/discord-webhook.js +1 -1
  100. package/mcp-server/dist/tools/discord-webhook.js.map +1 -1
  101. package/mcp-server/dist/tools/fb-groups.d.ts +1 -1
  102. package/mcp-server/dist/tools/fb-groups.d.ts.map +1 -1
  103. package/mcp-server/dist/tools/fb-groups.js +1 -1
  104. package/mcp-server/dist/tools/fb-groups.js.map +1 -1
  105. package/mcp-server/dist/tools/forget.d.ts +1 -1
  106. package/mcp-server/dist/tools/forget.d.ts.map +1 -1
  107. package/mcp-server/dist/tools/forget.js +1 -1
  108. package/mcp-server/dist/tools/forget.js.map +1 -1
  109. package/mcp-server/dist/tools/git-tools.d.ts +1 -1
  110. package/mcp-server/dist/tools/git-tools.d.ts.map +1 -1
  111. package/mcp-server/dist/tools/git-tools.js +1 -1
  112. package/mcp-server/dist/tools/git-tools.js.map +1 -1
  113. package/mcp-server/dist/tools/gsc/experience.d.ts +1 -1
  114. package/mcp-server/dist/tools/gsc/experience.d.ts.map +1 -1
  115. package/mcp-server/dist/tools/gsc/experience.js +1 -1
  116. package/mcp-server/dist/tools/gsc/experience.js.map +1 -1
  117. package/mcp-server/dist/tools/gsc/indexing.d.ts +1 -1
  118. package/mcp-server/dist/tools/gsc/indexing.d.ts.map +1 -1
  119. package/mcp-server/dist/tools/gsc/indexing.js +1 -1
  120. package/mcp-server/dist/tools/gsc/indexing.js.map +1 -1
  121. package/mcp-server/dist/tools/gsc/inspect.d.ts +1 -1
  122. package/mcp-server/dist/tools/gsc/inspect.d.ts.map +1 -1
  123. package/mcp-server/dist/tools/gsc/inspect.js +1 -1
  124. package/mcp-server/dist/tools/gsc/inspect.js.map +1 -1
  125. package/mcp-server/dist/tools/gsc/navigate.d.ts +1 -1
  126. package/mcp-server/dist/tools/gsc/navigate.d.ts.map +1 -1
  127. package/mcp-server/dist/tools/gsc/navigate.js +1 -1
  128. package/mcp-server/dist/tools/gsc/navigate.js.map +1 -1
  129. package/mcp-server/dist/tools/gsc/performance.d.ts +1 -1
  130. package/mcp-server/dist/tools/gsc/performance.d.ts.map +1 -1
  131. package/mcp-server/dist/tools/gsc/performance.js +1 -1
  132. package/mcp-server/dist/tools/gsc/performance.js.map +1 -1
  133. package/mcp-server/dist/tools/gsc/settings.d.ts +1 -1
  134. package/mcp-server/dist/tools/gsc/settings.d.ts.map +1 -1
  135. package/mcp-server/dist/tools/gsc/settings.js +1 -1
  136. package/mcp-server/dist/tools/gsc/settings.js.map +1 -1
  137. package/mcp-server/dist/tools/image-tools.d.ts +1 -1
  138. package/mcp-server/dist/tools/image-tools.d.ts.map +1 -1
  139. package/mcp-server/dist/tools/image-tools.js +1 -1
  140. package/mcp-server/dist/tools/image-tools.js.map +1 -1
  141. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +1 -1
  142. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +1 -1
  143. package/mcp-server/dist/tools/leonardo-browser-tools.js +1 -1
  144. package/mcp-server/dist/tools/leonardo-browser-tools.js.map +1 -1
  145. package/mcp-server/dist/tools/loop.d.ts +1 -1
  146. package/mcp-server/dist/tools/loop.d.ts.map +1 -1
  147. package/mcp-server/dist/tools/loop.js +1 -1
  148. package/mcp-server/dist/tools/loop.js.map +1 -1
  149. package/mcp-server/dist/tools/memories.d.ts +1 -1
  150. package/mcp-server/dist/tools/memories.d.ts.map +1 -1
  151. package/mcp-server/dist/tools/memories.js +1 -1
  152. package/mcp-server/dist/tools/memories.js.map +1 -1
  153. package/mcp-server/dist/tools/morelogin.d.ts +1 -1
  154. package/mcp-server/dist/tools/morelogin.d.ts.map +1 -1
  155. package/mcp-server/dist/tools/morelogin.js +1 -1
  156. package/mcp-server/dist/tools/morelogin.js.map +1 -1
  157. package/mcp-server/dist/tools/profile.d.ts +1 -1
  158. package/mcp-server/dist/tools/profile.d.ts.map +1 -1
  159. package/mcp-server/dist/tools/profile.js +1 -1
  160. package/mcp-server/dist/tools/profile.js.map +1 -1
  161. package/mcp-server/dist/tools/recall.d.ts +1 -1
  162. package/mcp-server/dist/tools/recall.d.ts.map +1 -1
  163. package/mcp-server/dist/tools/recall.js +1 -1
  164. package/mcp-server/dist/tools/recall.js.map +1 -1
  165. package/mcp-server/dist/tools/reflect.d.ts +1 -1
  166. package/mcp-server/dist/tools/reflect.d.ts.map +1 -1
  167. package/mcp-server/dist/tools/reflect.js +1 -1
  168. package/mcp-server/dist/tools/reflect.js.map +1 -1
  169. package/mcp-server/dist/tools/remember.d.ts +1 -1
  170. package/mcp-server/dist/tools/remember.d.ts.map +1 -1
  171. package/mcp-server/dist/tools/remember.js +13 -11
  172. package/mcp-server/dist/tools/remember.js.map +1 -1
  173. package/mcp-server/dist/tools/restore.d.ts +1 -1
  174. package/mcp-server/dist/tools/restore.d.ts.map +1 -1
  175. package/mcp-server/dist/tools/restore.js +1 -1
  176. package/mcp-server/dist/tools/restore.js.map +1 -1
  177. package/mcp-server/dist/tools/style-guide-tools.d.ts +1 -1
  178. package/mcp-server/dist/tools/style-guide-tools.d.ts.map +1 -1
  179. package/mcp-server/dist/tools/style-guide-tools.js +1 -1
  180. package/mcp-server/dist/tools/style-guide-tools.js.map +1 -1
  181. package/mcp-server/dist/tools/sync.d.ts +1 -1
  182. package/mcp-server/dist/tools/sync.d.ts.map +1 -1
  183. package/mcp-server/dist/tools/sync.js +1 -1
  184. package/mcp-server/dist/tools/sync.js.map +1 -1
  185. package/mcp-server/dist/tools/tictactoe-tools.d.ts +1 -1
  186. package/mcp-server/dist/tools/tictactoe-tools.d.ts.map +1 -1
  187. package/mcp-server/dist/tools/tictactoe-tools.js +1 -1
  188. package/mcp-server/dist/tools/tictactoe-tools.js.map +1 -1
  189. package/mcp-server/dist/tools/utils.d.ts +1 -1
  190. package/mcp-server/dist/tools/utils.d.ts.map +1 -1
  191. package/mcp-server/dist/tools/utils.js +1 -1
  192. package/mcp-server/dist/tools/utils.js.map +1 -1
  193. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +1 -1
  194. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -1
  195. package/mcp-server/dist/tools/whatsapp-send-file.js +1 -1
  196. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -1
  197. package/mcp-server/dist/tools/whiteboard-tools.d.ts +3 -3
  198. package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -1
  199. package/mcp-server/dist/tools/whiteboard-tools.js +1 -1
  200. package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -1
  201. package/mcp-server/dist/tools/youtube/analytics.d.ts +1 -1
  202. package/mcp-server/dist/tools/youtube/analytics.d.ts.map +1 -1
  203. package/mcp-server/dist/tools/youtube/analytics.js +1 -1
  204. package/mcp-server/dist/tools/youtube/analytics.js.map +1 -1
  205. package/mcp-server/dist/tools/youtube/download.d.ts +1 -1
  206. package/mcp-server/dist/tools/youtube/download.d.ts.map +1 -1
  207. package/mcp-server/dist/tools/youtube/download.js +1 -1
  208. package/mcp-server/dist/tools/youtube/download.js.map +1 -1
  209. package/mcp-server/dist/tools/youtube/media.d.ts +1 -1
  210. package/mcp-server/dist/tools/youtube/media.d.ts.map +1 -1
  211. package/mcp-server/dist/tools/youtube/media.js +1 -1
  212. package/mcp-server/dist/tools/youtube/media.js.map +1 -1
  213. package/mcp-server/dist/tools/youtube/metadata.d.ts +1 -1
  214. package/mcp-server/dist/tools/youtube/metadata.d.ts.map +1 -1
  215. package/mcp-server/dist/tools/youtube/metadata.js +1 -1
  216. package/mcp-server/dist/tools/youtube/metadata.js.map +1 -1
  217. package/mcp-server/dist/tools/youtube/navigate.d.ts +1 -1
  218. package/mcp-server/dist/tools/youtube/navigate.d.ts.map +1 -1
  219. package/mcp-server/dist/tools/youtube/navigate.js +1 -1
  220. package/mcp-server/dist/tools/youtube/navigate.js.map +1 -1
  221. package/mcp-server/dist/tools/youtube/playlist.d.ts +1 -1
  222. package/mcp-server/dist/tools/youtube/playlist.d.ts.map +1 -1
  223. package/mcp-server/dist/tools/youtube/playlist.js +1 -1
  224. package/mcp-server/dist/tools/youtube/playlist.js.map +1 -1
  225. package/mcp-server/dist/tools/youtube/schedule.d.ts +1 -1
  226. package/mcp-server/dist/tools/youtube/schedule.d.ts.map +1 -1
  227. package/mcp-server/dist/tools/youtube/schedule.js +1 -1
  228. package/mcp-server/dist/tools/youtube/schedule.js.map +1 -1
  229. package/mcp-server/dist/tools/youtube/source.d.ts +1 -1
  230. package/mcp-server/dist/tools/youtube/source.d.ts.map +1 -1
  231. package/mcp-server/dist/tools/youtube/source.js +1 -1
  232. package/mcp-server/dist/tools/youtube/source.js.map +1 -1
  233. package/mcp-server/dist/tools/youtube/upload.d.ts +1 -1
  234. package/mcp-server/dist/tools/youtube/upload.d.ts.map +1 -1
  235. package/mcp-server/dist/tools/youtube/upload.js +4 -9
  236. package/mcp-server/dist/tools/youtube/upload.js.map +1 -1
  237. package/mcp-server/dist/tui.js +16 -4
  238. package/mcp-server/dist/tui.js.map +1 -1
  239. package/mcp-server/package-lock.json +3 -3
  240. package/mcp-server/package.json +3 -3
  241. package/mcp-server/src/services/browser-bench.ts +1 -1
  242. package/mcp-server/src/services/categories.ts +77 -32
  243. package/mcp-server/src/services/desktop-bench.ts +1 -1
  244. package/mcp-server/src/tools/agents.ts +1 -1
  245. package/mcp-server/src/tools/bluesky.ts +1 -1
  246. package/mcp-server/src/tools/browser-advanced.ts +1 -1
  247. package/mcp-server/src/tools/browser-batch.ts +1 -1
  248. package/mcp-server/src/tools/browser-cheatsheet.ts +1 -1
  249. package/mcp-server/src/tools/browser-interact.ts +1 -1
  250. package/mcp-server/src/tools/browser-navigate.ts +1 -1
  251. package/mcp-server/src/tools/browser-observe.ts +1 -1
  252. package/mcp-server/src/tools/card-tools.ts +1 -1
  253. package/mcp-server/src/tools/category.ts +6 -1
  254. package/mcp-server/src/tools/choice.ts +1 -1
  255. package/mcp-server/src/tools/computer.ts +1 -1
  256. package/mcp-server/src/tools/discord-channel.ts +1 -1
  257. package/mcp-server/src/tools/discord-guild.ts +1 -1
  258. package/mcp-server/src/tools/discord-member.ts +1 -1
  259. package/mcp-server/src/tools/discord-message.ts +1 -1
  260. package/mcp-server/src/tools/discord-onboarding.ts +1 -1
  261. package/mcp-server/src/tools/discord-role.ts +1 -1
  262. package/mcp-server/src/tools/discord-thread.ts +1 -1
  263. package/mcp-server/src/tools/discord-webhook.ts +1 -1
  264. package/mcp-server/src/tools/fb-groups.ts +1 -1
  265. package/mcp-server/src/tools/forget.ts +1 -1
  266. package/mcp-server/src/tools/git-tools.ts +1 -1
  267. package/mcp-server/src/tools/gsc/experience.ts +1 -1
  268. package/mcp-server/src/tools/gsc/indexing.ts +1 -1
  269. package/mcp-server/src/tools/gsc/inspect.ts +1 -1
  270. package/mcp-server/src/tools/gsc/navigate.ts +1 -1
  271. package/mcp-server/src/tools/gsc/performance.ts +1 -1
  272. package/mcp-server/src/tools/gsc/settings.ts +1 -1
  273. package/mcp-server/src/tools/image-tools.ts +1 -1
  274. package/mcp-server/src/tools/leonardo-browser-tools.ts +1 -1
  275. package/mcp-server/src/tools/loop.ts +1 -1
  276. package/mcp-server/src/tools/memories.ts +1 -1
  277. package/mcp-server/src/tools/morelogin.ts +1 -1
  278. package/mcp-server/src/tools/profile.ts +1 -1
  279. package/mcp-server/src/tools/recall.ts +1 -1
  280. package/mcp-server/src/tools/reflect.ts +1 -1
  281. package/mcp-server/src/tools/remember.ts +13 -12
  282. package/mcp-server/src/tools/restore.ts +1 -1
  283. package/mcp-server/src/tools/style-guide-tools.ts +1 -1
  284. package/mcp-server/src/tools/sync.ts +1 -1
  285. package/mcp-server/src/tools/tictactoe-tools.ts +1 -1
  286. package/mcp-server/src/tools/utils.ts +1 -1
  287. package/mcp-server/src/tools/whatsapp-send-file.ts +1 -1
  288. package/mcp-server/src/tools/whiteboard-tools.ts +1 -1
  289. package/mcp-server/src/tools/youtube/analytics.ts +1 -1
  290. package/mcp-server/src/tools/youtube/download.ts +1 -1
  291. package/mcp-server/src/tools/youtube/media.ts +1 -1
  292. package/mcp-server/src/tools/youtube/metadata.ts +1 -1
  293. package/mcp-server/src/tools/youtube/navigate.ts +1 -1
  294. package/mcp-server/src/tools/youtube/playlist.ts +1 -1
  295. package/mcp-server/src/tools/youtube/schedule.ts +1 -1
  296. package/mcp-server/src/tools/youtube/source.ts +1 -1
  297. package/mcp-server/src/tools/youtube/upload.ts +4 -12
  298. package/mcp-server/src/tui.ts +22 -9
  299. package/neural-interface/i18n/en.json +236 -7
  300. package/neural-interface/i18n/pt-BR.json +238 -9
  301. package/neural-interface/lib/account-usage.js +5 -3
  302. package/neural-interface/lib/agent-sandbox-elevation.js +102 -0
  303. package/neural-interface/lib/agent-sandbox-setting.js +57 -0
  304. package/neural-interface/lib/app-controls-api.js +161 -0
  305. package/neural-interface/lib/assistant-api.js +40 -7
  306. package/neural-interface/lib/assistant-brains/opencode.js +13 -1
  307. package/neural-interface/lib/assistant-brains/opencode2-browser-policy/index.js +44 -0
  308. package/neural-interface/lib/assistant-brains/opencode2-route-gate/index.js +57 -0
  309. package/neural-interface/lib/assistant-catalog.js +17 -2
  310. package/neural-interface/lib/assistant-dispatch.js +3 -0
  311. package/neural-interface/lib/assistant-envelope.js +19 -1
  312. package/neural-interface/lib/assistant-route-gate-codex.js +3 -1
  313. package/neural-interface/lib/assistant-runtime.js +131 -8
  314. package/neural-interface/lib/claude-model-catalog.js +64 -14
  315. package/neural-interface/lib/codex-account-auth.js +4 -2
  316. package/neural-interface/lib/codex-accounts.js +14 -1
  317. package/neural-interface/lib/codex-settings-schema.js +111 -47
  318. package/neural-interface/lib/editor-completion-opencode.js +132 -35
  319. package/neural-interface/lib/exec-loop-command.js +11 -2
  320. package/neural-interface/lib/install-details.js +23 -5
  321. package/neural-interface/lib/launch-cache.js +293 -0
  322. package/neural-interface/lib/login-start.js +524 -0
  323. package/neural-interface/lib/native-loop-providers.js +24 -4
  324. package/neural-interface/lib/native-loop-runtime.js +19 -0
  325. package/neural-interface/lib/opencode-db-schema.js +75 -0
  326. package/neural-interface/lib/opencode-history.js +16 -1
  327. package/neural-interface/lib/opencode-protocol.js +173 -0
  328. package/neural-interface/lib/opencode-provider-view.js +81 -0
  329. package/neural-interface/lib/opencode-runtime-config.js +10 -3
  330. package/neural-interface/lib/opencode-serve-records.js +606 -0
  331. package/neural-interface/lib/opencode-v2-client.js +33 -6
  332. package/neural-interface/lib/opencode-v2-ws-requests.js +19 -5
  333. package/neural-interface/lib/opencode-worker-activity.js +229 -0
  334. package/neural-interface/lib/opencode2-client.js +760 -0
  335. package/neural-interface/lib/opencode2-proxy.js +116 -0
  336. package/neural-interface/lib/opencode2-translate.js +1008 -0
  337. package/neural-interface/lib/process-identity.js +361 -0
  338. package/neural-interface/lib/provider-panel-terminal-auth.js +3 -2
  339. package/neural-interface/lib/session-cache-builder.js +26 -5
  340. package/neural-interface/lib/session-indexer.js +3 -1
  341. package/neural-interface/lib/session-title-generator.js +20 -0
  342. package/neural-interface/lib/style-guide/api.js +4 -3
  343. package/neural-interface/lib/untrusted-file-headers.js +27 -0
  344. package/neural-interface/package-lock.json +1342 -44
  345. package/neural-interface/package.json +5 -1
  346. package/neural-interface/public/index.html +2 -2
  347. package/neural-interface/public/shared/account-menu.js +4 -1
  348. package/neural-interface/public/shared/account-usage.js +62 -2
  349. package/neural-interface/public/shared/account-view.js +2 -1
  350. package/neural-interface/public/shared/agent-runtime-options.js +55 -11
  351. package/neural-interface/public/shared/assistant/asst-account-menu.js +298 -0
  352. package/neural-interface/public/shared/assistant/asst-agents-dock.js +172 -17
  353. package/neural-interface/public/shared/assistant/asst-api.js +18 -3
  354. package/neural-interface/public/shared/assistant/asst-brain-picker.js +424 -152
  355. package/neural-interface/public/shared/assistant/asst-composer.js +1 -1
  356. package/neural-interface/public/shared/assistant/asst-hosts.js +49 -2
  357. package/neural-interface/public/shared/assistant/asst-menu.js +275 -77
  358. package/neural-interface/public/shared/assistant/asst-model-chip.js +135 -0
  359. package/neural-interface/public/shared/assistant/asst-panel.js +236 -35
  360. package/neural-interface/public/shared/assistant/asst-rack-styles.js +28 -10
  361. package/neural-interface/public/shared/assistant/asst-redact.js +186 -0
  362. package/neural-interface/public/shared/assistant/asst-render.js +382 -36
  363. package/neural-interface/public/shared/assistant/asst-run-stage.js +231 -0
  364. package/neural-interface/public/shared/assistant/asst-sanitize.js +193 -3
  365. package/neural-interface/public/shared/assistant/asst-scene-styles.js +273 -0
  366. package/neural-interface/public/shared/assistant/asst-scenes.js +528 -0
  367. package/neural-interface/public/shared/assistant/asst-sidepanel-styles.js +3 -15
  368. package/neural-interface/public/shared/assistant/asst-state.js +14 -0
  369. package/neural-interface/public/shared/assistant/asst-styles.js +109 -17
  370. package/neural-interface/public/shared/assistant/asst-tool-kinds.js +13 -1
  371. package/neural-interface/public/shared/cc-dropdown.js +480 -0
  372. package/neural-interface/public/shared/cdx/cdx-panel.js +14 -2
  373. package/neural-interface/public/shared/cdx/cdx-render.js +15 -3
  374. package/neural-interface/public/shared/cdx/cdx-styles.js +49 -5
  375. package/neural-interface/public/shared/cdx/cdx-tabs.js +152 -35
  376. package/neural-interface/public/shared/cp/cp-markdown.js +4 -57
  377. package/neural-interface/public/shared/data-cache.js +223 -0
  378. package/neural-interface/public/shared/desktop-bridge.js +11 -0
  379. package/neural-interface/public/shared/desktop-titlebar-view.js +78 -1
  380. package/neural-interface/public/shared/desktop-titlebar.js +214 -28
  381. package/neural-interface/public/shared/desktop-update-view.js +15 -5
  382. package/neural-interface/public/shared/file-links.js +73 -8
  383. package/neural-interface/public/shared/html-shell.js +74 -80
  384. package/neural-interface/public/shared/launch-prefetch.js +59 -0
  385. package/neural-interface/public/shared/ocp-v2/ocp-v2-caps.js +3 -2
  386. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +11 -3
  387. package/neural-interface/public/shared/ocp-v2/ocp-v2-modelpicker.js +54 -17
  388. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +97 -3
  389. package/neural-interface/public/shared/ocp-v2/ocp-v2-projectbar.js +3 -2
  390. package/neural-interface/public/shared/ocp-v2/ocp-v2-providers.js +11 -9
  391. package/neural-interface/public/shared/ocp-v2/ocp-v2-rehydrate.js +15 -4
  392. package/neural-interface/public/shared/ocp-v2/ocp-v2-render.js +1 -1
  393. package/neural-interface/public/shared/ocp-v2/ocp-v2-send-logic.js +401 -0
  394. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +16 -9
  395. package/neural-interface/public/shared/ocp-v2/ocp-v2-sessions-logic.js +60 -0
  396. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +83 -5
  397. package/neural-interface/public/shared/ocp-v2/ocp-v2-ws.js +195 -19
  398. package/neural-interface/public/shared/opencode-providers.js +39 -0
  399. package/neural-interface/public/shared/schedules-view.js +318 -0
  400. package/neural-interface/public/shared/settings/settings-app-pane.js +335 -0
  401. package/neural-interface/public/shared/settings/settings-app.js +243 -0
  402. package/neural-interface/public/shared/settings/settings-data.js +88 -0
  403. package/neural-interface/public/shared/settings/settings-ia.js +17 -0
  404. package/neural-interface/public/shared/showcase-privacy.js +2 -1
  405. package/neural-interface/public/shared/sidepanel-session-status.js +49 -0
  406. package/neural-interface/public/shared/styles.css +543 -372
  407. package/neural-interface/public/shared/ui-assistant.js +27 -5
  408. package/neural-interface/public/shared/ui-automation-studio.js +67 -27
  409. package/neural-interface/public/shared/ui-bookmarks.js +2 -1
  410. package/neural-interface/public/shared/ui-claude-panel.js +126 -12
  411. package/neural-interface/public/shared/ui-codex-panel.js +1 -0
  412. package/neural-interface/public/shared/ui-detail.js +6 -6
  413. package/neural-interface/public/shared/ui-explorer.js +2 -0
  414. package/neural-interface/public/shared/ui-file-links.js +1 -0
  415. package/neural-interface/public/shared/ui-image-gallery.js +6 -5
  416. package/neural-interface/public/shared/ui-invite.js +18 -2
  417. package/neural-interface/public/shared/ui-link.js +9 -9
  418. package/neural-interface/public/shared/ui-menubar.js +232 -33
  419. package/neural-interface/public/shared/ui-native-loop-router.js +2 -0
  420. package/neural-interface/public/shared/ui-opencode-panel-v2.js +1 -0
  421. package/neural-interface/public/shared/ui-schedules-studio.js +2099 -869
  422. package/neural-interface/public/shared/ui-sessions.js +15 -4
  423. package/neural-interface/public/shared/ui-settings.js +87 -36
  424. package/neural-interface/public/shared/ui-sidebar.js +4 -4
  425. package/neural-interface/public/shared/ui-sidepanel-session-entry.js +13 -0
  426. package/neural-interface/public/shared/ui-sidepanel-sessions.js +55 -4
  427. package/neural-interface/public/shared/ui-sidepanel-tray.js +84 -8
  428. package/neural-interface/public/shared/ui-skills.js +70 -12
  429. package/neural-interface/public/shared/ui-sync.js +9 -0
  430. package/neural-interface/public/shared/ui-terminal.js +36 -20
  431. package/neural-interface/public/shared/ui-trash.js +8 -1
  432. package/neural-interface/public/shared/ui-tutorial-steps.js +7 -3
  433. package/neural-interface/public/shared/ui-tutorial.js +15 -5
  434. package/neural-interface/public/shared/ui-update.js +87 -16
  435. package/neural-interface/public/shared/ui-whiteboard.js +2 -1
  436. package/neural-interface/public/variant/2d/main.js +4 -1
  437. package/neural-interface/public/variant/3d/main.js +3 -0
  438. package/neural-interface/public/vendor/highlight/LICENSE +29 -0
  439. package/neural-interface/public/vendor/highlight/github-dark-dimmed-11.12.0.min.css +9 -0
  440. package/neural-interface/public/vendor/highlight/highlight-11.12.0.min.js +1262 -0
  441. package/neural-interface/public/vendor/highlight/manifest.json +5 -0
  442. package/neural-interface/public/vendor/html2canvas/LICENSE +22 -0
  443. package/neural-interface/public/vendor/html2canvas/html2canvas-1.4.1.min.js +20 -0
  444. package/neural-interface/public/vendor/html2canvas/manifest.json +4 -0
  445. package/neural-interface/public/vendor/marked/LICENSE.md +44 -0
  446. package/neural-interface/public/vendor/marked/manifest.json +4 -0
  447. package/neural-interface/public/vendor/marked/marked-14.1.4.esm.js +2502 -0
  448. package/neural-interface/public/vendor/mermaid/LICENSE +21 -0
  449. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/abnfDiagram-PAR5IXJI.mjs +1 -0
  450. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/architecture-7GRP2DOG-M7G3SWCO.mjs +1 -0
  451. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/architectureDiagram-2PNWDENW.mjs +36 -0
  452. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/blockDiagram-PUWGUBOA.mjs +129 -0
  453. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/c4Diagram-3NLNHZP5.mjs +38 -0
  454. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-2NMSUPJH.mjs +1 -0
  455. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-32MCUW2P.mjs +206 -0
  456. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-3NPNU5GP.mjs +1 -0
  457. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-3SMGFRGC.mjs +1 -0
  458. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-4CAJECRU.mjs +14 -0
  459. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-4OADUZ5Q.mjs +10 -0
  460. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5IMINLNL.mjs +1 -0
  461. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5IQCK5RY.mjs +3 -0
  462. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-5MVFYWFU.mjs +1 -0
  463. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-AQ6EADP3.mjs +1 -0
  464. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-AZZRMDJM.mjs +15 -0
  465. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-CLS4B6BI.mjs +168 -0
  466. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-DZP67EKU.mjs +70 -0
  467. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-EICP7K5X.mjs +1 -0
  468. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-GDY2UAB4.mjs +1 -0
  469. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-H7VHZCWX.mjs +1 -0
  470. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-IJBDOHL6.mjs +1 -0
  471. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JFFVMJIA.mjs +1 -0
  472. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JQRUD6KW.mjs +1 -0
  473. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-JZGDI5JK.mjs +1 -0
  474. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-KD6SPIPN.mjs +2 -0
  475. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-NF366YI6.mjs +231 -0
  476. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-NUDPKBEF.mjs +32 -0
  477. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-OJINMHTS.mjs +1 -0
  478. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-Q6NGPIAH.mjs +2 -0
  479. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-QJSWEUOL.mjs +132 -0
  480. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-R7JDMUKV.mjs +1 -0
  481. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-RCXAM2GK.mjs +1 -0
  482. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-RT4MAWR3.mjs +1 -0
  483. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-SFMINX4W.mjs +1 -0
  484. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-SLDQ463B.mjs +88 -0
  485. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-STOV2HOB.mjs +1 -0
  486. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-T5OCTHI4.mjs +1 -0
  487. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-TCEHX7R3.mjs +1 -0
  488. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VQC2EELJ.mjs +1 -0
  489. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VRZKL3EH.mjs +1 -0
  490. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VTHDQ7BV.mjs +1 -0
  491. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-VY2OSTJR.mjs +2 -0
  492. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-WUBWJARI.mjs +1 -0
  493. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-YBMQ5JGJ.mjs +2 -0
  494. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-YDMLL4PJ.mjs +1 -0
  495. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-Z6V44ARX.mjs +62 -0
  496. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZJJ7VQJP.mjs +339 -0
  497. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZNLK7HRR.mjs +1 -0
  498. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZQN7UHIL.mjs +1 -0
  499. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZS6VBONO.mjs +174 -0
  500. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/chunk-ZUNWM646.mjs +1 -0
  501. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-FGAMPOII.mjs +1 -0
  502. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/classDiagram-v2-AI5RJEK4.mjs +1 -0
  503. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cose-bilkent-Q72C2OJ3.mjs +1 -0
  504. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cynefin-OW5HDTMX-6OCTWOGG.mjs +1 -0
  505. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/cynefinDiagram-2OOWUY4M.mjs +62 -0
  506. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/dagre-MPVFI544.mjs +4 -0
  507. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-AE4NWXFW.mjs +24 -0
  508. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-BUJID5UG.mjs +41 -0
  509. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-C44CZ5NI.mjs +24 -0
  510. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-EYOLEXXS.mjs +30 -0
  511. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/diagram-P7B6PWOJ.mjs +3 -0
  512. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ebnfDiagram-FTPZ3PV6.mjs +1 -0
  513. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/erDiagram-H3ERR26V.mjs +99 -0
  514. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/eventmodeling-NTZA5JFV-WKSWQHFS.mjs +1 -0
  515. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/flowDiagram-YHGXBVSY.mjs +1 -0
  516. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ganttDiagram-RWFTB7ED.mjs +292 -0
  517. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/gitGraph-4MIJSDKK-JH2HKYUO.mjs +1 -0
  518. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/gitGraphDiagram-MAR6GKOP.mjs +106 -0
  519. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/info-A6RAGUB7-3TX3OFWW.mjs +1 -0
  520. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/infoDiagram-LV547AFH.mjs +2 -0
  521. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/ishikawaDiagram-PUJQGNDS.mjs +70 -0
  522. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/journeyDiagram-EW5QJT4W.mjs +139 -0
  523. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/kanban-definition-JSQERAZB.mjs +89 -0
  524. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/katex-CZ4GXH2S.mjs +257 -0
  525. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/mindmap-definition-VBJCLQLM.mjs +96 -0
  526. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/packet-AYTQ26CC-MKII6P3Y.mjs +1 -0
  527. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pegDiagram-7WILOQTJ.mjs +1 -0
  528. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pie-WAS4IAKB-6B4OPMJ5.mjs +1 -0
  529. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/pieDiagram-EFA7CBSB.mjs +39 -0
  530. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/quadrantDiagram-WARVYA7P.mjs +7 -0
  531. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/radar-RG4KPBEZ-UPTR3XUL.mjs +1 -0
  532. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-74A4TZTK-MEFUPFMS.mjs +1 -0
  533. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-abnf-HS5TGJTU-R5YG2S5D.mjs +1 -0
  534. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-ebnf-LZEXJU2U-G5QEX2L6.mjs +1 -0
  535. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroad-peg-WCYAUIDC-H5I2ZRY2.mjs +1 -0
  536. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/railroadDiagram-5NIQVNLQ.mjs +1 -0
  537. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/requirementDiagram-SXREGOKZ.mjs +84 -0
  538. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sankeyDiagram-VV74M2VP.mjs +40 -0
  539. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sequenceDiagram-52ZFIFDL.mjs +162 -0
  540. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/sizeCapture-FGA5AGYP.mjs +1 -0
  541. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-3QAKPIVI.mjs +1 -0
  542. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/stateDiagram-v2-HN2P4RDZ.mjs +1 -0
  543. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/swimlanes-QLVP42QD.mjs +1 -0
  544. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/swimlanesDiagram-JKPDBFCN.mjs +8 -0
  545. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/timeline-definition-GS45XAXW.mjs +120 -0
  546. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/treeView-Q6P3EWNA-ZRJGANNQ.mjs +1 -0
  547. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/treemap-WGGIJYW6-K64VN5AS.mjs +1 -0
  548. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/vennDiagram-QIQU2ZRJ.mjs +34 -0
  549. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/wardley-WFR3VGLG-D67OWCCH.mjs +1 -0
  550. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/wardleyDiagram-U6YNM66N.mjs +78 -0
  551. package/neural-interface/public/vendor/mermaid/chunks/mermaid.esm.min/xychartDiagram-XJAZEDNM.mjs +7 -0
  552. package/neural-interface/public/vendor/mermaid/manifest.json +4 -0
  553. package/neural-interface/public/vendor/mermaid/mermaid-11.17.2.esm.min.mjs +14 -0
  554. package/neural-interface/scripts/build-vendor.mjs +84 -3
  555. package/neural-interface/scripts/rebuild-pty.js +14 -2
  556. package/neural-interface/server.js +1279 -372
  557. package/npm-shrinkwrap.json +4424 -0
  558. package/package.json +54 -2
  559. package/postinstall.js +61 -47
  560. package/preuninstall.js +8 -35
  561. package/setup.js +76 -44
  562. package/skills/synabun/modules/idea.md +1 -1
  563. package/skills/synabun/modules/schedule.md +2 -2
  564. package/updater.cmd +2 -2
  565. package/updater.mjs +97 -3
@@ -12,6 +12,8 @@ import { createClaudeAccounts as createPanelClaudeAccounts } from './lib/claude-
12
12
  import { createClaudePanelAuth } from './lib/claude-panel-auth.js';
13
13
  import { createCodexPanelAuth } from './lib/codex-panel-auth.js';
14
14
  import { createCodexAccounts, applyCodexLaunchEnv, codexAppServerArgs, codexExecEnv } from './lib/codex-accounts.js';
15
+ import { codexWindowsSandbox, codexWindowsSandboxArgs } from './lib/agent-sandbox-elevation.js';
16
+ import { createAdminSandboxSetting } from './lib/agent-sandbox-setting.js';
15
17
  import { createCodexAccountAuth } from './lib/codex-account-auth.js';
16
18
  import { registerProviderPanelApiKeyAuthRoutes, redactProviderSecrets, providerApiKeyBodyError, providerDiagnosticSink } from './lib/provider-panel-api-key-auth.js';
17
19
  import { createProviderPanelTerminalAuth, providerLoginSpec, registerProviderPanelTerminalAuthRoutes } from './lib/provider-panel-terminal-auth.js';
@@ -123,6 +125,8 @@ import { createCliResolver, spawnArgsFor } from './lib/cli-resolver.js';
123
125
  import { osPath } from './lib/os-path.js';
124
126
  import { createCliInstaller, CLI_INSTALL_GUIDES } from './lib/cli-install.js';
125
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';
126
130
  import { createCliPathManager } from './lib/cli-path.js';
127
131
  import { composeTerminalPath, terminalLaunchCommand, terminalArgument } from './lib/terminal-path.js';
128
132
  import {
@@ -143,6 +147,11 @@ import {
143
147
  } from './lib/backup-zip-reader.js';
144
148
  import { planRestoreWrites, removeRestoreUpload, saveRestoreUpload } from './lib/system-restore.js';
145
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';
146
155
  import {
147
156
  handleOpencodeV2Request, opencodeV2WsCapabilities, opencodeSdkVersion, buildOpencodeRuntimeMcpConfig, shouldRelayOpencodeV2Message,
148
157
  buildSessionListQuery, mapSessionListRow, OPENCODE_V2_WS_MUTATION_TYPES, guardOpencodeV2Request,
@@ -158,6 +167,7 @@ import {
158
167
  createCodexNativeLoopAdapter,
159
168
  createOpenCodeNativeLoopAdapter,
160
169
  } from './lib/native-loop-providers.js';
170
+ import { createOpenCodeActivityRelay } from './lib/opencode-worker-activity.js';
161
171
  import {
162
172
  mergeCodexModelContextCapabilities,
163
173
  resolveCodexExtendedContext,
@@ -236,15 +246,20 @@ import { generateSessionTitle } from './lib/session-title-generator.js';
236
246
  import {
237
247
  CLAUDE_EFFORT_LEVELS,
238
248
  CLAUDE_FALLBACK_MODELS,
249
+ binaryIdentity,
239
250
  checkClaudeCliSkew,
240
251
  discoverClaudeModels,
252
+ markClaudeModelsStale,
241
253
  onClaudeModelsChanged,
254
+ setClaudeModelStore,
242
255
  shellCommand,
243
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';
244
259
  import { resolveClaudeSdkExecutable } from './lib/claude-executable.js';
245
260
  import { collectInstallDetails } from './lib/install-details.js';
246
261
  import { withPathEnv, pathEnvValue, getAugmentedPath, rememberedOsPath, rememberPackagedPath, rememberedPackagedPath, withPackagedPathLast } from './lib/augmented-path.js';
247
- import { readCodexSettingsSchema, readCodexFeatureSupport, validateCodexSettings } from './lib/codex-settings-schema.js';
262
+ import { cachedCodexSettingsSchema, readCodexSettingsSchema, readCodexFeatureSupport, setCodexSettingsStore, validateCodexSettings } from './lib/codex-settings-schema.js';
248
263
  import { semverNewer, synabunReleaseChannel } from './lib/synabun-version.js';
249
264
  import { selectSynabunUpdate, buildSynabunInstallPlan, refuseStaleUpdateClick } from './lib/synabun-update-plan.js';
250
265
  import { collectActivity, countWhere, recentCliSessions } from './lib/system-activity.js';
@@ -272,11 +287,12 @@ import {
272
287
  clampPointsToRect as wbClampPointsToRect,
273
288
  convertPctInPlace as wbConvertPctInPlace,
274
289
  } from '../mcp-server/dist/services/whiteboard-geometry.js';
275
- import { ensureProjectCategories } from '../hooks/claude-code/shared.mjs';
290
+ import { ensureProjectCategories as ensureProjectCategoriesInFile, ensureCategories as ensureCategoriesInFile, FEATURE_CATEGORIES } from '../hooks/claude-code/shared.mjs';
276
291
  import { getDataHome, getDataHomeDiagnostics, isGlobalInstall, pathIsInside } from '../lib/paths.js';
277
292
  import { launcherLogPath } from '../lib/first-launch-protection.js';
278
- import { registerStartLauncher, startLauncherState } from '../lib/start-launcher.js';
293
+ import { launcherRegistrationAllowed, registerStartLauncher, startLauncherState } from '../lib/start-launcher.js';
279
294
  import { effectiveNpmPrefix } from '../lib/child-path.js';
295
+ import { checkRuntimeDependencies, fallbackInstallArgs, fallbackInstallEnv, findInstalled, mcpCompiler, npmCommand, npmInvocation } from '../lib/runtime-dependencies.js';
280
296
  import { redactDeep, redactSecrets } from '../lib/log-redact.js';
281
297
  import { applyPackagedChildPath, durableEntry, mcpStdioCommand, npmGlobalBinFor, packagedNpmPrefix, packagedPathFolders, packagedRuntime } from '../lib/packaged-runtime.js';
282
298
  import { dataHomeId } from '../lib/desktop-app.js';
@@ -304,6 +320,7 @@ import {
304
320
  clearOpenCodeHistoryInWorker,
305
321
  getOpenCodeHistoryStats,
306
322
  OPENCODE_HISTORY_CONFIRMATION,
323
+ OPENCODE2_HISTORY_REFUSAL,
307
324
  } from './lib/opencode-history.js';
308
325
  import {
309
326
  clearProjectStorageInWorker,
@@ -328,11 +345,15 @@ const __dirname = dirname(__filename);
328
345
  // This sweeps ALL platform directories, not just this host's: a vendored
329
346
  // checkout can carry several, and repairing only the current one left e.g.
330
347
  // prebuilds/darwin-x64/spawn-helper broken for Intel Macs.
348
+ // Both node_modules an install can have: the package root's (`npm i -g synabun` puts every dependency there) and
349
+ // the Neural Interface's own (a development checkout, a desktop build).
331
350
  try {
332
- const sweep = ensureVendoredExecutables({ root: __dirname, log: (msg) => console.log(msg) });
333
- for (const failure of sweep.failed) {
334
- console.warn(`[binaries] ${failure.path} is not launchable (${failure.state}).`
335
- + (failure.repairCommand ? ` Try: ${failure.repairCommand}` : ''));
351
+ for (const root of new Set([__dirname, resolve(__dirname, '..')])) {
352
+ const sweep = ensureVendoredExecutables({ root, log: (msg) => console.log(msg) });
353
+ for (const failure of sweep.failed) {
354
+ console.warn(`[binaries] ${failure.path} is not launchable (${failure.state}).`
355
+ + (failure.repairCommand ? ` Try: ${failure.repairCommand}` : ''));
356
+ }
336
357
  }
337
358
  } catch (err) {
338
359
  console.warn('[binaries] Could not verify vendored binary permissions:', err.message);
@@ -454,6 +475,8 @@ import {
454
475
  import { createOpencodeCompletionEngine } from './lib/editor-completion-opencode.js';
455
476
  import { createEndpointCompletionEngine } from './lib/editor-completion-endpoint.js';
456
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';
457
480
  import { createCloudflaredManager } from './lib/cloudflared/manager.js';
458
481
  import { createQuickTunnel } from './lib/cloudflared/tunnel.js';
459
482
  import { createGitBashManager } from './lib/git-bash/manager.js';
@@ -464,6 +487,9 @@ const PORT = process.env.NEURAL_PORT || 3344;
464
487
  const EMBEDDING_DIMS = getEmbeddingDims();
465
488
  const PACKAGE_ROOT = resolve(__dirname, '..');
466
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);
467
493
  registerCodexPlanRoutes(app, resolve(DATA_HOME, 'data', 'codex-plans'));
468
494
  const DATA_HOME_DIAGNOSTICS = getDataHomeDiagnostics(DATA_HOME, PACKAGE_ROOT);
469
495
  const CODEX_SIDEPANEL_PERMISSIONS_PATH = resolve(DATA_HOME, 'data', 'codex-sidepanel-permissions.json');
@@ -538,49 +564,31 @@ const GLOBAL_CATEGORIES_PATH = resolve(CATEGORIES_DATA_DIR, 'custom-categories.j
538
564
  const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
539
565
  const CATEGORIES_POINT_ID = '00000000-0000-0000-0000-000000000000';
540
566
 
541
- // Default categories seeded on first startup (new connection)
542
- // Must stay in sync with hooks/claude-code/shared.mjs STANDALONE_DEFAULTS
543
- const DEFAULT_CATEGORIES = [
544
- {
545
- name: 'uncategorized',
546
- description: 'Fallback category for memories stored without an explicit category. Any memory whose category is null, undefined, or empty is automatically assigned here during data normalization. Ensures every memory is always categorized for filtering, sidebar visibility toggling, and graph rendering.',
547
- color: '#6b7280',
548
- },
549
- {
550
- name: 'conversations',
551
- description: 'Indexed conversation sessions with compacted summaries for cross-session recall. Stores session metadata (date, branch, session ID, file path) and topic summaries so users can search and retrieve past conversations naturally — e.g. "what did we work on yesterday?" or "remember that conversation about auth?"',
552
- color: '#a855f7',
553
- is_parent: true,
554
- },
555
- {
556
- name: 'communication-style',
557
- description: 'User communication patterns and preferences.',
558
- },
559
- {
560
- name: 'plans',
561
- description: 'Implementation plans stored after plan mode approval.',
562
- color: '#06d6a0',
563
- is_parent: true,
564
- },
565
- {
566
- name: 'ideas',
567
- description: 'Feature ideas, product concepts, brainstorms, and future plans. A general-purpose category for capturing forward-looking thoughts that haven\'t been implemented yet — new features to build, architectural experiments to try, UX improvements to explore, or any creative concept worth revisiting later.',
568
- color: '#f59e0b',
569
- },
570
- ];
571
-
567
+ // No default categories: a fresh install has none. A project's defaults are made
568
+ // when the project is added (ensureProjectCategories), a feature's category the
569
+ // first time the feature saves into it.
572
570
  function getCategoriesPath() {
573
571
  // Single categories file — no per-connection paths needed with SQLite
574
- if (!existsSync(GLOBAL_CATEGORIES_PATH)) {
575
- const dir = resolve(GLOBAL_CATEGORIES_PATH, '..');
576
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
577
- writeFileSync(GLOBAL_CATEGORIES_PATH, JSON.stringify({ version: 1, categories: DEFAULT_CATEGORIES }, null, 2), 'utf-8');
578
- }
579
-
580
572
  return GLOBAL_CATEGORIES_PATH;
581
573
  }
582
574
 
575
+ // The file not written yet: what SQLite (the source of truth) holds, which is nothing on a fresh install.
576
+ function categoriesFromDb() {
577
+ try {
578
+ return dbGetCategories().map((row) => {
579
+ const category = { name: row.name, description: row.description || '', created_at: row.created_at };
580
+ if (row.parent) category.parent = row.parent;
581
+ if (row.color) category.color = row.color;
582
+ if (row.is_parent) category.is_parent = true;
583
+ return category;
584
+ });
585
+ } catch {
586
+ return [];
587
+ }
588
+ }
589
+
583
590
  function loadCategories() {
591
+ if (!existsSync(GLOBAL_CATEGORIES_PATH)) return categoriesFromDb();
584
592
  try {
585
593
  const raw = readFileSync(getCategoriesPath(), 'utf-8');
586
594
  const data = JSON.parse(raw);
@@ -593,6 +601,27 @@ function loadCategories() {
593
601
  }
594
602
  }
595
603
 
604
+ // The categories file is written by the hooks' helpers (the file the MCP server watches); SQLite gets a copy, as with every other category write.
605
+ function syncCategoriesToDb(created) {
606
+ if (!created?.length) return created || [];
607
+ try { dbSaveCategories(loadCategories()); } catch (err) { console.error('Failed to sync categories to SQLite:', err.message); }
608
+ return created;
609
+ }
610
+
611
+ // A project's default categories (parent + project / architecture / bugs / config), made once, when the project is
612
+ // registered; never for a project already in the registry. Adds what is missing only.
613
+ function ensureProjectCategories(projects) {
614
+ return syncCategoriesToDb(ensureProjectCategoriesInFile(projects).created);
615
+ }
616
+
617
+ // A feature's own category (FEATURE_CATEGORIES: conversations, communication-style, plans, uncategorized), made the
618
+ // first time the feature saves into it; a fresh install has none. Adds it only when it is missing.
619
+ function ensureFeatureCategory(name) {
620
+ const feature = Object.prototype.hasOwnProperty.call(FEATURE_CATEGORIES, name) ? FEATURE_CATEGORIES[name] : null;
621
+ if (!feature) return [];
622
+ return syncCategoriesToDb(ensureCategoriesInFile([{ name, description: feature.description, is_parent: feature.is_parent }]).created);
623
+ }
624
+
596
625
  function saveCategories(categories) {
597
626
  const filePath = getCategoriesPath();
598
627
  const dir = resolve(filePath, '..');
@@ -823,11 +852,19 @@ app.use('/skins', express.static(join(__dirname, 'skins'), {
823
852
  lastModified: true,
824
853
  setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
825
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).
826
857
  app.use('/custom-icons', express.static(CUSTOM_ICONS_DIR, {
827
858
  etag: true,
828
859
  lastModified: true,
829
- setHeaders: (res) => res.setHeader('Cache-Control', 'no-cache'),
860
+ setHeaders: (res, path) => { res.setHeader('Cache-Control', 'no-cache'); setUntrustedFileHeaders(res, path); },
830
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
+ });
831
868
  app.use(express.static(join(__dirname, 'public'), {
832
869
  etag: true,
833
870
  lastModified: true,
@@ -2391,6 +2428,18 @@ function cleanupOldUpdaterStages(baseDir) {
2391
2428
  } catch {}
2392
2429
  }
2393
2430
 
2431
+ // lib/package-processes.js and the module it imports (only node: modules in either).
2432
+ const UPDATER_STAGED_LIBS = ['package-processes.js', 'runtime-dependencies.js'];
2433
+
2434
+ // The forms of the package folder this server knows: as it resolved it, and as it was started
2435
+ // (`node <prefix>\node_modules\synabun\neural-interface\server.js` through a junction or a link).
2436
+ function updaterPackageRoots() {
2437
+ const roots = [PACKAGE_ROOT];
2438
+ const started = process.argv[1] ? resolve(process.argv[1]) : '';
2439
+ if (basename(started) === 'server.js' && basename(dirname(started)) === 'neural-interface') roots.push(resolve(started, '..', '..'));
2440
+ return [...new Set(roots)];
2441
+ }
2442
+
2394
2443
  function stageSynabunUpdater({ installPlan, serverPid, autoRestart, cleanupPids = [], safetySnapshot = null, handoffPath = null }) {
2395
2444
  const sourceUpdater = resolve(PACKAGE_ROOT, 'updater.mjs');
2396
2445
  if (!existsSync(sourceUpdater)) {
@@ -2408,6 +2457,16 @@ function stageSynabunUpdater({ installPlan, serverPid, autoRestart, cleanupPids
2408
2457
  const payloadPath = resolve(stageDir, 'payload.json');
2409
2458
  const runnerPath = resolve(stageDir, process.platform === 'win32' ? 'run-update.cmd' : 'run-update.sh');
2410
2459
  copyFileSync(sourceUpdater, stagedUpdater);
2460
+ // What updater.mjs imports on Windows to stop the processes AI clients started from this folder: staged as lib/
2461
+ // next to it, with a package.json that makes those .js files ES modules on every Node SynaBun runs on. One that
2462
+ // cannot be copied leaves the updater without that step, never without the update.
2463
+ try {
2464
+ mkdirSync(resolve(stageDir, 'lib'), { recursive: true });
2465
+ writeFileSync(resolve(stageDir, 'lib', 'package.json'), '{ "type": "module" }\n', 'utf8');
2466
+ for (const file of UPDATER_STAGED_LIBS) copyFileSync(resolve(PACKAGE_ROOT, 'lib', file), resolve(stageDir, 'lib', file));
2467
+ } catch (err) {
2468
+ console.warn(`[server] could not stage lib/ for the updater: ${err?.message}`);
2469
+ }
2411
2470
 
2412
2471
  const payload = {
2413
2472
  serverPid,
@@ -2419,6 +2478,9 @@ function stageSynabunUpdater({ installPlan, serverPid, autoRestart, cleanupPids
2419
2478
  target: installPlan.target,
2420
2479
  channel: installPlan.channel,
2421
2480
  cleanupPids,
2481
+ // The folder `npm i -g` replaces, in every form known here: on Windows the updater stops what still runs from it.
2482
+ packageRoot: PACKAGE_ROOT,
2483
+ packageRoots: updaterPackageRoots(),
2422
2484
  safetySnapshot,
2423
2485
  handoffPath,
2424
2486
  // Where the relaunched launcher leaves its reason if it stops.
@@ -3438,22 +3500,60 @@ function getNativeCodexBin() {
3438
3500
  // its full path; on Windows the opencode.exe found, started directly with the
3439
3501
  // PATH a new terminal has (a command file that leads nowhere: through cmd.exe,
3440
3502
  // every word escaped). A command line in Settings is run as written.
3441
- 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 } = {}) {
3442
3507
  const found = cliResolver.launcher('opencode');
3443
3508
  _noteCliLookup('opencode', found, 'OpenCode');
3444
3509
  const line = configuredCommandLine('opencode');
3510
+ const spec = serveSpecFor(protocol, port, { env, password });
3511
+ const words = spec.args.join(' ');
3445
3512
  if (IS_WIN) {
3446
- const spawnEnv = withPathEnv(env, getAugmentedPath());
3513
+ const spawnEnv = withPathEnv(spec.env, getAugmentedPath());
3447
3514
  if (!line && found.spec) {
3448
- const launch = spawnArgsFor(found.spec, ['serve', '--port', String(port)], { env: spawnEnv });
3449
- 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 };
3450
3517
  }
3451
- const text = `${line || 'opencode'} serve --port ${port}`;
3452
- 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 };
3453
3520
  }
3454
3521
  const cmd = line || (found.path ? shellSingleQuote(found.path) : 'opencode');
3455
3522
  // A packaged application's runtime and npm after the user's own (the login shell adds the user's profile on top).
3456
- 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;
3457
3557
  }
3458
3558
 
3459
3559
  function extractCodexConfigValues(result) {
@@ -3708,6 +3808,25 @@ function readCodexRuntimeModelCatalog(codexHome = CODEX_DEFAULT_HOME) {
3708
3808
  }
3709
3809
  const CODEX_ACCOUNTS_PATH = resolve(DATA_HOME, 'data', 'codex-accounts.json');
3710
3810
 
3811
+ // Admin sandbox (lib/agent-sandbox-setting.js, lib/agent-sandbox-elevation.js): one global value,
3812
+ // off by default, for every agent SynaBun starts: may it use a sandbox whose setup needs Windows
3813
+ // administrator approval. Codex is the provider it changes (windows.sandbox, a launch override on
3814
+ // every Codex process, never written into config.toml). Changed from the Assistant panel only; idle
3815
+ // Codex app-servers (panel tabs, the Assistant's brain) restart under it, busy ones keep theirs.
3816
+ const _agentSandbox = createAdminSandboxSetting({
3817
+ path: resolve(DATA_HOME, 'data', 'agent-sandbox.json'),
3818
+ broadcast: (message) => broadcastSync(message),
3819
+ log: (tag, message) => console.log(`[${tag}]`, message),
3820
+ });
3821
+ function adminSandboxOn() { return _agentSandbox.enabled(); }
3822
+ /** The `-c` words every Codex SynaBun starts gets for the setting (outside an account's launch plan). */
3823
+ function codexSandboxArgs() { return codexWindowsSandboxArgs(adminSandboxOn()); }
3824
+ _agentSandbox.subscribe(() => {
3825
+ for (const connection of [..._codexLiveConnections]) {
3826
+ if (!connection.busy()) { try { connection.credentialsChanged(); } catch {} }
3827
+ }
3828
+ });
3829
+
3711
3830
  // The Codex accounts registry (lib/codex-accounts.js): one instance for the
3712
3831
  // sidepanel, the Assistant (brain, workers, catalog), loops and every route, so
3713
3832
  // one account state is shown everywhere. Its ChatGPT half is Codex's own
@@ -3721,6 +3840,8 @@ function _codexAccounts() {
3721
3840
  dataHome: DATA_HOME, homeDir: getHomePath(), accountsRoot: CODEX_ACCOUNTS_ROOT,
3722
3841
  readNativeStatus: (accountId) => codexAccountAuth().nativeStatus(accountId),
3723
3842
  prepareDefaultConfig: () => ensureCanonicalCodexConfig({ strict: true }),
3843
+ // Every launch plan carries the Windows sandbox the Admin sandbox setting allows.
3844
+ adminSandbox: () => adminSandboxOn(),
3724
3845
  log: (tag, message) => console.log(`[${tag}]`, message),
3725
3846
  });
3726
3847
  _codexAccountsInstance.subscribe((event) => {
@@ -3728,6 +3849,8 @@ function _codexAccounts() {
3728
3849
  broadcastSync({ type: 'codex:accounts-changed', accountId: event.accountId || null, removed: !!event.removed, created: !!event.created });
3729
3850
  broadcastSync({ type: 'assistant:accounts-changed', provider: 'codex', accountId: event.accountId || null, removed: !!event.removed });
3730
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 });
3731
3854
  } catch {}
3732
3855
  });
3733
3856
  }
@@ -3850,6 +3973,8 @@ function handleCodexSkinWebSocket(ws) {
3850
3973
  let childUsesHeldKey = false;
3851
3974
  // Which credential the running app-server started with (launchFor().credential).
3852
3975
  let childCredential = null;
3976
+ // Which Windows sandbox it started with (launchFor().windowsSandbox, the Admin sandbox setting).
3977
+ let childWindowsSandbox = null;
3853
3978
  const CODEX_GREETING_MARKER_START = '[SYNABUN_CODEX_GREETING_START]';
3854
3979
  const CODEX_GREETING_MARKER_END = '[SYNABUN_CODEX_GREETING_END]';
3855
3980
  const CODEX_PLAN_MODE_PREFIXES = [
@@ -3978,7 +4103,7 @@ CRITICAL — When you need clarification during planning:
3978
4103
  codexSessionSettings.set(activeAccountId, threadId, settings);
3979
4104
  }
3980
4105
  if (['skills_config_write', 'experimental_features_set'].includes(type)) {
3981
- if (type === 'experimental_features_set') codexContextModels = [];
4106
+ if (type === 'experimental_features_set') { codexContextModels = []; codexModelListsChanged(null); }
3982
4107
  sendToClient({ type: 'catalogs_changed', catalogs: type === 'skills_config_write' ? ['skills'] : ['models', 'skills', 'apps', 'mcp', 'features'] });
3983
4108
  }
3984
4109
  }
@@ -4092,12 +4217,12 @@ CRITICAL — When you need clarification during planning:
4092
4217
  sendToClient({ type: 'account_state', accountId: activeAccountId, account: _codexAccounts().describeForClient(activeAccountId), accounts: listCodexAccountsForClient() });
4093
4218
  }
4094
4219
 
4095
- // The account's credential or mode changed (withCodexAccountCredentialChange): an idle
4096
- // app-server is retired, and the next request starts one under the new credential; the
4097
- // conversation resumes from its thread then. The transcript stays.
4220
+ // The account's credential or mode changed (withCodexAccountCredentialChange), or the Admin
4221
+ // sandbox setting did: an idle app-server is retired, and the next request starts one under
4222
+ // the new launch plan; the conversation resumes from its thread then. The transcript stays.
4098
4223
  function retireForCredentialChange() {
4099
4224
  const next = _codexAccounts().launchFor(activeAccountId);
4100
- if (child && !activeTurnId && !pendingServerRequests.size && (!next.ok || next.credential !== childCredential)) {
4225
+ if (child && !activeTurnId && !pendingServerRequests.size && (!next.ok || next.credential !== childCredential || next.windowsSandbox !== childWindowsSandbox)) {
4101
4226
  const old = child;
4102
4227
  const oldHome = activeCodexHome;
4103
4228
  const oldThreadIds = ownedWriterThreadIds();
@@ -4118,6 +4243,7 @@ CRITICAL — When you need clarification during planning:
4118
4243
  unregisterCodexMcpProfileRuntime();
4119
4244
  child = null;
4120
4245
  childCredential = null;
4246
+ childWindowsSandbox = null;
4121
4247
  retireCodexProcess(old, { codexHome: oldHome, threadIds: oldThreadIds, reason: 'Codex account sign-in changed' });
4122
4248
  }
4123
4249
  sendAccountState();
@@ -4488,9 +4614,10 @@ CRITICAL — When you need clarification during planning:
4488
4614
  }
4489
4615
 
4490
4616
  async function readSettingsMetadata() {
4491
- 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());
4492
4619
  const features = await listCodexPages(request, 'experimentalFeature/list', { limit: 100 });
4493
- const featureSupport = readCodexFeatureSupport(getCodexBin(), features.data);
4620
+ const featureSupport = await readCodexFeatureSupport(getCodexBin(), features.data);
4494
4621
  // The catalog's enabled field is a process-start snapshot. config/read is
4495
4622
  // authoritative for saved defaults after reloadUserConfig writes.
4496
4623
  return { ...schema, features: features.data.map(feature => ({ ...feature,
@@ -5183,7 +5310,8 @@ CRITICAL — When you need clarification during planning:
5183
5310
  args.unshift(codexBin);
5184
5311
  codexBin = process.execPath;
5185
5312
  }
5186
- // API-key mode with a key SynaBun holds: an ephemeral credential store, before the subcommand.
5313
+ // The Windows sandbox the Admin sandbox setting allows and, in API-key mode with a key SynaBun
5314
+ // holds, an ephemeral credential store: before the subcommand.
5187
5315
  args.splice(args.length - 1, 0, ...codexAppServerArgs(launch));
5188
5316
  const useShell = !codexBin.includes(sep)
5189
5317
  || (process.platform === 'win32' && /\.(cmd|bat)$/i.test(codexBin));
@@ -5212,10 +5340,11 @@ CRITICAL — When you need clarification during planning:
5212
5340
  childApiKeyLogin = launch.apiKey || null;
5213
5341
  childUsesHeldKey = !!launch.apiKey;
5214
5342
  childCredential = launch.credential;
5343
+ childWindowsSandbox = launch.windowsSandbox;
5215
5344
  childRetirementPromise = null;
5216
5345
  writerLifecycleState.releaseRequested = false;
5217
5346
  registerCodexMcpProfileRuntime();
5218
- console.log(`[codex-skin] Spawned app-server pid ${proc.pid || '?'} account=${activeAccountId} mode=${launch.mode || 'none'} profile=${codexMcpProfileState.value} runtime=${codexMcpRuntimeId} CODEX_HOME=${activeCodexHome}`);
5347
+ console.log(`[codex-skin] Spawned app-server pid ${proc.pid || '?'} account=${activeAccountId} mode=${launch.mode || 'none'} windowsSandbox=${launch.windowsSandbox} profile=${codexMcpProfileState.value} runtime=${codexMcpRuntimeId} CODEX_HOME=${activeCodexHome}`);
5219
5348
  proc.stdout.setEncoding('utf-8');
5220
5349
  proc.stdout.on('data', (chunk) => {
5221
5350
  if (child !== proc) return; // stale process from a previous account
@@ -5479,13 +5608,13 @@ CRITICAL — When you need clarification during planning:
5479
5608
  // Sync tool approvals only after the base transport is known to be valid.
5480
5609
  syncCodexToolPermissions([..._codexPermittedTools], activeCodexHome);
5481
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).
5482
5614
  let cliVersion = null;
5483
5615
  let sdkVersion = null;
5484
- try {
5485
- const versionResult = spawnSync(codexBin, ['--version'], { encoding: 'utf-8', timeout: 5000, windowsHide: true });
5486
- cliVersion = parseCodexVersion(versionResult.stdout);
5487
- } catch {}
5488
- try { sdkVersion = JSON.parse(readFileSync(join(__dirname, 'node_modules', '@openai', 'codex-sdk', 'package.json'), 'utf-8')).version; } catch {}
5616
+ try { cliVersion = parseCodexVersion(cliResolver.launcher('codex').version); } catch {}
5617
+ sdkVersion = findInstalled(__dirname, '@openai/codex-sdk', { stopAt: PACKAGE_ROOT })?.version || null;
5489
5618
  codexCapabilities.reset({ codexBin, codexBinSource: _codexBinSource, cliVersion, sdkVersion });
5490
5619
  startChild();
5491
5620
  bootPromise = request('initialize', {
@@ -6372,11 +6501,18 @@ CRITICAL — When you need clarification during planning:
6372
6501
  }
6373
6502
  return;
6374
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
+ }
6375
6509
  if (await handleCodexCompatMessage(msg, {
6376
6510
  activeThreadId, activeTurnId, cwd: PACKAGE_ROOT,
6377
6511
  role: codexRole, getActiveThreadId: () => activeThreadId,
6378
6512
  sandboxPolicyForMode: (mode, cwd) => {
6379
- 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);
6380
6516
  return buildCodexSandboxPolicy(cwd, mode);
6381
6517
  },
6382
6518
  limitAppCall: message => appBoundary.consume(activeThreadId, message.originCallId),
@@ -6723,6 +6859,8 @@ CRITICAL — When you need clarification during planning:
6723
6859
  await request('config/batchWrite', { edits, reloadUserConfig: true }, 10000);
6724
6860
  const config = await readConfigState(msg.cwd || PACKAGE_ROOT);
6725
6861
  codexContextModels = [];
6862
+ // config.toml is every account's (shared): their kept model lists are read again.
6863
+ codexModelListsChanged(null);
6726
6864
  sendToClient({ type: 'config_saved', requestId: msg.requestId || null, config });
6727
6865
  sendToClient({ type: 'catalogs_changed', catalogs: ['models', 'skills', 'apps', 'mcp', 'features', 'config'] });
6728
6866
  } catch (err) {
@@ -7373,8 +7511,63 @@ CRITICAL — When you need clarification during planning:
7373
7511
  ws.on('close', handleCodexSocketClose);
7374
7512
  }
7375
7513
 
7376
- const _codexModelListCache = new Map();
7377
- 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
+ }
7378
7571
 
7379
7572
  // `launch` (lib/codex-accounts.js launchFor()): the list is read under the account's own credential,
7380
7573
  // with no inherited one, as its sessions would run (in API-key mode, the key it holds over stdin).
@@ -7382,7 +7575,8 @@ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME, launch
7382
7575
  ensureCanonicalCodexConfig({ strict: true, codexHome: launch?.home || codexHome });
7383
7576
  // The Codex the resolver found (Settings' path first); a command line in Settings ("wsl codex") as written.
7384
7577
  let codexBin = configuredCommandLine('codex') || getCodexBin();
7385
- const args = [...(launch?.ok ? codexAppServerArgs(launch) : []), 'app-server'];
7578
+ // Without an account's launch plan, still the Windows sandbox the Admin sandbox setting allows.
7579
+ const args = [...(launch?.ok ? codexAppServerArgs(launch) : codexSandboxArgs()), 'app-server'];
7386
7580
  if (!/\s/.test(codexBin) && /\.js$/i.test(codexBin)) {
7387
7581
  args.unshift(codexBin);
7388
7582
  codexBin = process.execPath;
@@ -7501,22 +7695,11 @@ app.get('/api/codex/models', async (req, res) => {
7501
7695
  const accountId = String(req.query?.accountId || 'default');
7502
7696
  const account = findCodexAccount(accountId);
7503
7697
  if (!account) return res.status(404).json({ ok: false, error: `Codex account not found: ${accountId}`, models: [] });
7504
- 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.
7505
7700
  const force = req.query?.refresh === '1';
7506
- const cached = _codexModelListCache.get(codexHome);
7507
- if (!force && cached?.models.length && Date.now() - cached.at < CODEX_MODEL_LIST_CACHE_MS) {
7508
- return res.json({ ok: true, source: 'cache', models: cached.models });
7509
- }
7510
- const models = mergeCodexModelContextCapabilities(
7511
- await queryCodexAppServerModels(codexHome, _codexAccounts().launchFor(account.id)),
7512
- readCodexRuntimeModelCatalog(codexHome),
7513
- );
7514
- // The Assistant's catalog is built from the default account's list: when that list
7515
- // changes, its copy is dropped and rebuilt (which tells the open windows).
7516
- const listChanged = codexHome === CODEX_DEFAULT_HOME && !!cached && JSON.stringify(cached.models) !== JSON.stringify(models);
7517
- _codexModelListCache.set(codexHome, { at: Date.now(), models });
7518
- if (listChanged) _assistantCatalog?.refresh?.().catch(() => {});
7519
- 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 : [] });
7520
7703
  } catch (err) {
7521
7704
  res.json({ ok: false, source: 'fallback', error: err.message, models: [] });
7522
7705
  }
@@ -7552,6 +7735,12 @@ let _ocpBaseUrl = () => `http://127.0.0.1:${_ocpPort}`;
7552
7735
  let _ocpStarting = false;
7553
7736
  let _ocpReady = false;
7554
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;
7555
7744
  let _ocpHistoryClearInProgress = false;
7556
7745
 
7557
7746
  // ── OpenCode V2 (clean-slate sidepanel) — client set & bridge ──
@@ -7559,9 +7748,18 @@ const _ocpV2WsClients = new Set(); // all connected /ws/opencode-v2 clients
7559
7748
  let _ocpV2ListenerInstalled = false;
7560
7749
  let _ocpV2ClientSeq = 0;
7561
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();
7562
7759
  let _ocpBootReconcileTried = false;
7563
7760
  let _ocpLastBootAt = 0; // Date.now() when serve process last became ready
7564
7761
  let _ocpAuthReconciling = false; // prevent concurrent reconciliation
7762
+ let _ocpAuthStaleNoted = 0; // the boot whose server SynaBun said it would not restart (once per boot)
7565
7763
 
7566
7764
  // ── Verbose trace logging (opt-in: OCP_VERBOSE=1 to enable) ──
7567
7765
  const OCP_VERBOSE = process.env.OCP_VERBOSE === '1';
@@ -7626,24 +7824,95 @@ function persistMcpToOpencodeConfig(name, mcpConfig) {
7626
7824
  console.log(`[opencode] Persisted MCP server "${name}" to ${configPath}`);
7627
7825
  }
7628
7826
 
7629
- function readOpencodeManagedPid() {
7630
- try {
7631
- const pid = Number(readFileSync(OCP_MANAGED_PID_PATH, 'utf8').trim());
7632
- if (Number.isFinite(pid) && pid > 0) return pid;
7633
- } catch {}
7634
- 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
+ };
7635
7868
  }
7636
7869
 
7637
- function writeOpencodeManagedPid(pid) {
7638
- if (!Number.isFinite(pid) || pid <= 0) return;
7639
- try {
7640
- mkdirSync(dirname(OCP_MANAGED_PID_PATH), { recursive: true });
7641
- writeFileSync(OCP_MANAGED_PID_PATH, `${pid}\n`, 'utf8');
7642
- } 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);
7877
+ }
7878
+
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);
7643
7892
  }
7644
7893
 
7645
- function clearOpencodeManagedPid() {
7646
- try { if (existsSync(OCP_MANAGED_PID_PATH)) unlinkSync(OCP_MANAGED_PID_PATH); } catch {}
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;
7647
7916
  }
7648
7917
 
7649
7918
  function parseOpencodeLogTimestamp(fileName) {
@@ -7686,7 +7955,9 @@ function shouldReconcileOpencodeForStoredAuth() {
7686
7955
  return false;
7687
7956
  }
7688
7957
 
7689
- // 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.
7690
7961
  async function reconcileAuthIfStale() {
7691
7962
  if (_ocpAuthReconciling || !_ocpReady || !_ocpLastBootAt) return false;
7692
7963
  try {
@@ -7694,6 +7965,13 @@ async function reconcileAuthIfStale() {
7694
7965
  if (!existsSync(authPath)) return false;
7695
7966
  const authMtime = statSync(authPath).mtimeMs;
7696
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
+ }
7697
7975
  _ocpAuthReconciling = true;
7698
7976
  console.log('[opencode] auth.json modified after last boot — restarting to pick up new credentials');
7699
7977
  await takeoverOpencodeServer();
@@ -7717,15 +7995,7 @@ function listPortListenerPids(port) {
7717
7995
  return [];
7718
7996
  }
7719
7997
 
7720
- function listOpencodeServePids() {
7721
- if (process.platform === 'win32') return [];
7722
- try {
7723
- const out = execSync('pgrep -f "opencode.*serve"', { encoding: 'utf8' }).trim();
7724
- return out.split('\n').filter(Boolean).map((pid) => Number(pid)).filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid);
7725
- } catch {}
7726
- return [];
7727
- }
7728
-
7998
+ // The raw signal. Only signalOwnedOpencodeProcess calls it, for a process it has just checked is SynaBun's.
7729
7999
  function signalPids(pids, signal) {
7730
8000
  const targeted = [];
7731
8001
  for (const pid of pids) {
@@ -7737,19 +8007,6 @@ function signalPids(pids, signal) {
7737
8007
  return targeted;
7738
8008
  }
7739
8009
 
7740
- async function waitForOpencodeShutdown(timeoutMs = 6000) {
7741
- const deadline = Date.now() + timeoutMs;
7742
- while (Date.now() < deadline) {
7743
- const listenerPids = listPortListenerPids(_ocpPort);
7744
- if (!listenerPids.length) {
7745
- const healthy = await checkOpencodeHealth();
7746
- if (!healthy) return true;
7747
- }
7748
- await new Promise((resolve) => setTimeout(resolve, 250));
7749
- }
7750
- return false;
7751
- }
7752
-
7753
8010
  // ─────────────────────────────────────────────────────────────────────────────
7754
8011
  // OpenCode V2 — clean-slate broadcast bridge & WS handler
7755
8012
  // Pass-through: forwards opencodeV2Client envelopes verbatim to /ws/opencode-v2
@@ -7784,7 +8041,15 @@ const OPENCODE_HISTORY_WRITE_TYPES = new Set([
7784
8041
  ...OPENCODE_V2_WS_MUTATION_TYPES,
7785
8042
  ]);
7786
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
+
7787
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();
7788
8053
  // OpenCode 1.18 mirrors every bus event as a `sync` envelope; the panel acts
7789
8054
  // on the bus event, so relaying the mirror only doubles the traffic.
7790
8055
  if (!shouldRelayOpencodeV2Message(data)) return;
@@ -7826,6 +8091,7 @@ function installOpencodeV2Listener() {
7826
8091
  type: 'server:status',
7827
8092
  status: event?.status || 'unknown',
7828
8093
  version: _ocpVersion,
8094
+ protocol: _ocpProtocol,
7829
8095
  port: event?.port || _ocpPort,
7830
8096
  managed: !!_ocpProc,
7831
8097
  });
@@ -7862,6 +8128,78 @@ function allocOpencodeIsoPort() {
7862
8128
  return null;
7863
8129
  }
7864
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
+
7865
8203
  // Sidepanel variant of setupOpencodeLoopConfig: inject the terminal identity and
7866
8204
  // pinned profile into the MCP env (no browser pins → the child acquires its own
7867
8205
  // tab) and do not add the loop permission bypass — the panel is user-supervised.
@@ -7978,13 +8316,18 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
7978
8316
  for (let i = 0; i < 60 && !entry.ready; i++) await new Promise(r => setTimeout(r, 250));
7979
8317
  return entry.ready ? entry : null;
7980
8318
  }
7981
- const port = allocOpencodeIsoPort();
7982
- if (port == null) { console.warn('[ocp-iso] no free port for isolated serve'); return null; }
7983
8319
  let xdgRoot;
7984
8320
  try { xdgRoot = requestedXdgRoot || setupOpencodeSidepanelConfig(termId, requestedProfile); }
7985
8321
  catch (err) { console.warn(`[ocp-iso] config setup failed: ${err.message}`); return null; }
7986
- 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() };
7987
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;
7988
8331
  try {
7989
8332
  const isolatedEnv = {
7990
8333
  ...process.env,
@@ -8003,33 +8346,85 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
8003
8346
  };
8004
8347
  delete isolatedEnv.CLAUDECODE;
8005
8348
  if (!brainCredential) delete isolatedEnv.SYNABUN_BRAIN_TOKEN;
8006
- const launch = opencodeServeLaunch(port, { env: isolatedEnv });
8007
- const proc = spawn(launch.command, launch.args, {
8008
- stdio: ['ignore', 'pipe', 'pipe'],
8009
- ...launch.options,
8010
- detached: false, shell: false,
8011
- });
8012
- 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();
8013
8352
  const tag = termId.slice(-8);
8014
- proc.stdout?.on('data', (d) => console.log(`[ocp-iso:${tag}:out] ${String(d).trim()}`));
8015
- proc.stderr?.on('data', (d) => console.log(`[ocp-iso:${tag}:err] ${String(d).trim()}`));
8016
- proc.on('exit', (code) => {
8017
- console.log(`[ocp-iso] serve ${tag} exited code=${code}`);
8018
- if (_ocpIsoServes.get(termId)?.proc === proc) stopIsolatedServe(termId);
8019
- });
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
+ });
8020
8385
 
8021
- let healthy = false;
8022
- for (let i = 0; i < 60; i++) {
8023
- await new Promise(r => setTimeout(r, 250));
8024
- if (!_ocpIsoServes.has(termId)) return null; // torn down mid-boot
8025
- try {
8026
- const resp = await fetch(`http://127.0.0.1:${port}/global/health`, { signal: AbortSignal.timeout(3000) });
8027
- if (resp.ok) { healthy = true; break; }
8028
- } 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;
8029
8422
  }
8030
- 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;
8031
8426
 
8032
- const client = opencodeV2Client.createClientInstance({ port });
8427
+ const client = opencodeV2Client.createClientInstance({ port, protocol: entry.protocol, password: entry.password, version: entry.version || '' });
8033
8428
  entry.client = client;
8034
8429
  entry.unsub = client.onEvent((envelope) => {
8035
8430
  if (!envelope) return;
@@ -8057,6 +8452,7 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
8057
8452
  });
8058
8453
  client.start();
8059
8454
  await client.waitUntilConnected?.(10_000);
8455
+ if (!wanted()) { endIsolatedStart(termId, entry); return null; }
8060
8456
  entry.ready = true;
8061
8457
  entry.starting = false;
8062
8458
  entry.lastUsed = Date.now();
@@ -8064,7 +8460,7 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
8064
8460
  return entry;
8065
8461
  } catch (err) {
8066
8462
  console.warn(`[ocp-iso] spawn failed: ${err.message}`);
8067
- stopIsolatedServe(termId);
8463
+ endIsolatedStart(termId, entry);
8068
8464
  return null;
8069
8465
  }
8070
8466
  }
@@ -8083,19 +8479,7 @@ function stopIsolatedServe(termId, { preserveBindings = false } = {}) {
8083
8479
  }
8084
8480
  try { entry.unsub?.(); } catch {}
8085
8481
  try { entry.client?.stop(); } catch {}
8086
- try {
8087
- if (entry.proc && !entry.proc.killed) {
8088
- if (IS_WIN && entry.proc.pid) {
8089
- spawn('taskkill', ['/pid', String(entry.proc.pid), '/T', '/F'], {
8090
- shell: true,
8091
- windowsHide: true,
8092
- stdio: 'ignore',
8093
- });
8094
- } else {
8095
- entry.proc.kill('SIGTERM');
8096
- }
8097
- }
8098
- } catch {}
8482
+ killIsolatedServeProcess(entry.proc);
8099
8483
  try { if (entry.xdgRoot && existsSync(entry.xdgRoot)) rmSync(entry.xdgRoot, { recursive: true, force: true }); } catch {}
8100
8484
  }
8101
8485
 
@@ -8318,7 +8702,7 @@ function handleOpencodeV2Ws(ws) {
8318
8702
  }
8319
8703
  if (type === 'identify') {
8320
8704
  // Sent on every (re)connect: the panel gates features on this list.
8321
- send({ type: 'capabilities', capabilities: opencodeV2WsCapabilities() });
8705
+ send({ type: 'capabilities', capabilities: opencodeV2WsCapabilities({ protocol: _ocpProtocol }) });
8322
8706
  return;
8323
8707
  }
8324
8708
  // Canonical session id first (msg.sessionId is rewritten in place), then
@@ -8344,11 +8728,14 @@ function handleOpencodeV2Ws(ws) {
8344
8728
  id,
8345
8729
  ready,
8346
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 }),
8347
8734
  sdkVersion: opencodeSdkVersion(),
8348
8735
  port: _ocpPort,
8349
8736
  managed: !!_ocpProc,
8350
8737
  connected: opencodeV2Client.isConnected(),
8351
- capabilities: opencodeV2WsCapabilities(),
8738
+ capabilities: opencodeV2WsCapabilities({ protocol: _ocpProtocol }),
8352
8739
  });
8353
8740
  break;
8354
8741
  }
@@ -8359,11 +8746,14 @@ function handleOpencodeV2Ws(ws) {
8359
8746
  // registered project. Reading the DB directly returns ALL sessions
8360
8747
  // so the sidepanel resume dropdown and boot tab-restore can
8361
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.
8362
8751
  const db = getOpencodeDb();
8363
- if (db) {
8752
+ const flavor = db ? opencodeDbFlavor(db, _ocpProtocol) : null;
8753
+ if (db && flavor) {
8364
8754
  try {
8365
8755
  // msg.options: search / archived / roots / limit (see buildSessionListQuery).
8366
- const query = buildSessionListQuery(msg.options || {});
8756
+ const query = buildSessionListQuery(msg.options || {}, { flavor });
8367
8757
  const rows = db.prepare(query.sql).all(...query.params);
8368
8758
  const sessions = rows.filter((row) => {
8369
8759
  const run = activeNativeOpenCodeRunForSession(row.id);
@@ -8586,30 +8976,37 @@ function handleOpencodeV2Ws(ws) {
8586
8976
  });
8587
8977
  }
8588
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;
8589
8984
  async function checkOpencodeHealth() {
8590
- try {
8591
- const resp = await fetch(`${_ocpBaseUrl()}/global/health`, { signal: AbortSignal.timeout(3000) });
8592
- if (resp.ok) {
8593
- const body = await resp.json().catch(() => null);
8594
- _ocpVersion = body?.version || null;
8595
- return true;
8596
- }
8597
- } catch {}
8598
- 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;
8599
8992
  }
8600
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.
8601
8997
  async function reconcileOpencodeServerIfStale() {
8602
- const listenerPids = listPortListenerPids(_ocpPort);
8603
- const managedPid = readOpencodeManagedPid();
8604
- const shouldReconcile = !_ocpBootReconcileTried && (
8605
- (managedPid && listenerPids.includes(managedPid)) ||
8606
- shouldReconcileOpencodeForStoredAuth()
8607
- );
8608
- 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;
8609
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
+ }
8610
9007
  _ocpReady = false;
8611
9008
  _ocpStarting = false;
8612
- 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`);
8613
9010
  await takeoverOpencodeServer();
8614
9011
  return true;
8615
9012
  }
@@ -8641,6 +9038,65 @@ async function prewarmOpencodeMcpServers() {
8641
9038
  }
8642
9039
  }
8643
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
+
8644
9100
  async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
8645
9101
  if (_ocpHistoryClearInProgress && !allowDuringHistoryClear) return false;
8646
9102
  if (_ocpReady) return true;
@@ -8653,6 +9109,23 @@ async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
8653
9109
  }
8654
9110
  _ocpStarting = true;
8655
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
+
8656
9129
  // 1. Check if already running externally
8657
9130
  if (await checkOpencodeHealth()) {
8658
9131
  if (await reconcileOpencodeServerIfStale()) return true;
@@ -8663,66 +9136,111 @@ async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
8663
9136
  // One event stream on this serve: the v2 client's. MCP prewarm runs on
8664
9137
  // the v2 client too.
8665
9138
  installOpencodeV2Listener();
8666
- await opencodeV2Client.connect({ port: _ocpPort });
8667
- broadcastToOpencodeV2Clients({ type: 'server:status', status: 'ready', version: _ocpVersion, port: _ocpPort, managed: false });
8668
- 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)`);
8669
9142
  prewarmOpencodeMcpServers();
8670
9143
  return true;
8671
9144
  }
9145
+ if (_ocpLastCheck && _ocpLastCheck.state !== 'down' && !(await freeSharedOpencodePortIfHeld(_ocpLastCheck))) {
9146
+ _ocpStarting = false;
9147
+ reportOpencodeProblem(opencodeProblemText({ check: _ocpLastCheck, port: _ocpPort }));
9148
+ return false;
9149
+ }
8672
9150
 
8673
- // 2. Spawn opencode serve
8674
- try {
8675
- const launch = opencodeServeLaunch(_ocpPort, { env: { ...process.env, FORCE_COLOR: '0', OPENCODE_DISABLE_CLAUDE_CODE: '1' } });
8676
- console.log(`[opencode] Spawning: ${launch.label}`);
8677
- _ocpProc = spawn(launch.command, launch.args, {
8678
- stdio: ['ignore', 'pipe', 'pipe'],
8679
- ...launch.options,
8680
- detached: false,
8681
- shell: false,
8682
- });
8683
- _ocpProc.stdout?.on('data', (d) => console.log(`[opencode:stdout] ${d.toString().trim()}`));
8684
- _ocpProc.stderr?.on('data', (d) => console.log(`[opencode:stderr] ${d.toString().trim()}`));
8685
- _ocpProc.on('exit', (code) => {
8686
- console.log(`[opencode] Process exited with code ${code}`);
8687
- _ocpProc = null;
8688
- _ocpReady = false;
8689
- clearOpencodeManagedPid();
8690
- opencodeV2Client.disconnect().catch(() => {});
8691
- 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';
8692
9210
  });
8693
- } catch (err) {
8694
- 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') {
8695
9215
  _ocpStarting = false;
8696
9216
  return false;
8697
9217
  }
8698
-
8699
- // 3. Poll until ready (max 15s)
8700
- for (let i = 0; i < 30; i++) {
8701
- await new Promise(r => setTimeout(r, 500));
8702
- if (await checkOpencodeHealth()) {
8703
- _ocpReady = true;
8704
- _ocpStarting = false;
8705
- _ocpBootReconcileTried = true;
8706
- _ocpLastBootAt = Date.now();
8707
- writeOpencodeManagedPid(listPortListenerPids(_ocpPort)[0] || _ocpProc?.pid);
8708
- installOpencodeV2Listener();
8709
- await opencodeV2Client.connect({ port: _ocpPort });
8710
- broadcastToOpencodeV2Clients({ type: 'server:status', status: 'ready', version: _ocpVersion, port: _ocpPort, managed: true });
8711
- console.log(`[opencode] Server ready on port ${_ocpPort} (v${_ocpVersion || '?'})`);
8712
- prewarmOpencodeMcpServers();
8713
- return true;
8714
- }
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;
8715
9229
  }
8716
9230
 
8717
- console.error('[opencode] Server did not become healthy within 15s');
8718
- 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}`);
8719
9237
  _ocpStarting = false;
9238
+ reportOpencodeProblem(why);
8720
9239
  return false;
8721
9240
  }
8722
9241
 
8723
9242
  function stopOpencodeServer({ stopManagedOllama = true } = {}) {
8724
9243
  opencodeV2Client.disconnect().catch(() => {});
8725
- const managedPid = readOpencodeManagedPid();
8726
9244
  const procPid = _ocpProc?.pid || null;
8727
9245
  const isWin = process.platform === 'win32';
8728
9246
 
@@ -8748,14 +9266,16 @@ function stopOpencodeServer({ stopManagedOllama = true } = {}) {
8748
9266
  }
8749
9267
  _ocpProc = null;
8750
9268
  }
8751
- if (managedPid && managedPid !== procPid) {
8752
- if (isWin) {
8753
- winKill(managedPid);
8754
- } else {
8755
- signalPids([managedPid], 'SIGTERM');
8756
- }
8757
- }
8758
- 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();
8759
9279
  _ocpReady = false;
8760
9280
  _ocpStarting = false;
8761
9281
  broadcastToOpencodeV2Clients({ type: 'server:status', status: 'offline', port: _ocpPort });
@@ -8765,7 +9285,13 @@ function stopOpencodeServer({ stopManagedOllama = true } = {}) {
8765
9285
  }
8766
9286
  }
8767
9287
 
8768
- // 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.
8769
9295
  async function takeoverOpencodeServer() {
8770
9296
  if (_ocpHistoryClearInProgress) throw new Error('OpenCode history is being cleared');
8771
9297
  if (_ocpProc) {
@@ -8774,32 +9300,23 @@ async function takeoverOpencodeServer() {
8774
9300
  await new Promise(r => setTimeout(r, 1000));
8775
9301
  return ensureOpencodeServer();
8776
9302
  }
8777
- // External server — terminate the actual port listener so we can take over with
8778
- // a managed process that will boot from the updated auth/config files.
8779
9303
  _ocpReady = false;
8780
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
+ }
8781
9313
  try {
8782
9314
  await ocpProxy('POST', '/global/dispose');
8783
9315
  } catch {}
8784
- const initialPortPids = listPortListenerPids(_ocpPort);
8785
- const commandPids = listOpencodeServePids();
8786
- const candidatePids = [...new Set([...initialPortPids, ...commandPids])];
8787
- if (candidatePids.length) {
8788
- const terminated = signalPids(candidatePids, 'SIGTERM');
8789
- console.log(`[opencode] Sent SIGTERM to external server PIDs: ${terminated.join(', ') || 'none'}`);
8790
- } else {
8791
- console.log(`[opencode] No external listener PIDs found on port ${_ocpPort}; waiting for shutdown`);
8792
- }
8793
- if (!(await waitForOpencodeShutdown())) {
8794
- const stubbornPids = [...new Set([...listPortListenerPids(_ocpPort), ...listOpencodeServePids()])];
8795
- if (stubbornPids.length) {
8796
- const killed = signalPids(stubbornPids, 'SIGKILL');
8797
- console.log(`[opencode] Sent SIGKILL to stubborn external PIDs: ${killed.join(', ') || 'none'}`);
8798
- }
8799
- await waitForOpencodeShutdown(4000);
8800
- }
8801
- if (await checkOpencodeHealth()) {
8802
- 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`);
8803
9320
  }
8804
9321
  await new Promise(r => setTimeout(r, 500));
8805
9322
  return ensureOpencodeServer();
@@ -8835,7 +9352,43 @@ function getOpencodeDb() {
8835
9352
 
8836
9353
  // ── Proxy helper: forward REST calls to opencode serve ──
8837
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
+
8838
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
+ }
8839
9392
  const url = `${baseUrl}${path}`;
8840
9393
  const opts = {
8841
9394
  method,
@@ -9067,6 +9620,7 @@ app.post('/api/sidepanel/session-title', async (req, res) => {
9067
9620
  execFile: execFileAsync,
9068
9621
  getCodexBin,
9069
9622
  getCodexAccountHome,
9623
+ codexConfigArgs: codexSandboxArgs,
9070
9624
  getAugmentedPath,
9071
9625
  ensureOpenCode: ensureOpencodeServer,
9072
9626
  openCodeClient: opencodeV2Client,
@@ -9125,40 +9679,29 @@ async function opencodePortIsAlive(port) {
9125
9679
  }
9126
9680
  }
9127
9681
 
9128
- function opencodeProcessIsAlive(pid) {
9129
- if (!Number.isFinite(pid) || pid <= 0 || pid === process.pid) return false;
9130
- try {
9131
- process.kill(pid, 0);
9132
- return true;
9133
- } catch {
9134
- return false;
9135
- }
9136
- }
9137
-
9138
- async function waitForOpencodeProcessesToStop(ports, pids, timeoutMs = 8000) {
9682
+ async function waitForOpencodeProcessesToStop(ports, stopped, timeoutMs = 8000) {
9139
9683
  const uniquePorts = [...new Set(ports.filter((port) => Number.isFinite(port) && port > 0))];
9140
- const uniquePids = [...new Set(pids.filter((pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid))];
9141
9684
  const deadline = Date.now() + timeoutMs;
9142
9685
  while (Date.now() < deadline) {
9143
9686
  const alive = await Promise.all(uniquePorts.map(opencodePortIsAlive));
9144
- if (!alive.some(Boolean) && !uniquePids.some(opencodeProcessIsAlive)) return true;
9687
+ if (!alive.some(Boolean) && stopped()) return true;
9145
9688
  await new Promise((resolveWait) => setTimeout(resolveWait, 250));
9146
9689
  }
9147
9690
  return false;
9148
9691
  }
9149
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.
9150
9696
  async function stopOpenCodeForHistoryClear() {
9151
- const isolated = [..._ocpIsoServes.values()].map((entry) => ({
9152
- port: entry.port,
9153
- pid: entry.proc?.pid || null,
9154
- }));
9697
+ const isolated = [..._ocpIsoServes.values()].map((entry) => ({ port: entry.port, proc: entry.proc || null }));
9155
9698
  const ports = [_ocpPort, ...isolated.map((entry) => entry.port)];
9156
- const exactPids = new Set([
9157
- _ocpProc?.pid,
9158
- readOpencodeManagedPid(),
9159
- ...isolated.map((entry) => entry.pid),
9160
- ...ports.flatMap((port) => listPortListenerPids(port)),
9161
- ].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();
9162
9705
 
9163
9706
  await Promise.allSettled(isolated.map(({ port }) => fetch(
9164
9707
  `http://127.0.0.1:${port}/global/dispose`,
@@ -9168,21 +9711,23 @@ async function stopOpenCodeForHistoryClear() {
9168
9711
  stopAllIsolatedServes();
9169
9712
  stopOpencodeServer({ stopManagedOllama: false });
9170
9713
 
9171
- if (await waitForOpencodeProcessesToStop(ports, [...exactPids])) return;
9172
- if (process.platform !== 'win32' && exactPids.size) {
9173
- signalPids([...exactPids], 'SIGKILL');
9174
- 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);
9175
9719
  }
9176
9720
  const stillAlive = (await Promise.all(ports.map(async (port) => (
9177
9721
  (await opencodePortIsAlive(port)) ? port : null
9178
9722
  )))).filter(Boolean);
9179
- const stillRunningPids = [...exactPids].filter(opencodeProcessIsAlive);
9723
+ const stillRunningPids = [...children.filter((child) => !gone(child)).map((child) => child.pid), ...(ownedRuns() ? [owned.pid] : [])];
9180
9724
  if (stillAlive.length || stillRunningPids.length) {
9181
9725
  const details = [
9182
9726
  ...stillAlive.map((port) => `port ${port}`),
9183
9727
  ...stillRunningPids.map((pid) => `process ${pid}`),
9184
9728
  ];
9185
- 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}`);
9186
9731
  }
9187
9732
  }
9188
9733
 
@@ -9225,6 +9770,12 @@ app.delete('/api/opencode/history', async (req, res) => {
9225
9770
  blockers,
9226
9771
  });
9227
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
+ }
9228
9779
 
9229
9780
  wasRunning = await checkOpencodeHealth();
9230
9781
  await stopOpenCodeForHistoryClear();
@@ -9300,7 +9851,7 @@ app.get('/api/opencode/status', async (req, res) => {
9300
9851
  } else if (!running && _ocpReady) {
9301
9852
  _ocpReady = false;
9302
9853
  }
9303
- 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) }) });
9304
9855
  } catch (err) {
9305
9856
  res.status(500).json({ ok: false, error: err.message });
9306
9857
  }
@@ -9357,14 +9908,108 @@ app.post('/api/opencode/snapshot', (req, res) => {
9357
9908
  }
9358
9909
  });
9359
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
+
9360
10005
  app.get('/api/opencode/providers', async (req, res) => {
9361
10006
  try {
9362
10007
  if (!_ocpReady) return res.json({ ok: false, error: 'OpenCode server not running', data: [] });
9363
- await reconcileAuthIfStale();
9364
- const r = await ocpProxy('GET', '/provider');
9365
- 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');
9366
10010
  } catch (err) {
9367
- 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 });
9368
10013
  }
9369
10014
  });
9370
10015
 
@@ -9404,8 +10049,10 @@ app.get('/api/opencode-v2/status', async (req, res) => {
9404
10049
  running,
9405
10050
  port: _ocpPort,
9406
10051
  version: _ocpVersion,
10052
+ protocol: _ocpProtocol,
9407
10053
  managed: !!_ocpProc,
9408
10054
  connected: opencodeV2Client.isConnected(),
10055
+ ...(running ? {} : { problem: _ocpProblem || (_ocpLastCheck && _ocpLastCheck.state !== 'down' ? opencodeProblemText({ check: _ocpLastCheck, port: _ocpPort }) : null) }),
9409
10056
  });
9410
10057
  } catch (err) {
9411
10058
  res.status(500).json({ ok: false, error: err.message });
@@ -9437,15 +10084,17 @@ app.post('/api/opencode-v2/serve/stop', (req, res) => {
9437
10084
  // Models disabled in Settings → OpenCode (GET / PUT, broadcasts opencode:hidden-models-changed)
9438
10085
  app.use('/api/opencode/hidden-models', createOpencodeHiddenModelsRouter({ store: opencodeHiddenModels, isGuestRequest, broadcastSync }));
9439
10086
 
9440
- // 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.
9441
10089
  app.get('/api/opencode/providers/full', async (req, res) => {
9442
10090
  try {
9443
10091
  if (!_ocpReady) return res.json({ ok: false, error: 'OpenCode server not running', data: {} });
9444
- await reconcileAuthIfStale();
9445
- const r = await ocpProxy('GET', '/provider');
9446
- 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);
9447
10095
  } catch (err) {
9448
- 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 });
9449
10098
  }
9450
10099
  });
9451
10100
 
@@ -9797,6 +10446,8 @@ function _panelClaudeAccounts() {
9797
10446
  // The Assistant's account list and catalog listen to their own event.
9798
10447
  broadcastSync({ type: 'assistant:accounts-changed', provider: 'claude-code', accountId: event.accountId || null, removed: !!event.removed });
9799
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'); }
9800
10451
  } catch {}
9801
10452
  });
9802
10453
  }
@@ -9997,6 +10648,31 @@ function handleClaudeSkinWebSocket(ws) {
9997
10648
 
9998
10649
  // Ask the installed CLI which models this account can run. Falls back to a small
9999
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
+
10000
10676
  async function getClaudeModelsForClient(force = false) {
10001
10677
  try {
10002
10678
  const bin = getClaudeBin();
@@ -10005,7 +10681,9 @@ async function getClaudeModelsForClient(force = false) {
10005
10681
  // installation is worth one line in the log. Cached per binary (a spawn only
10006
10682
  // when the CLI changes).
10007
10683
  checkClaudeCliSkew(bin).catch(() => {});
10008
- 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() });
10009
10687
  if (result?.models?.length) return result;
10010
10688
  } catch (err) {
10011
10689
  console.warn('[claude-models] discovery failed:', err.message);
@@ -10770,11 +11448,15 @@ app.get('/api/images', (req, res) => {
10770
11448
  }
10771
11449
  });
10772
11450
 
10773
- // 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.
10774
11455
  app.get('/api/images/file/:filename', (req, res) => {
10775
11456
  const filename = basename(req.params.filename);
10776
11457
  const fp = join(IMAGES_DIR, filename);
10777
11458
  if (!existsSync(fp)) return res.status(404).json({ error: 'Not found' });
11459
+ setUntrustedFileHeaders(res, filename);
10778
11460
  res.sendFile(fp);
10779
11461
  });
10780
11462
 
@@ -12613,15 +13295,17 @@ function unknownAutomationContextMode(mode) {
12613
13295
  return mode != null && mode !== '' && !['default', 'extended', '1m'].includes(mode);
12614
13296
  }
12615
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.
12616
13300
  async function codexAutomationModels(codexHome) {
12617
13301
  const home = codexHome || CODEX_DEFAULT_HOME;
12618
- const cached = _codexModelListCache.get(home);
12619
- if (cached?.models.length && Date.now() - cached.at < CODEX_MODEL_LIST_CACHE_MS) return cached.models;
12620
- const models = mergeCodexModelContextCapabilities(
12621
- await queryCodexAppServerModels(home), readCodexRuntimeModelCatalog(home),
12622
- );
12623
- _codexModelListCache.set(home, { at: Date.now(), models });
12624
- 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 : [];
12625
13309
  }
12626
13310
 
12627
13311
  async function resolveAutomationLaunchChoice(profile, model, contextMode, codexHome) {
@@ -13497,6 +14181,8 @@ app.post('/api/loop/launch', async (req, res) => {
13497
14181
  mcpProfile: cliMcpProfile,
13498
14182
  codexAccountId: codexAccount?.id || null,
13499
14183
  codexHome: codexAccount ? (codexAccount.home || CODEX_DEFAULT_HOME) : null,
14184
+ // A Codex exec loop keeps the Admin sandbox setting it started with (every iteration is its own `codex exec`).
14185
+ adminSandbox: cliProfile === 'codex' ? adminSandboxOn() : undefined,
13500
14186
  // Continuation mechanism. 'hook': Claude Code ONLY — the Stop/UserPromptSubmit hooks in
13501
14187
  // .claude/ set awaitingNext and attachLoopDriver() drives /clear + re-prompt; if those
13502
14188
  // hooks are absent (e.g. a claude-code task run under a hookless harness) the loop STALLS.
@@ -14495,6 +15181,8 @@ async function launchScheduledLoop(schedule) {
14495
15181
  mcpProfile: cliMcpProfile || null,
14496
15182
  codexAccountId: codexAccount?.id || null,
14497
15183
  codexHome: codexAccount ? (codexAccount.home || CODEX_DEFAULT_HOME) : null,
15184
+ // A Codex exec loop keeps the Admin sandbox setting it started with (every iteration is its own `codex exec`).
15185
+ adminSandbox: cliProfile === 'codex' ? adminSandboxOn() : undefined,
14498
15186
  driverType: nativeRuntime ? 'native' : (cliProfile === 'claude-code' ? 'hook' : 'exec'),
14499
15187
  runtimeType: nativeRuntime ? 'native' : 'pty',
14500
15188
  surface: nativeRuntime ? 'sidepanel' : 'terminal',
@@ -16593,18 +17281,52 @@ async function checkSynabunUpdate() {
16593
17281
  }
16594
17282
 
16595
17283
  app.get('/api/system/version', async (req, res) => {
16596
- const force = req.query.force === '1';
16597
- 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)) {
16598
17289
  try { await checkSynabunUpdate(); } catch {}
16599
- } else {
17290
+ } else if (auto) {
16600
17291
  const age = Date.now() - new Date(_synabunUpdateCache.checkedAt).getTime();
16601
17292
  if (age > 60 * 60 * 1000) {
16602
17293
  checkSynabunUpdate().catch(() => {});
16603
17294
  }
16604
17295
  }
16605
- res.json(_synabunUpdateCache);
17296
+ res.json({ ..._synabunUpdateCache, current: _synabunUpdateCache.current || installedSynabunVersion(), autoCheck: auto });
16606
17297
  });
16607
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
+
16608
17330
  // GET /api/system/about — Settings → About (lib/system-about.js): the app, the runtime, the project's links and
16609
17331
  // every dependency of the product's packages with the version installed, read from the files on disk on the first
16610
17332
  // request and kept for the life of the process. No environment, no token, no setting. Owner only (/api/system is
@@ -17150,12 +17872,13 @@ app.post('/api/system/restore', async (req, res) => {
17150
17872
  const mcpDistCheck = resolve(PACKAGE_ROOT, 'mcp-server', 'dist', 'index.js');
17151
17873
  if (!existsSync(mcpDistCheck)) {
17152
17874
  try {
17153
- // Ensure dev deps are installed for TypeScript
17875
+ // TypeScript is a devDependency of the package root (a development checkout also has it in mcp-server/).
17154
17876
  const mcpDir = resolve(PACKAGE_ROOT, 'mcp-server');
17155
- if (!existsSync(resolve(mcpDir, 'node_modules', 'typescript'))) {
17156
- execSync('npm install', { cwd: mcpDir, stdio: 'pipe', timeout: 180_000, env: withPackagedPathLast({ ...process.env, NODE_ENV: 'development' }) });
17877
+ if (!mcpCompiler(PACKAGE_ROOT)) {
17878
+ const npm = npmCommand(npmInvocation(), fallbackInstallArgs({ includeDev: true }));
17879
+ await execFileAsync(npm.file, npm.args, { cwd: PACKAGE_ROOT, timeout: 600_000, maxBuffer: 16 * 1024 * 1024, shell: npm.shell, windowsHide: true, env: withPackagedPathLast(fallbackInstallEnv({ ...process.env, NODE_ENV: 'development' })) });
17157
17880
  }
17158
- execSync('npm run build', { cwd: mcpDir, stdio: 'pipe', timeout: 60_000, env: withPackagedPathLast(process.env) });
17881
+ await execFileAsync(process.execPath, [mcpCompiler(PACKAGE_ROOT), '-p', mcpDir], { cwd: mcpDir, timeout: 120_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true });
17159
17882
  results.files.push('(auto-built MCP server)');
17160
17883
  } catch (buildErr) {
17161
17884
  results.errors.push(`MCP server auto-build failed: ${buildErr.message}`);
@@ -17422,6 +18145,8 @@ const editorConfigStore = createEditorConfigStore({
17422
18145
  // the first request, stopped after ten idle minutes and at shutdown, never the shared serve), or a
17423
18146
  // user-configured endpoint is, whose key is SYNABUN_EDITOR_AI_KEY in the data home's .env.
17424
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();
17425
18150
  const editorCompletionServe = createOpencodeCompletionEngine({
17426
18151
  dataDir: resolve(DATA_HOME, 'data', 'editor-complete'),
17427
18152
  system: COMPLETION_SYSTEM,
@@ -17431,18 +18156,30 @@ const editorCompletionServe = createOpencodeCompletionEngine({
17431
18156
  },
17432
18157
  // A provider's {file:./key} is relative to that configuration: it is written whole for the serve's own folder.
17433
18158
  userConfigDir: () => dirname(getOpencodeConfigPath()),
17434
- 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(),
17435
18165
  // As the isolated serves are started (opencodeServeLaunch): the CLI the resolver found, through the login shell.
17436
- spawnServe: ({ port, env, cwd }) => {
17437
- 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 });
17438
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 }));
17439
18175
  proc.stderr?.on('data', (d) => console.log(`[editor-complete:err] ${String(d).trim().slice(0, 500)}`));
17440
18176
  return proc;
17441
18177
  },
17442
- 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') => {
17443
18180
  try {
17444
18181
  if (IS_WIN && proc.pid) spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F'], { shell: true, windowsHide: true, stdio: 'ignore' });
17445
- else proc.kill('SIGTERM');
18182
+ else proc.kill(signal);
17446
18183
  } catch { /* already gone */ }
17447
18184
  },
17448
18185
  createClient: opencodeV2Client.createClientInstance,
@@ -17455,8 +18192,8 @@ const editorCompletion = createEditorCompletionService({
17455
18192
  // The model list comes from the shared serve while it runs: listing models starts nothing.
17456
18193
  sharedProviders: async () => {
17457
18194
  if (!_ocpReady) return null;
17458
- const r = await ocpProxy('GET', '/provider', null, 8000);
17459
- 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; }
17460
18197
  },
17461
18198
  isHiddenModel: (id) => opencodeHiddenModels.has(id),
17462
18199
  usage: createUsageStore({
@@ -18421,29 +19158,34 @@ app.post('/api/setup/build', async (req, res) => {
18421
19158
  const mcpDist = resolve(PACKAGE_ROOT, 'mcp-server', 'dist', 'index.js');
18422
19159
  const mcpNeedsBuild = !existsSync(mcpDist);
18423
19160
 
18424
- // Phase 1: npm install for each directory
18425
- for (const dir of dirs) {
18426
- const hasModules = existsSync(resolve(dir.path, 'node_modules'));
18427
- // If MCP server needs building, reinstall with dev deps even if node_modules exists
18428
- const isMcp = dir.name === 'MCP Server';
18429
- const needsDevDeps = isMcp && mcpNeedsBuild && !existsSync(resolve(dir.path, 'node_modules', 'typescript'));
18430
- if (hasModules && !needsDevDeps) {
18431
- send('install', { dir: dir.name, status: 'skipped', output: 'node_modules already present' });
18432
- continue;
18433
- }
18434
-
18435
- send('install', { dir: dir.name, status: 'start' });
19161
+ // Phase 1: the dependencies. `npm i -g synabun` installed them at the package root (lib/runtime-dependencies.js
19162
+ // checks that Node finds them from both parts); only what is missing is installed, once, at the package root,
19163
+ // and never inside a packaged application. A fresh checkout also needs the TypeScript that builds the MCP server.
19164
+ const report = checkRuntimeDependencies({ packageRoot: PACKAGE_ROOT });
19165
+ const compilerMissing = mcpNeedsBuild && !mcpCompiler(PACKAGE_ROOT);
19166
+ if (report.ok && !compilerMissing) {
19167
+ for (const dir of dirs) send('install', { dir: dir.name, status: 'skipped', output: 'dependencies already installed' });
19168
+ } else if (PACKAGED_RUNTIME) {
19169
+ for (const dir of dirs) send('install', { dir: dir.name, status: 'error', output: 'This application is incomplete: reinstall SynaBun.' });
19170
+ send('complete', { ok: false, error: 'The application is incomplete' });
19171
+ return res.end();
19172
+ } else {
19173
+ for (const dir of dirs) send('install', { dir: dir.name, status: 'start' });
18436
19174
  try {
18437
- // The user's own npm when they have one, a packaged application's after it (withPackagedPathLast).
18438
- const { stdout, stderr } = await execAsync('npm install', {
18439
- cwd: dir.path,
18440
- timeout: 180_000,
18441
- env: withPackagedPathLast({ ...process.env, NODE_ENV: 'development' }),
19175
+ // The npm that started SynaBun, else the user's own on PATH, else the one next to this Node (npmInvocation).
19176
+ const npm = npmCommand(npmInvocation(), fallbackInstallArgs({ includeDev: mcpNeedsBuild }));
19177
+ const { stdout } = await execFileAsync(npm.file, npm.args, {
19178
+ cwd: PACKAGE_ROOT,
19179
+ timeout: 600_000,
19180
+ maxBuffer: 16 * 1024 * 1024,
19181
+ shell: npm.shell,
19182
+ windowsHide: true,
19183
+ env: withPackagedPathLast(fallbackInstallEnv({ ...process.env, NODE_ENV: 'development' })),
18442
19184
  });
18443
- send('install', { dir: dir.name, status: 'done', output: tail(stdout) });
19185
+ for (const dir of dirs) send('install', { dir: dir.name, status: 'done', output: tail(stdout) });
18444
19186
  } catch (err) {
18445
- send('install', { dir: dir.name, status: 'error', output: tail(err.stderr || err.message) });
18446
- send('complete', { ok: false, error: `npm install failed for ${dir.name}` });
19187
+ for (const dir of dirs) send('install', { dir: dir.name, status: 'error', output: tail(err.stderr || err.message) });
19188
+ send('complete', { ok: false, error: 'npm install failed in the package root' });
18447
19189
  return res.end();
18448
19190
  }
18449
19191
  }
@@ -18460,10 +19202,14 @@ app.post('/api/setup/build', async (req, res) => {
18460
19202
  } else {
18461
19203
  send('build', { status: 'start' });
18462
19204
  try {
18463
- const { stdout, stderr } = await execAsync('npm run build', {
19205
+ // The TypeScript Node finds from mcp-server/ (the root's devDependency in a fresh checkout), by this Node.
19206
+ const tsc = mcpCompiler(PACKAGE_ROOT);
19207
+ if (!tsc) throw new Error('TypeScript is not installed: npm install in the package root installs it');
19208
+ const { stdout } = await execFileAsync(process.execPath, [tsc, '-p', resolve(PACKAGE_ROOT, 'mcp-server')], {
18464
19209
  cwd: resolve(PACKAGE_ROOT, 'mcp-server'),
18465
- timeout: 60_000,
18466
- env: withPackagedPathLast(process.env),
19210
+ timeout: 120_000,
19211
+ maxBuffer: 16 * 1024 * 1024,
19212
+ windowsHide: true,
18467
19213
  });
18468
19214
  send('build', { status: 'done', output: tail(stdout) });
18469
19215
  } catch (err) {
@@ -20000,11 +20746,13 @@ app.get('/api/opencode/sessions', (req, res) => {
20000
20746
 
20001
20747
  let rows;
20002
20748
  try {
20749
+ // OpenCode 1's tables, or OpenCode 2's (lib/opencode-db-schema.js).
20750
+ const tables = sessionTablesFor(opencodeDbFlavor(db, _ocpProtocol));
20003
20751
  const stmt = db.prepare(`
20004
20752
  SELECT s.id, s.title, s.directory, s.time_created, s.time_updated,
20005
20753
  p.worktree as project_worktree,
20006
- (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) as message_count
20007
- FROM session s
20754
+ ${tables.messageCount} as message_count
20755
+ FROM ${tables.session} s
20008
20756
  LEFT JOIN project p ON s.project_id = p.id
20009
20757
  WHERE s.time_archived IS NULL
20010
20758
  ORDER BY s.time_updated DESC
@@ -20547,7 +21295,7 @@ app.post('/api/session-indexing/mirror', async (req, res) => {
20547
21295
  try {
20548
21296
  await mirrorExistingChunks((event) => {
20549
21297
  try { broadcastSync(event); } catch {}
20550
- });
21298
+ }, { ensureCategory: () => ensureFeatureCategory('conversations') });
20551
21299
  } catch (err) {
20552
21300
  console.error('Mirror error:', err);
20553
21301
  try { broadcastSync({ type: 'mirror:error', error: err.message }); } catch {}
@@ -20686,16 +21434,15 @@ app.post('/api/claude-code/integrations', (req, res) => {
20686
21434
  writeClaudeSettings(filePath, settings);
20687
21435
  }
20688
21436
 
20689
- // Save to registered projects list
21437
+ // Save to registered projects list, and create the new project's default category tree (parent + children)
21438
+ // then, once: a project already registered gets nothing, so a default the user deleted stays deleted
20690
21439
  const projectLabel = label || basename(normalized);
20691
21440
  if (!registered) {
20692
21441
  projects.push({ path: normalized, label: projectLabel });
20693
21442
  saveHookProjects(projects);
21443
+ try { ensureProjectCategories([{ path: normalized, label: projectLabel }]); } catch {}
20694
21444
  }
20695
21445
 
20696
- // Create default category tree for the project (parent + children)
20697
- try { ensureProjectCategories(); } catch {}
20698
-
20699
21446
  // Probe for git "dubious ownership" — signals the UI to show an explicit trust prompt.
20700
21447
  // Only relevant on Windows when the project dir is owned by a different SID than the
20701
21448
  // account running Node (external drives, copied repos, etc.). No-op on Mac/Linux.
@@ -22310,6 +23057,8 @@ app.get('/api/skills-studio/artifact/:id/icon', (req, res) => {
22310
23057
  const mimeMap = { '.png': 'image/png', '.svg': 'image/svg+xml', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' };
22311
23058
  res.setHeader('Content-Type', mimeMap[ext] || 'application/octet-stream');
22312
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]);
22313
23062
  res.send(readConfinedFile(resolveArtifactFile(id).realBase, iconPath, { maxBytes: ICON_MAX_BYTES }));
22314
23063
  } catch (err) {
22315
23064
  res.status(skillsStudioStatus(err)).json({ error: err.message });
@@ -24282,7 +25031,8 @@ function getTerminalProfile(profileId, opts = {}) {
24282
25031
  'claude-code': (m) => `${cmd} --model ${m}`,
24283
25032
  'codex': (m) => `${cmd} --model ${m}`,
24284
25033
  'gemini': (m) => `${cmd} --model ${m}`,
24285
- '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}`),
24286
25036
  };
24287
25037
  const builder = modelMap[profileId];
24288
25038
  if (builder) cmd = builder(quotedModel);
@@ -24299,6 +25049,11 @@ function getTerminalProfile(profileId, opts = {}) {
24299
25049
  cmd = `${cmd} -c model_reasoning_effort="${codexEffort}"`;
24300
25050
  }
24301
25051
  }
25052
+ // Every Codex terminal: the Windows sandbox the Admin sandbox setting allows. A bare value
25053
+ // (Codex reads one that is not TOML as a string), so no shell can strip a quote from it.
25054
+ if (profileId === 'codex') {
25055
+ cmd = `${cmd} -c ${terminalArgument(`windows.sandbox=${codexWindowsSandbox(adminSandboxOn())}`, DEFAULT_SHELL)}`;
25056
+ }
24302
25057
 
24303
25058
  // Loop-only: bypass permission prompts for unattended Claude Code loops.
24304
25059
  // The loop runs in a PTY with no human to answer prompts, so accessing
@@ -25132,6 +25887,9 @@ async function releaseNativeLoopResources(run) {
25132
25887
  reconcileKeepAwake();
25133
25888
  }
25134
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
+
25135
25893
  _nativeLoopRuntime = new NativeLoopRuntime({
25136
25894
  stateDir: LOOP_DIR,
25137
25895
  ledgerPath: resolve(LOOP_DATA_DIR, 'native-loop-runs.json'),
@@ -25142,11 +25900,10 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25142
25900
  judgeGoal: createLoopGoalJudge({ judgeLoopGoal, surfaceConfig: typesafeSurfaceConfig }),
25143
25901
  log: (runId, tag, message, meta) => loopLog(runId, tag, message, meta),
25144
25902
  onEvent: (message) => {
25145
- // OpenCode already streams from its isolated serve into the V2 panel. Claude
25146
- // and Codex SDK events need the shared sync socket to reach their renderers.
25147
- if (message.type !== 'sidepanel:provider-event' || message.event?.provider !== 'opencode') {
25148
- broadcastSync(message);
25149
- }
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);
25150
25907
  },
25151
25908
  onFinish: releaseNativeLoopResources,
25152
25909
  providerFactories: {
@@ -25194,6 +25951,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25194
25951
  extraEnv: { ...(state.extraEnv || {}), ...codexExecEnv(launch) },
25195
25952
  unsetEnv: launch.unset,
25196
25953
  codexHome: launch.home,
25954
+ // The Admin sandbox setting as the run starts: it keeps that Windows sandbox while it lives.
25955
+ adminSandbox: adminSandboxOn(),
25197
25956
  // Native schedules use the same trusted global CLI as the interactive
25198
25957
  // sidepanel; SynaBun carries no Codex for the SDK to find by itself.
25199
25958
  codexPath: getNativeCodexBin(),
@@ -25283,6 +26042,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25283
26042
  return codexAccountAuth().rateLimits(id, opts);
25284
26043
  } });
25285
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);
25286
26047
  claudeAccounts.subscribe(({ accountId }) => accountUsage.invalidate('claude-code', accountId));
25287
26048
  _codexAccounts().subscribe(({ accountId }) => accountUsage.invalidate('codex', accountId));
25288
26049
  // A Claude Code worker whose dispatch names no account runs under this one (Assistant → accounts).
@@ -25350,6 +26111,12 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25350
26111
  });
25351
26112
  const assistantUsage = createUsageLedger({ dataDir: resolve(DATA_HOME, 'data'), log: assistantLog, judgments });
25352
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
+ }
25353
26120
  try {
25354
26121
  const response = await fetch(`http://127.0.0.1:${PORT}${path}`, { signal: AbortSignal.timeout(4000) });
25355
26122
  return response.ok ? await response.json() : null;
@@ -25377,9 +26144,13 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25377
26144
  reconcileHidden: (args) => assistantHiddenModels.reconcile(args),
25378
26145
  // A provider lists other models than before: every open window reloads its menus.
25379
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,
25380
26149
  log: (tag, message) => assistantLog(tag, message),
25381
26150
  });
25382
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);
25383
26154
  // A Models toggle or a Settings → OpenCode checkbox reaches routing on the next call.
25384
26155
  assistantHiddenModels.onChange((change) => {
25385
26156
  // What a catalog build settled about a hidden alias is already in that build. When it
@@ -25549,7 +26320,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25549
26320
  app.use('/api/assistant/hidden-models', createAssistantHiddenModelsRouter({
25550
26321
  hiddenModels: assistantHiddenModels, catalog: assistantCatalog, opencodeStore: opencodeHiddenModels, isGuestRequest, broadcastSync,
25551
26322
  }));
25552
- 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 }) }; };
25553
26325
  // Clarification: a materially ambiguous request gets 1-3 questions before it
25554
26326
  // is routed; the user's words and answers become the brief every worker of
25555
26327
  // that request receives. Knobs: assistant-config.json `clarify` (read live).
@@ -25738,6 +26510,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25738
26510
  userContext: assistantUserContext,
25739
26511
  // The greeting a conversation opened with (told to the brain once) and when the person was last here.
25740
26512
  greeting: _greeting,
26513
+ // The Admin sandbox setting: every session view shows it (features.adminSandbox gates the panel's control).
26514
+ adminSandbox: _agentSandbox,
25741
26515
  });
25742
26516
  assistantDesktop?.attach?.({ runtime: _assistantRuntime, dispatcher: _assistantDispatcher });
25743
26517
  // Warm the catalog so brain capabilities (vision, tier) are known on first attach,
@@ -25794,6 +26568,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25794
26568
  // router (startWhatsAppLink), and the port for that route's localhost Host check.
25795
26569
  whatsapp: () => _whatsapp,
25796
26570
  port: () => Number(PORT),
26571
+ // GET / PUT /api/assistant/admin-sandbox (the PUT answers UI_ONLY to agents).
26572
+ adminSandbox: _agentSandbox,
25797
26573
  }));
25798
26574
  const shutdownAssistant = () => {
25799
26575
  try { assistantRouter.shutdown(); } catch {}
@@ -26056,6 +26832,9 @@ function prepareExecWrapperFile(terminalSessionId, loopState, taskFile, sentinel
26056
26832
  model: loopState.model,
26057
26833
  effort: loopState.effort,
26058
26834
  mcpEnvOverrides,
26835
+ // The Admin sandbox setting the loop started with (a loop file from before it: the current one).
26836
+ codexConfigArgs: codexWindowsSandboxArgs(typeof loopState.adminSandbox === 'boolean' ? loopState.adminSandbox : adminSandboxOn()),
26837
+ ...(profile === 'opencode' ? { opencodeMajor: knownOpencodeMajor() } : {}),
26059
26838
  });
26060
26839
  const wrapperFile = join(
26061
26840
  os.tmpdir(),
@@ -26781,6 +27560,7 @@ const providerPanelTerminalAuth = createProviderPanelTerminalAuth({
26781
27560
  provider, accountId, claudeAccounts: _panelClaudeAccounts(), codexAccount: findCodexAccount,
26782
27561
  claudeExecutable: getClaudeSdkExecutable, codexExecutable: getCodexBin,
26783
27562
  env: withPathEnv(process.env, getAugmentedPath()),
27563
+ codexConfigArgs: codexSandboxArgs(),
26784
27564
  }),
26785
27565
  createSession: createTerminalSession,
26786
27566
  whenSpawned: id => terminalHost.whenSpawned(id),
@@ -26855,7 +27635,8 @@ app.post('/api/terminal/sessions', async (req, res) => {
26855
27635
  } catch (err) {
26856
27636
  let msg = err.message;
26857
27637
  if (process.platform !== 'win32' && (msg.includes('posix_spawn') || msg.includes('EACCES'))) {
26858
- msg = `Terminal failed: spawn-helper lacks execute permission. Try: chmod +x neural-interface/node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/spawn-helper`;
27638
+ const ptyDir = findInstalled(__dirname, 'node-pty', { stopAt: PACKAGE_ROOT })?.path || join(__dirname, 'node_modules', 'node-pty');
27639
+ msg = `Terminal failed: spawn-helper lacks execute permission. Try: chmod +x "${join(ptyDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper')}"`;
26859
27640
  }
26860
27641
  return res.status(500).json({ error: msg });
26861
27642
  }
@@ -26937,6 +27718,7 @@ const IS_WINDOWS = process.platform === 'win32';
26937
27718
  let _gitMissingLogged = false;
26938
27719
  let _gitExePath = null; // Cached absolute path — preferring .exe on Windows
26939
27720
  let _gitResolved = false;
27721
+ let _gitResolving = null; // the lookup resolveGitPathInBackground() runs, while it runs
26940
27722
 
26941
27723
  // Resolve git's absolute path at first use. On Windows, `where git` lists all matches;
26942
27724
  // prefer .exe so spawnSync works without cmd.exe quoting issues. On Unix, use `command -v`.
@@ -27002,15 +27784,39 @@ function logGitFailure(where, err) {
27002
27784
  console.warn(`[git] '${where}' failed:`, msg.trim().split('\n')[0]);
27003
27785
  }
27004
27786
 
27005
- // 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.
27006
27811
  setImmediate(() => {
27007
- const probe = spawnGit(['--version'], { timeout: 3000 });
27008
- if (probe.status === 0) {
27009
- console.log(`[git] ${probe.stdout.toString().trim()} — OK`);
27010
- } else {
27011
- const msg = probe.stderr?.toString?.()?.trim() || probe.error?.message || 'unknown error';
27012
- console.warn(`[git] startup probe FAILED: ${msg}. File explorer git UI will be hidden.`);
27013
- }
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(() => {});
27014
27820
  });
27015
27821
 
27016
27822
  // Git "dubious ownership" detection — CVE-2022-24765 safety check refuses to operate on
@@ -27897,7 +28703,9 @@ function spawnSidecarProcess(linkId, sessionId, message) {
27897
28703
  const found = CLI_DEFAULTS[profile] && command === CLI_DEFAULTS[profile].command ? cliResolver.launcher(profile) : null;
27898
28704
  const direct = found?.spec?.kind === 'direct' ? found.launchPath : null;
27899
28705
  let spawnCmd = direct || command;
27900
- let spawnArgs = [...sidecarConfig.args];
28706
+ // Codex: the Windows sandbox the Admin sandbox setting allows, before its flag (a bare value: no quote for a shell to lose).
28707
+ const leadArgs = profile === 'codex' ? ['-c', `windows.sandbox=${codexWindowsSandbox(adminSandboxOn())}`] : [];
28708
+ let spawnArgs = [...leadArgs, ...sidecarConfig.args];
27901
28709
  let useShell = IS_WIN && !direct;
27902
28710
  let tmpFile = null;
27903
28711
 
@@ -27909,10 +28717,11 @@ function spawnSidecarProcess(linkId, sessionId, message) {
27909
28717
  // Use PowerShell which correctly passes $msg as a single argument value.
27910
28718
  tmpFile = join(os.tmpdir(), `synabun-relay-${randomBytes(4).toString('hex')}.txt`);
27911
28719
  writeFileSync(tmpFile, message, 'utf-8');
27912
- const flag = spawnArgs[0]; // e.g. '-p', '-q'
28720
+ const flag = sidecarConfig.args[0]; // e.g. '-p', '-q'
28721
+ const lead = leadArgs.map((word) => ` '${word}'`).join('');
27913
28722
  spawnCmd = 'powershell.exe';
27914
28723
  spawnArgs = ['-NoProfile', '-NonInteractive', '-Command',
27915
- `$msg = Get-Content -Raw '${tmpFile}'; & '${String(direct || command).replace(/'/g, "''")}' ${flag} $msg`];
28724
+ `$msg = Get-Content -Raw '${tmpFile}'; & '${String(direct || command).replace(/'/g, "''")}'${lead} ${flag} $msg`];
27916
28725
  useShell = false;
27917
28726
  } else {
27918
28727
  // Unix: pass directly as the flag's value arg (no shell quoting issues)
@@ -33383,6 +34192,83 @@ app.get('/api/browser/detect-browsers', (req, res) => {
33383
34192
  res.json({ browsers });
33384
34193
  });
33385
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
+
33386
34272
  // --- Start ---
33387
34273
  reloadConfig();
33388
34274
 
@@ -33415,6 +34301,15 @@ const httpServer = app.listen(PORT, async () => {
33415
34301
  else if (!launcher.ok) console.warn(`[server] Start Server launcher not registered: ${launcher.errors[0]}`);
33416
34302
  } catch {}
33417
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
+
33418
34313
  // Auto-heal legacy synabun-symlink plugin state and reinstall via CC CLI
33419
34314
  try {
33420
34315
  const repair = pluginInstaller.repairSynabunPlugins();
@@ -33462,11 +34357,19 @@ const httpServer = app.listen(PORT, async () => {
33462
34357
  // opencode-<sid> node_modules that never got their per-loop cleanup.
33463
34358
  setTimeout(() => { try { cleanupStaleLoopConfigs(); } catch {} }, 1500);
33464
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
+
33465
34365
  // Compare the installed Claude CLI with the release the Agent SDK was built
33466
34366
  // against, once, so an older installation is named in the log before the
33467
34367
  // first session runs on it (checkClaudeCliSkew in lib/claude-model-catalog.js).
33468
34368
  setTimeout(() => { checkClaudeCliSkew(getClaudeBin()).catch(() => {}); }, 1000);
33469
34369
 
34370
+ // The lists windows, menus and Settings wait for, read once in the background (see LAUNCH_TASKS above).
34371
+ startLaunchWarmup();
34372
+
33470
34373
  // Windows: the Git Bash every Claude Code gets (lib/git-bash/). Looked for now (the settings of the user's own
33471
34374
  // claude follow it); a PC with none, and an app that carries none, gets SynaBun's copy, fetched in the background.
33472
34375
  setTimeout(() => {
@@ -33501,7 +34404,7 @@ const httpServer = app.listen(PORT, async () => {
33501
34404
  },
33502
34405
  () => {
33503
34406
  try {
33504
- const oc = rebuildOpencodeCache({ getOpencodeDb });
34407
+ const oc = rebuildOpencodeCache({ getOpencodeDb, protocol: _ocpProtocol });
33505
34408
  if (oc.updated > 0) console.log(` Session cache (opencode): indexed ${oc.updated}/${oc.sessions}`);
33506
34409
  } catch (err) { console.warn(' Session cache (opencode) error:', err.message); }
33507
34410
  },
@@ -33586,8 +34489,12 @@ const httpServer = app.listen(PORT, async () => {
33586
34489
  }
33587
34490
  } catch { /* ok */ }
33588
34491
 
33589
- // Check for SynaBun updates (npm + github releases)
33590
- 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
+ }
33591
34498
 
33592
34499
  // Prune any external CLI tool left in our node_modules before the version
33593
34500
  // check runs — defends against `npm install` not auto-pruning packages that