synabun 2.0.5 → 2.0.9

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 (527) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/README.md +2 -1
  3. package/THIRD-PARTY-LICENSES.md +13 -0
  4. package/docs/whatsapp.md +151 -16
  5. package/hooks/claude-code/post-plan.mjs +6 -30
  6. package/hooks/claude-code/session-start.mjs +1 -4
  7. package/hooks/claude-code/shared.mjs +112 -75
  8. package/lib/child-path.js +303 -0
  9. package/lib/claude-hooks.js +2 -1
  10. package/lib/data-home-migration.js +2 -0
  11. package/lib/desktop-app.js +265 -0
  12. package/lib/external-tools.js +7 -3
  13. package/lib/log-redact.js +102 -0
  14. package/lib/package-processes.js +670 -0
  15. package/lib/packaged-runtime.js +138 -11
  16. package/lib/runtime-dependencies.js +225 -0
  17. package/mcp-server/dist/http.d.ts +3 -1
  18. package/mcp-server/dist/http.d.ts.map +1 -1
  19. package/mcp-server/dist/http.js +15 -4
  20. package/mcp-server/dist/http.js.map +1 -1
  21. package/mcp-server/dist/index.d.ts.map +1 -1
  22. package/mcp-server/dist/index.js +9 -6
  23. package/mcp-server/dist/index.js.map +1 -1
  24. package/mcp-server/dist/services/browser-bench.d.ts +1 -1
  25. package/mcp-server/dist/services/browser-bench.d.ts.map +1 -1
  26. package/mcp-server/dist/services/browser-bench.js +1 -1
  27. package/mcp-server/dist/services/browser-bench.js.map +1 -1
  28. package/mcp-server/dist/services/budget-judgments.d.ts +108 -0
  29. package/mcp-server/dist/services/budget-judgments.d.ts.map +1 -0
  30. package/mcp-server/dist/services/budget-judgments.js +153 -0
  31. package/mcp-server/dist/services/budget-judgments.js.map +1 -0
  32. package/mcp-server/dist/services/categories.d.ts +35 -2
  33. package/mcp-server/dist/services/categories.d.ts.map +1 -1
  34. package/mcp-server/dist/services/categories.js +77 -33
  35. package/mcp-server/dist/services/categories.js.map +1 -1
  36. package/mcp-server/dist/services/codex-config-heal.d.ts +5 -1
  37. package/mcp-server/dist/services/codex-config-heal.d.ts.map +1 -1
  38. package/mcp-server/dist/services/codex-config-heal.js +41 -4
  39. package/mcp-server/dist/services/codex-config-heal.js.map +1 -1
  40. package/mcp-server/dist/services/desktop-bench.d.ts +7 -7
  41. package/mcp-server/dist/services/desktop-bench.d.ts.map +1 -1
  42. package/mcp-server/dist/services/desktop-bench.js +1 -1
  43. package/mcp-server/dist/services/desktop-bench.js.map +1 -1
  44. package/mcp-server/dist/services/identity.d.ts +11 -0
  45. package/mcp-server/dist/services/identity.d.ts.map +1 -1
  46. package/mcp-server/dist/services/identity.js +24 -0
  47. package/mcp-server/dist/services/identity.js.map +1 -1
  48. package/mcp-server/dist/services/neural-interface.d.ts +13 -0
  49. package/mcp-server/dist/services/neural-interface.d.ts.map +1 -1
  50. package/mcp-server/dist/services/neural-interface.js +34 -2
  51. package/mcp-server/dist/services/neural-interface.js.map +1 -1
  52. package/mcp-server/dist/services/typesafe-config.d.ts +1 -1
  53. package/mcp-server/dist/services/typesafe-config.d.ts.map +1 -1
  54. package/mcp-server/dist/services/typesafe-config.js +4 -1
  55. package/mcp-server/dist/services/typesafe-config.js.map +1 -1
  56. package/mcp-server/dist/tools/agents.d.ts +11 -8
  57. package/mcp-server/dist/tools/agents.d.ts.map +1 -1
  58. package/mcp-server/dist/tools/agents.js +35 -14
  59. package/mcp-server/dist/tools/agents.js.map +1 -1
  60. package/mcp-server/dist/tools/bluesky.js +1 -1
  61. package/mcp-server/dist/tools/bluesky.js.map +1 -1
  62. package/mcp-server/dist/tools/browser-advanced.d.ts +1 -1
  63. package/mcp-server/dist/tools/browser-advanced.d.ts.map +1 -1
  64. package/mcp-server/dist/tools/browser-advanced.js +1 -1
  65. package/mcp-server/dist/tools/browser-advanced.js.map +1 -1
  66. package/mcp-server/dist/tools/browser-batch.d.ts +1 -1
  67. package/mcp-server/dist/tools/browser-batch.d.ts.map +1 -1
  68. package/mcp-server/dist/tools/browser-batch.js +1 -1
  69. package/mcp-server/dist/tools/browser-batch.js.map +1 -1
  70. package/mcp-server/dist/tools/browser-cheatsheet.d.ts +1 -1
  71. package/mcp-server/dist/tools/browser-cheatsheet.d.ts.map +1 -1
  72. package/mcp-server/dist/tools/browser-cheatsheet.js +1 -1
  73. package/mcp-server/dist/tools/browser-cheatsheet.js.map +1 -1
  74. package/mcp-server/dist/tools/browser-interact.d.ts +1 -1
  75. package/mcp-server/dist/tools/browser-interact.d.ts.map +1 -1
  76. package/mcp-server/dist/tools/browser-interact.js +1 -1
  77. package/mcp-server/dist/tools/browser-interact.js.map +1 -1
  78. package/mcp-server/dist/tools/browser-navigate.d.ts +1 -1
  79. package/mcp-server/dist/tools/browser-navigate.d.ts.map +1 -1
  80. package/mcp-server/dist/tools/browser-navigate.js +1 -1
  81. package/mcp-server/dist/tools/browser-navigate.js.map +1 -1
  82. package/mcp-server/dist/tools/browser-observe.d.ts +1 -1
  83. package/mcp-server/dist/tools/browser-observe.d.ts.map +1 -1
  84. package/mcp-server/dist/tools/browser-observe.js +1 -1
  85. package/mcp-server/dist/tools/browser-observe.js.map +1 -1
  86. package/mcp-server/dist/tools/card-tools.d.ts +1 -1
  87. package/mcp-server/dist/tools/card-tools.d.ts.map +1 -1
  88. package/mcp-server/dist/tools/card-tools.js +1 -1
  89. package/mcp-server/dist/tools/card-tools.js.map +1 -1
  90. package/mcp-server/dist/tools/category.d.ts +1 -1
  91. package/mcp-server/dist/tools/category.d.ts.map +1 -1
  92. package/mcp-server/dist/tools/category.js +5 -1
  93. package/mcp-server/dist/tools/category.js.map +1 -1
  94. package/mcp-server/dist/tools/choice.d.ts +1 -1
  95. package/mcp-server/dist/tools/choice.d.ts.map +1 -1
  96. package/mcp-server/dist/tools/choice.js +1 -1
  97. package/mcp-server/dist/tools/choice.js.map +1 -1
  98. package/mcp-server/dist/tools/computer.d.ts +1 -1
  99. package/mcp-server/dist/tools/computer.d.ts.map +1 -1
  100. package/mcp-server/dist/tools/computer.js +1 -1
  101. package/mcp-server/dist/tools/computer.js.map +1 -1
  102. package/mcp-server/dist/tools/discord-channel.d.ts +1 -1
  103. package/mcp-server/dist/tools/discord-channel.d.ts.map +1 -1
  104. package/mcp-server/dist/tools/discord-channel.js +1 -1
  105. package/mcp-server/dist/tools/discord-channel.js.map +1 -1
  106. package/mcp-server/dist/tools/discord-guild.d.ts +1 -1
  107. package/mcp-server/dist/tools/discord-guild.d.ts.map +1 -1
  108. package/mcp-server/dist/tools/discord-guild.js +1 -1
  109. package/mcp-server/dist/tools/discord-guild.js.map +1 -1
  110. package/mcp-server/dist/tools/discord-member.d.ts +1 -1
  111. package/mcp-server/dist/tools/discord-member.d.ts.map +1 -1
  112. package/mcp-server/dist/tools/discord-member.js +1 -1
  113. package/mcp-server/dist/tools/discord-member.js.map +1 -1
  114. package/mcp-server/dist/tools/discord-message.d.ts +1 -1
  115. package/mcp-server/dist/tools/discord-message.d.ts.map +1 -1
  116. package/mcp-server/dist/tools/discord-message.js +1 -1
  117. package/mcp-server/dist/tools/discord-message.js.map +1 -1
  118. package/mcp-server/dist/tools/discord-onboarding.d.ts +1 -1
  119. package/mcp-server/dist/tools/discord-onboarding.d.ts.map +1 -1
  120. package/mcp-server/dist/tools/discord-onboarding.js +1 -1
  121. package/mcp-server/dist/tools/discord-onboarding.js.map +1 -1
  122. package/mcp-server/dist/tools/discord-role.d.ts +1 -1
  123. package/mcp-server/dist/tools/discord-role.d.ts.map +1 -1
  124. package/mcp-server/dist/tools/discord-role.js +1 -1
  125. package/mcp-server/dist/tools/discord-role.js.map +1 -1
  126. package/mcp-server/dist/tools/discord-thread.d.ts +1 -1
  127. package/mcp-server/dist/tools/discord-thread.d.ts.map +1 -1
  128. package/mcp-server/dist/tools/discord-thread.js +1 -1
  129. package/mcp-server/dist/tools/discord-thread.js.map +1 -1
  130. package/mcp-server/dist/tools/discord-webhook.d.ts +1 -1
  131. package/mcp-server/dist/tools/discord-webhook.d.ts.map +1 -1
  132. package/mcp-server/dist/tools/discord-webhook.js +1 -1
  133. package/mcp-server/dist/tools/discord-webhook.js.map +1 -1
  134. package/mcp-server/dist/tools/fb-groups.d.ts +1 -1
  135. package/mcp-server/dist/tools/fb-groups.d.ts.map +1 -1
  136. package/mcp-server/dist/tools/fb-groups.js +1 -1
  137. package/mcp-server/dist/tools/fb-groups.js.map +1 -1
  138. package/mcp-server/dist/tools/forget.d.ts +1 -1
  139. package/mcp-server/dist/tools/forget.d.ts.map +1 -1
  140. package/mcp-server/dist/tools/forget.js +1 -1
  141. package/mcp-server/dist/tools/forget.js.map +1 -1
  142. package/mcp-server/dist/tools/git-tools.d.ts +1 -1
  143. package/mcp-server/dist/tools/git-tools.d.ts.map +1 -1
  144. package/mcp-server/dist/tools/git-tools.js +1 -1
  145. package/mcp-server/dist/tools/git-tools.js.map +1 -1
  146. package/mcp-server/dist/tools/gsc/experience.d.ts +1 -1
  147. package/mcp-server/dist/tools/gsc/experience.d.ts.map +1 -1
  148. package/mcp-server/dist/tools/gsc/experience.js +1 -1
  149. package/mcp-server/dist/tools/gsc/experience.js.map +1 -1
  150. package/mcp-server/dist/tools/gsc/indexing.d.ts +1 -1
  151. package/mcp-server/dist/tools/gsc/indexing.d.ts.map +1 -1
  152. package/mcp-server/dist/tools/gsc/indexing.js +1 -1
  153. package/mcp-server/dist/tools/gsc/indexing.js.map +1 -1
  154. package/mcp-server/dist/tools/gsc/inspect.d.ts +1 -1
  155. package/mcp-server/dist/tools/gsc/inspect.d.ts.map +1 -1
  156. package/mcp-server/dist/tools/gsc/inspect.js +1 -1
  157. package/mcp-server/dist/tools/gsc/inspect.js.map +1 -1
  158. package/mcp-server/dist/tools/gsc/navigate.d.ts +1 -1
  159. package/mcp-server/dist/tools/gsc/navigate.d.ts.map +1 -1
  160. package/mcp-server/dist/tools/gsc/navigate.js +1 -1
  161. package/mcp-server/dist/tools/gsc/navigate.js.map +1 -1
  162. package/mcp-server/dist/tools/gsc/performance.d.ts +1 -1
  163. package/mcp-server/dist/tools/gsc/performance.d.ts.map +1 -1
  164. package/mcp-server/dist/tools/gsc/performance.js +1 -1
  165. package/mcp-server/dist/tools/gsc/performance.js.map +1 -1
  166. package/mcp-server/dist/tools/gsc/settings.d.ts +1 -1
  167. package/mcp-server/dist/tools/gsc/settings.d.ts.map +1 -1
  168. package/mcp-server/dist/tools/gsc/settings.js +1 -1
  169. package/mcp-server/dist/tools/gsc/settings.js.map +1 -1
  170. package/mcp-server/dist/tools/image-tools.d.ts +1 -1
  171. package/mcp-server/dist/tools/image-tools.d.ts.map +1 -1
  172. package/mcp-server/dist/tools/image-tools.js +1 -1
  173. package/mcp-server/dist/tools/image-tools.js.map +1 -1
  174. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts +1 -1
  175. package/mcp-server/dist/tools/leonardo-browser-tools.d.ts.map +1 -1
  176. package/mcp-server/dist/tools/leonardo-browser-tools.js +1 -1
  177. package/mcp-server/dist/tools/leonardo-browser-tools.js.map +1 -1
  178. package/mcp-server/dist/tools/loop.d.ts +1 -1
  179. package/mcp-server/dist/tools/loop.d.ts.map +1 -1
  180. package/mcp-server/dist/tools/loop.js +1 -1
  181. package/mcp-server/dist/tools/loop.js.map +1 -1
  182. package/mcp-server/dist/tools/memories.d.ts +1 -1
  183. package/mcp-server/dist/tools/memories.d.ts.map +1 -1
  184. package/mcp-server/dist/tools/memories.js +1 -1
  185. package/mcp-server/dist/tools/memories.js.map +1 -1
  186. package/mcp-server/dist/tools/morelogin.d.ts +1 -1
  187. package/mcp-server/dist/tools/morelogin.d.ts.map +1 -1
  188. package/mcp-server/dist/tools/morelogin.js +1 -1
  189. package/mcp-server/dist/tools/morelogin.js.map +1 -1
  190. package/mcp-server/dist/tools/profile.d.ts +1 -1
  191. package/mcp-server/dist/tools/profile.d.ts.map +1 -1
  192. package/mcp-server/dist/tools/profile.js +1 -1
  193. package/mcp-server/dist/tools/profile.js.map +1 -1
  194. package/mcp-server/dist/tools/recall.d.ts +1 -1
  195. package/mcp-server/dist/tools/recall.d.ts.map +1 -1
  196. package/mcp-server/dist/tools/recall.js +1 -1
  197. package/mcp-server/dist/tools/recall.js.map +1 -1
  198. package/mcp-server/dist/tools/reflect.d.ts +1 -1
  199. package/mcp-server/dist/tools/reflect.d.ts.map +1 -1
  200. package/mcp-server/dist/tools/reflect.js +1 -1
  201. package/mcp-server/dist/tools/reflect.js.map +1 -1
  202. package/mcp-server/dist/tools/remember.d.ts +1 -1
  203. package/mcp-server/dist/tools/remember.d.ts.map +1 -1
  204. package/mcp-server/dist/tools/remember.js +13 -11
  205. package/mcp-server/dist/tools/remember.js.map +1 -1
  206. package/mcp-server/dist/tools/restore.d.ts +1 -1
  207. package/mcp-server/dist/tools/restore.d.ts.map +1 -1
  208. package/mcp-server/dist/tools/restore.js +1 -1
  209. package/mcp-server/dist/tools/restore.js.map +1 -1
  210. package/mcp-server/dist/tools/style-guide-tools.d.ts +1 -1
  211. package/mcp-server/dist/tools/style-guide-tools.d.ts.map +1 -1
  212. package/mcp-server/dist/tools/style-guide-tools.js +1 -1
  213. package/mcp-server/dist/tools/style-guide-tools.js.map +1 -1
  214. package/mcp-server/dist/tools/sync.d.ts +1 -1
  215. package/mcp-server/dist/tools/sync.d.ts.map +1 -1
  216. package/mcp-server/dist/tools/sync.js +1 -1
  217. package/mcp-server/dist/tools/sync.js.map +1 -1
  218. package/mcp-server/dist/tools/tictactoe-tools.d.ts +1 -1
  219. package/mcp-server/dist/tools/tictactoe-tools.d.ts.map +1 -1
  220. package/mcp-server/dist/tools/tictactoe-tools.js +1 -1
  221. package/mcp-server/dist/tools/tictactoe-tools.js.map +1 -1
  222. package/mcp-server/dist/tools/utils.d.ts +1 -1
  223. package/mcp-server/dist/tools/utils.d.ts.map +1 -1
  224. package/mcp-server/dist/tools/utils.js +1 -1
  225. package/mcp-server/dist/tools/utils.js.map +1 -1
  226. package/mcp-server/dist/tools/whatsapp-send-file.d.ts +17 -0
  227. package/mcp-server/dist/tools/whatsapp-send-file.d.ts.map +1 -0
  228. package/mcp-server/dist/tools/whatsapp-send-file.js +44 -0
  229. package/mcp-server/dist/tools/whatsapp-send-file.js.map +1 -0
  230. package/mcp-server/dist/tools/whiteboard-tools.d.ts +3 -3
  231. package/mcp-server/dist/tools/whiteboard-tools.d.ts.map +1 -1
  232. package/mcp-server/dist/tools/whiteboard-tools.js +1 -1
  233. package/mcp-server/dist/tools/whiteboard-tools.js.map +1 -1
  234. package/mcp-server/dist/tools/youtube/analytics.d.ts +1 -1
  235. package/mcp-server/dist/tools/youtube/analytics.d.ts.map +1 -1
  236. package/mcp-server/dist/tools/youtube/analytics.js +1 -1
  237. package/mcp-server/dist/tools/youtube/analytics.js.map +1 -1
  238. package/mcp-server/dist/tools/youtube/download.d.ts +1 -1
  239. package/mcp-server/dist/tools/youtube/download.d.ts.map +1 -1
  240. package/mcp-server/dist/tools/youtube/download.js +1 -1
  241. package/mcp-server/dist/tools/youtube/download.js.map +1 -1
  242. package/mcp-server/dist/tools/youtube/media.d.ts +1 -1
  243. package/mcp-server/dist/tools/youtube/media.d.ts.map +1 -1
  244. package/mcp-server/dist/tools/youtube/media.js +1 -1
  245. package/mcp-server/dist/tools/youtube/media.js.map +1 -1
  246. package/mcp-server/dist/tools/youtube/metadata.d.ts +1 -1
  247. package/mcp-server/dist/tools/youtube/metadata.d.ts.map +1 -1
  248. package/mcp-server/dist/tools/youtube/metadata.js +1 -1
  249. package/mcp-server/dist/tools/youtube/metadata.js.map +1 -1
  250. package/mcp-server/dist/tools/youtube/navigate.d.ts +1 -1
  251. package/mcp-server/dist/tools/youtube/navigate.d.ts.map +1 -1
  252. package/mcp-server/dist/tools/youtube/navigate.js +1 -1
  253. package/mcp-server/dist/tools/youtube/navigate.js.map +1 -1
  254. package/mcp-server/dist/tools/youtube/playlist.d.ts +1 -1
  255. package/mcp-server/dist/tools/youtube/playlist.d.ts.map +1 -1
  256. package/mcp-server/dist/tools/youtube/playlist.js +1 -1
  257. package/mcp-server/dist/tools/youtube/playlist.js.map +1 -1
  258. package/mcp-server/dist/tools/youtube/schedule.d.ts +1 -1
  259. package/mcp-server/dist/tools/youtube/schedule.d.ts.map +1 -1
  260. package/mcp-server/dist/tools/youtube/schedule.js +1 -1
  261. package/mcp-server/dist/tools/youtube/schedule.js.map +1 -1
  262. package/mcp-server/dist/tools/youtube/source.d.ts +1 -1
  263. package/mcp-server/dist/tools/youtube/source.d.ts.map +1 -1
  264. package/mcp-server/dist/tools/youtube/source.js +1 -1
  265. package/mcp-server/dist/tools/youtube/source.js.map +1 -1
  266. package/mcp-server/dist/tools/youtube/upload.d.ts +1 -1
  267. package/mcp-server/dist/tools/youtube/upload.d.ts.map +1 -1
  268. package/mcp-server/dist/tools/youtube/upload.js +4 -9
  269. package/mcp-server/dist/tools/youtube/upload.js.map +1 -1
  270. package/mcp-server/dist/tui.js +18 -5
  271. package/mcp-server/dist/tui.js.map +1 -1
  272. package/mcp-server/dist/version.d.ts +5 -0
  273. package/mcp-server/dist/version.d.ts.map +1 -0
  274. package/mcp-server/dist/version.js +21 -0
  275. package/mcp-server/dist/version.js.map +1 -0
  276. package/mcp-server/package-lock.json +3 -3
  277. package/mcp-server/package.json +3 -3
  278. package/mcp-server/run.mjs +15 -9
  279. package/mcp-server/src/http.ts +18 -3
  280. package/mcp-server/src/index.ts +9 -6
  281. package/mcp-server/src/services/browser-bench.ts +1 -1
  282. package/mcp-server/src/services/budget-judgments.ts +183 -0
  283. package/mcp-server/src/services/categories.ts +77 -32
  284. package/mcp-server/src/services/codex-config-heal.ts +35 -4
  285. package/mcp-server/src/services/desktop-bench.ts +1 -1
  286. package/mcp-server/src/services/identity.ts +27 -0
  287. package/mcp-server/src/services/neural-interface.ts +34 -2
  288. package/mcp-server/src/services/typesafe-config.ts +4 -1
  289. package/mcp-server/src/tools/agents.ts +34 -15
  290. package/mcp-server/src/tools/bluesky.ts +1 -1
  291. package/mcp-server/src/tools/browser-advanced.ts +1 -1
  292. package/mcp-server/src/tools/browser-batch.ts +1 -1
  293. package/mcp-server/src/tools/browser-cheatsheet.ts +1 -1
  294. package/mcp-server/src/tools/browser-interact.ts +1 -1
  295. package/mcp-server/src/tools/browser-navigate.ts +1 -1
  296. package/mcp-server/src/tools/browser-observe.ts +1 -1
  297. package/mcp-server/src/tools/card-tools.ts +1 -1
  298. package/mcp-server/src/tools/category.ts +6 -1
  299. package/mcp-server/src/tools/choice.ts +1 -1
  300. package/mcp-server/src/tools/computer.ts +1 -1
  301. package/mcp-server/src/tools/discord-channel.ts +1 -1
  302. package/mcp-server/src/tools/discord-guild.ts +1 -1
  303. package/mcp-server/src/tools/discord-member.ts +1 -1
  304. package/mcp-server/src/tools/discord-message.ts +1 -1
  305. package/mcp-server/src/tools/discord-onboarding.ts +1 -1
  306. package/mcp-server/src/tools/discord-role.ts +1 -1
  307. package/mcp-server/src/tools/discord-thread.ts +1 -1
  308. package/mcp-server/src/tools/discord-webhook.ts +1 -1
  309. package/mcp-server/src/tools/fb-groups.ts +1 -1
  310. package/mcp-server/src/tools/forget.ts +1 -1
  311. package/mcp-server/src/tools/git-tools.ts +1 -1
  312. package/mcp-server/src/tools/gsc/experience.ts +1 -1
  313. package/mcp-server/src/tools/gsc/indexing.ts +1 -1
  314. package/mcp-server/src/tools/gsc/inspect.ts +1 -1
  315. package/mcp-server/src/tools/gsc/navigate.ts +1 -1
  316. package/mcp-server/src/tools/gsc/performance.ts +1 -1
  317. package/mcp-server/src/tools/gsc/settings.ts +1 -1
  318. package/mcp-server/src/tools/image-tools.ts +1 -1
  319. package/mcp-server/src/tools/leonardo-browser-tools.ts +1 -1
  320. package/mcp-server/src/tools/loop.ts +1 -1
  321. package/mcp-server/src/tools/memories.ts +1 -1
  322. package/mcp-server/src/tools/morelogin.ts +1 -1
  323. package/mcp-server/src/tools/profile.ts +1 -1
  324. package/mcp-server/src/tools/recall.ts +1 -1
  325. package/mcp-server/src/tools/reflect.ts +1 -1
  326. package/mcp-server/src/tools/remember.ts +13 -12
  327. package/mcp-server/src/tools/restore.ts +1 -1
  328. package/mcp-server/src/tools/style-guide-tools.ts +1 -1
  329. package/mcp-server/src/tools/sync.ts +1 -1
  330. package/mcp-server/src/tools/tictactoe-tools.ts +1 -1
  331. package/mcp-server/src/tools/utils.ts +1 -1
  332. package/mcp-server/src/tools/whatsapp-send-file.ts +51 -0
  333. package/mcp-server/src/tools/whiteboard-tools.ts +1 -1
  334. package/mcp-server/src/tools/youtube/analytics.ts +1 -1
  335. package/mcp-server/src/tools/youtube/download.ts +1 -1
  336. package/mcp-server/src/tools/youtube/media.ts +1 -1
  337. package/mcp-server/src/tools/youtube/metadata.ts +1 -1
  338. package/mcp-server/src/tools/youtube/navigate.ts +1 -1
  339. package/mcp-server/src/tools/youtube/playlist.ts +1 -1
  340. package/mcp-server/src/tools/youtube/schedule.ts +1 -1
  341. package/mcp-server/src/tools/youtube/source.ts +1 -1
  342. package/mcp-server/src/tools/youtube/upload.ts +4 -12
  343. package/mcp-server/src/tui.ts +24 -10
  344. package/mcp-server/src/version.ts +22 -0
  345. package/neural-interface/i18n/en.json +645 -57
  346. package/neural-interface/i18n/pt-BR.json +644 -56
  347. package/neural-interface/lib/account-usage.js +163 -0
  348. package/neural-interface/lib/agent-sandbox-elevation.js +102 -0
  349. package/neural-interface/lib/agent-sandbox-setting.js +57 -0
  350. package/neural-interface/lib/assistant-api.js +274 -52
  351. package/neural-interface/lib/assistant-brains/claude.js +15 -2
  352. package/neural-interface/lib/assistant-brains/codex.js +3 -0
  353. package/neural-interface/lib/assistant-brains/opencode.js +17 -4
  354. package/neural-interface/lib/assistant-budget-planner.js +390 -0
  355. package/neural-interface/lib/assistant-budget-split.js +408 -0
  356. package/neural-interface/lib/assistant-budget.js +94 -95
  357. package/neural-interface/lib/assistant-config.js +62 -14
  358. package/neural-interface/lib/assistant-dispatch.js +432 -125
  359. package/neural-interface/lib/assistant-jev.js +26 -3
  360. package/neural-interface/lib/assistant-persona.js +68 -11
  361. package/neural-interface/lib/assistant-plan-permissions.js +8 -1
  362. package/neural-interface/lib/assistant-route-accounts.js +14 -0
  363. package/neural-interface/lib/assistant-route-gate-codex.js +3 -1
  364. package/neural-interface/lib/assistant-route-gate.js +19 -3
  365. package/neural-interface/lib/assistant-router.js +373 -42
  366. package/neural-interface/lib/assistant-runtime.js +312 -48
  367. package/neural-interface/lib/assistant-user-writer.js +4 -2
  368. package/neural-interface/lib/augmented-path.js +317 -53
  369. package/neural-interface/lib/claude-accounts.js +342 -31
  370. package/neural-interface/lib/claude-agent-bridge.js +90 -3
  371. package/neural-interface/lib/claude-executable.js +6 -1
  372. package/neural-interface/lib/claude-model-catalog.js +3 -3
  373. package/neural-interface/lib/claude-panel-auth.js +221 -0
  374. package/neural-interface/lib/claude-profile-api-key.js +62 -0
  375. package/neural-interface/lib/cli-install-api.js +137 -0
  376. package/neural-interface/lib/cli-install.js +797 -0
  377. package/neural-interface/lib/cli-path.js +130 -0
  378. package/neural-interface/lib/cli-resolver.js +744 -0
  379. package/neural-interface/lib/cli-update-planner.js +143 -7
  380. package/neural-interface/lib/codex-account-auth.js +180 -0
  381. package/neural-interface/lib/codex-accounts.js +502 -0
  382. package/neural-interface/lib/codex-capabilities.js +2 -1
  383. package/neural-interface/lib/codex-compat-rpc.js +8 -2
  384. package/neural-interface/lib/codex-mcp-approvals.js +5 -1
  385. package/neural-interface/lib/codex-panel-auth.js +62 -0
  386. package/neural-interface/lib/codex-session-settings.js +1 -1
  387. package/neural-interface/lib/codex-settings-schema.js +122 -0
  388. package/neural-interface/lib/desktop/config.js +3 -0
  389. package/neural-interface/lib/exec-loop-command.js +3 -1
  390. package/neural-interface/lib/git-bash/bzip2.js +339 -0
  391. package/neural-interface/lib/git-bash/claude-settings.js +73 -0
  392. package/neural-interface/lib/git-bash/extract-worker.js +34 -0
  393. package/neural-interface/lib/git-bash/install.js +529 -0
  394. package/neural-interface/lib/git-bash/licenses/COPYING +360 -0
  395. package/neural-interface/lib/git-bash/licenses/LGPL-2.1 +511 -0
  396. package/neural-interface/lib/git-bash/manager.js +268 -0
  397. package/neural-interface/lib/git-bash/pins.js +206 -0
  398. package/neural-interface/lib/git-bash/pins.json +34 -0
  399. package/neural-interface/lib/git-bash/resolver.js +194 -0
  400. package/neural-interface/lib/git-read.js +34 -2
  401. package/neural-interface/lib/install-details.js +163 -0
  402. package/neural-interface/lib/mcp-installer.js +14 -2
  403. package/neural-interface/lib/native-loop-providers.js +22 -4
  404. package/neural-interface/lib/native-loop-runtime.js +9 -4
  405. package/neural-interface/lib/os-path.js +319 -0
  406. package/neural-interface/lib/provider-panel-api-key-auth.js +92 -0
  407. package/neural-interface/lib/provider-panel-terminal-auth.js +96 -0
  408. package/neural-interface/lib/pty-host/core.js +2 -1
  409. package/neural-interface/lib/pty-host/manager.js +27 -2
  410. package/neural-interface/lib/remote-policy.js +20 -11
  411. package/neural-interface/lib/session-indexer.js +3 -1
  412. package/neural-interface/lib/session-title-generator.js +6 -3
  413. package/neural-interface/lib/system-about.js +340 -0
  414. package/neural-interface/lib/system-activity.js +66 -0
  415. package/neural-interface/lib/terminal-env.js +11 -3
  416. package/neural-interface/lib/terminal-path.js +55 -0
  417. package/neural-interface/lib/virtual-socket.js +12 -1
  418. package/neural-interface/lib/whatsapp/baileys-adapter.js +165 -45
  419. package/neural-interface/lib/whatsapp/bridge.js +78 -34
  420. package/neural-interface/lib/whatsapp/commands.js +13 -14
  421. package/neural-interface/lib/whatsapp/config.js +134 -20
  422. package/neural-interface/lib/whatsapp/fake-baileys.js +99 -14
  423. package/neural-interface/lib/whatsapp/host-core.js +168 -35
  424. package/neural-interface/lib/whatsapp/inbound.js +5 -3
  425. package/neural-interface/lib/whatsapp/local-file.js +524 -0
  426. package/neural-interface/lib/whatsapp/manager.js +92 -32
  427. package/neural-interface/lib/whatsapp/paths.js +11 -1
  428. package/neural-interface/lib/whatsapp/protocol.js +65 -5
  429. package/neural-interface/lib/whatsapp/redact.js +63 -0
  430. package/neural-interface/lib/whatsapp/service.js +343 -91
  431. package/neural-interface/lib/whatsapp/transcription.js +463 -0
  432. package/neural-interface/package-lock.json +2 -2
  433. package/neural-interface/package.json +1 -1
  434. package/neural-interface/public/index.html +2 -0
  435. package/neural-interface/public/index2d.html +2 -0
  436. package/neural-interface/public/offline.html +16 -1
  437. package/neural-interface/public/onboarding.html +96 -2
  438. package/neural-interface/public/shared/account-i18n.js +7 -0
  439. package/neural-interface/public/shared/account-menu.js +542 -0
  440. package/neural-interface/public/shared/account-usage.js +79 -0
  441. package/neural-interface/public/shared/account-view.js +336 -0
  442. package/neural-interface/public/shared/agent-runtime-options.js +28 -8
  443. package/neural-interface/public/shared/assistant/asst-account-menu.js +298 -0
  444. package/neural-interface/public/shared/assistant/asst-accounts.js +182 -184
  445. package/neural-interface/public/shared/assistant/asst-api.js +39 -2
  446. package/neural-interface/public/shared/assistant/asst-brain-picker.js +288 -32
  447. package/neural-interface/public/shared/assistant/asst-budget.js +259 -303
  448. package/neural-interface/public/shared/assistant/asst-mascot.js +126 -7
  449. package/neural-interface/public/shared/assistant/asst-menu.js +219 -80
  450. package/neural-interface/public/shared/assistant/asst-model-chip.js +135 -0
  451. package/neural-interface/public/shared/assistant/asst-panel.js +87 -16
  452. package/neural-interface/public/shared/assistant/asst-render.js +38 -7
  453. package/neural-interface/public/shared/assistant/asst-route.js +72 -14
  454. package/neural-interface/public/shared/assistant/asst-routes-editor.js +45 -6
  455. package/neural-interface/public/shared/assistant/asst-slash.js +1 -1
  456. package/neural-interface/public/shared/assistant/asst-state.js +1 -1
  457. package/neural-interface/public/shared/assistant/asst-styles.js +160 -29
  458. package/neural-interface/public/shared/cdx/cdx-commands.js +4 -1
  459. package/neural-interface/public/shared/cdx/cdx-features.js +22 -14
  460. package/neural-interface/public/shared/cdx/cdx-icons.js +2 -0
  461. package/neural-interface/public/shared/cdx/cdx-panel.js +49 -108
  462. package/neural-interface/public/shared/cdx/cdx-settings.js +231 -0
  463. package/neural-interface/public/shared/cdx/cdx-styles.js +172 -111
  464. package/neural-interface/public/shared/cdx/cdx-tabs.js +264 -664
  465. package/neural-interface/public/shared/cdx/cdx-ws.js +2 -0
  466. package/neural-interface/public/shared/claude-account-view.js +3 -0
  467. package/neural-interface/public/shared/cli-install-view.js +392 -0
  468. package/neural-interface/public/shared/cli-status.js +282 -22
  469. package/neural-interface/public/shared/cp/cp-events.js +2 -1
  470. package/neural-interface/public/shared/cp/cp-styles.js +46 -0
  471. package/neural-interface/public/shared/desktop-bridge.js +39 -0
  472. package/neural-interface/public/shared/desktop-titlebar-page.js +59 -0
  473. package/neural-interface/public/shared/desktop-titlebar-view.js +122 -0
  474. package/neural-interface/public/shared/desktop-titlebar.js +153 -0
  475. package/neural-interface/public/shared/desktop-update-view.js +462 -0
  476. package/neural-interface/public/shared/editor/editor-panel.js +1 -1
  477. package/neural-interface/public/shared/explorer/fx-main.js +1 -1
  478. package/neural-interface/public/shared/git-bash-view.js +145 -0
  479. package/neural-interface/public/shared/html-shell.js +1 -0
  480. package/neural-interface/public/shared/i18n.js +19 -1
  481. package/neural-interface/public/shared/install-details-view.js +86 -0
  482. package/neural-interface/public/shared/ocp-v2/ocp-v2-childpanel.js +4 -1
  483. package/neural-interface/public/shared/ocp-v2/ocp-v2-panel.js +36 -1
  484. package/neural-interface/public/shared/ocp-v2/ocp-v2-send.js +17 -0
  485. package/neural-interface/public/shared/ocp-v2/ocp-v2-styles.js +169 -2
  486. package/neural-interface/public/shared/pwa-install-page.js +3 -1
  487. package/neural-interface/public/shared/settings/settings-about-pane.js +271 -0
  488. package/neural-interface/public/shared/settings/settings-about.js +254 -0
  489. package/neural-interface/public/shared/settings/settings-ia.js +15 -0
  490. package/neural-interface/public/shared/showcase-privacy.js +490 -0
  491. package/neural-interface/public/shared/storage.js +30 -3
  492. package/neural-interface/public/shared/styles.css +281 -32
  493. package/neural-interface/public/shared/synabun-mascot.js +48 -2
  494. package/neural-interface/public/shared/ui-claude-panel.js +262 -217
  495. package/neural-interface/public/shared/ui-cli-banner.js +355 -0
  496. package/neural-interface/public/shared/ui-explorer.js +2 -0
  497. package/neural-interface/public/shared/ui-git-bash.js +124 -0
  498. package/neural-interface/public/shared/ui-judgments.js +10 -1
  499. package/neural-interface/public/shared/ui-loading.js +30 -6
  500. package/neural-interface/public/shared/ui-menubar.js +13 -3
  501. package/neural-interface/public/shared/ui-navbar.js +3 -1
  502. package/neural-interface/public/shared/ui-provider-auth.js +104 -0
  503. package/neural-interface/public/shared/ui-provider-terminal-auth.js +223 -0
  504. package/neural-interface/public/shared/ui-sessions.js +9 -3
  505. package/neural-interface/public/shared/ui-settings.js +55 -3
  506. package/neural-interface/public/shared/ui-sidepanel-sessions.js +3 -1
  507. package/neural-interface/public/shared/ui-sync.js +8 -0
  508. package/neural-interface/public/shared/ui-terminal.js +17 -0
  509. package/neural-interface/public/shared/ui-tool-updates.js +5 -0
  510. package/neural-interface/public/shared/ui-update.js +598 -7
  511. package/neural-interface/public/shared/ui-whatsapp.js +21 -6
  512. package/neural-interface/public/shared/whatsapp/wa-view.js +51 -8
  513. package/neural-interface/public/shared/worker-end.js +2 -0
  514. package/neural-interface/public/sw.js +1 -1
  515. package/neural-interface/public/variant/2d/main.js +10 -1
  516. package/neural-interface/public/variant/3d/main.js +10 -1
  517. package/neural-interface/scripts/rebuild-pty.js +14 -2
  518. package/neural-interface/server.js +1384 -679
  519. package/npm-shrinkwrap.json +4424 -0
  520. package/package.json +57 -2
  521. package/postinstall.js +61 -47
  522. package/preuninstall.js +8 -35
  523. package/setup.js +80 -47
  524. package/skills/synabun/modules/idea.md +1 -1
  525. package/skills/synabun/modules/schedule.md +1 -1
  526. package/updater.cmd +2 -2
  527. package/updater.mjs +97 -3
@@ -9,6 +9,14 @@ import { createSessionListOverlay, overlaySessions, matchesSessionFilter } from
9
9
  import { findSessionTranscript, sessionProjects, confineArtifact, confineSubPath, confineToRoot, staysInside, isInsideReal, isInside, isPlainName, PathConfineError } from './lib/path-confine.js';
10
10
  import { readConfinedFile, writeConfinedFile, makeConfinedDir, removeConfinedEntry, listConfined, openConfinedStream } from './lib/confined-fs.js';
11
11
  import { createClaudeAccounts as createPanelClaudeAccounts } from './lib/claude-accounts.js';
12
+ import { createClaudePanelAuth } from './lib/claude-panel-auth.js';
13
+ import { createCodexPanelAuth } from './lib/codex-panel-auth.js';
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';
17
+ import { createCodexAccountAuth } from './lib/codex-account-auth.js';
18
+ import { registerProviderPanelApiKeyAuthRoutes, redactProviderSecrets, providerApiKeyBodyError, providerDiagnosticSink } from './lib/provider-panel-api-key-auth.js';
19
+ import { createProviderPanelTerminalAuth, providerLoginSpec, registerProviderPanelTerminalAuthRoutes } from './lib/provider-panel-terminal-auth.js';
12
20
  import { claudeLegacyPricing, claudeUsageCostUsd } from './lib/assistant-pricing.js';
13
21
  import { HOOK_SCRIPTS, hookDefinitionsForEvent, hookCommandString, hookInstallBlocker, hookInstallPlan, isSynaBunHookCommand, sweepSettingsHooks, ensureHookRoot } from '../lib/claude-hooks.js';
14
22
  import { browserV2Enabled, browserRequestMiddleware, assertBrowserRequestActive, browserActionTimeout, markBrowserActionStarted, runBrowserRequest, cancellableBrowserDelay, measureBrowserPhase, createEngagementScheduler, engagementTargetSelector, isEngagementWriteSelector, isXShortcutPublish, isXScriptedWrite } from './lib/browser-execution.js';
@@ -34,8 +42,9 @@ import { createPtyHostManager } from './lib/pty-host/manager.js';
34
42
  import { hostNameOf, isAllowedHost, isAllowedWebSocketOrigin, parseCookies, refuseUpgrade } from './lib/http-guards.js';
35
43
  import { createRequestGates, extraHostNames, hostsOf, savedValueOf, startedValueOf } from './lib/request-gate.js';
36
44
  import { createGitDiffRouter } from './lib/git-diff-api.js';
37
- import { gitRead, gitReadSync } from './lib/git-read.js';
45
+ import { gitRead, gitReadSync, setGitFallbackFolder, withGitFallback } from './lib/git-read.js';
38
46
  import { createPhoneAuthority } from './lib/remote-policy.js';
47
+ import { brainCredentialOf } from './lib/virtual-socket.js';
39
48
  import { buildTerminalEnv, interactiveShellArgs, cliWrapperArgs } from './lib/terminal-env.js';
40
49
  import { startJudgeBackfill, stopJudgeBackfill } from '../mcp-server/dist/services/memory-judge-backfill.js';
41
50
  import { judgeLoopGoal } from '../mcp-server/dist/services/memory-judgments.js';
@@ -106,12 +115,18 @@ import { loopLog, loopLogPath, flushLoopLogs } from './lib/loop-logger.js';
106
115
  import {
107
116
  buildInstallCommand,
108
117
  buildUpdateCommand,
109
- detectInstallSource,
110
118
  isBrewSource,
111
119
  parseBrewOutdated,
120
+ quoteCommandPath,
112
121
  resolveBrewToken,
113
122
  resolveLatest,
114
123
  } from './lib/cli-update-planner.js';
124
+ import { createCliResolver, spawnArgsFor } from './lib/cli-resolver.js';
125
+ import { osPath } from './lib/os-path.js';
126
+ import { createCliInstaller, CLI_INSTALL_GUIDES } from './lib/cli-install.js';
127
+ import { createCliInstallApi, createCliPathApi, createGitBashApi } from './lib/cli-install-api.js';
128
+ import { createCliPathManager } from './lib/cli-path.js';
129
+ import { composeTerminalPath, terminalLaunchCommand, terminalArgument } from './lib/terminal-path.js';
115
130
  import {
116
131
  applyBackupRetention,
117
132
  createVerifiedBackup,
@@ -145,11 +160,6 @@ import {
145
160
  createCodexNativeLoopAdapter,
146
161
  createOpenCodeNativeLoopAdapter,
147
162
  } from './lib/native-loop-providers.js';
148
- import {
149
- resolveTrustedWindowsCodexBinary,
150
- selectWindowsCodexLauncher,
151
- windowsCodexPackageJsonCandidates,
152
- } from './lib/codex-runtime-path.js';
153
163
  import {
154
164
  mergeCodexModelContextCapabilities,
155
165
  resolveCodexExtendedContext,
@@ -233,10 +243,14 @@ import {
233
243
  onClaudeModelsChanged,
234
244
  shellCommand,
235
245
  } from './lib/claude-model-catalog.js';
236
- import { resolveClaudeLauncher, resolveClaudeSdkExecutable } from './lib/claude-executable.js';
237
- import { getAugmentedPath } from './lib/augmented-path.js';
246
+ import { resolveClaudeSdkExecutable } from './lib/claude-executable.js';
247
+ import { collectInstallDetails } from './lib/install-details.js';
248
+ import { withPathEnv, pathEnvValue, getAugmentedPath, rememberedOsPath, rememberPackagedPath, rememberedPackagedPath, withPackagedPathLast } from './lib/augmented-path.js';
249
+ import { readCodexSettingsSchema, readCodexFeatureSupport, validateCodexSettings } from './lib/codex-settings-schema.js';
238
250
  import { semverNewer, synabunReleaseChannel } from './lib/synabun-version.js';
239
251
  import { selectSynabunUpdate, buildSynabunInstallPlan, refuseStaleUpdateClick } from './lib/synabun-update-plan.js';
252
+ import { collectActivity, countWhere, recentCliSessions } from './lib/system-activity.js';
253
+ import { createSystemAbout } from './lib/system-about.js';
240
254
  import {
241
255
  listCodexSessionsFromAccounts,
242
256
  mergeCodexSessionSummaries,
@@ -260,11 +274,15 @@ import {
260
274
  clampPointsToRect as wbClampPointsToRect,
261
275
  convertPctInPlace as wbConvertPctInPlace,
262
276
  } from '../mcp-server/dist/services/whiteboard-geometry.js';
263
- import { ensureProjectCategories } from '../hooks/claude-code/shared.mjs';
277
+ import { ensureProjectCategories as ensureProjectCategoriesInFile, ensureCategories as ensureCategoriesInFile, FEATURE_CATEGORIES } from '../hooks/claude-code/shared.mjs';
264
278
  import { getDataHome, getDataHomeDiagnostics, isGlobalInstall, pathIsInside } from '../lib/paths.js';
265
279
  import { launcherLogPath } from '../lib/first-launch-protection.js';
266
280
  import { registerStartLauncher, startLauncherState } from '../lib/start-launcher.js';
267
- import { durableEntry, mcpStdioCommand, packagedRuntime } from '../lib/packaged-runtime.js';
281
+ import { effectiveNpmPrefix } from '../lib/child-path.js';
282
+ import { checkRuntimeDependencies, fallbackInstallArgs, fallbackInstallEnv, findInstalled, mcpCompiler, npmCommand, npmInvocation } from '../lib/runtime-dependencies.js';
283
+ import { redactDeep, redactSecrets } from '../lib/log-redact.js';
284
+ import { applyPackagedChildPath, durableEntry, mcpStdioCommand, npmGlobalBinFor, packagedNpmPrefix, packagedPathFolders, packagedRuntime } from '../lib/packaged-runtime.js';
285
+ import { dataHomeId } from '../lib/desktop-app.js';
268
286
  import { pruneExternalTools } from '../lib/external-tools.js';
269
287
  import { renderOfflinePage } from './lib/offline-page.js';
270
288
  import {
@@ -313,11 +331,15 @@ const __dirname = dirname(__filename);
313
331
  // This sweeps ALL platform directories, not just this host's: a vendored
314
332
  // checkout can carry several, and repairing only the current one left e.g.
315
333
  // prebuilds/darwin-x64/spawn-helper broken for Intel Macs.
334
+ // Both node_modules an install can have: the package root's (`npm i -g synabun` puts every dependency there) and
335
+ // the Neural Interface's own (a development checkout, a desktop build).
316
336
  try {
317
- const sweep = ensureVendoredExecutables({ root: __dirname, log: (msg) => console.log(msg) });
318
- for (const failure of sweep.failed) {
319
- console.warn(`[binaries] ${failure.path} is not launchable (${failure.state}).`
320
- + (failure.repairCommand ? ` Try: ${failure.repairCommand}` : ''));
337
+ for (const root of new Set([__dirname, resolve(__dirname, '..')])) {
338
+ const sweep = ensureVendoredExecutables({ root, log: (msg) => console.log(msg) });
339
+ for (const failure of sweep.failed) {
340
+ console.warn(`[binaries] ${failure.path} is not launchable (${failure.state}).`
341
+ + (failure.repairCommand ? ` Try: ${failure.repairCommand}` : ''));
342
+ }
321
343
  }
322
344
  } catch (err) {
323
345
  console.warn('[binaries] Could not verify vendored binary permissions:', err.message);
@@ -346,6 +368,11 @@ let _greetingError = null;
346
368
  let _assistantDispatcher = null;
347
369
  // The Assistant's model catalog (lib/assistant-catalog.js): Settings → WhatsApp checks its brain choice against it.
348
370
  let _assistantCatalog = null;
371
+ // The Claude account the Assistant's Claude Code workers use when a dispatch names none (assistant-config.json
372
+ // `workers.claudeAccount`); a function once the Assistant is wired. An account it names cannot be removed.
373
+ let _assistantClaudeWorkerAccount = null;
374
+ // The same for Codex workers (`workers.codexAccount`).
375
+ let _assistantCodexWorkerAccount = null;
349
376
  // Who may ask, in front of every route (lib/request-gate.js). A request for the owner's data must name this
350
377
  // server: everything under /api and /mcp, the invite sign-in, the rendered offline page and the uploaded icons
351
378
  // answer 403 BAD_HOST to any other Host (a page whose name was re-pointed at this machine sends its own); the
@@ -371,6 +398,7 @@ app.use(express.json({ limit: '25mb' }));
371
398
  // exceed the JSON limit. Default Express handler dumps a 10-line raw-body stack with no
372
399
  // URL — this logs one line with context and responds 413 instead.
373
400
  app.use((err, req, res, next) => {
401
+ if (/^\/api\/provider-panel-auth\/[^/]+\/[^/]+\/api-key(?:\?|$)/.test(req.originalUrl || req.url || '')) return providerApiKeyBodyError(err, req, res, next);
374
402
  if (err && (err.type === 'entity.too.large' || err.type === 'entity.parse.failed')) {
375
403
  const len = req.headers['content-length'] || '?';
376
404
  console.warn(`[body-parser] ${err.type} ${req.method} ${req.originalUrl || req.url} content-length=${len} limit=${err.limit || '?'}`);
@@ -435,6 +463,8 @@ import { createEndpointCompletionEngine } from './lib/editor-completion-endpoint
435
463
  import { ownerRouteGuard } from './lib/http-guards.js';
436
464
  import { createCloudflaredManager } from './lib/cloudflared/manager.js';
437
465
  import { createQuickTunnel } from './lib/cloudflared/tunnel.js';
466
+ import { createGitBashManager } from './lib/git-bash/manager.js';
467
+ import { settingsGitBashValue, syncClaudeSettingsGitBash } from './lib/git-bash/claude-settings.js';
438
468
 
439
469
  const PORT = process.env.NEURAL_PORT || 3344;
440
470
  // SQLite database is used directly via lib/db.js
@@ -515,49 +545,31 @@ const GLOBAL_CATEGORIES_PATH = resolve(CATEGORIES_DATA_DIR, 'custom-categories.j
515
545
  const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
516
546
  const CATEGORIES_POINT_ID = '00000000-0000-0000-0000-000000000000';
517
547
 
518
- // Default categories seeded on first startup (new connection)
519
- // Must stay in sync with hooks/claude-code/shared.mjs STANDALONE_DEFAULTS
520
- const DEFAULT_CATEGORIES = [
521
- {
522
- name: 'uncategorized',
523
- 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.',
524
- color: '#6b7280',
525
- },
526
- {
527
- name: 'conversations',
528
- 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?"',
529
- color: '#a855f7',
530
- is_parent: true,
531
- },
532
- {
533
- name: 'communication-style',
534
- description: 'User communication patterns and preferences.',
535
- },
536
- {
537
- name: 'plans',
538
- description: 'Implementation plans stored after plan mode approval.',
539
- color: '#06d6a0',
540
- is_parent: true,
541
- },
542
- {
543
- name: 'ideas',
544
- 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.',
545
- color: '#f59e0b',
546
- },
547
- ];
548
-
548
+ // No default categories: a fresh install has none. A project's defaults are made
549
+ // when the project is added (ensureProjectCategories), a feature's category the
550
+ // first time the feature saves into it.
549
551
  function getCategoriesPath() {
550
552
  // Single categories file — no per-connection paths needed with SQLite
551
- if (!existsSync(GLOBAL_CATEGORIES_PATH)) {
552
- const dir = resolve(GLOBAL_CATEGORIES_PATH, '..');
553
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
554
- writeFileSync(GLOBAL_CATEGORIES_PATH, JSON.stringify({ version: 1, categories: DEFAULT_CATEGORIES }, null, 2), 'utf-8');
555
- }
556
-
557
553
  return GLOBAL_CATEGORIES_PATH;
558
554
  }
559
555
 
556
+ // The file not written yet: what SQLite (the source of truth) holds, which is nothing on a fresh install.
557
+ function categoriesFromDb() {
558
+ try {
559
+ return dbGetCategories().map((row) => {
560
+ const category = { name: row.name, description: row.description || '', created_at: row.created_at };
561
+ if (row.parent) category.parent = row.parent;
562
+ if (row.color) category.color = row.color;
563
+ if (row.is_parent) category.is_parent = true;
564
+ return category;
565
+ });
566
+ } catch {
567
+ return [];
568
+ }
569
+ }
570
+
560
571
  function loadCategories() {
572
+ if (!existsSync(GLOBAL_CATEGORIES_PATH)) return categoriesFromDb();
561
573
  try {
562
574
  const raw = readFileSync(getCategoriesPath(), 'utf-8');
563
575
  const data = JSON.parse(raw);
@@ -570,6 +582,27 @@ function loadCategories() {
570
582
  }
571
583
  }
572
584
 
585
+ // 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.
586
+ function syncCategoriesToDb(created) {
587
+ if (!created?.length) return created || [];
588
+ try { dbSaveCategories(loadCategories()); } catch (err) { console.error('Failed to sync categories to SQLite:', err.message); }
589
+ return created;
590
+ }
591
+
592
+ // A project's default categories (parent + project / architecture / bugs / config), made once, when the project is
593
+ // registered; never for a project already in the registry. Adds what is missing only.
594
+ function ensureProjectCategories(projects) {
595
+ return syncCategoriesToDb(ensureProjectCategoriesInFile(projects).created);
596
+ }
597
+
598
+ // A feature's own category (FEATURE_CATEGORIES: conversations, communication-style, plans, uncategorized), made the
599
+ // first time the feature saves into it; a fresh install has none. Adds it only when it is missing.
600
+ function ensureFeatureCategory(name) {
601
+ const feature = Object.prototype.hasOwnProperty.call(FEATURE_CATEGORIES, name) ? FEATURE_CATEGORIES[name] : null;
602
+ if (!feature) return [];
603
+ return syncCategoriesToDb(ensureCategoriesInFile([{ name, description: feature.description, is_parent: feature.is_parent }]).created);
604
+ }
605
+
573
606
  function saveCategories(categories) {
574
607
  const filePath = getCategoriesPath();
575
608
  const dir = resolve(filePath, '..');
@@ -754,6 +787,26 @@ const INSTALL_KIND = PACKAGED_RUNTIME ? 'app' : isGlobalInstall() ? 'npm' : 'git
754
787
  // What a person types to start a packaged application: its entry executable,
755
788
  // when that is a path that will still be there once this run is over.
756
789
  const START_ENTRY = durableEntry(PACKAGED_RUNTIME) || '';
790
+ // What the desktop app's window asks /api/health (lib/desktop-app.js): the version this server runs and which
791
+ // data home it uses, as a hash and never the path, so it can tell its own server from another installation's.
792
+ const SERVER_VERSION = (() => { try { return String(JSON.parse(readFileSync(resolve(PACKAGE_ROOT, 'package.json'), 'utf-8')).version || ''); } catch { return ''; } })();
793
+ const DATA_HOME_ID = dataHomeId(DATA_HOME);
794
+ // The entry this install runs from, even when it does not outlive the run (START_ENTRY is then empty): how the
795
+ // desktop app tells its own server from another install's (lib/desktop-app.js classifyServer).
796
+ const INSTALL_ENTRY = PACKAGED_RUNTIME?.entry || '';
797
+ // A packaged application's own Node runtime, and the global bin of the npm that came with it (where `npm i -g`
798
+ // through it puts commands: the prefix as npm settles it from npm_config_prefix, the user's config file and the
799
+ // global one, else SynaBun's per-user folder its builtin npmrc names, never the application: lib/child-path.js,
800
+ // lib/packaged-runtime.js packagedNpmPrefix). On the PATH of everything this server starts, after everything of
801
+ // the user's: the user's own Node and npm stay first, these are the fallback on a machine without. The bin is asked
802
+ // for each PATH (cached for seconds), so a prefix set later counts without a restart. This process's own environment
803
+ // gets the same order, for every child that simply inherits it (an npm install, git, a CLI); the server's own
804
+ // Node children are started by process.execPath, never through PATH.
805
+ rememberPackagedPath({
806
+ last: packagedPathFolders(PACKAGED_RUNTIME).last,
807
+ npmBin: PACKAGED_RUNTIME ? () => packagedNpmPrefix(PACKAGED_RUNTIME)?.bin || null : null,
808
+ });
809
+ applyPackagedChildPath(process.env, PACKAGED_RUNTIME);
757
810
 
758
811
  // Serve offline.html with what it needs once we are down: the install path,
759
812
  // how it was installed, the launcher's state and the bridge code itself.
@@ -2142,6 +2195,10 @@ async function runGracefulShutdown(reason) {
2142
2195
  // The public address: its cloudflared is ended with the server that owns the address (killed when it does not
2143
2196
  // end by itself; never waited for more than a moment).
2144
2197
  try { await tunnel.shutdown({ timeoutMs: 1500 }); } catch (err) { console.warn(' tunnel stop err:', err?.message); }
2198
+ // A CLI install under way (lib/cli-install.js): its installer and what it started are stopped.
2199
+ try { cliInstaller.stopAll(); } catch (err) { console.warn(' cli install stop err:', err?.message); }
2200
+ // A Git for Windows download under way (lib/git-bash/): stopped; its work folder goes with the next attempt.
2201
+ try { gitBash.cancel(); } catch (err) { console.warn(' git bash stop err:', err?.message); }
2145
2202
 
2146
2203
  // 1. opencode sidecar — has its own teardown that handles managed PID + ollama.
2147
2204
  try { stopOpencodeServer(); } catch (err) { console.warn(' opencode stop err:', err?.message); }
@@ -2344,6 +2401,18 @@ function cleanupOldUpdaterStages(baseDir) {
2344
2401
  } catch {}
2345
2402
  }
2346
2403
 
2404
+ // lib/package-processes.js and the module it imports (only node: modules in either).
2405
+ const UPDATER_STAGED_LIBS = ['package-processes.js', 'runtime-dependencies.js'];
2406
+
2407
+ // The forms of the package folder this server knows: as it resolved it, and as it was started
2408
+ // (`node <prefix>\node_modules\synabun\neural-interface\server.js` through a junction or a link).
2409
+ function updaterPackageRoots() {
2410
+ const roots = [PACKAGE_ROOT];
2411
+ const started = process.argv[1] ? resolve(process.argv[1]) : '';
2412
+ if (basename(started) === 'server.js' && basename(dirname(started)) === 'neural-interface') roots.push(resolve(started, '..', '..'));
2413
+ return [...new Set(roots)];
2414
+ }
2415
+
2347
2416
  function stageSynabunUpdater({ installPlan, serverPid, autoRestart, cleanupPids = [], safetySnapshot = null, handoffPath = null }) {
2348
2417
  const sourceUpdater = resolve(PACKAGE_ROOT, 'updater.mjs');
2349
2418
  if (!existsSync(sourceUpdater)) {
@@ -2361,6 +2430,16 @@ function stageSynabunUpdater({ installPlan, serverPid, autoRestart, cleanupPids
2361
2430
  const payloadPath = resolve(stageDir, 'payload.json');
2362
2431
  const runnerPath = resolve(stageDir, process.platform === 'win32' ? 'run-update.cmd' : 'run-update.sh');
2363
2432
  copyFileSync(sourceUpdater, stagedUpdater);
2433
+ // What updater.mjs imports on Windows to stop the processes AI clients started from this folder: staged as lib/
2434
+ // next to it, with a package.json that makes those .js files ES modules on every Node SynaBun runs on. One that
2435
+ // cannot be copied leaves the updater without that step, never without the update.
2436
+ try {
2437
+ mkdirSync(resolve(stageDir, 'lib'), { recursive: true });
2438
+ writeFileSync(resolve(stageDir, 'lib', 'package.json'), '{ "type": "module" }\n', 'utf8');
2439
+ for (const file of UPDATER_STAGED_LIBS) copyFileSync(resolve(PACKAGE_ROOT, 'lib', file), resolve(stageDir, 'lib', file));
2440
+ } catch (err) {
2441
+ console.warn(`[server] could not stage lib/ for the updater: ${err?.message}`);
2442
+ }
2364
2443
 
2365
2444
  const payload = {
2366
2445
  serverPid,
@@ -2372,6 +2451,9 @@ function stageSynabunUpdater({ installPlan, serverPid, autoRestart, cleanupPids
2372
2451
  target: installPlan.target,
2373
2452
  channel: installPlan.channel,
2374
2453
  cleanupPids,
2454
+ // The folder `npm i -g` replaces, in every form known here: on Windows the updater stops what still runs from it.
2455
+ packageRoot: PACKAGE_ROOT,
2456
+ packageRoots: updaterPackageRoots(),
2375
2457
  safetySnapshot,
2376
2458
  handoffPath,
2377
2459
  // Where the relaunched launcher leaves its reason if it stops.
@@ -2462,6 +2544,13 @@ app.post('/api/system/run-update', async (req, res) => {
2462
2544
  // Nothing is ever installed into the resources of a packaged application.
2463
2545
  if (PACKAGED_RUNTIME) {
2464
2546
  const openUrl = `${SYNABUN_GITHUB_REPO_URL}/releases`;
2547
+ // The desktop app updates itself, through its window's update function (packaging/desktop).
2548
+ if (PACKAGED_RUNTIME.shell) {
2549
+ return res.status(409).json({
2550
+ error: 'This copy of SynaBun is the desktop app, and it updates itself: use Check for updates in its menu. Nothing was installed.',
2551
+ packaged: true, desktopManaged: true, openUrl,
2552
+ });
2553
+ }
2465
2554
  return res.status(409).json({
2466
2555
  error: `This copy of SynaBun is a packaged application. It is updated by installing a newer build over it, not from inside the app. Nothing was installed. Releases: ${openUrl}`,
2467
2556
  packaged: true,
@@ -3247,36 +3336,88 @@ function _heartbeatLock(sessionId, windowId) {
3247
3336
  }
3248
3337
  }
3249
3338
 
3250
- // Cache resolved claude binary path (avoids a PATH walk on every query)
3251
- let _claudeBinPath = null;
3252
- // A tool that was not found is looked for again after a moment: installing it
3253
- // is what a user does next, and that must not take a restart of SynaBun.
3254
- const MISSING_TOOL_RECHECK_MS = 15_000;
3255
- let _claudeBinMissingAt = 0;
3339
+ // ── Where the user's CLIs are (lib/cli-resolver.js) ──
3340
+ // One resolver for every lookup and launch of Claude Code, Codex and OpenCode:
3341
+ // the version check behind the side panels' banner and the update badges,
3342
+ // Settings' Detect, the one-click install (lib/cli-install.js) and the
3343
+ // launchers below. The path in Settings > Terminal first, then PATH with the OS
3344
+ // PATH read afresh (lib/os-path.js: the registry on Windows, the login shell on
3345
+ // macOS and Linux), then the folders each install method uses; never a copy
3346
+ // inside SynaBun. A CLI installed while SynaBun runs is found by the next
3347
+ // lookup: nothing here is remembered for more than a few seconds, and Re-check
3348
+ // forgets everything.
3349
+ const cliResolver = createCliResolver({
3350
+ isInsideSynabun: (file) => isInsideSynabun(file),
3351
+ configured: (toolKey) => loadCliConfig()[toolKey]?.command ?? null,
3352
+ });
3353
+ // The first read of the OS PATH starts now, so the first lookups already have it.
3354
+ osPath.refresh().catch(() => {});
3355
+
3356
+ // ── Git Bash for Claude Code on Windows (lib/git-bash/) ──
3357
+ // Claude Code on Windows runs its Bash tool and every hook through Git Bash and
3358
+ // falls back to PowerShell without one, where SynaBun's own hooks of an
3359
+ // installed app do not even parse. Which Git Bash a Claude Code started by
3360
+ // SynaBun gets: CLAUDE_CODE_GIT_BASH_PATH (this environment, or the user's
3361
+ // settings), a Git for Windows of the PC's own, then SynaBun's copy
3362
+ // (<resources>\git in the installer; DATA_HOME/runtime/git/<version>, fetched
3363
+ // once, on an npm install). claudeChildEnv() is the last word on the
3364
+ // environment of every Claude Code process SynaBun starts. Nothing changes on
3365
+ // macOS and Linux, or where the PC has a Git Bash Claude Code finds by itself;
3366
+ // the system's PATH and registry are never changed.
3367
+ const gitBash = createGitBashManager({
3368
+ dataHome: DATA_HOME,
3369
+ pathValue: () => [pathEnvValue(process.env), ...rememberedOsPath()].join(';'),
3370
+ settingsValue: () => settingsGitBashValue({ settingsPath: getGlobalClaudeSettingsPath(), read: readClaudeSettingsForWrite }),
3371
+ log: (line) => console.log(line),
3372
+ });
3373
+ function claudeChildEnv(env = process.env) {
3374
+ // A packaged application's runtime and npm after the user's own on PATH (rememberPackagedPath above).
3375
+ const ordered = withPackagedPathLast(env);
3376
+ try { return gitBash.childEnv(ordered); } catch { return ordered; }
3377
+ }
3378
+ // SynaBun's own git features (the terminal's branch and checkout, the File Explorer's and the code editor's git,
3379
+ // the git routes) run the copy's git.exe on a PC without git: its path for spawnGit, its folder for every read.
3380
+ setGitFallbackFolder(() => { const git = gitBash.gitExecutable(); return git ? dirname(git) : null; });
3381
+ // The user's own `claude`, started in another terminal: ~/.claude/settings.json names SynaBun's copy while it is
3382
+ // the one in use and loses that value when it is not; a value of the user's own is never touched. Windows only.
3383
+ function syncGitBashSettings(resolution = gitBash.resolution()) {
3384
+ const settingsPath = getGlobalClaudeSettingsPath();
3385
+ const result = syncClaudeSettingsGitBash({ resolution, settingsPath, read: readClaudeSettingsForWrite, write: writeClaudeSettings, ownRoots: gitBash.ownRoots });
3386
+ gitBash.noteSettings(result);
3387
+ if (result.state === 'written') console.log(`[git-bash] ${settingsPath}: CLAUDE_CODE_GIT_BASH_PATH set to ${result.value} (SynaBun's Git Bash, for a claude started outside SynaBun)`);
3388
+ else if (result.state === 'removed') console.log(`[git-bash] ${settingsPath}: CLAUDE_CODE_GIT_BASH_PATH removed (SynaBun's Git Bash is not the one in use)`);
3389
+ else if (result.state === 'error') console.warn(`[git-bash] ${settingsPath} was not changed: ${result.message}`);
3390
+ return result;
3391
+ }
3392
+ gitBash.onChange((resolution) => { syncGitBashSettings(resolution); });
3393
+ const _cliMissingLogged = new Set();
3394
+ function _noteCliLookup(toolKey, found, label) {
3395
+ if (!found?.path) {
3396
+ if (!_cliMissingLogged.has(toolKey)) {
3397
+ _cliMissingLogged.add(toolKey);
3398
+ console.warn(`[cli] ${label} not found (Settings path, PATH, the OS PATH and the known install folders). The side panel offers to install it.`);
3399
+ }
3400
+ return;
3401
+ }
3402
+ if (_cliMissingLogged.delete(toolKey)) console.log(`[cli] ${label} found: ${found.path}`);
3403
+ }
3404
+
3405
+ /** The Settings > Terminal value of a tool when it is a command line ("wsl codex") that a shell runs as written. */
3406
+ function configuredCommandLine(toolKey) {
3407
+ let value = '';
3408
+ try { value = String(loadCliConfig()[toolKey]?.command || '').trim(); } catch {}
3409
+ if (!value || !/\s/.test(value) || existsSync(value.replace(/^"(.*)"$/, '$1'))) return null;
3410
+ return value;
3411
+ }
3256
3412
 
3257
- // The Claude Code a terminal would start: the command from cli-config.json
3258
- // (Settings > Terminal), else the first `claude` on the augmented PATH. Always
3259
- // the user's own installation: never a file inside SynaBun, never a command
3260
- // from a node_modules/.bin folder (lib/claude-executable.js).
3413
+ // The Claude Code a terminal would start: the resolver's answer (Settings'
3414
+ // path, PATH, the OS PATH, the known install folders), always the user's own
3415
+ // installation. The bare 'claude' when there is none, which
3416
+ // resolveClaudeSdkExecutable and every caller read as "not installed".
3261
3417
  function getClaudeBin() {
3262
- if (_claudeBinPath && _claudeBinPath !== 'claude') return _claudeBinPath;
3263
- if (_claudeBinPath && Date.now() - _claudeBinMissingAt < MISSING_TOOL_RECHECK_MS) return _claudeBinPath;
3264
- let configured = '';
3265
- try { configured = loadCliConfig()['claude-code']?.command?.trim() || ''; } catch {}
3266
- const found = resolveClaudeLauncher({ configured, isInsideSynabun });
3267
- for (const rejected of found.rejected) {
3268
- console.warn(`[claude-skin] Ignoring Claude CLI inside the SynaBun install: ${rejected}`);
3269
- }
3270
- if (!found.path) {
3271
- if (!_claudeBinMissingAt) console.warn('[claude-skin] Claude CLI not found. Install: npm install -g @anthropic-ai/claude-code');
3272
- _claudeBinMissingAt = Date.now();
3273
- _claudeBinPath = 'claude'; // last resort — will fail at spawn time with a clear error
3274
- return _claudeBinPath;
3275
- }
3276
- if (_claudeBinMissingAt) console.log(`[claude-skin] Claude CLI found: ${found.path}`);
3277
- _claudeBinMissingAt = 0;
3278
- _claudeBinPath = found.path;
3279
- return _claudeBinPath;
3418
+ const found = cliResolver.launcher('claude-code');
3419
+ _noteCliLookup('claude-code', found, 'Claude Code');
3420
+ return found.path || 'claude';
3280
3421
  }
3281
3422
 
3282
3423
  // The executable every Agent SDK call is given (pathToClaudeCodeExecutable):
@@ -3291,131 +3432,63 @@ function getClaudeSdkExecutable() {
3291
3432
  });
3292
3433
  }
3293
3434
 
3294
- let _codexBinPath = null;
3435
+ // Codex: what the SDK and the app-server start. On Windows the native codex.exe
3436
+ // an npm command file leads to (the SDK spawns without a shell); the file found
3437
+ // otherwise. SynaBun carries no Codex (lib/external-tools.js): without one,
3438
+ // Codex is reported as not installed. 'codex' when there is none.
3295
3439
  let _codexBinSource = 'missing';
3296
- let _codexBinMissingAt = 0;
3297
- let _nativeCodexBinPath = null;
3298
3440
  // The SDK is statically imported at module load; reaching this point is the
3299
3441
  // authoritative capability check. Its package.json is intentionally not an
3300
3442
  // exported subpath, so resolving that file reports a false negative.
3301
3443
  const _codexSdkInstalled = true;
3302
3444
 
3303
- function resolveWindowsGlobalCodexRuntime() {
3304
- const globalPath = getAugmentedPath().split(delimiter)
3305
- .filter((entry) => !/[\\/]node_modules[\\/]\.bin[\\/]?$/i.test(entry))
3306
- .join(delimiter);
3307
- const env = { ...process.env, PATH: globalPath };
3308
- const whereExecutable = process.env.SystemRoot
3309
- ? join(process.env.SystemRoot, 'System32', 'where.exe')
3310
- : 'where.exe';
3311
- const lookup = spawnSync(whereExecutable, ['codex'], {
3312
- encoding: 'utf-8',
3313
- env,
3314
- windowsHide: true,
3315
- });
3316
- const launchers = lookup.status === 0
3317
- ? String(lookup.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
3318
- : [];
3319
-
3320
- const npmRootProbe = spawnSync(
3321
- process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
3322
- ['/d', '/s', '/c', 'npm root -g'],
3323
- { encoding: 'utf-8', env, windowsHide: true, timeout: 5000 },
3324
- );
3325
- const npmRoots = npmRootProbe.status === 0
3326
- ? String(npmRootProbe.stdout || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean)
3327
- : [];
3328
- const codexPackageJsonPaths = windowsCodexPackageJsonCandidates({
3329
- npmRoots,
3330
- appData: process.env.APPDATA,
3331
- npmConfigPrefix: process.env.NPM_CONFIG_PREFIX || process.env.npm_config_prefix,
3332
- synabunPackageRoot: PACKAGE_ROOT,
3333
- nodeExecutable: process.execPath,
3334
- });
3335
- const resolved = resolveTrustedWindowsCodexBinary({
3336
- launchers,
3337
- codexPackageJsonPaths,
3338
- arch: process.arch,
3339
- acceptBinary: (candidate) => !isInsideSynabun(candidate),
3340
- });
3341
- return { ...resolved, launchers, codexPackageJsonPaths, npmRoots };
3342
- }
3343
-
3344
3445
  function getCodexBin() {
3345
- // (Not found is looked for again after a moment, like Claude Code above.)
3346
- if (_codexBinPath && (_codexBinSource !== 'missing' || Date.now() - _codexBinMissingAt < MISSING_TOOL_RECHECK_MS)) return _codexBinPath;
3347
- // All Codex surfaces follow the user's trusted global install. SynaBun
3348
- // carries no Codex of its own (lib/external-tools.js): the SDK is always
3349
- // handed this path, and without one Codex is reported as not installed.
3350
- const globalPath = getAugmentedPath().split(delimiter)
3351
- .filter((entry) => !/[\\/]node_modules[\\/]\.bin[\\/]?$/i.test(entry))
3352
- .join(delimiter);
3353
- const envWithPath = { ...process.env, PATH: globalPath };
3354
- if (process.platform === 'win32') {
3355
- const resolution = resolveWindowsGlobalCodexRuntime();
3356
- if (resolution.path) {
3357
- _codexBinPath = resolution.path;
3358
- _nativeCodexBinPath = resolution.path;
3359
- } else {
3360
- _codexBinPath = selectWindowsCodexLauncher(
3361
- resolution.launchers,
3362
- (launcher) => !isInsideSynabun(launcher),
3363
- );
3364
- }
3365
- } else {
3366
- try { _codexBinPath = execSync('which codex', { encoding: 'utf-8', env: envWithPath }).trim(); }
3367
- catch { _codexBinPath = null; }
3368
- }
3369
- if (_codexBinPath && _codexBinPath !== 'codex' && !isInsideSynabun(_codexBinPath)) {
3370
- if (_codexBinMissingAt) console.log(`[codex-skin] Codex CLI found: ${_codexBinPath}`);
3371
- _codexBinMissingAt = 0;
3372
- _codexBinSource = 'global';
3373
- return _codexBinPath;
3374
- }
3375
- if (!_codexBinMissingAt) {
3376
- if (_codexBinPath && _codexBinPath !== 'codex') console.warn(`[codex-skin] Ignoring Codex CLI inside the SynaBun install: ${_codexBinPath}`);
3377
- console.warn('[codex-skin] Codex CLI not found. Install: npm install -g @openai/codex');
3378
- }
3379
- _codexBinMissingAt = Date.now();
3380
- _codexBinPath = 'codex';
3381
- _codexBinSource = 'missing';
3382
- return _codexBinPath;
3446
+ const found = cliResolver.launcher('codex');
3447
+ _noteCliLookup('codex', found, 'Codex');
3448
+ _codexBinSource = found.path ? 'global' : 'missing';
3449
+ return found.launchPath || found.path || 'codex';
3383
3450
  }
3384
3451
 
3452
+ // Native Codex schedules hand the SDK a program it can start without a shell:
3453
+ // on Windows a codex.exe, never a command file.
3385
3454
  function getNativeCodexBin() {
3386
- if (_nativeCodexBinPath) return _nativeCodexBinPath;
3387
-
3455
+ const found = cliResolver.launcher('codex');
3456
+ _codexBinSource = found.path ? 'global' : 'missing';
3388
3457
  if (process.platform === 'win32') {
3389
- const resolved = resolveWindowsGlobalCodexRuntime();
3390
- if (resolved.path) {
3391
- _nativeCodexBinPath = resolved.path;
3392
- _codexBinPath = resolved.path;
3393
- _codexBinSource = 'global';
3394
- console.log(`[codex-native] Resolved Windows runtime (${resolved.source}): ${resolved.path}`);
3395
- return _nativeCodexBinPath;
3396
- }
3397
- if (resolved.reason) {
3398
- console.warn(`[codex-native] ${resolved.reason}`);
3399
- }
3400
- if (resolved.checked?.length) {
3401
- console.warn(`[codex-native] Checked: ${resolved.checked.slice(0, 16).join(' | ')}`);
3402
- }
3403
- const globalPackageFound = resolved.codexPackageJsonPaths.some((candidate) => existsSync(candidate));
3404
- throw new Error(
3405
- globalPackageFound
3406
- ? 'The global Codex package was found, but its Windows runtime is missing. Reinstall it with: npm install -g @openai/codex@latest --include=optional, then restart SynaBun.'
3407
- : 'Codex is not installed globally or could not be located. Install it with: npm install -g @openai/codex@latest --include=optional, then restart SynaBun.',
3408
- );
3458
+ if (found.launchPath && /\.exe$/i.test(found.launchPath)) return found.launchPath;
3459
+ throw new Error(found.path
3460
+ ? `Codex was found at ${found.path}, but not the codex.exe it runs. Reinstall Codex (the Codex panel's Install button, or npm install -g @openai/codex@latest --include=optional), then try again.`
3461
+ : 'Codex is not installed. Install it with the Codex panel\'s Install button, or run: powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"');
3409
3462
  }
3410
-
3411
- const codexBin = getCodexBin();
3412
- if (_codexBinSource !== 'global' || !codexBin || codexBin === 'codex') {
3413
- throw new Error(
3414
- 'Codex schedules require a trusted global Codex CLI. Install it with: npm install -g @openai/codex',
3415
- );
3463
+ if (!found.path) {
3464
+ throw new Error('Codex schedules need Codex installed. Install it with the Codex panel\'s Install button, or run: curl -fsSL https://chatgpt.com/codex/install.sh | sh');
3465
+ }
3466
+ return found.launchPath || found.path;
3467
+ }
3468
+
3469
+ // How `opencode serve --port N` starts, for the shared serve, the isolated
3470
+ // serves of sessions and loops and the editor's completion serve. On macOS and
3471
+ // Linux through the user's login shell, as a terminal starts it (OpenCode gets
3472
+ // the provider keys exported in .zshrc), naming the CLI the resolver found by
3473
+ // its full path; on Windows the opencode.exe found, started directly with the
3474
+ // PATH a new terminal has (a command file that leads nowhere: through cmd.exe,
3475
+ // every word escaped). A command line in Settings is run as written.
3476
+ function opencodeServeLaunch(port, { env = process.env } = {}) {
3477
+ const found = cliResolver.launcher('opencode');
3478
+ _noteCliLookup('opencode', found, 'OpenCode');
3479
+ const line = configuredCommandLine('opencode');
3480
+ if (IS_WIN) {
3481
+ const spawnEnv = withPathEnv(env, getAugmentedPath());
3482
+ if (!line && found.spec) {
3483
+ const launch = spawnArgsFor(found.spec, ['serve', '--port', String(port)], { env: spawnEnv });
3484
+ return { command: launch.command, args: launch.args, options: { ...launch.options, env: spawnEnv, windowsHide: true }, label: `${found.launchPath} serve --port ${port}` };
3485
+ }
3486
+ const text = `${line || 'opencode'} serve --port ${port}`;
3487
+ return { command: DEFAULT_SHELL, args: ['/d', '/s', '/c', text], options: { env: spawnEnv, windowsHide: true }, label: text };
3416
3488
  }
3417
- _nativeCodexBinPath = codexBin;
3418
- return _nativeCodexBinPath;
3489
+ const cmd = line || (found.path ? shellSingleQuote(found.path) : 'opencode');
3490
+ // A packaged application's runtime and npm after the user's own (the login shell adds the user's profile on top).
3491
+ return { command: DEFAULT_SHELL, args: ['-lc', `exec ${cmd} serve --port ${port}`], options: { env: withPackagedPathLast(env) }, label: `${cmd} serve --port ${port}` };
3419
3492
  }
3420
3493
 
3421
3494
  function extractCodexConfigValues(result) {
@@ -3455,10 +3528,10 @@ function buildCodexWritableRoots(cwd = PACKAGE_ROOT) {
3455
3528
  return [...roots];
3456
3529
  }
3457
3530
 
3458
- function getCodexSidepanelApprovalPolicy() {
3531
+ function getCodexSidepanelApprovalPolicy(config = {}) {
3459
3532
  // The sidepanel is interactive; Auto Accept decides which emitted requests
3460
3533
  // can be approved without presenting a card.
3461
- return CODEX_SIDEPANEL_APPROVAL_POLICY;
3534
+ return config.approval_policy ?? CODEX_SIDEPANEL_APPROVAL_POLICY;
3462
3535
  }
3463
3536
 
3464
3537
  function buildCodexSandboxPolicy(cwd = PACKAGE_ROOT, sandboxMode) {
@@ -3654,7 +3727,7 @@ async function resolveOpenCodeMcpDirectory(entry, sessionIds) {
3654
3727
  // auth.json + sessions/ + state DB, while config.toml and prompts/ are
3655
3728
  // symlinked back to the canonical ~/.codex so model/MCP/tool-permission config
3656
3729
  // and custom prompts stay shared. The "default" account IS ~/.codex itself.
3657
- const CODEX_DEFAULT_HOME = resolve(getHomePath(), '.codex');
3730
+ const CODEX_DEFAULT_HOME = resolve(process.env.CODEX_HOME || resolve(getHomePath(), '.codex'));
3658
3731
  const CODEX_ACCOUNTS_ROOT = resolve(getHomePath(), '.codex-accounts');
3659
3732
 
3660
3733
  function readCodexRuntimeModelCatalog(codexHome = CODEX_DEFAULT_HOME) {
@@ -3670,78 +3743,83 @@ function readCodexRuntimeModelCatalog(codexHome = CODEX_DEFAULT_HOME) {
3670
3743
  }
3671
3744
  const CODEX_ACCOUNTS_PATH = resolve(DATA_HOME, 'data', 'codex-accounts.json');
3672
3745
 
3673
- function loadCodexAccounts() {
3674
- let data = { accounts: [], defaultId: 'default' };
3675
- try {
3676
- if (existsSync(CODEX_ACCOUNTS_PATH)) {
3677
- const parsed = JSON.parse(readFileSync(CODEX_ACCOUNTS_PATH, 'utf-8'));
3678
- if (parsed && Array.isArray(parsed.accounts)) data = parsed;
3679
- }
3680
- } catch (err) {
3681
- console.warn('[codex-accounts] Failed to read registry:', err.message);
3682
- }
3683
- // Auto-seed the Default account (the existing ~/.codex)
3684
- if (!data.accounts.some((a) => a && a.id === 'default')) {
3685
- data.accounts.unshift({
3686
- id: 'default',
3687
- label: 'Default',
3688
- email: '',
3689
- planType: '',
3690
- type: 'chatgpt',
3691
- home: CODEX_DEFAULT_HOME,
3692
- createdAt: new Date().toISOString(),
3746
+ // Admin sandbox (lib/agent-sandbox-setting.js, lib/agent-sandbox-elevation.js): one global value,
3747
+ // off by default, for every agent SynaBun starts: may it use a sandbox whose setup needs Windows
3748
+ // administrator approval. Codex is the provider it changes (windows.sandbox, a launch override on
3749
+ // every Codex process, never written into config.toml). Changed from the Assistant panel only; idle
3750
+ // Codex app-servers (panel tabs, the Assistant's brain) restart under it, busy ones keep theirs.
3751
+ const _agentSandbox = createAdminSandboxSetting({
3752
+ path: resolve(DATA_HOME, 'data', 'agent-sandbox.json'),
3753
+ broadcast: (message) => broadcastSync(message),
3754
+ log: (tag, message) => console.log(`[${tag}]`, message),
3755
+ });
3756
+ function adminSandboxOn() { return _agentSandbox.enabled(); }
3757
+ /** The `-c` words every Codex SynaBun starts gets for the setting (outside an account's launch plan). */
3758
+ function codexSandboxArgs() { return codexWindowsSandboxArgs(adminSandboxOn()); }
3759
+ _agentSandbox.subscribe(() => {
3760
+ for (const connection of [..._codexLiveConnections]) {
3761
+ if (!connection.busy()) { try { connection.credentialsChanged(); } catch {} }
3762
+ }
3763
+ });
3764
+
3765
+ // The Codex accounts registry (lib/codex-accounts.js): one instance for the
3766
+ // sidepanel, the Assistant (brain, workers, catalog), loops and every route, so
3767
+ // one account state is shown everywhere. Its ChatGPT half is Codex's own
3768
+ // `account/read` for each account (codexAccountAuth().nativeStatus); a change
3769
+ // of any account reaches every window as `codex:accounts-changed`.
3770
+ let _assistantAccountUsage = null;
3771
+ let _codexAccountsInstance = null;
3772
+ function _codexAccounts() {
3773
+ if (!_codexAccountsInstance) {
3774
+ _codexAccountsInstance = createCodexAccounts({
3775
+ dataHome: DATA_HOME, homeDir: getHomePath(), accountsRoot: CODEX_ACCOUNTS_ROOT,
3776
+ readNativeStatus: (accountId) => codexAccountAuth().nativeStatus(accountId),
3777
+ prepareDefaultConfig: () => ensureCanonicalCodexConfig({ strict: true }),
3778
+ // Every launch plan carries the Windows sandbox the Admin sandbox setting allows.
3779
+ adminSandbox: () => adminSandboxOn(),
3780
+ log: (tag, message) => console.log(`[${tag}]`, message),
3781
+ });
3782
+ _codexAccountsInstance.subscribe((event) => {
3783
+ try {
3784
+ broadcastSync({ type: 'codex:accounts-changed', accountId: event.accountId || null, removed: !!event.removed, created: !!event.created });
3785
+ broadcastSync({ type: 'assistant:accounts-changed', provider: 'codex', accountId: event.accountId || null, removed: !!event.removed });
3786
+ _assistantCatalog?.invalidate?.();
3787
+ } catch {}
3693
3788
  });
3694
- data.defaultId = 'default';
3695
3789
  }
3696
- return data;
3790
+ return _codexAccountsInstance;
3697
3791
  }
3698
3792
 
3699
- function saveCodexAccounts(data) {
3700
- try {
3701
- const dir = dirname(CODEX_ACCOUNTS_PATH);
3702
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
3703
- writeFileSync(CODEX_ACCOUNTS_PATH, JSON.stringify(data, null, 2), 'utf-8');
3704
- } catch (err) {
3705
- console.warn('[codex-accounts] Failed to write registry:', err.message);
3706
- }
3793
+ // The registry rows (id, label, home, …) for readers that only need where an account lives.
3794
+ function loadCodexAccounts() {
3795
+ return { accounts: _codexAccounts().registryRows(), defaultId: 'default' };
3707
3796
  }
3708
3797
 
3798
+ // The account, or Default when none is named or it is gone (launch selectors, session catalogs).
3709
3799
  function getCodexAccount(accountId) {
3710
- const data = loadCodexAccounts();
3711
- return data.accounts.find((a) => a.id === accountId)
3712
- || data.accounts.find((a) => a.id === data.defaultId)
3713
- || data.accounts[0]
3714
- || null;
3800
+ const accounts = _codexAccounts();
3801
+ return (accountId ? accounts.profileFor(accountId) : null) || accounts.profileFor('default');
3715
3802
  }
3716
3803
 
3804
+ // The account a request names, or null: never another account in its place.
3717
3805
  function findCodexAccount(accountId) {
3718
3806
  if (!accountId) return null;
3719
- return loadCodexAccounts().accounts.find((account) => account?.id === accountId) || null;
3807
+ return _codexAccounts().profileFor(accountId);
3720
3808
  }
3721
3809
 
3722
3810
  function getCodexAccountHome(accountId) {
3723
3811
  return getCodexAccount(accountId)?.home || CODEX_DEFAULT_HOME;
3724
3812
  }
3725
3813
 
3726
- function sanitizeCodexAccountForClient(a) {
3727
- return {
3728
- id: a.id,
3729
- label: a.label || a.email || 'Account',
3730
- email: a.email || '',
3731
- planType: a.planType || '',
3732
- type: a.type || 'chatgpt',
3733
- isDefault: a.id === 'default',
3734
- };
3735
- }
3736
-
3814
+ // Every account with its state (mode, signed in, identity: an email or a masked key, never a key).
3737
3815
  function listCodexAccountsForClient() {
3738
- return loadCodexAccounts().accounts.map(sanitizeCodexAccountForClient);
3816
+ return _codexAccounts().listForClient();
3739
3817
  }
3740
3818
 
3741
- function ensureCanonicalCodexConfig({ strict = false } = {}) {
3819
+ function ensureCanonicalCodexConfig({ strict = false, codexHome = CODEX_DEFAULT_HOME } = {}) {
3742
3820
  try {
3743
- if (!existsSync(CODEX_DEFAULT_HOME)) mkdirSync(CODEX_DEFAULT_HOME, { recursive: true });
3744
- const cfg = resolve(CODEX_DEFAULT_HOME, 'config.toml');
3821
+ if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
3822
+ const cfg = resolve(codexHome, 'config.toml');
3745
3823
  if (!existsSync(cfg)) writeFileSync(cfg, '', 'utf-8');
3746
3824
  const repair = healCodexConfig(cfg);
3747
3825
  if (repair.healed) {
@@ -3751,50 +3829,85 @@ function ensureCanonicalCodexConfig({ strict = false } = {}) {
3751
3829
  if (strict) throw error;
3752
3830
  console.warn(`[codex-config] ${error.message}`);
3753
3831
  }
3832
+ // Env/tool CLI overrides require a transport, even after a fresh CLI install
3833
+ // or when setup never connected Codex. Reuse Connect Codex's canonical writer;
3834
+ // leave an already connected transport and all unrelated settings alone.
3835
+ const health = inspectCodexConfig(cfg);
3836
+ if (!health.connected || health.issues.includes('missing_transport')) {
3837
+ const inspection = upsertCodexMcpConfig(cfg, buildCodexMcpDefinition());
3838
+ if (!inspection.valid) throw new Error(`Could not register SynaBun in ${cfg}`);
3839
+ return { healed: true, reason: 'registered_missing_transport', inspection };
3840
+ }
3841
+ if (!health.valid) throw new Error(`Invalid SynaBun MCP configuration in ${cfg}: ${health.issues.join(', ')}`);
3754
3842
  return repair;
3755
3843
  } catch (err) {
3756
- console.warn(`[codex-config] Could not prepare ${CODEX_DEFAULT_HOME}/config.toml: ${err.message}`);
3844
+ console.warn(`[codex-config] Could not prepare ${codexHome}/config.toml: ${err.message}`);
3757
3845
  if (strict) throw err;
3758
3846
  return null;
3759
3847
  }
3760
3848
  }
3761
3849
 
3762
- function pathOrLinkExists(p) {
3763
- try { lstatSync(p); return true; } catch { return false; }
3850
+ // Live Codex connections (panel tabs, the Assistant's brain): their account,
3851
+ // whether a turn is running, and how they restart under a changed credential.
3852
+ const _codexLiveConnections = new Set();
3853
+
3854
+ // A Codex account's credential or mode changes: refused while one of its
3855
+ // connections is mid-turn; afterwards its idle app-servers restart, so the next
3856
+ // message runs under the new credential. Other accounts are not touched.
3857
+ async function withCodexAccountCredentialChange(accountId, change) {
3858
+ const connections = [..._codexLiveConnections].filter((connection) => connection.accountId() === accountId);
3859
+ if (connections.some((connection) => connection.busy())) {
3860
+ throw Object.assign(new Error('Finish or stop this account’s Codex work before changing its sign-in.'), { status: 409 });
3861
+ }
3862
+ const result = await change();
3863
+ for (const connection of connections) { try { connection.credentialsChanged(); } catch {} }
3864
+ return result;
3764
3865
  }
3765
3866
 
3766
- function linkSharedCodexResource(linkPath, targetPath, kind) {
3767
- // kind: 'file' | 'dir'
3768
- try {
3769
- if (pathOrLinkExists(linkPath)) return;
3770
- if (!existsSync(targetPath)) {
3771
- if (kind === 'dir') mkdirSync(targetPath, { recursive: true });
3772
- else writeFileSync(targetPath, '', 'utf-8');
3773
- }
3774
- symlinkSync(targetPath, linkPath, kind === 'dir' ? 'dir' : 'file');
3775
- } catch (err) {
3776
- console.warn('[codex-accounts] Failed to link', linkPath, '→', targetPath, err.message);
3867
+ // Codex's own store changed outside a request (a terminal sign-in ended): idle connections restart.
3868
+ function codexAccountStoreChanged(accountId) {
3869
+ for (const connection of [..._codexLiveConnections]) {
3870
+ if (connection.accountId() === accountId && !connection.busy()) { try { connection.credentialsChanged(); } catch {} }
3777
3871
  }
3778
3872
  }
3779
3873
 
3780
- function createCodexAccountHome(id) {
3781
- const home = resolve(CODEX_ACCOUNTS_ROOT, id);
3782
- mkdirSync(home, { recursive: true });
3783
- mkdirSync(resolve(home, 'sessions'), { recursive: true });
3784
- ensureCanonicalCodexConfig();
3785
- // Share config + prompts with the canonical ~/.codex; isolate auth + sessions.
3786
- linkSharedCodexResource(resolve(home, 'config.toml'), resolve(CODEX_DEFAULT_HOME, 'config.toml'), 'file');
3787
- linkSharedCodexResource(resolve(home, 'prompts'), resolve(CODEX_DEFAULT_HOME, 'prompts'), 'dir');
3788
- // Global instructions (and SynaBun's rules block in them) reach every account.
3789
- // Only when the file exists: the rules installer links it later otherwise,
3790
- // and an empty ~/.codex/AGENTS.md is not ours to create.
3791
- if (existsSync(resolve(CODEX_DEFAULT_HOME, 'AGENTS.md'))) {
3792
- linkSharedCodexResource(resolve(home, 'AGENTS.md'), resolve(CODEX_DEFAULT_HOME, 'AGENTS.md'), 'file');
3874
+ let _codexAccountAuthInstance = null;
3875
+ function codexAccountAuth() {
3876
+ if (!_codexAccountAuthInstance) {
3877
+ _codexAccountAuthInstance = createCodexAccountAuth({
3878
+ accounts: _codexAccounts(), codexBin: () => getCodexBin(),
3879
+ prepareConfig: (codexHome) => ensureCanonicalCodexConfig({ strict: true, codexHome }),
3880
+ env: () => (withPathEnv(process.env, getAugmentedPath())),
3881
+ changeCredentials: withCodexAccountCredentialChange,
3882
+ });
3793
3883
  }
3794
- return home;
3884
+ return _codexAccountAuthInstance;
3885
+ }
3886
+
3887
+ // Why a Codex account cannot be removed now ('' when it can): a live connection
3888
+ // (a panel tab, the Assistant's brain) other than `except`, a native loop, a
3889
+ // dispatched run, an Assistant session, or the Assistant's worker choice.
3890
+ function codexAccountInUse(accountId, { except = null } = {}) {
3891
+ if ([..._codexLiveConnections].some((connection) => connection !== except && connection.accountId() === accountId)) {
3892
+ return 'A Codex tab or the Assistant is running under this account. Switch it or close it first.';
3893
+ }
3894
+ try { if (_nativeLoopRuntime?.list({ activeOnly: true }).some((run) => run.provider === 'codex' && run.accountId === accountId)) return 'Stop native Codex automations using this account before removing it.'; } catch {}
3895
+ try { if (_assistantDispatcher?.accountInUse?.('codex', accountId)) return 'Dispatched runs are using this account. Stop them first.'; } catch {}
3896
+ try { if (_assistantRuntime?.accountInUse?.('codex', accountId)) return 'An Assistant session uses this account. Close it first.'; } catch {}
3897
+ try { if (_assistantCodexWorkerAccount?.() === accountId) return 'This is the Assistant\'s Codex worker account. Choose another one first.'; } catch {}
3898
+ return '';
3795
3899
  }
3796
3900
 
3797
3901
  function handleCodexSkinWebSocket(ws) {
3902
+ // In API-key mode with a key SynaBun holds, the app-server starts with an ephemeral
3903
+ // credential store and this key is its first request over stdin (lib/codex-accounts.js).
3904
+ // Spawn-only: never logged, sent or kept after that request.
3905
+ let childApiKeyLogin = null;
3906
+ let childUsesHeldKey = false;
3907
+ // Which credential the running app-server started with (launchFor().credential).
3908
+ let childCredential = null;
3909
+ // Which Windows sandbox it started with (launchFor().windowsSandbox, the Admin sandbox setting).
3910
+ let childWindowsSandbox = null;
3798
3911
  const CODEX_GREETING_MARKER_START = '[SYNABUN_CODEX_GREETING_START]';
3799
3912
  const CODEX_GREETING_MARKER_END = '[SYNABUN_CODEX_GREETING_END]';
3800
3913
  const CODEX_PLAN_MODE_PREFIXES = [
@@ -3843,7 +3956,27 @@ CRITICAL — When you need clarification during planning:
3843
3956
  // Per-tab Codex account → drives CODEX_HOME of the spawned app-server.
3844
3957
  let activeAccountId = 'default';
3845
3958
  let activeCodexHome = CODEX_DEFAULT_HOME;
3846
- let _codexLoginWatcher = null;
3959
+ const panelAuth = createCodexPanelAuth({
3960
+ request: (...args) => request(...args), accountId: () => activeAccountId,
3961
+ onChange: state => sendToClient({ type: 'account_login_state', state }),
3962
+ onSuccess: async id => {
3963
+ const acct = await request('account/read', { refreshToken: false }, 10000);
3964
+ if (id !== activeAccountId) return;
3965
+ // What Codex now holds is the account's state everywhere (lib/codex-accounts.js).
3966
+ if (acct?.account?.type === 'chatgpt') _codexAccounts().noteSubscriptionSignIn(id, acct);
3967
+ else _codexAccounts().noteNativeStatus(id, acct || {});
3968
+ sendToClient({ type: 'account_info', accountId: id, account: acct, accountState: _codexAccounts().describeForClient(id) });
3969
+ sendToClient({ type: 'account_list', accounts: listCodexAccountsForClient(), activeAccountId });
3970
+ },
3971
+ });
3972
+ // This connection in the live set: account changes ask it whether it is busy and restart it when idle.
3973
+ const liveConnection = {
3974
+ accountId: () => activeAccountId,
3975
+ readRateLimits: () => initialized && !childUsesHeldKey ? request('account/rateLimits/read', {}, 2500) : null,
3976
+ busy: () => !!activeTurnId || pendingServerRequests.size > 0 || panelAuth.active(),
3977
+ credentialsChanged: () => retireForCredentialChange(),
3978
+ };
3979
+ _codexLiveConnections.add(liveConnection);
3847
3980
  let codexMcpRuntimeId = `codex-sp-${randomUUID()}`;
3848
3981
  let codexMcpRuntimeBinding = null;
3849
3982
  // 'assistant' when this connection is the main assistant's brain: the MCP
@@ -3852,6 +3985,12 @@ CRITICAL — When you need clarification during planning:
3852
3985
  // The assistant brain's computer-use grant (lib/desktop/grants.js): unlocks
3853
3986
  // the capability-gated `computer` group in this app-server's MCP child.
3854
3987
  let codexDesktopGrant = null;
3988
+ // A WhatsApp conversation brain's credential (whatsapp_send_file): taken from the in-process
3989
+ // brain's own socket object (lib/virtual-socket.js brainCredentialOf), never from a message a
3990
+ // client sends; it reaches the MCP child through the environment only (SYNABUN_BRAIN_TOKEN,
3991
+ // forwarded by name with env_vars), never argv. A name with TOKEN in it stays out of the
3992
+ // brain's own shell commands under Codex's default shell_environment_policy.
3993
+ let codexBrainCredential = null;
3855
3994
  // The assistant brain's route gate (lib/assistant-route-gate-codex.js): a
3856
3995
  // trusted session-flag PreToolUse hook plus the env it reads. Assistant only.
3857
3996
  let codexRouteGate = null;
@@ -3880,8 +4019,10 @@ CRITICAL — When you need clarification during planning:
3880
4019
 
3881
4020
  async function beforeCodexCompatRequest(type, params) {
3882
4021
  if (type === 'thread_settings_update') {
3883
- validateCodexConfigRequirements({ approval_policy: params.approvalPolicy, approvals_reviewer: params.approvalsReviewer }, await readCodexRequirements());
4022
+ validateCodexConfigRequirements({ approval_policy: params.approvalPolicy, approvals_reviewer: params.approvalsReviewer, sandbox_mode: ({ readOnly: 'read-only', workspaceWrite: 'workspace-write', dangerFullAccess: 'danger-full-access' })[params.sandboxPolicy?.type] }, await readCodexRequirements());
3884
4023
  } else if (type === 'experimental_features_set') {
4024
+ const metadata = await readSettingsMetadata();
4025
+ validateCodexSettings({ features: params.enablement }, metadata, metadata.features);
3885
4026
  validateCodexConfigRequirements({ features: params.enablement }, await readCodexRequirements());
3886
4027
  } else if (type === 'feedback_upload') {
3887
4028
  await readCodexRequirements();
@@ -3997,9 +4138,48 @@ CRITICAL — When you need clarification during planning:
3997
4138
  ...details,
3998
4139
  }));
3999
4140
  }
4141
+ // A tab names its account; one that is no longer set up runs as Default, and says so
4142
+ // (every answer carries activeAccountId), never under Default's home with another name.
4000
4143
  function setActiveCodexAccount(id) {
4001
- activeAccountId = id || 'default';
4002
- activeCodexHome = getCodexAccountHome(activeAccountId);
4144
+ const account = getCodexAccount(id || 'default');
4145
+ activeAccountId = account?.id || 'default';
4146
+ activeCodexHome = account?.home || CODEX_DEFAULT_HOME;
4147
+ }
4148
+
4149
+ function sendAccountState() {
4150
+ sendToClient({ type: 'account_state', accountId: activeAccountId, account: _codexAccounts().describeForClient(activeAccountId), accounts: listCodexAccountsForClient() });
4151
+ }
4152
+
4153
+ // The account's credential or mode changed (withCodexAccountCredentialChange), or the Admin
4154
+ // sandbox setting did: an idle app-server is retired, and the next request starts one under
4155
+ // the new launch plan; the conversation resumes from its thread then. The transcript stays.
4156
+ function retireForCredentialChange() {
4157
+ const next = _codexAccounts().launchFor(activeAccountId);
4158
+ if (child && !activeTurnId && !pendingServerRequests.size && (!next.ok || next.credential !== childCredential || next.windowsSandbox !== childWindowsSandbox)) {
4159
+ const old = child;
4160
+ const oldHome = activeCodexHome;
4161
+ const oldThreadIds = ownedWriterThreadIds();
4162
+ panelAuth.reset();
4163
+ initialized = false;
4164
+ bootPromise = null;
4165
+ activeThreadId = null;
4166
+ appBoundary.invalidate();
4167
+ activeThreadRuntimeFingerprint = null;
4168
+ subscribedThreadIds.clear();
4169
+ threadOwnerRoots.clear();
4170
+ activeTurnAutoAccept = false;
4171
+ for (const [, entry] of pending) {
4172
+ try { clearTimeout(entry.timer); entry.reject(new Error('Codex account sign-in changed')); } catch {}
4173
+ }
4174
+ pending.clear();
4175
+ childStdoutBuf = '';
4176
+ unregisterCodexMcpProfileRuntime();
4177
+ child = null;
4178
+ childCredential = null;
4179
+ childWindowsSandbox = null;
4180
+ retireCodexProcess(old, { codexHome: oldHome, threadIds: oldThreadIds, reason: 'Codex account sign-in changed' });
4181
+ }
4182
+ sendAccountState();
4003
4183
  }
4004
4184
 
4005
4185
  async function withCodexWriterOperation(operation, action) {
@@ -4022,6 +4202,7 @@ CRITICAL — When you need clarification during planning:
4022
4202
  })();
4023
4203
 
4024
4204
  function sendToClient(data) {
4205
+ data = redactProviderSecrets(data);
4025
4206
  if (data?.type === 'error') {
4026
4207
  // Some legacy catch paths forward only a prefixed RPC message.
4027
4208
  const rpcError = { ...data, code: data.code ?? (/Invalid request: unknown variant/i.test(data.message || '') ? -32600 : null) };
@@ -4146,6 +4327,7 @@ CRITICAL — When you need clarification during planning:
4146
4327
  const codexHome = activeCodexHome;
4147
4328
  const threadIds = ownedWriterThreadIds();
4148
4329
  shuttingDown = true;
4330
+ panelAuth.interrupted();
4149
4331
  unregisterCodexMcpProfileRuntime({ cleanup: true });
4150
4332
  childRetirementPromise = (async () => {
4151
4333
  if (unsubscribe) await unsubscribeAllThreadsBeforeRetirement();
@@ -4364,6 +4546,19 @@ CRITICAL — When you need clarification during planning:
4364
4546
  return result || {};
4365
4547
  }
4366
4548
 
4549
+ async function readSettingsMetadata() {
4550
+ const schema = readCodexSettingsSchema(getCodexBin());
4551
+ const features = await listCodexPages(request, 'experimentalFeature/list', { limit: 100 });
4552
+ const featureSupport = readCodexFeatureSupport(getCodexBin(), features.data);
4553
+ // The catalog's enabled field is a process-start snapshot. config/read is
4554
+ // authoritative for saved defaults after reloadUserConfig writes.
4555
+ return { ...schema, features: features.data.map(feature => ({ ...feature,
4556
+ configurable: featureSupport[feature.name] === true,
4557
+ enabled: typeof _cachedConfig.features?.[feature.name] === 'boolean'
4558
+ ? _cachedConfig.features[feature.name] : feature.enabled,
4559
+ })) };
4560
+ }
4561
+
4367
4562
  async function setCodexMcpServerApprovalMode(serverName, approvalMode) {
4368
4563
  const name = String(serverName || '').trim();
4369
4564
  if (!name) throw new Error('MCP approval request is missing its server identity');
@@ -4408,7 +4603,7 @@ CRITICAL — When you need clarification during planning:
4408
4603
  rejected: isolationStats.rejected,
4409
4604
  reasons: { ...isolationStats.reasons },
4410
4605
  },
4411
- approvalPolicy: getCodexSidepanelApprovalPolicy(),
4606
+ approvalPolicy: getCodexSidepanelApprovalPolicy(_cachedConfig),
4412
4607
  sessionOverrides: codexSessionSettings.get(activeAccountId, activeThreadId),
4413
4608
  cwd,
4414
4609
  },
@@ -4498,7 +4693,7 @@ CRITICAL — When you need clarification during planning:
4498
4693
  function getDefaultThreadParams(cwd = PACKAGE_ROOT, runtime = {}) {
4499
4694
  const params = {
4500
4695
  cwd,
4501
- approvalPolicy: getCodexSidepanelApprovalPolicy(),
4696
+ approvalPolicy: getCodexSidepanelApprovalPolicy(_cachedConfig),
4502
4697
  sandbox: getCachedConfigValue('sandbox_mode', 'workspace-write'),
4503
4698
  personality: getCachedConfigValue('personality', 'pragmatic'),
4504
4699
  };
@@ -4513,16 +4708,19 @@ CRITICAL — When you need clarification during planning:
4513
4708
  threadId,
4514
4709
  cwd,
4515
4710
  excludeTurns: true,
4516
- approvalPolicy: getCodexSidepanelApprovalPolicy(),
4711
+ approvalPolicy: getCodexSidepanelApprovalPolicy(_cachedConfig),
4517
4712
  sandbox: getCachedConfigValue('sandbox_mode', 'workspace-write'),
4518
4713
  personality: getCachedConfigValue('personality', 'pragmatic'),
4519
4714
  };
4520
4715
  const sessionSettings = codexSessionSettings.get(activeAccountId, threadId);
4716
+ const sessionSandboxMode = ({ readOnly: 'read-only', workspaceWrite: 'workspace-write', dangerFullAccess: 'danger-full-access' })[sessionSettings.sandboxPolicy?.type];
4717
+ if (sessionSandboxMode) params.sandbox = sessionSandboxMode;
4521
4718
  for (const key of ['approvalPolicy', 'approvalsReviewer', 'personality', 'serviceTier']) {
4522
4719
  if (Object.prototype.hasOwnProperty.call(sessionSettings, key)) params[key] = sessionSettings[key];
4523
4720
  }
4524
4721
  if (runtime.model) params.model = runtime.model;
4525
4722
  else if (getCachedConfigValue('model')) params.model = getCachedConfigValue('model');
4723
+ if (sessionSettings.model != null) params.model = sessionSettings.model;
4526
4724
  if (runtime.config) params.config = runtime.config;
4527
4725
  return params;
4528
4726
  }
@@ -5011,6 +5209,12 @@ CRITICAL — When you need clarification during planning:
5011
5209
 
5012
5210
  function startChild() {
5013
5211
  if (child) return child;
5212
+ // The account decides the credential (lib/codex-accounts.js launchFor()): inherited
5213
+ // OpenAI/Codex credentials out, its CODEX_HOME in, and in API-key mode the key it
5214
+ // holds, given over stdin after initialize to an app-server with an ephemeral store.
5215
+ const launch = _codexAccounts().launchFor(activeAccountId);
5216
+ if (!launch.ok) throw new Error(launch.message);
5217
+ activeCodexHome = launch.home;
5014
5218
  let codexBin = getCodexBin();
5015
5219
  const runtimeProfilePath = writeCodexRuntimeProfile(codexMcpRuntimeId, codexMcpProfileState.value);
5016
5220
  const args = [
@@ -5031,24 +5235,31 @@ CRITICAL — When you need clarification during planning:
5031
5235
  // (lib/browser-tool-policy.js), so Codex's own web search stays off, as for its workers.
5032
5236
  if (codexRole === 'assistant') args.splice(args.length - 1, 0, '-c', 'web_search="disabled"');
5033
5237
  if (codexDesktopGrant) args.splice(args.length - 1, 0, '-c', `mcp_servers.SynaBun.env.SYNABUN_DESKTOP_GRANT=${JSON.stringify(codexDesktopGrant)}`);
5238
+ // The variable's name on the command line, its value only in the app-server's environment (below).
5239
+ if (codexRole === 'assistant' && codexBrainCredential) args.splice(args.length - 1, 0, '-c', 'mcp_servers.SynaBun.env_vars=["SYNABUN_BRAIN_TOKEN"]');
5034
5240
  if (codexRole === 'assistant' && codexRouteGate?.flags?.length) args.splice(args.length - 1, 0, ...codexRouteGate.flags);
5035
5241
  if (/\.js$/i.test(codexBin)) {
5036
5242
  args.unshift(codexBin);
5037
5243
  codexBin = process.execPath;
5038
5244
  }
5245
+ // The Windows sandbox the Admin sandbox setting allows and, in API-key mode with a key SynaBun
5246
+ // holds, an ephemeral credential store: before the subcommand.
5247
+ args.splice(args.length - 1, 0, ...codexAppServerArgs(launch));
5039
5248
  const useShell = !codexBin.includes(sep)
5040
5249
  || (process.platform === 'win32' && /\.(cmd|bat)$/i.test(codexBin));
5041
5250
  const env = {
5042
- ...process.env,
5251
+ ...applyCodexLaunchEnv(process.env, launch),
5043
5252
  NO_COLOR: '1',
5044
- CODEX_HOME: activeCodexHome,
5045
5253
  SYNABUN_PROFILE: codexMcpProfileState.value,
5046
5254
  SYNABUN_TERMINAL_SESSION: codexMcpRuntimeId,
5047
5255
  SYNABUN_RUNTIME_PROFILE_PATH: runtimeProfilePath,
5048
5256
  SYNABUN_TOOL_CATALOG_MODE: 'deferred',
5049
5257
  ...(codexDesktopGrant ? { SYNABUN_DESKTOP_GRANT: codexDesktopGrant } : {}),
5050
5258
  ...(codexRole === 'assistant' && codexRouteGate?.env ? codexRouteGate.env : {}),
5259
+ ...(codexRole === 'assistant' && codexBrainCredential ? { SYNABUN_BRAIN_TOKEN: codexBrainCredential } : {}),
5051
5260
  };
5261
+ // Never one inherited from the launching shell: only this brain's own credential reaches its MCP child.
5262
+ if (!(codexRole === 'assistant' && codexBrainCredential)) delete env.SYNABUN_BRAIN_TOKEN;
5052
5263
  delete env.FORCE_COLOR;
5053
5264
  delete env.CLICOLOR_FORCE;
5054
5265
  const proc = spawn(shellCommand(codexBin, useShell, { isFile: existsSync }), args, {
@@ -5058,10 +5269,14 @@ CRITICAL — When you need clarification during planning:
5058
5269
  shell: useShell,
5059
5270
  });
5060
5271
  child = proc;
5272
+ childApiKeyLogin = launch.apiKey || null;
5273
+ childUsesHeldKey = !!launch.apiKey;
5274
+ childCredential = launch.credential;
5275
+ childWindowsSandbox = launch.windowsSandbox;
5061
5276
  childRetirementPromise = null;
5062
5277
  writerLifecycleState.releaseRequested = false;
5063
5278
  registerCodexMcpProfileRuntime();
5064
- console.log(`[codex-skin] Spawned app-server pid ${proc.pid || '?'} account=${activeAccountId} profile=${codexMcpProfileState.value} runtime=${codexMcpRuntimeId} CODEX_HOME=${activeCodexHome}`);
5279
+ 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}`);
5065
5280
  proc.stdout.setEncoding('utf-8');
5066
5281
  proc.stdout.on('data', (chunk) => {
5067
5282
  if (child !== proc) return; // stale process from a previous account
@@ -5073,9 +5288,9 @@ CRITICAL — When you need clarification during planning:
5073
5288
  if (!line) continue;
5074
5289
  let msg;
5075
5290
  try {
5076
- msg = JSON.parse(line);
5291
+ msg = redactProviderSecrets(JSON.parse(line));
5077
5292
  } catch (err) {
5078
- console.warn('[codex-skin] Invalid JSON from app-server:', err.message, line.slice(0, 160));
5293
+ console.warn('[codex-skin] Invalid JSON from app-server');
5079
5294
  continue;
5080
5295
  }
5081
5296
 
@@ -5188,6 +5403,9 @@ CRITICAL — When you need clarification during planning:
5188
5403
  if (msg.method) {
5189
5404
  const method = msg.method;
5190
5405
  const params = msg.params || {};
5406
+ if (method === 'account/rateLimits/updated') _assistantAccountUsage?.note('codex', activeAccountId, params);
5407
+ if (method === 'account/login/completed') void panelAuth.complete(params);
5408
+ if (method === 'account/updated' && params.authMode === null && !panelAuth.active()) panelAuth.reset();
5191
5409
  const scopedThreadId = notificationThreadId(method, params);
5192
5410
  const scopedTurnId = notificationTurnId(method, params);
5193
5411
  const expectedThreadId = displayedThreadId;
@@ -5262,11 +5480,11 @@ CRITICAL — When you need clarification during planning:
5262
5480
  }
5263
5481
  });
5264
5482
  proc.stderr.setEncoding('utf-8');
5265
- proc.stderr.on('data', (chunk) => {
5266
- const text = chunk.toString().trim();
5267
- if (!text) return;
5268
- console.log('[codex-skin][stderr]', text);
5483
+ const recordNativeDiagnostic = providerDiagnosticSink(text => {
5484
+ // Key-login attempts expose only structured, filtered errors to the UI.
5485
+ if (!childUsesHeldKey && !panelAuth.active()) console.log('[codex-skin][stderr]', text);
5269
5486
  });
5487
+ proc.stderr.on('data', recordNativeDiagnostic);
5270
5488
  proc.on('error', (err) => {
5271
5489
  if (child !== proc) return;
5272
5490
  console.error('[codex-skin] app-server spawn error:', err.message);
@@ -5279,6 +5497,7 @@ CRITICAL — When you need clarification during planning:
5279
5497
  });
5280
5498
  proc.on('close', (code) => {
5281
5499
  if (child !== proc) return; // a newer process replaced this one (e.g. account switch)
5500
+ panelAuth.interrupted();
5282
5501
  unregisterCodexMcpProfileRuntime();
5283
5502
  const wasShuttingDown = shuttingDown;
5284
5503
  child = null;
@@ -5310,7 +5529,7 @@ CRITICAL — When you need clarification during planning:
5310
5529
  // Repair SynaBun's legacy duplicate-env config before Codex parses it.
5311
5530
  // Doing this at the sidepanel boundary avoids the circular failure where
5312
5531
  // Codex rejects config.toml before it can launch SynaBun's MCP process.
5313
- const repair = ensureCanonicalCodexConfig();
5532
+ const repair = ensureCanonicalCodexConfig({ strict: true, codexHome: activeCodexHome });
5314
5533
  if (repair?.healed) {
5315
5534
  sendToClient({
5316
5535
  type: 'codex_config_repaired',
@@ -5319,7 +5538,7 @@ CRITICAL — When you need clarification during planning:
5319
5538
  });
5320
5539
  }
5321
5540
  // Sync tool approvals only after the base transport is known to be valid.
5322
- syncCodexToolPermissions([..._codexPermittedTools]);
5541
+ syncCodexToolPermissions([..._codexPermittedTools], activeCodexHome);
5323
5542
  const codexBin = getCodexBin();
5324
5543
  let cliVersion = null;
5325
5544
  let sdkVersion = null;
@@ -5327,7 +5546,7 @@ CRITICAL — When you need clarification during planning:
5327
5546
  const versionResult = spawnSync(codexBin, ['--version'], { encoding: 'utf-8', timeout: 5000, windowsHide: true });
5328
5547
  cliVersion = parseCodexVersion(versionResult.stdout);
5329
5548
  } catch {}
5330
- try { sdkVersion = JSON.parse(readFileSync(join(__dirname, 'node_modules', '@openai', 'codex-sdk', 'package.json'), 'utf-8')).version; } catch {}
5549
+ sdkVersion = findInstalled(__dirname, '@openai/codex-sdk', { stopAt: PACKAGE_ROOT })?.version || null;
5331
5550
  codexCapabilities.reset({ codexBin, codexBinSource: _codexBinSource, cliVersion, sdkVersion });
5332
5551
  startChild();
5333
5552
  bootPromise = request('initialize', {
@@ -5339,9 +5558,27 @@ CRITICAL — When you need clarification during planning:
5339
5558
  capabilities: {
5340
5559
  experimentalApi: true,
5341
5560
  },
5342
- }, 15000).then((result) => {
5343
- initialized = true;
5561
+ }, 15000).then(async (result) => {
5344
5562
  writeRpc({ jsonrpc: '2.0', method: 'initialized' });
5563
+ // API-key mode: the key SynaBun holds signs this app-server in before anything else
5564
+ // runs; its ephemeral store keeps nothing and leaves the account's ChatGPT login alone.
5565
+ if (childApiKeyLogin) {
5566
+ let apiKey = childApiKeyLogin;
5567
+ childApiKeyLogin = null;
5568
+ try {
5569
+ const login = await request('account/login/start', { type: 'apiKey', apiKey }, 15000);
5570
+ if (login?.type !== 'apiKey') throw new Error('unexpected login answer');
5571
+ } catch {
5572
+ // A half-started app-server is not reused: the next request starts a fresh one.
5573
+ const failed = child;
5574
+ child = null;
5575
+ childCredential = null;
5576
+ unregisterCodexMcpProfileRuntime();
5577
+ try { failed?.kill('SIGTERM'); } catch {}
5578
+ throw new Error('Codex could not start on this account\'s API key. Save the key again, or switch the account to its ChatGPT sign-in.');
5579
+ } finally { apiKey = null; }
5580
+ }
5581
+ initialized = true;
5345
5582
  codexCapabilities.runtime.serverInfo = result || {};
5346
5583
  codexCapabilities.runtime.cliVersion ||= parseCodexVersion(result?.userAgent);
5347
5584
  sendToClient({ type: 'capabilities', ...codexCapabilitySnapshot() });
@@ -5412,9 +5649,11 @@ CRITICAL — When you need clarification during planning:
5412
5649
  // The close handler's `child !== proc` guard prevents the dying process from
5413
5650
  // clobbering the freshly-spawned one. Callers must ensure no turn is active.
5414
5651
  async function switchCodexAccount(id) {
5652
+ if (panelAuth.active()) throw new Error('Cancel or finish sign-in before switching accounts');
5415
5653
  const oldHome = activeCodexHome;
5416
5654
  const oldThreadIds = ownedWriterThreadIds();
5417
5655
  setActiveCodexAccount(id);
5656
+ panelAuth.reset();
5418
5657
  for (const key of Object.keys(_cachedConfig)) delete _cachedConfig[key];
5419
5658
  const old = child;
5420
5659
  initialized = false;
@@ -5498,45 +5737,6 @@ CRITICAL — When you need clarification during planning:
5498
5737
  return { profile: normalized, restarted: true, threadId: resumeId };
5499
5738
  }
5500
5739
 
5501
- // After account/login/start opens the OAuth browser, watch the new home's
5502
- // auth.json. Once Codex writes credentials, read the account and persist its
5503
- // email/plan as the account label, then push the update to the client.
5504
- function watchCodexLogin(id, home) {
5505
- try { _codexLoginWatcher?.close?.(); } catch {}
5506
- _codexLoginWatcher = null;
5507
- let finished = false;
5508
- const finalize = async () => {
5509
- if (finished) return;
5510
- finished = true;
5511
- try { _codexLoginWatcher?.close?.(); } catch {}
5512
- _codexLoginWatcher = null;
5513
- try {
5514
- const acct = await safeAccountRead(false);
5515
- const info = acct?.account || acct || {};
5516
- const data = loadCodexAccounts();
5517
- const entry = data.accounts.find((a) => a.id === id);
5518
- if (entry) {
5519
- entry.email = info.email || entry.email || '';
5520
- entry.planType = info.planType || entry.planType || '';
5521
- entry.label = info.email || entry.label || 'ChatGPT account';
5522
- saveCodexAccounts(data);
5523
- }
5524
- sendToClient({ type: 'account_info', accountId: id, account: acct || {} });
5525
- sendToClient({ type: 'account_list', accounts: listCodexAccountsForClient(), activeAccountId });
5526
- } catch (err) {
5527
- console.warn('[codex-accounts] login finalize failed:', err.message);
5528
- }
5529
- };
5530
- try {
5531
- _codexLoginWatcher = fsWatch(home, (_evt, fname) => {
5532
- if (fname === 'auth.json') setTimeout(finalize, 500);
5533
- });
5534
- setTimeout(() => { try { _codexLoginWatcher?.close?.(); } catch {} _codexLoginWatcher = null; }, 5 * 60 * 1000);
5535
- } catch (err) {
5536
- console.warn('[codex-accounts] auth.json watch failed:', err.message);
5537
- }
5538
- }
5539
-
5540
5740
  async function bootstrapThread(threadId, cwd = PACKAGE_ROOT) {
5541
5741
  await ensureInitialized();
5542
5742
  if (!threadId) {
@@ -5889,7 +6089,7 @@ CRITICAL — When you need clarification during planning:
5889
6089
  const turnParams = {
5890
6090
  threadId,
5891
6091
  cwd,
5892
- approvalPolicy: getCodexSidepanelApprovalPolicy(),
6092
+ approvalPolicy: getCodexSidepanelApprovalPolicy(_cachedConfig),
5893
6093
  personality: getCachedConfigValue('personality', 'pragmatic'),
5894
6094
  // opts.sandboxMode 'read-only': the Assistant brain of a WhatsApp session (lib/assistant-brains/codex.js).
5895
6095
  sandboxPolicy: buildCodexSandboxPolicy(cwd, isPlanModeTurn || opts.sandboxMode === 'read-only' ? 'read-only' : getCachedConfigValue('sandbox_mode')),
@@ -5898,8 +6098,8 @@ CRITICAL — When you need clarification during planning:
5898
6098
  if (Object.prototype.hasOwnProperty.call(_cachedConfig, 'service_tier')) turnParams.serviceTier = _cachedConfig.service_tier;
5899
6099
  if (getCachedConfigValue('model_reasoning_summary')) turnParams.summary = getCachedConfigValue('model_reasoning_summary');
5900
6100
  const sessionSettings = codexSessionSettings.get(activeAccountId, threadId);
5901
- for (const key of ['personality', 'summary', 'serviceTier', 'approvalPolicy', 'approvalsReviewer', 'disabledPluginIds']) {
5902
- if (Object.prototype.hasOwnProperty.call(sessionSettings, key)) turnParams[key] = sessionSettings[key];
6101
+ for (const key of ['personality', 'summary', 'serviceTier', 'approvalPolicy', 'sandboxPolicy', 'approvalsReviewer', 'disabledPluginIds']) {
6102
+ if (Object.prototype.hasOwnProperty.call(sessionSettings, key) && sessionSettings[key] !== null) turnParams[key] = sessionSettings[key];
5903
6103
  }
5904
6104
  if (Array.isArray(opts.localImages) && opts.localImages.length) {
5905
6105
  turnParams.local_images = opts.localImages;
@@ -5908,10 +6108,13 @@ CRITICAL — When you need clarification during planning:
5908
6108
  else if (getCachedConfigValue('model')) turnParams.model = getCachedConfigValue('model');
5909
6109
  if (opts.effort) turnParams.effort = opts.effort;
5910
6110
  else if (getCachedConfigValue('model_reasoning_effort')) turnParams.effort = getCachedConfigValue('model_reasoning_effort');
6111
+ if (isPlanModeTurn || opts.sandboxMode === 'read-only') turnParams.sandboxPolicy = buildCodexSandboxPolicy(cwd, 'read-only');
6112
+ if (sessionSettings.model != null) turnParams.model = sessionSettings.model;
6113
+ if (sessionSettings.effort != null) turnParams.effort = sessionSettings.effort;
5911
6114
  const collaborationMode = buildCodexCollaborationMode({
5912
6115
  mode: isPlanModeTurn ? 'plan' : 'default',
5913
6116
  model: turnParams.model,
5914
- effort: opts.effort || (isPlanModeTurn
6117
+ effort: sessionSettings.effort || opts.effort || (isPlanModeTurn
5915
6118
  ? getCachedConfigValue('plan_mode_reasoning_effort', turnParams.effort)
5916
6119
  : turnParams.effort),
5917
6120
  developerInstructions: opts.developerInstructions || null,
@@ -6140,6 +6343,8 @@ CRITICAL — When you need clarification during planning:
6140
6343
  if (msg.windowId) wsWindowId = msg.windowId;
6141
6344
  if (!child && msg.role === 'assistant') codexRole = 'assistant';
6142
6345
  if (!child && msg.role === 'assistant' && typeof msg.desktopGrant === 'string' && /^sbd_[A-Za-z0-9_-]{43}$/.test(msg.desktopGrant)) codexDesktopGrant = msg.desktopGrant;
6346
+ // Only the in-process brain's socket carries one (a client's WebSocket never does); no message field is read.
6347
+ if (!child && msg.role === 'assistant' && brainCredentialOf(ws)) codexBrainCredential = brainCredentialOf(ws);
6143
6348
  if (!child && msg.role === 'assistant' && msg.routeGate && Array.isArray(msg.routeGate.flags) && msg.routeGate.flags.every((f) => typeof f === 'string')) {
6144
6349
  codexRouteGate = { flags: [...msg.routeGate.flags], env: Object.fromEntries(Object.entries(msg.routeGate.env || {}).filter(([k, v]) => /^SYNABUN_ROUTE_GATE_[A-Z_]+$/.test(k) && typeof v === 'string')) };
6145
6350
  }
@@ -6231,10 +6436,21 @@ CRITICAL — When you need clarification during planning:
6231
6436
  if (await handleCodexCompatMessage(msg, {
6232
6437
  activeThreadId, activeTurnId, cwd: PACKAGE_ROOT,
6233
6438
  role: codexRole, getActiveThreadId: () => activeThreadId,
6439
+ sandboxPolicyForMode: (mode, cwd) => {
6440
+ validateCodexSettings({ sandbox_mode: mode }, readCodexSettingsSchema(getCodexBin()));
6441
+ return buildCodexSandboxPolicy(cwd, mode);
6442
+ },
6234
6443
  limitAppCall: message => appBoundary.consume(activeThreadId, message.originCallId),
6235
6444
  resolveAppCard: (callId, threadId) => appBoundary.resolve(threadId, callId,
6236
6445
  () => request('thread/read', { threadId, includeTurns: true }, 10000)),
6237
6446
  ensureInitialized, request, send: sendToClient,
6447
+ setFeatureEnablement: async enablement => {
6448
+ await request('config/batchWrite', {
6449
+ edits: buildCodexConfigEdits({ features: enablement }), reloadUserConfig: true,
6450
+ }, 10000);
6451
+ await readConfigState();
6452
+ return { enablement };
6453
+ },
6238
6454
  registry: codexCapabilities, capabilities: codexCapabilitySnapshot,
6239
6455
  withWriterOperation: withCodexWriterOperation,
6240
6456
  beforeRequest: beforeCodexCompatRequest, afterRequest: afterCodexCompatRequest,
@@ -6397,30 +6613,54 @@ CRITICAL — When you need clarification during planning:
6397
6613
  return;
6398
6614
  }
6399
6615
  if (msg.type === 'account_read') {
6616
+ // `accountState` is the account as every surface shows it (lib/codex-accounts.js describe()):
6617
+ // its mode, whether it is signed in and its identity. `account` is the running app-server's
6618
+ // own answer, kept for older pages; an account that is signed out is not started for it.
6619
+ const accounts = _codexAccounts();
6620
+ const known = accounts.describeForClient(activeAccountId);
6621
+ if (known?.signedIn === false && !child) {
6622
+ sendToClient({ type: 'account_info', requestId: msg.requestId || null, accountId: activeAccountId, account: { account: null }, accountState: known, state: panelAuth.snapshot() });
6623
+ return;
6624
+ }
6400
6625
  try {
6401
6626
  await ensureInitialized();
6402
6627
  const result = await request('account/read', { refreshToken: !!msg.refreshToken }, 10000);
6403
- sendToClient({ type: 'account_info', requestId: msg.requestId || null, account: result || {} });
6628
+ // The running app-server answers for its own store only while it runs on it (not on a held key).
6629
+ if (!childUsesHeldKey && result && Object.hasOwn(result, 'account')) accounts.noteNativeStatus(activeAccountId, result);
6630
+ sendToClient({ type: 'account_info', requestId: msg.requestId || null, accountId: activeAccountId, account: result || {}, accountState: accounts.describeForClient(activeAccountId), state: panelAuth.snapshot() });
6404
6631
  } catch (err) {
6405
- sendToClient({ type: 'account_info', requestId: msg.requestId || null, account: {}, error: err.message });
6632
+ sendToClient({ type: 'account_info', requestId: msg.requestId || null, accountId: activeAccountId, account: {}, accountState: accounts.describeForClient(activeAccountId), error: err.message });
6406
6633
  }
6407
6634
  return;
6408
6635
  }
6409
6636
  if (msg.type === 'codex_login') {
6410
6637
  try {
6638
+ if (activeTurnId || pendingServerRequests.size) throw new Error('Finish or stop the current turn before signing in');
6411
6639
  await ensureInitialized();
6412
- const result = await request('account/login/start', { type: msg.loginType || 'chatgpt' }, 10000);
6413
- sendToClient({ type: 'account_login_started', requestId: msg.requestId || null, login: result || {} });
6640
+ const state = await panelAuth.start(msg.loginType || 'chatgpt');
6641
+ sendToClient({ type: 'account_login_started', requestId: msg.requestId || null, state });
6414
6642
  } catch (err) {
6415
6643
  sendToClient({ type: 'error', requestId: msg.requestId || null, message: `Login failed: ${err.message}` });
6416
6644
  }
6417
6645
  return;
6418
6646
  }
6647
+ if (msg.type === 'account_login_status' || msg.type === 'account_login_cancel') {
6648
+ try {
6649
+ if (msg.type === 'account_login_cancel') await panelAuth.cancel();
6650
+ sendToClient({ type: 'account_login_state', requestId: msg.requestId || null, state: panelAuth.snapshot() });
6651
+ } catch (err) { sendToClient({ type: 'error', requestId: msg.requestId || null, message: err.message }); }
6652
+ return;
6653
+ }
6419
6654
  if (msg.type === 'account_logout') {
6655
+ // An older page's Logout: the credential the account uses now is signed out, the
6656
+ // same way as the account menu's per-credential sign-out (lib/codex-account-auth.js).
6420
6657
  try {
6421
- await ensureInitialized();
6422
- const result = await request('account/logout', {}, 10000);
6423
- sendToClient({ type: 'account_logged_out', requestId: msg.requestId || null, result: result || {} });
6658
+ if (activeTurnId || pendingServerRequests.size) throw new Error('Finish or stop the current turn before signing out');
6659
+ await panelAuth.cancel();
6660
+ const row = _codexAccounts().describe(activeAccountId);
6661
+ const account = await codexAccountAuth().signOut(activeAccountId, row?.mode === 'apiKey' ? 'apiKey' : 'subscription');
6662
+ panelAuth.reset();
6663
+ sendToClient({ type: 'account_logged_out', requestId: msg.requestId || null, result: {}, accountState: account });
6424
6664
  } catch (err) {
6425
6665
  sendToClient({ type: 'error', requestId: msg.requestId || null, message: `Logout failed: ${err.message}` });
6426
6666
  }
@@ -6437,7 +6677,7 @@ CRITICAL — When you need clarification during planning:
6437
6677
  }
6438
6678
  if (msg.type === 'account_switch') {
6439
6679
  try {
6440
- const target = getCodexAccount(msg.accountId);
6680
+ const target = findCodexAccount(msg.accountId);
6441
6681
  if (!target) throw new Error('Unknown account');
6442
6682
  if (target.id === activeAccountId) {
6443
6683
  const acct = await safeAccountRead();
@@ -6456,22 +6696,15 @@ CRITICAL — When you need clarification during planning:
6456
6696
  if (msg.type === 'account_add_start') {
6457
6697
  try {
6458
6698
  if (activeTurnId) throw new Error('Finish or stop the current turn before adding an account');
6459
- const id = `acct-${Date.now().toString(36)}`;
6460
- const home = createCodexAccountHome(id);
6461
- const data = loadCodexAccounts();
6462
- data.accounts.push({
6463
- id, label: 'New account', email: '', planType: '',
6464
- type: 'chatgpt', home, createdAt: new Date().toISOString(),
6465
- });
6466
- saveCodexAccounts(data);
6699
+ if (panelAuth.active()) throw new Error('Cancel or finish sign-in before adding an account');
6700
+ const { id } = _codexAccounts().create({});
6467
6701
  await withCodexWriterOperation('account_add_switch', () => switchCodexAccount(id));
6468
- const result = await request('account/login/start', { type: 'chatgpt' }, 15000);
6469
- watchCodexLogin(id, home);
6702
+ const state = await panelAuth.start('chatgpt');
6470
6703
  sendToClient({
6471
6704
  type: 'account_add_started',
6472
6705
  requestId: msg.requestId || null,
6473
6706
  accountId: id,
6474
- login: result || {},
6707
+ state,
6475
6708
  accounts: listCodexAccountsForClient(),
6476
6709
  activeAccountId,
6477
6710
  });
@@ -6483,28 +6716,14 @@ CRITICAL — When you need clarification during planning:
6483
6716
  if (msg.type === 'account_remove') {
6484
6717
  try {
6485
6718
  if (msg.accountId === 'default') throw new Error('The Default account cannot be removed');
6486
- const data = loadCodexAccounts();
6487
- const idx = data.accounts.findIndex((a) => a.id === msg.accountId);
6488
- if (idx === -1) throw new Error('Unknown account');
6489
- const nativeAccountInUse = _nativeLoopRuntime?.list({ activeOnly: true })
6490
- .some((run) => run.provider === 'codex' && run.accountId === msg.accountId)
6491
- || _assistantRuntime?.accountInUse?.('codex', msg.accountId)
6492
- || _assistantDispatcher?.accountInUse?.('codex', msg.accountId);
6493
- if (nativeAccountInUse) {
6494
- throw new Error('Stop native Codex automations using this account before removing it');
6495
- }
6496
- const removed = data.accounts[idx];
6719
+ if (!findCodexAccount(msg.accountId)) throw new Error('Unknown account');
6720
+ const reason = codexAccountInUse(msg.accountId, { except: liveConnection });
6721
+ if (reason) throw new Error(reason);
6497
6722
  if (activeAccountId === msg.accountId) {
6498
6723
  if (activeTurnId) throw new Error('Finish or stop the current turn before removing the active account');
6499
6724
  await withCodexWriterOperation('account_remove_switch', () => switchCodexAccount('default'));
6500
6725
  }
6501
- data.accounts.splice(idx, 1);
6502
- saveCodexAccounts(data);
6503
- try {
6504
- if (removed.home && removed.home !== CODEX_DEFAULT_HOME && removed.home.startsWith(CODEX_ACCOUNTS_ROOT)) {
6505
- rmSync(removed.home, { recursive: true, force: true });
6506
- }
6507
- } catch (e) { console.warn('[codex-accounts] Failed to delete home:', e.message); }
6726
+ _codexAccounts().remove(msg.accountId, { inUse: (id) => codexAccountInUse(id, { except: liveConnection }) });
6508
6727
  sendToClient({
6509
6728
  type: 'account_removed',
6510
6729
  requestId: msg.requestId || null,
@@ -6519,12 +6738,9 @@ CRITICAL — When you need clarification during planning:
6519
6738
  }
6520
6739
  if (msg.type === 'account_rename') {
6521
6740
  try {
6522
- const data = loadCodexAccounts();
6523
- const acct = data.accounts.find((a) => a.id === msg.accountId);
6524
- if (!acct) throw new Error('Unknown account');
6525
- const label = String(msg.label || '').trim().slice(0, 60);
6526
- if (label) acct.label = label;
6527
- saveCodexAccounts(data);
6741
+ const label = String(msg.label || '').trim().slice(0, 80);
6742
+ const acct = label ? _codexAccounts().rename(msg.accountId, label) : _codexAccounts().describe(msg.accountId);
6743
+ if (!acct || acct.id !== msg.accountId) throw new Error('Unknown account');
6528
6744
  sendToClient({
6529
6745
  type: 'account_renamed',
6530
6746
  requestId: msg.requestId || null,
@@ -6551,7 +6767,8 @@ CRITICAL — When you need clarification during planning:
6551
6767
  try {
6552
6768
  await ensureInitialized();
6553
6769
  const result = await readConfigState(msg.cwd || PACKAGE_ROOT);
6554
- sendToClient({ type: 'config_data', requestId: msg.requestId || null, config: result || {}, sessionOverrides: codexSessionSettings.get(activeAccountId, msg.threadId || activeThreadId) });
6770
+ const settingsSchema = await readSettingsMetadata();
6771
+ sendToClient({ type: 'config_data', requestId: msg.requestId || null, config: result || {}, settingsSchema, sessionOverrides: codexSessionSettings.get(activeAccountId, msg.threadId || activeThreadId) });
6555
6772
  } catch (err) {
6556
6773
  sendToClient({ type: 'config_data', requestId: msg.requestId || null, config: {}, error: err.message });
6557
6774
  }
@@ -6560,12 +6777,14 @@ CRITICAL — When you need clarification during planning:
6560
6777
  if (msg.type === 'config_write') {
6561
6778
  try {
6562
6779
  await ensureInitialized();
6563
- const nextConfig = validateCodexConfigRequirements(msg.config || {}, await readCodexRequirements());
6780
+ const metadata = await readSettingsMetadata();
6781
+ const supported = validateCodexSettings(msg.config || {}, metadata, metadata.features);
6782
+ const nextConfig = validateCodexConfigRequirements(supported, await readCodexRequirements());
6564
6783
  const edits = buildCodexConfigEdits(nextConfig);
6565
6784
  await request('config/batchWrite', { edits, reloadUserConfig: true }, 10000);
6566
- await readConfigState(msg.cwd || PACKAGE_ROOT);
6785
+ const config = await readConfigState(msg.cwd || PACKAGE_ROOT);
6567
6786
  codexContextModels = [];
6568
- sendToClient({ type: 'config_saved', requestId: msg.requestId || null });
6787
+ sendToClient({ type: 'config_saved', requestId: msg.requestId || null, config });
6569
6788
  sendToClient({ type: 'catalogs_changed', catalogs: ['models', 'skills', 'apps', 'mcp', 'features', 'config'] });
6570
6789
  } catch (err) {
6571
6790
  sendToClient({ type: 'error', requestId: msg.requestId || null, message: `Config save failed: ${err.message}` });
@@ -6671,7 +6890,11 @@ CRITICAL — When you need clarification during planning:
6671
6890
  if (msg.type === 'experimental_features') {
6672
6891
  try {
6673
6892
  await ensureInitialized();
6893
+ await readConfigState(msg.cwd || PACKAGE_ROOT);
6674
6894
  const result = await request('experimentalFeature/list', codexPagination(msg), 10000);
6895
+ for (const feature of result?.data || []) {
6896
+ if (typeof _cachedConfig.features?.[feature.name] === 'boolean') feature.enabled = _cachedConfig.features[feature.name];
6897
+ }
6675
6898
  sendToClient({ type: 'experimental_features', requestId: msg.requestId || null, features: result || {} });
6676
6899
  } catch (err) {
6677
6900
  sendToClient({ type: 'experimental_features', requestId: msg.requestId || null, features: {}, error: err.message });
@@ -7207,15 +7430,21 @@ CRITICAL — When you need clarification during planning:
7207
7430
  }
7208
7431
  void retireCurrentCodexChild('WebSocket closed without a reattach identity', { unsubscribe: false });
7209
7432
  };
7433
+ ws.on('close', () => _codexLiveConnections.delete(liveConnection));
7210
7434
  ws.on('close', handleCodexSocketClose);
7211
7435
  }
7212
7436
 
7213
7437
  const _codexModelListCache = new Map();
7214
7438
  const CODEX_MODEL_LIST_CACHE_MS = 60_000;
7215
7439
 
7216
- async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME) {
7217
- let codexBin = loadCliConfig()?.codex?.command?.trim() || getCodexBin();
7218
- const args = ['app-server'];
7440
+ // `launch` (lib/codex-accounts.js launchFor()): the list is read under the account's own credential,
7441
+ // with no inherited one, as its sessions would run (in API-key mode, the key it holds over stdin).
7442
+ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME, launch = null) {
7443
+ ensureCanonicalCodexConfig({ strict: true, codexHome: launch?.home || codexHome });
7444
+ // The Codex the resolver found (Settings' path first); a command line in Settings ("wsl codex") as written.
7445
+ let codexBin = configuredCommandLine('codex') || getCodexBin();
7446
+ // Without an account's launch plan, still the Windows sandbox the Admin sandbox setting allows.
7447
+ const args = [...(launch?.ok ? codexAppServerArgs(launch) : codexSandboxArgs()), 'app-server'];
7219
7448
  if (!/\s/.test(codexBin) && /\.js$/i.test(codexBin)) {
7220
7449
  args.unshift(codexBin);
7221
7450
  codexBin = process.execPath;
@@ -7223,9 +7452,12 @@ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME) {
7223
7452
  const useShell = /\s/.test(codexBin)
7224
7453
  || !codexBin.includes(sep)
7225
7454
  || (process.platform === 'win32' && /\.(cmd|bat)$/i.test(codexBin));
7226
- const env = { ...process.env, CODEX_HOME: codexHome, NO_COLOR: '1' };
7455
+ const env = launch?.ok
7456
+ ? { ...applyCodexLaunchEnv(process.env, launch), NO_COLOR: '1' }
7457
+ : { ...process.env, CODEX_HOME: codexHome, NO_COLOR: '1' };
7227
7458
  delete env.FORCE_COLOR;
7228
7459
  delete env.CLICOLOR_FORCE;
7460
+ let apiKey = launch?.ok ? launch.apiKey : null;
7229
7461
 
7230
7462
  return new Promise((resolve, reject) => {
7231
7463
  // (A configured command may be a command line; a file with a space in its path is one word.)
@@ -7311,6 +7543,10 @@ async function queryCodexAppServerModels(codexHome = CODEX_DEFAULT_HOME) {
7311
7543
  clientInfo: { name: 'synabun-codex-models', title: 'SynaBun Codex Models', version: '0.0.1' },
7312
7544
  capabilities: { experimentalApi: true },
7313
7545
  }, 15000);
7546
+ if (apiKey) {
7547
+ child.stdin.write(JSON.stringify({ jsonrpc: '2.0', method: 'initialized' }) + '\n');
7548
+ try { await request('account/login/start', { type: 'apiKey', apiKey }, 15000); } finally { apiKey = null; }
7549
+ }
7314
7550
  const result = await request('model/list', { includeHidden: false }, 10000);
7315
7551
  cleanup();
7316
7552
  resolve(Array.isArray(result?.models) ? result.models : (Array.isArray(result?.data) ? result.data : []));
@@ -7334,7 +7570,7 @@ app.get('/api/codex/models', async (req, res) => {
7334
7570
  return res.json({ ok: true, source: 'cache', models: cached.models });
7335
7571
  }
7336
7572
  const models = mergeCodexModelContextCapabilities(
7337
- await queryCodexAppServerModels(codexHome),
7573
+ await queryCodexAppServerModels(codexHome, _codexAccounts().launchFor(account.id)),
7338
7574
  readCodexRuntimeModelCatalog(codexHome),
7339
7575
  );
7340
7576
  // The Assistant's catalog is built from the default account's list: when that list
@@ -7348,12 +7584,20 @@ app.get('/api/codex/models', async (req, res) => {
7348
7584
  }
7349
7585
  });
7350
7586
 
7351
- // GET /api/codex/accounts — list saved ChatGPT/Codex accounts for launch selectors
7352
- // (Automation Studio + Schedules). Mirrors the per-tab account registry used by
7353
- // the Codex sidepanel; each account maps to its own CODEX_HOME.
7354
- app.get('/api/codex/accounts', (_req, res) => {
7587
+ // GET /api/codex/accounts — every Codex account with its state (mode, signed in,
7588
+ // identity: an email or a masked key, never a key) for the Codex panel's account
7589
+ // menu and the launch selectors (Automation Studio, Schedules). Emails are
7590
+ // personal data: not for invite guests. `?refresh=true` asks Codex about every
7591
+ // account's own store first; otherwise an old answer is asked again in the
7592
+ // background and announced (`codex:accounts-changed`) when it changes.
7593
+ app.get('/api/codex/accounts', async (req, res) => {
7594
+ if (isGuestRequest(req)) return res.status(403).json({ ok: false, error: 'Admin only' });
7595
+ res.setHeader('Cache-Control', 'no-store');
7355
7596
  try {
7356
- res.json({ ok: true, accounts: listCodexAccountsForClient() });
7597
+ const accounts = _codexAccounts();
7598
+ if (req.query?.refresh === 'true') await accounts.refreshStatus(null, { force: true });
7599
+ else accounts.refreshStatus().catch(() => {});
7600
+ res.json({ ok: true, accounts: accounts.listForClient() });
7357
7601
  } catch (err) {
7358
7602
  res.json({ ok: false, error: err.message, accounts: [] });
7359
7603
  }
@@ -7779,7 +8023,7 @@ function updateOpencodeRuntimeProfileConfig(xdgRoot, profile) {
7779
8023
  return true;
7780
8024
  }
7781
8025
 
7782
- async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, keepAlive = false, mcpProfile = null } = {}) {
8026
+ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, keepAlive = false, mcpProfile = null, brainCredential = null } = {}) {
7783
8027
  if (_ocpHistoryClearInProgress) throw new Error('OpenCode history is being cleared');
7784
8028
  const requestedProfile = normalizeMcpProfileName(mcpProfile || readActiveMcpProfile());
7785
8029
  let entry = _ocpIsoServes.get(termId);
@@ -7804,11 +8048,6 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
7804
8048
  entry = { termId, port, xdgRoot, mcpProfile: requestedProfile, mcpProfileRefreshFailed: false, proc: null, client: null, ready: false, starting: true, keepAlive: !!keepAlive, unsub: null, sessions: new Set(), lastUsed: Date.now() };
7805
8049
  _ocpIsoServes.set(termId, entry);
7806
8050
  try {
7807
- const config = loadCliConfig();
7808
- const cmd = config.opencode?.command || 'opencode';
7809
- const shellArgs = IS_WIN
7810
- ? ['/d', '/s', '/c', `${cmd} serve --port ${port}`]
7811
- : ['-lc', `exec ${cmd} serve --port ${port}`];
7812
8051
  const isolatedEnv = {
7813
8052
  ...process.env,
7814
8053
  XDG_CONFIG_HOME: xdgRoot,
@@ -7820,11 +8059,16 @@ async function ensureIsolatedServe(termId, { xdgRoot: requestedXdgRoot = null, k
7820
8059
  // Keep this above the script's own deadlines while retaining a hard cap.
7821
8060
  OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS:
7822
8061
  process.env.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || '300000',
8062
+ // A WhatsApp conversation's brain credential: the config names it ({env:SYNABUN_BRAIN_TOKEN}), only this
8063
+ // serve's environment holds it (a fresh serve per brain launch: the brain stops its serve when it ends).
8064
+ ...(brainCredential ? { SYNABUN_BRAIN_TOKEN: brainCredential } : {}),
7823
8065
  };
7824
8066
  delete isolatedEnv.CLAUDECODE;
7825
- const proc = spawn(DEFAULT_SHELL, shellArgs, {
8067
+ if (!brainCredential) delete isolatedEnv.SYNABUN_BRAIN_TOKEN;
8068
+ const launch = opencodeServeLaunch(port, { env: isolatedEnv });
8069
+ const proc = spawn(launch.command, launch.args, {
7826
8070
  stdio: ['ignore', 'pipe', 'pipe'],
7827
- env: isolatedEnv,
8071
+ ...launch.options,
7828
8072
  detached: false, shell: false,
7829
8073
  });
7830
8074
  entry.proc = proc;
@@ -8490,15 +8734,11 @@ async function ensureOpencodeServer({ allowDuringHistoryClear = false } = {}) {
8490
8734
 
8491
8735
  // 2. Spawn opencode serve
8492
8736
  try {
8493
- const config = loadCliConfig();
8494
- const cmd = config.opencode?.command || 'opencode';
8495
- console.log(`[opencode] Spawning: ${cmd} serve --port ${_ocpPort}`);
8496
- const shellArgs = IS_WIN
8497
- ? ['/d', '/s', '/c', `${cmd} serve --port ${_ocpPort}`]
8498
- : ['-lc', `exec ${cmd} serve --port ${_ocpPort}`];
8499
- _ocpProc = spawn(DEFAULT_SHELL, shellArgs, {
8737
+ const launch = opencodeServeLaunch(_ocpPort, { env: { ...process.env, FORCE_COLOR: '0', OPENCODE_DISABLE_CLAUDE_CODE: '1' } });
8738
+ console.log(`[opencode] Spawning: ${launch.label}`);
8739
+ _ocpProc = spawn(launch.command, launch.args, {
8500
8740
  stdio: ['ignore', 'pipe', 'pipe'],
8501
- env: { ...process.env, FORCE_COLOR: '0', OPENCODE_DISABLE_CLAUDE_CODE: '1' },
8741
+ ...launch.options,
8502
8742
  detached: false,
8503
8743
  shell: false,
8504
8744
  });
@@ -8786,7 +9026,7 @@ app.post('/api/ollama/configure', async (req, res) => {
8786
9026
  } catch {}
8787
9027
  if (needsInstall) {
8788
9028
  console.log('[ollama] Installing @ai-sdk/openai-compatible…');
8789
- execSync('npm install @ai-sdk/openai-compatible --save', { cwd: configDir, timeout: 60000, stdio: 'pipe' });
9029
+ execSync('npm install @ai-sdk/openai-compatible --save', { cwd: configDir, timeout: 60000, stdio: 'pipe', env: withPackagedPathLast(process.env) });
8790
9030
  console.log('[ollama] Package installed');
8791
9031
  }
8792
9032
 
@@ -8885,9 +9125,11 @@ app.post('/api/sidepanel/session-title', async (req, res) => {
8885
9125
  }
8886
9126
  const result = await generateSessionTitle(input, {
8887
9127
  claudeExecutable: getClaudeSdkExecutable,
9128
+ childEnv: claudeChildEnv,
8888
9129
  execFile: execFileAsync,
8889
9130
  getCodexBin,
8890
9131
  getCodexAccountHome,
9132
+ codexConfigArgs: codexSandboxArgs,
8891
9133
  getAugmentedPath,
8892
9134
  ensureOpenCode: ensureOpencodeServer,
8893
9135
  openCodeClient: opencodeV2Client,
@@ -9516,6 +9758,7 @@ const _claudeBridgeReady = (async () => {
9516
9758
  try {
9517
9759
  _claudeBridge = await import('./lib/claude-agent-bridge.js');
9518
9760
  _claudeBridge.configureClaudeBridge({
9761
+ accountRateLimit: (id, info) => _assistantAccountUsage?.note('claude-code', id, info),
9519
9762
  // The greeting a panel conversation opened with, as the one note its model gets (lib/greeting/).
9520
9763
  greetingNote: (id, opts) => _greeting?.consumeNote(id, opts) || '',
9521
9764
  // A prompt typed in the panel: when the person was last here (a temporary chat is named to nobody).
@@ -9556,9 +9799,16 @@ const _claudeBridgeReady = (async () => {
9556
9799
  // a session without one is refused with the install hint. Lazy, so the
9557
9800
  // (memoized) resolution happens on demand, not at boot.
9558
9801
  claudeExecutable: () => getClaudeSdkExecutable(),
9802
+ // On Windows, the Git Bash every session's Claude Code gets (lib/git-bash/): SynaBun's copy where the PC has
9803
+ // none, so the Bash tool exists and the hooks run in bash. Panel tabs and the Assistant's brain.
9804
+ childEnv: (env) => claudeChildEnv(env),
9559
9805
  // Sidepanel tabs can run under another Claude account (its own config
9560
9806
  // directory). The default account adds nothing to the environment.
9561
9807
  claudeAccountEnv: (accountId) => _panelClaudeAccounts().envFor(accountId),
9808
+ // How a session runs under an account: inherited credentials removed, the
9809
+ // account's directory and, in API-key mode, its key added (launchFor() in
9810
+ // lib/claude-accounts.js). Panel tabs and the Assistant's brain.
9811
+ claudeAccountLaunch: (accountId) => _panelClaudeAccounts().launchFor(accountId),
9562
9812
  // This server has the session routes (rename, tag, fork, delete, subagent
9563
9813
  // transcripts): the panel offers those actions only when told so.
9564
9814
  sessionOps: true,
@@ -9591,14 +9841,42 @@ const _claudeBridgeReady = (async () => {
9591
9841
  }
9592
9842
  })();
9593
9843
 
9594
- // The Claude accounts registry, as the sidepanel reads it (the Assistant keeps
9595
- // its own instance over the same file; both only read here).
9844
+ // The Claude accounts registry: one instance for the sidepanel, the Assistant
9845
+ // (brain, workers, catalog) and every route, so one account state is shown
9846
+ // everywhere (lib/claude-accounts.js describe()). Its subscription half is
9847
+ // Claude Code's own `auth status --json` for each account; a change of any
9848
+ // account reaches every window as `claude:accounts-changed`.
9596
9849
  let _panelClaudeAccountsInstance = null;
9597
9850
  function _panelClaudeAccounts() {
9598
- if (!_panelClaudeAccountsInstance) _panelClaudeAccountsInstance = createPanelClaudeAccounts({ dataHome: DATA_HOME, homeDir: getHomePath() });
9851
+ if (!_panelClaudeAccountsInstance) {
9852
+ _panelClaudeAccountsInstance = createPanelClaudeAccounts({
9853
+ dataHome: DATA_HOME, homeDir: getHomePath(),
9854
+ readNativeStatus: (accountId) => panelClaudeAuth().nativeStatus(accountId),
9855
+ log: (tag, message) => console.log(`[${tag}]`, message),
9856
+ });
9857
+ _panelClaudeAccountsInstance.subscribe((event) => {
9858
+ try {
9859
+ broadcastSync({ type: 'claude:accounts-changed', accountId: event.accountId || null, removed: !!event.removed, created: !!event.created });
9860
+ // The Assistant's account list and catalog listen to their own event.
9861
+ broadcastSync({ type: 'assistant:accounts-changed', provider: 'claude-code', accountId: event.accountId || null, removed: !!event.removed });
9862
+ _assistantCatalog?.invalidate?.();
9863
+ } catch {}
9864
+ });
9865
+ }
9599
9866
  return _panelClaudeAccountsInstance;
9600
9867
  }
9601
9868
 
9869
+ // Why a Claude account cannot be removed now ('' when it can): a live session
9870
+ // (a panel tab, the Assistant's brain), a dispatched run, or the Assistant's
9871
+ // choice of worker account names it.
9872
+ function claudeAccountInUse(accountId) {
9873
+ try { if (_claudeBridge?.accountInUse?.(accountId)) return 'A Claude tab or the Assistant is running under this account. End that session first.'; } catch {}
9874
+ try { if (_assistantDispatcher?.accountInUse?.('claude-code', accountId)) return 'Dispatched runs are using this account. Stop them first.'; } catch {}
9875
+ try { if (_assistantRuntime?.accountInUse?.('claude-code', accountId)) return 'An Assistant session uses this account. Close it first.'; } catch {}
9876
+ try { if (_assistantClaudeWorkerAccount?.() === accountId) return 'This is the Assistant\'s Claude worker account. Choose another one first.'; } catch {}
9877
+ return '';
9878
+ }
9879
+
9602
9880
  // The config directory of the Claude account a sidepanel request names: '' for
9603
9881
  // the default account. An account that is no longer set up is an error (404,
9604
9882
  // `account_unavailable`), never the default account's folder in its place.
@@ -9611,14 +9889,159 @@ function panelAccountHome(value) {
9611
9889
  return home;
9612
9890
  }
9613
9891
 
9614
- // GET /api/claude/accounts — the accounts a sidepanel tab can run under. Emails
9615
- // are personal data: not for invite guests.
9616
- app.get('/api/claude/accounts', (req, res) => {
9892
+ // GET /api/claude/accounts — every Claude account with its state (mode,
9893
+ // signed in, identity: an email or a masked key, never a key). Emails are
9894
+ // personal data: not for invite guests. `?refresh=true` asks Claude Code about
9895
+ // every account's subscription first; otherwise an answer older than a few
9896
+ // minutes is asked again in the background and announced when it changes.
9897
+ app.get('/api/claude/accounts', async (req, res) => {
9617
9898
  if (isGuestRequest(req)) return res.status(403).json({ ok: false, error: 'Admin only' });
9618
- try { res.json({ ok: true, accounts: _panelClaudeAccounts().listForClient() }); }
9899
+ res.setHeader('Cache-Control', 'no-store');
9900
+ try {
9901
+ const accounts = _panelClaudeAccounts();
9902
+ if (req.query.refresh === 'true') await accounts.refreshStatus(null, { force: true });
9903
+ else accounts.refreshStatus().catch(() => {});
9904
+ res.json({ ok: true, accounts: accounts.listForClient() });
9905
+ }
9619
9906
  catch (err) { res.status(500).json({ ok: false, error: err.message }); }
9620
9907
  });
9621
9908
 
9909
+ let _panelClaudeAuthInstance = null;
9910
+ const panelAuthOwnerGuard = ownerRouteGuard({ isGuestRequest, port: PORT, allowedOrigins: publicWebOrigins, refuseCrossSite: true });
9911
+ function panelClaudeAuth() {
9912
+ if (!_panelClaudeAuthInstance) {
9913
+ _panelClaudeAuthInstance = createClaudePanelAuth({ accounts: _panelClaudeAccounts(), executable: getClaudeSdkExecutable, env: () => (withPathEnv(process.env, getAugmentedPath())), changeCredentials: (id, change) => _claudeBridge?.withPanelAccountAuthChange ? _claudeBridge.withPanelAccountAuthChange(id, change) : change() });
9914
+ process.once('exit', () => _panelClaudeAuthInstance?.close());
9915
+ }
9916
+ return _panelClaudeAuthInstance;
9917
+ }
9918
+
9919
+ // Admin-only account auth; OAuth stays native and pasted keys are profile-scoped.
9920
+ app.get('/api/claude/accounts/:id/auth', panelAuthOwnerGuard, async (req, res) => {
9921
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9922
+ res.setHeader('Cache-Control', 'no-store');
9923
+ try {
9924
+ panelAccountHome(req.params.id);
9925
+ const result = req.query.refresh === 'true' ? await panelClaudeAuth().read(req.params.id) : { state: panelClaudeAuth().snapshot(req.params.id) };
9926
+ res.json(result);
9927
+ } catch (error) { res.status(error.status || 500).json({ error: error.message }); }
9928
+ });
9929
+ app.post('/api/claude/accounts/:id/auth/:action', panelAuthOwnerGuard, async (req, res) => {
9930
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9931
+ res.setHeader('Cache-Control', 'no-store');
9932
+ try {
9933
+ panelAccountHome(req.params.id);
9934
+ const auth = panelClaudeAuth();
9935
+ if (req.params.action === 'start') return res.json({ state: auth.start(req.params.id) });
9936
+ if (req.params.action === 'cancel') return res.json({ state: auth.cancel(req.params.id, req.body?.operationId) });
9937
+ if (req.params.action === 'code') return res.json({ state: auth.submitCode(req.params.id, req.body?.operationId, req.body?.code) });
9938
+ if (req.params.action === 'logout') {
9939
+ // `credential`: 'apiKey', 'subscription', or both when left out (the older /logout).
9940
+ const credential = typeof req.body?.credential === 'string' ? req.body.credential : 'all';
9941
+ await auth.signOut(req.params.id, credential);
9942
+ return res.json({ ok: true, account: _panelClaudeAccounts().describeForClient(req.params.id) });
9943
+ }
9944
+ res.status(404).json({ error: 'Unknown authentication action' });
9945
+ } catch (error) { res.status(error.status || 500).json({ error: error.message }); }
9946
+ });
9947
+
9948
+ registerProviderPanelApiKeyAuthRoutes(app, {
9949
+ guard: panelAuthOwnerGuard,
9950
+ claude: (id, key) => panelClaudeAuth().signInApiKey(id, key),
9951
+ // A Codex key is checked with OpenAI and kept by SynaBun for that account (lib/codex-account-auth.js);
9952
+ // the account's idle Codex processes restart on it. Codex's own store (its ChatGPT login) is not touched.
9953
+ codex: (id, key) => codexAccountAuth().signInApiKey(id, key),
9954
+ });
9955
+
9956
+ // Codex accounts: the same model and routes as Claude's (lib/codex-accounts.js describe(), launchFor()).
9957
+ // Everything but the list carries the owner guard; a credential or mode change restarts the
9958
+ // account's idle panel processes and is refused while one of them is mid-turn.
9959
+ app.post('/api/codex/accounts', panelAuthOwnerGuard, (req, res) => {
9960
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9961
+ res.setHeader('Cache-Control', 'no-store');
9962
+ try {
9963
+ const created = _codexAccounts().create({ label: req.body?.label });
9964
+ res.json({ account: _codexAccounts().describeForClient(created.id) });
9965
+ } catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9966
+ });
9967
+ // Rename (`label`) and/or choose what the account's sessions use (`mode`: 'subscription' (ChatGPT) | 'apiKey').
9968
+ app.patch('/api/codex/accounts/:id', panelAuthOwnerGuard, async (req, res) => {
9969
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9970
+ res.setHeader('Cache-Control', 'no-store');
9971
+ try {
9972
+ const accounts = _codexAccounts();
9973
+ if (accounts.describe(req.params.id)?.id !== req.params.id) return res.status(404).json({ code: 'ACCOUNT_NOT_FOUND', error: 'That Codex account is no longer set up in SynaBun.' });
9974
+ const { label, mode } = req.body || {};
9975
+ if (label === undefined && mode === undefined) return res.status(400).json({ code: 'NOTHING_TO_CHANGE', error: 'Send a label or a mode.' });
9976
+ if (label !== undefined) accounts.rename(req.params.id, label);
9977
+ if (mode !== undefined) await codexAccountAuth().setMode(req.params.id, mode);
9978
+ res.json({ ok: true, account: accounts.describeForClient(req.params.id) });
9979
+ } catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9980
+ });
9981
+ // Remove an added account and its folder. Default stays; an account something uses stays.
9982
+ app.delete('/api/codex/accounts/:id', panelAuthOwnerGuard, (req, res) => {
9983
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9984
+ res.setHeader('Cache-Control', 'no-store');
9985
+ try { res.json(_codexAccounts().remove(req.params.id, { inUse: (id) => codexAccountInUse(id) })); }
9986
+ catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9987
+ });
9988
+ // `?refresh=true` asks Codex what the account's own store holds first.
9989
+ app.get('/api/codex/accounts/:id/auth', panelAuthOwnerGuard, async (req, res) => {
9990
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
9991
+ res.setHeader('Cache-Control', 'no-store');
9992
+ try {
9993
+ if (_codexAccounts().describe(req.params.id)?.id !== req.params.id) return res.status(404).json({ code: 'ACCOUNT_NOT_FOUND', error: 'That Codex account is no longer set up in SynaBun.' });
9994
+ if (req.query.refresh === 'true') return res.json(await codexAccountAuth().read(req.params.id));
9995
+ res.json({ accountId: req.params.id, account: _codexAccounts().describeForClient(req.params.id) });
9996
+ } catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
9997
+ });
9998
+ // `credential`: 'apiKey' (the account's key), 'subscription' (Codex's own logout in its home), or both when left out.
9999
+ app.post('/api/codex/accounts/:id/auth/logout', panelAuthOwnerGuard, async (req, res) => {
10000
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
10001
+ res.setHeader('Cache-Control', 'no-store');
10002
+ try {
10003
+ if (_codexAccounts().describe(req.params.id)?.id !== req.params.id) return res.status(404).json({ code: 'ACCOUNT_NOT_FOUND', error: 'That Codex account is no longer set up in SynaBun.' });
10004
+ const credential = typeof req.body?.credential === 'string' ? req.body.credential : 'all';
10005
+ const account = await codexAccountAuth().signOut(req.params.id, credential);
10006
+ res.json({ ok: true, account });
10007
+ } catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
10008
+ });
10009
+
10010
+ app.post('/api/claude/accounts', panelAuthOwnerGuard, (req, res) => {
10011
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
10012
+ try {
10013
+ const created = _panelClaudeAccounts().create({ label: req.body?.label });
10014
+ const row = _panelClaudeAccounts().listForClient().find(a => a.id === created.id);
10015
+ res.json({ account: row });
10016
+ } catch (error) { res.status(error.status || 500).json({ error: error.message }); }
10017
+ });
10018
+
10019
+ // Rename an account (`label`) or choose what its sessions use (`mode`:
10020
+ // 'subscription' | 'apiKey'; the chosen credential must be there). A mode
10021
+ // change ends the account's idle sessions so the next message starts under it.
10022
+ app.patch('/api/claude/accounts/:id', panelAuthOwnerGuard, async (req, res) => {
10023
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
10024
+ res.setHeader('Cache-Control', 'no-store');
10025
+ try {
10026
+ const accounts = _panelClaudeAccounts();
10027
+ if (!accounts.describe(req.params.id) || accounts.describe(req.params.id).id !== req.params.id) return res.status(404).json({ code: 'ACCOUNT_NOT_FOUND', error: 'That Claude account is no longer set up in SynaBun.' });
10028
+ const { label, mode } = req.body || {};
10029
+ if (label === undefined && mode === undefined) return res.status(400).json({ code: 'NOTHING_TO_CHANGE', error: 'Send a label or a mode.' });
10030
+ if (label !== undefined) accounts.rename(req.params.id, label);
10031
+ if (mode !== undefined) await panelClaudeAuth().setMode(req.params.id, mode);
10032
+ res.json({ ok: true, account: accounts.describeForClient(req.params.id) });
10033
+ } catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
10034
+ });
10035
+
10036
+ // Remove an added account and its config directory. Default stays; an account
10037
+ // a live session, a dispatched run or the Assistant's worker choice uses stays.
10038
+ app.delete('/api/claude/accounts/:id', panelAuthOwnerGuard, (req, res) => {
10039
+ if (isGuestRequest(req)) return res.status(403).json({ error: 'Admin only' });
10040
+ res.setHeader('Cache-Control', 'no-store');
10041
+ try { res.json(_panelClaudeAccounts().remove(req.params.id, { inUse: claudeAccountInUse })); }
10042
+ catch (error) { res.status(error.status || 500).json({ code: error.code || null, error: error.message }); }
10043
+ });
10044
+
9622
10045
  function _readClaudeSkinConfig() {
9623
10046
  try {
9624
10047
  const cfg = JSON.parse(readFileSync(resolve(DATA_HOME, 'data', 'cli-config.json'), 'utf-8'));
@@ -10806,8 +11229,9 @@ app.post('/api/youtube/download', async (req, res) => {
10806
11229
 
10807
11230
  // A GUI-launched NI may not inherit the user's shell PATH, so yt-dlp can't find
10808
11231
  // node/deno/ffmpeg. Prepend the running node's dir + the common Homebrew/local bins.
11232
+ // A packaged application's own runtime still goes last (withPackagedPathLast): the user's node first when there is one.
10809
11233
  const extraPath = [dirname(process.execPath), '/opt/homebrew/bin', '/usr/local/bin', join(os.homedir(), '.local/bin')].join(delimiter);
10810
- const childEnv = { ...process.env, PATH: `${extraPath}${delimiter}${process.env.PATH || ''}` };
11234
+ const childEnv = withPackagedPathLast(withPathEnv(process.env, `${extraPath}${delimiter}${pathEnvValue(process.env)}`));
10811
11235
 
10812
11236
  const runYtDlp = (finalArgs) => new Promise((done) => {
10813
11237
  const child = spawn(yd.cmd, finalArgs, { windowsHide: true, env: childEnv });
@@ -12767,8 +13191,10 @@ app.get('/api/loop/active', (req, res) => {
12767
13191
  if (data?.active) {
12768
13192
  const elapsed = Date.now() - new Date(data.startedAt).getTime();
12769
13193
  const maxMs = ((data.maxMinutes || 30) + 5) * 60 * 1000;
12770
- // Stale loop — past time cap + grace, delete it
12771
- if (elapsed >= maxMs) {
13194
+ // Stale loop — past time cap + grace, delete it. Not a dispatched worker's (runMode
13195
+ // 'task'): its maxMinutes is work time, which the dispatcher enforces, and a warm run
13196
+ // idles for a follow-up far past it; a dead one goes with the check below.
13197
+ if (data.runMode !== 'task' && elapsed >= maxMs) {
12772
13198
  try { unlinkSync(filePath); } catch { /* ok */ }
12773
13199
  continue;
12774
13200
  }
@@ -12780,6 +13206,10 @@ app.get('/api/loop/active', (req, res) => {
12780
13206
  continue;
12781
13207
  }
12782
13208
  }
13209
+ // A dispatched worker's time left is its work time left (the dispatcher's work clock); null
13210
+ // while the dispatcher does not know the run (right after a restart): never the wall clock.
13211
+ const isTaskRun = data.runMode === 'task';
13212
+ const work = isTaskRun && data.runId ? (_assistantDispatcher?.peek?.(data.runId) || null) : null;
12783
13213
  activeLoops.push({
12784
13214
  sessionId: f.replace('.json', ''),
12785
13215
  task: data.task,
@@ -12788,7 +13218,9 @@ app.get('/api/loop/active', (req, res) => {
12788
13218
  totalIterations: data.totalIterations || 10,
12789
13219
  maxMinutes: data.maxMinutes || 30,
12790
13220
  elapsedMinutes: Math.round(elapsed / 60000),
12791
- remainingMinutes: Math.max(0, Math.round(((data.maxMinutes || 30) * 60 * 1000 - elapsed) / 60000)),
13221
+ remainingMinutes: work && Number.isFinite(work.workLeftMs)
13222
+ ? Math.round(work.workLeftMs / 60000)
13223
+ : isTaskRun ? null : Math.max(0, Math.round(((data.maxMinutes || 30) * 60 * 1000 - elapsed) / 60000)),
12792
13224
  startedAt: data.startedAt,
12793
13225
  lastIterationAt: data.lastIterationAt,
12794
13226
  browserSessionId: data.browserSessionId || null,
@@ -13128,6 +13560,8 @@ app.post('/api/loop/launch', async (req, res) => {
13128
13560
  mcpProfile: cliMcpProfile,
13129
13561
  codexAccountId: codexAccount?.id || null,
13130
13562
  codexHome: codexAccount ? (codexAccount.home || CODEX_DEFAULT_HOME) : null,
13563
+ // A Codex exec loop keeps the Admin sandbox setting it started with (every iteration is its own `codex exec`).
13564
+ adminSandbox: cliProfile === 'codex' ? adminSandboxOn() : undefined,
13131
13565
  // Continuation mechanism. 'hook': Claude Code ONLY — the Stop/UserPromptSubmit hooks in
13132
13566
  // .claude/ set awaitingNext and attachLoopDriver() drives /clear + re-prompt; if those
13133
13567
  // hooks are absent (e.g. a claude-code task run under a hookless harness) the loop STALLS.
@@ -14126,6 +14560,8 @@ async function launchScheduledLoop(schedule) {
14126
14560
  mcpProfile: cliMcpProfile || null,
14127
14561
  codexAccountId: codexAccount?.id || null,
14128
14562
  codexHome: codexAccount ? (codexAccount.home || CODEX_DEFAULT_HOME) : null,
14563
+ // A Codex exec loop keeps the Admin sandbox setting it started with (every iteration is its own `codex exec`).
14564
+ adminSandbox: cliProfile === 'codex' ? adminSandboxOn() : undefined,
14129
14565
  driverType: nativeRuntime ? 'native' : (cliProfile === 'claude-code' ? 'hook' : 'exec'),
14130
14566
  runtimeType: nativeRuntime ? 'native' : 'pty',
14131
14567
  surface: nativeRuntime ? 'sidepanel' : 'terminal',
@@ -15454,7 +15890,7 @@ function spawnAgentProcess(agent, prompt, extraEnv = {}) {
15454
15890
 
15455
15891
  const child = spawn(shellCommand(claudeBin, AGENT_IS_WIN), args, {
15456
15892
  cwd: agent.cwd,
15457
- env: { ...cleanEnv, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb', ...extraEnv },
15893
+ env: claudeChildEnv({ ...cleanEnv, FORCE_COLOR: '0', NO_COLOR: '1', TERM: 'dumb', ...extraEnv }),
15458
15894
  stdio: ['ignore', 'pipe', 'pipe'],
15459
15895
  shell: AGENT_IS_WIN,
15460
15896
  windowsHide: true,
@@ -16129,6 +16565,18 @@ async function checkSynabunUpdate() {
16129
16565
  // stable releases with a suffix.
16130
16566
  const installedChannel = synabunReleaseChannel(current);
16131
16567
 
16568
+ // The desktop app updates itself (packaging/desktop: its window's update function, fed by the GitHub
16569
+ // releases' desktop files). Nothing here offers it an npm or GitHub update; there is nothing to check.
16570
+ if (PACKAGED_RUNTIME?.shell) {
16571
+ _synabunUpdateCache = {
16572
+ ..._synabunUpdateCache,
16573
+ current, installedChannel, latest: null, npmUpdateAvailable: false, gitUpdateAvailable: false, updateAvailable: false,
16574
+ source: null, installPlan: null, checkedAt: new Date().toISOString(), npmError: null, gitError: null, desktopManaged: true,
16575
+ };
16576
+ console.log(` Updates: from the SynaBun app (v${current})`);
16577
+ return;
16578
+ }
16579
+
16132
16580
  const fetchJsonWithTimeout = async (url, ms = 5000) => {
16133
16581
  const controller = new AbortController();
16134
16582
  const timeout = setTimeout(() => controller.abort(), ms);
@@ -16224,6 +16672,72 @@ app.get('/api/system/version', async (req, res) => {
16224
16672
  res.json(_synabunUpdateCache);
16225
16673
  });
16226
16674
 
16675
+ // GET /api/system/about — Settings → About (lib/system-about.js): the app, the runtime, the project's links and
16676
+ // every dependency of the product's packages with the version installed, read from the files on disk on the first
16677
+ // request and kept for the life of the process. No environment, no token, no setting. Owner only (/api/system is
16678
+ // in ADMIN_ONLY_PREFIXES).
16679
+ const systemAbout = createSystemAbout({
16680
+ packageRoot: PACKAGE_ROOT,
16681
+ dataHome: DATA_HOME,
16682
+ port: PORT,
16683
+ install: PACKAGED_RUNTIME?.shell ? 'desktop' : INSTALL_KIND,
16684
+ // The WhatsApp connector is installed on demand, into the data folder (lib/whatsapp/paths.js runtimeDir).
16685
+ installDirs: { 'whatsapp-connector': [join(DATA_HOME, 'runtime', 'whatsapp')] },
16686
+ vendorDir: join(__dirname, 'public', 'vendor'),
16687
+ });
16688
+ app.get('/api/system/about', (req, res) => {
16689
+ res.set('Cache-Control', 'no-store');
16690
+ try {
16691
+ res.json(systemAbout.get());
16692
+ } catch (err) {
16693
+ res.status(500).json({ error: 'about_failed', detail: String(err?.message || err).slice(0, 200) });
16694
+ }
16695
+ });
16696
+
16697
+ // GET /api/system/activity — whether anything runs that a restart would end. The
16698
+ // desktop app installs a downloaded update only while this says idle
16699
+ // (packaging/desktop/src/updater/controller.mjs); lib/system-activity.js adds the
16700
+ // counters up, and one that throws makes the answer "not idle". Terminals are not
16701
+ // counted: nothing here can tell a command that runs from a shell that waits.
16702
+ function systemActivitySources() {
16703
+ const nativeRuns = () => _nativeLoopRuntime?.list?.({ activeOnly: true }) || [];
16704
+ return {
16705
+ assistant: () => {
16706
+ const runtime = _assistantRuntime;
16707
+ const sessions = runtime?._internals?.sessions;
16708
+ const brains = sessions ? countWhere(sessions.keys(), (id) => runtime.isBusy(id)) : 0;
16709
+ const runs = _assistantDispatcher ? _assistantDispatcher.list({ activeOnly: true, limit: 500 }).length : 0;
16710
+ const agents = countWhere(agentRegistry, (agent) => agent?.status === 'running');
16711
+ return brains + runs + agents;
16712
+ },
16713
+ 'claude-panel': () => _claudeBridge?.panelSessionsWorking?.() || 0,
16714
+ 'codex-panel': () => countWhere(_codexLiveConnections, (connection) => !!connection?.busy?.())
16715
+ + countWhere(_codexOrphanedProcs, (orphan) => orphan?.isIdle?.() !== true),
16716
+ 'opencode-panel': () => {
16717
+ const blockers = getOpenCodeHistoryBlockers();
16718
+ return blockers.activeTurns + blockers.startingTurns;
16719
+ },
16720
+ 'claude-cli': () => recentCliSessions(sessionRegistry),
16721
+ loop: () => {
16722
+ // A dispatched worker also runs in the native runtime: it is counted under assistant.
16723
+ const native = countWhere(nativeRuns(), (run) => run?.runMode !== 'task' && run?.source !== 'assistant');
16724
+ const files = countWhere(readLoopStateFiles(), ({ data }) => !!data?.active && (!data.terminalSessionId || isLoopRuntimeAlive(data)));
16725
+ const terminals = countWhere(terminalSessions, (session) => !!session?._loopTerminalSessionId);
16726
+ return Math.max(native + terminals, files);
16727
+ },
16728
+ schedule: () => _scheduleQueue.length + (_scheduleQueueRunning ? 1 : 0),
16729
+ backup: () => (_backupOperationActive ? 1 : 0) + (_autoBackupPromise ? 1 : 0),
16730
+ maintenance: () => (_reindexJob?.running ? 1 : 0) + (_indexingJob && !_indexingJob.done ? 1 : 0)
16731
+ + countWhere(Object.values(cliInstaller.jobs()), (job) => job?.state === 'running')
16732
+ + (_projectStorageClearInProgress ? 1 : 0) + (_ocpHistoryClearInProgress ? 1 : 0),
16733
+ };
16734
+ }
16735
+
16736
+ app.get('/api/system/activity', (req, res) => {
16737
+ res.set('Cache-Control', 'no-store');
16738
+ res.json(collectActivity(systemActivitySources(), { log: (line) => console.warn(`[activity] ${line}`) }));
16739
+ });
16740
+
16227
16741
  // ═══════════════════════════════════════════
16228
16742
  // EXTERNAL CLI TOOL VERSION CHECK
16229
16743
  // ═══════════════════════════════════════════
@@ -16236,6 +16750,7 @@ const TOOL_DEFS = [
16236
16750
  ];
16237
16751
 
16238
16752
  let _toolVersionCache = { checkedAt: null, tools: {} };
16753
+ let _toolVersionGeneration = 0;
16239
16754
 
16240
16755
  // Backwards-compat helper — returns a "MAJOR.MINOR.PATCH" string (no pre).
16241
16756
  // Kept because legacy callers expect a string for display purposes only.
@@ -16255,35 +16770,7 @@ function parseVersion(raw) {
16255
16770
  // parseSemver / compareSemver / semverNewer live in lib/synabun-version.js, with
16256
16771
  // the rules that order SynaBun's own date-style and semver-era releases.
16257
16772
 
16258
- function resolveBinPath(cmd, env) {
16259
- try {
16260
- const isWin = process.platform === 'win32';
16261
- const finder = isWin ? `where ${cmd}` : `command -v ${cmd}`;
16262
- const opts = { encoding: 'utf-8', timeout: 2000, stdio: ['pipe', 'pipe', 'pipe'], env };
16263
- if (!isWin) opts.shell = '/bin/sh';
16264
- const raw = execSync(finder, opts).trim();
16265
- const first = raw.split(/\r?\n/)[0] || null;
16266
- if (!first) return null;
16267
- // Follow symlinks so install-source detection sees the real install dir
16268
- // (e.g. /usr/local/bin/claude → /usr/local/lib/node_modules/.../bin/claude).
16269
- try { return realpathSync(first); } catch { return first; }
16270
- } catch { return null; }
16271
- }
16272
-
16273
- // Async twin of resolveBinPath — version checks run these probes for every
16274
- // tool; the sync variant blocks the event loop for the full subprocess time.
16275
- async function resolveBinPathAsync(cmd, env) {
16276
- try {
16277
- const isWin = process.platform === 'win32';
16278
- const finder = isWin ? `where ${cmd}` : `command -v ${cmd}`;
16279
- const opts = { encoding: 'utf-8', timeout: 2000, env, windowsHide: true };
16280
- if (!isWin) opts.shell = '/bin/sh';
16281
- const { stdout } = await execAsync(finder, opts);
16282
- const first = (stdout || '').trim().split(/\r?\n/)[0] || null;
16283
- if (!first) return null;
16284
- try { return realpathSync(first); } catch { return first; }
16285
- } catch { return null; }
16286
- }
16773
+ // Where each CLI is: lib/cli-resolver.js (cliResolver, near getClaudeBin).
16287
16774
 
16288
16775
  // detectInstallSource / buildUpdateCommand / NPM_PKGS now live in
16289
16776
  // ./lib/cli-update-planner.js so they can be unit tested away from this file.
@@ -16340,13 +16827,6 @@ function isInsideSynabun(binPath) {
16340
16827
  }
16341
16828
  }
16342
16829
 
16343
- // Versions of @anthropic-ai/claude-code that SynaBun has historically shipped
16344
- // as a bundled dependency. If a `claude --version` probe returns one of these,
16345
- // it's almost certainly the stale bundle (a real user-global install would be
16346
- // the current 2.1.116+). Reject hard regardless of where the binary path looks
16347
- // to come from. Add new versions here if SynaBun ever ships claude-code again.
16348
- const KNOWN_STALE_CLAUDE_VERSIONS = new Set(['2.1.71', '2.1.89', '2.1.91']);
16349
-
16350
16830
  // Remove any external tool that ended up in our own node_modules: Claude Code,
16351
16831
  // Codex, OpenCode and Gemini CLI are installed by the user, never by SynaBun,
16352
16832
  // and the agent SDKs are installed without the executables they would carry
@@ -16367,52 +16847,25 @@ function pruneBundledCliTools() {
16367
16847
  }
16368
16848
  }
16369
16849
 
16370
- async function checkToolVersions() {
16371
- // Strip node_modules/.bin entries so the version check reflects the user's
16372
- // global install rather than any copy bundled with SynaBun itself.
16373
- const augPath = getAugmentedPath()
16374
- .split(delimiter)
16375
- .filter(p => !/[\\/]node_modules[\\/]\.bin[\\/]?$/i.test(p))
16376
- .join(delimiter);
16377
- const cleanEnv = { ...process.env, PATH: augPath };
16378
- const execOpts = { encoding: 'utf-8', timeout: 5000, env: cleanEnv, windowsHide: true };
16379
-
16380
- // Probe all tools concurrently with async subprocesses — the sequential
16381
- // execSync version blocked the event loop for the sum of every probe
16382
- // (worst case: N tools × 5s timeout with nothing else able to run).
16383
- // probedBinPath is resolved by the caller (below) so the brew probe can be
16384
- // skipped entirely when no brew-managed tool is installed. If the binary
16385
- // lives inside SynaBun's own install dir (stale bundled copy), treat the
16386
- // tool as not installed — do NOT run --version against it, since that would
16387
- // report the bundled version and trigger a false-positive update badge.
16388
- const checkOneTool = async (tool, probedBinPath) => {
16389
- const isStaleBundle = isInsideSynabun(probedBinPath);
16390
-
16391
- let installed = null;
16392
- if (!isStaleBundle) {
16393
- try {
16394
- const { stdout } = await execAsync(`${tool.cmd} ${tool.versionArg}`, execOpts);
16395
- installed = parseVersion(stdout.trim());
16396
- } catch { /* not installed or errored */ }
16397
- }
16398
-
16399
- // Nuclear safety net for the recurring claude phantom-version bug:
16400
- // only reject when binary path detection failed (truly phantom). If we
16401
- // resolved a path AND it's outside SynaBun's own dirs, trust the version
16402
- // — Anthropic ships these versions in the native installer too, so a
16403
- // version-only blacklist hits legit installs at ~/.local/bin/claude.exe.
16404
- if (
16405
- tool.key === 'claude-code' &&
16406
- installed &&
16407
- KNOWN_STALE_CLAUDE_VERSIONS.has(installed) &&
16408
- !probedBinPath
16409
- ) {
16410
- console.warn(
16411
- ` [version-guard] Rejected claude v${installed} from (unknown path)` +
16412
- ` — matches known-stale SynaBun bundle and no binary path could be resolved.`
16413
- );
16414
- installed = null;
16415
- }
16850
+ async function checkToolVersions({ force = false } = {}) {
16851
+ const generation = ++_toolVersionGeneration;
16852
+ // Every CLI exactly as the panels and launchers find it (lib/cli-resolver.js):
16853
+ // the path in Settings first, then PATH with the OS PATH read afresh, then the
16854
+ // known install folders; installed (with its version), missing, or broken
16855
+ // (found, but `--version` failed: the real error). Never a copy inside
16856
+ // SynaBun, so the badge reflects the CLI the user would run in a terminal.
16857
+ // `force` is Re-check: the OS PATH is read again and every --version asked again.
16858
+ const statuses = await cliResolver.resolveAll(TOOL_DEFS.map((tool) => tool.key), { force });
16859
+ const cleanEnv = withPathEnv(process.env, getAugmentedPath());
16860
+
16861
+ // Only pay for the brew probe when a brew-managed tool is actually present.
16862
+ const brewOutdated = Object.values(statuses).some((status) => status.state === 'installed' && isBrewSource(status.installSource))
16863
+ ? await probeBrewOutdated(cleanEnv)
16864
+ : null;
16865
+
16866
+ const checkOneTool = async (tool) => {
16867
+ const status = statuses[tool.key];
16868
+ const installed = status.state === 'installed' ? status.version : null;
16416
16869
 
16417
16870
  // Upstream (npm) version. For a brew install this is NOT the yardstick —
16418
16871
  // Homebrew routinely trails npm by a few releases, so comparing against it
@@ -16433,9 +16886,8 @@ async function checkToolVersions() {
16433
16886
  }
16434
16887
  } catch { /* network error */ }
16435
16888
 
16436
- const binPath = installed ? probedBinPath : null;
16437
- const installSource = detectInstallSource(binPath);
16438
- const brewToken = isBrewSource(installSource) ? resolveBrewToken(tool.key, binPath) : null;
16889
+ const installSource = status.installSource;
16890
+ const brewToken = isBrewSource(installSource) ? resolveBrewToken(tool.key, status.realPath || status.path) : null;
16439
16891
  const latest = resolveLatest({
16440
16892
  installSource,
16441
16893
  brewToken,
@@ -16443,8 +16895,10 @@ async function checkToolVersions() {
16443
16895
  npmLatest: upstreamLatest,
16444
16896
  installed,
16445
16897
  });
16898
+ // A self-updater is started by the path found, so the update reaches this
16899
+ // very installation even where a terminal's PATH does not name its folder.
16446
16900
  const updateCommand = installed
16447
- ? buildUpdateCommand(tool.key, installSource, { brewToken })
16901
+ ? buildUpdateCommand(tool.key, installSource, { brewToken, binPath: status.path, platform: process.platform })
16448
16902
  : null;
16449
16903
 
16450
16904
  return [tool.key, {
@@ -16454,32 +16908,33 @@ async function checkToolVersions() {
16454
16908
  updateAvailable: !!(installed && latest && semverNewer(installed, latest)),
16455
16909
  installSource,
16456
16910
  brewToken,
16457
- binPath,
16911
+ binPath: status.path,
16458
16912
  updateCommand,
16459
16913
  canUpdate: !!updateCommand,
16914
+ state: status.state,
16915
+ version: status.version,
16916
+ error: status.error,
16917
+ foundBy: status.foundBy,
16918
+ realPath: status.realPath,
16919
+ launchPath: status.launchPath,
16920
+ configured: status.configured,
16921
+ checked: status.candidates,
16922
+ guideUrl: CLI_INSTALL_GUIDES[tool.key] || null,
16923
+ install: cliInstaller.describe(tool.key),
16924
+ pathStatus: cliPathManager.inspect(status),
16460
16925
  }];
16461
16926
  };
16462
16927
 
16463
- // Resolve every binary path first so we only pay for the brew probe when a
16464
- // brew-managed tool is actually present.
16465
- const probedPaths = await Promise.all(
16466
- TOOL_DEFS.map((tool) => resolveBinPathAsync(tool.cmd, cleanEnv).catch(() => null)),
16467
- );
16468
- const brewOutdated = probedPaths.some((p) => isBrewSource(detectInstallSource(p)))
16469
- ? await probeBrewOutdated(cleanEnv)
16470
- : null;
16471
-
16472
- const entries = await Promise.all(
16473
- TOOL_DEFS.map((tool, i) => checkOneTool(tool, probedPaths[i])),
16474
- );
16928
+ const entries = await Promise.all(TOOL_DEFS.map((tool) => checkOneTool(tool)));
16475
16929
  const results = Object.fromEntries(entries);
16476
16930
 
16477
- // Tell a missing tool's install banner to use the channel this machine
16478
- // actually uses. A tool that isn't installed has no path to classify, so
16479
- // infer it from the siblings that ARE installed — otherwise a Homebrew
16480
- // user is told to `npm install -g`, creating a second conflicting install
16481
- // that may not even win PATH.
16931
+ // Tell a missing tool's install banner the command that fits this machine:
16932
+ // the vendor's own installer for this system (Anthropic's native installer
16933
+ // for Claude Code always), unless the CLIs that ARE installed show a package
16934
+ // manager this machine uses (Homebrew, bun, pnpm …), which is kept to, so
16935
+ // that the manual command never creates a second, conflicting install.
16482
16936
  const siblingSources = Object.values(results)
16937
+ .filter((v) => v.state === 'installed')
16483
16938
  .map((v) => v.installSource)
16484
16939
  .filter((s) => s && s !== 'unknown' && s !== 'other');
16485
16940
  const dominantSource = siblingSources
@@ -16488,23 +16943,40 @@ async function checkToolVersions() {
16488
16943
  || 'unknown';
16489
16944
  for (const [key, info] of Object.entries(results)) {
16490
16945
  const source = info.installed ? info.installSource : dominantSource;
16491
- info.installCommand = buildInstallCommand(key, source);
16946
+ info.installCommand = buildInstallCommand(key, source, { platform: process.platform });
16492
16947
  }
16493
16948
 
16949
+ // A check started before a newer refresh must never publish its stale answer.
16950
+ if (generation !== _toolVersionGeneration) return;
16494
16951
  _toolVersionCache = { checkedAt: new Date().toISOString(), tools: results };
16495
16952
 
16496
16953
  const updates = Object.entries(results).filter(([, v]) => v.updateAvailable);
16954
+ const broken = Object.entries(results).filter(([, v]) => v.state === 'broken');
16497
16955
  if (updates.length) {
16498
16956
  console.log(` CLI Tools: ${updates.map(([k, v]) => `${k} v${v.installed}→v${v.latest}`).join(', ')}`);
16499
16957
  } else {
16500
16958
  console.log(' CLI Tools: all up to date');
16501
16959
  }
16960
+ for (const [key, info] of broken) console.warn(` CLI Tools: ${key} at ${info.binPath} does not start: ${info.error}`);
16502
16961
  }
16503
16962
 
16504
16963
  // Single-flight: concurrent requests share one refresh instead of each
16505
16964
  // spawning their own set of subprocess probes.
16506
16965
  let _toolVersionInFlight = null;
16507
- function refreshToolVersions() {
16966
+ let _toolVersionForced = null;
16967
+ function refreshToolVersions({ force = false, fresh = false } = {}) {
16968
+ // A Re-check never takes the answer of a plain check already under way: it
16969
+ // reads the OS PATH and asks every --version again.
16970
+ if (force) {
16971
+ if (fresh || !_toolVersionForced) {
16972
+ const refresh = checkToolVersions({ force: true })
16973
+ .catch(() => {})
16974
+ .finally(() => { if (_toolVersionForced === refresh) _toolVersionForced = null; });
16975
+ _toolVersionForced = refresh;
16976
+ }
16977
+ return _toolVersionForced;
16978
+ }
16979
+ if (_toolVersionForced) return _toolVersionForced;
16508
16980
  if (!_toolVersionInFlight) {
16509
16981
  _toolVersionInFlight = checkToolVersions()
16510
16982
  .catch(() => {})
@@ -16516,8 +16988,9 @@ function refreshToolVersions() {
16516
16988
  app.get('/api/system/tool-versions', async (req, res) => {
16517
16989
  const force = req.query.force === '1';
16518
16990
  if (force || !_toolVersionCache.checkedAt) {
16519
- // force=1 callers (post-update badge refresh) need fresh data — await.
16520
- await refreshToolVersions();
16991
+ // force=1 (Re-check, the post-update badge refresh, the panels' poll while
16992
+ // a CLI is missing) needs fresh data: await it.
16993
+ await refreshToolVersions({ force });
16521
16994
  } else {
16522
16995
  const age = Date.now() - new Date(_toolVersionCache.checkedAt).getTime();
16523
16996
  // Stale-while-revalidate: serve the cached value now, refresh in background.
@@ -16526,6 +16999,40 @@ app.get('/api/system/tool-versions', async (req, res) => {
16526
16999
  res.json(_toolVersionCache);
16527
17000
  });
16528
17001
 
17002
+ // ── One-click install of a missing CLI (lib/cli-install.js, lib/cli-install-api.js) ──
17003
+ // The side panels' Install button: the vendor's official installer for this
17004
+ // system (or OpenCode's release archive on Windows), never a source a request
17005
+ // names, guarded like the other local mutating endpoints, its progress streamed
17006
+ // as NDJSON. Its last step is the resolver looking again (force); the tool
17007
+ // versions are then refreshed, so every panel's next check shows the CLI.
17008
+ const cliPathManager = createCliPathManager({
17009
+ onChanged: () => { _toolVersionCache = { ..._toolVersionCache, checkedAt: null }; },
17010
+ });
17011
+ app.use('/api/cli/path', createCliPathApi({
17012
+ pathManager: cliPathManager, resolver: cliResolver, isGuestRequest, port: () => PORT,
17013
+ diagnostics: async () => {
17014
+ const fresh = await freshTerminalPath();
17015
+ const statuses = await cliResolver.resolveAll(['claude-code', 'codex', 'opencode']);
17016
+ return { platform: process.platform, pathKey: Object.keys(fresh.env).find(key => key.toUpperCase() === 'PATH'),
17017
+ entries: fresh.entries, os: fresh.os, pathext: fresh.env.PATHEXT || fresh.env.PathExt || null,
17018
+ tools: Object.fromEntries(Object.entries(statuses).map(([tool, status]) => [tool, { path: status.path, launchPath: status.launchPath, ...cliPathManager.inspect(status) }])) };
17019
+ },
17020
+ }));
17021
+ const cliInstaller = createCliInstaller({
17022
+ resolver: cliResolver,
17023
+ addToPath: status => cliPathManager.add(status),
17024
+ readOsPath: async () => (await osPath.refresh({ force: true })).entries,
17025
+ onFinished: () => {
17026
+ _toolVersionCache = { ..._toolVersionCache, checkedAt: null };
17027
+ // The install changes the disk: even a Re-check already running predates it.
17028
+ refreshToolVersions({ force: true, fresh: true });
17029
+ },
17030
+ log: (line) => console.log(line),
17031
+ });
17032
+ app.use('/api/cli/install', createCliInstallApi({ installer: cliInstaller, isGuestRequest, port: () => PORT, log: (line) => console.warn(line) }));
17033
+ // Settings beside the CLIs: the Git Bash Claude Code gets on Windows, its download and a new look (lib/git-bash/).
17034
+ app.use('/api/cli/git-bash', createGitBashApi({ manager: gitBash, isGuestRequest, port: () => PORT, onRecheck: async () => { syncGitBashSettings(); } }));
17035
+
16529
17036
  // ═══════════════════════════════════════════
16530
17037
  // FULL SYSTEM BACKUP & RESTORE
16531
17038
  // ═══════════════════════════════════════════
@@ -16710,12 +17217,13 @@ app.post('/api/system/restore', async (req, res) => {
16710
17217
  const mcpDistCheck = resolve(PACKAGE_ROOT, 'mcp-server', 'dist', 'index.js');
16711
17218
  if (!existsSync(mcpDistCheck)) {
16712
17219
  try {
16713
- // Ensure dev deps are installed for TypeScript
17220
+ // TypeScript is a devDependency of the package root (a development checkout also has it in mcp-server/).
16714
17221
  const mcpDir = resolve(PACKAGE_ROOT, 'mcp-server');
16715
- if (!existsSync(resolve(mcpDir, 'node_modules', 'typescript'))) {
16716
- execSync('npm install', { cwd: mcpDir, stdio: 'pipe', timeout: 180_000, env: { ...process.env, NODE_ENV: 'development' } });
17222
+ if (!mcpCompiler(PACKAGE_ROOT)) {
17223
+ const npm = npmCommand(npmInvocation(), fallbackInstallArgs({ includeDev: true }));
17224
+ 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' })) });
16717
17225
  }
16718
- execSync('npm run build', { cwd: mcpDir, stdio: 'pipe', timeout: 60_000 });
17226
+ await execFileAsync(process.execPath, [mcpCompiler(PACKAGE_ROOT), '-p', mcpDir], { cwd: mcpDir, timeout: 120_000, maxBuffer: 16 * 1024 * 1024, windowsHide: true });
16719
17227
  results.files.push('(auto-built MCP server)');
16720
17228
  } catch (buildErr) {
16721
17229
  results.errors.push(`MCP server auto-build failed: ${buildErr.message}`);
@@ -16992,13 +17500,10 @@ const editorCompletionServe = createOpencodeCompletionEngine({
16992
17500
  // A provider's {file:./key} is relative to that configuration: it is written whole for the serve's own folder.
16993
17501
  userConfigDir: () => dirname(getOpencodeConfigPath()),
16994
17502
  allocPort: allocOpencodeIsoPort,
16995
- // As the isolated serves are started: through the login shell, so the CLI is found where a terminal finds it.
17503
+ // As the isolated serves are started (opencodeServeLaunch): the CLI the resolver found, through the login shell.
16996
17504
  spawnServe: ({ port, env, cwd }) => {
16997
- const cmd = loadCliConfig().opencode?.command || 'opencode';
16998
- const shellArgs = IS_WIN
16999
- ? ['/d', '/s', '/c', `${cmd} serve --port ${port}`]
17000
- : ['-lc', `exec ${cmd} serve --port ${port}`];
17001
- const proc = spawn(DEFAULT_SHELL, shellArgs, { stdio: ['ignore', 'ignore', 'pipe'], env, cwd, detached: false, shell: false, windowsHide: true });
17505
+ const launch = opencodeServeLaunch(port, { env });
17506
+ const proc = spawn(launch.command, launch.args, { stdio: ['ignore', 'ignore', 'pipe'], ...launch.options, cwd, detached: false, shell: false, windowsHide: true });
17002
17507
  proc.stderr?.on('data', (d) => console.log(`[editor-complete:err] ${String(d).trim().slice(0, 500)}`));
17003
17508
  return proc;
17004
17509
  },
@@ -17856,7 +18361,7 @@ app.get('/api/setup/check-deps', async (req, res) => {
17856
18361
 
17857
18362
  // npm
17858
18363
  try {
17859
- const { stdout } = await execAsync('npm --version', { timeout: 5000 });
18364
+ const { stdout } = await execAsync('npm --version', { timeout: 5000, env: withPackagedPathLast(process.env) });
17860
18365
  deps.push({ id: 'npm', name: 'npm', ok: true, version: `v${stdout.trim()}`, detail: `v${stdout.trim()}`, url: 'https://nodejs.org/' });
17861
18366
  } catch {
17862
18367
  deps.push({ id: 'npm', name: 'npm', ok: false, version: null, detail: 'Not found', url: 'https://nodejs.org/' });
@@ -17867,6 +18372,38 @@ app.get('/api/setup/check-deps', async (req, res) => {
17867
18372
  res.json({ deps });
17868
18373
  });
17869
18374
 
18375
+ // GET /api/setup/install-details — what this install has (lib/install-details.js), for the Details areas of the
18376
+ // setup wizard inside the desktop app: the Node.js and the npm and npx SynaBun runs (each asked for its version),
18377
+ // where `npm i -g` puts packages, the user's own npm, every part's dependencies and versions, the MCP build, and
18378
+ // what a packaged application came with. Read only; the owner's only (/api/setup is admin-only for guests).
18379
+ app.get('/api/setup/install-details', async (req, res) => {
18380
+ try {
18381
+ // The one prefix PATH is built from (packagedNpmPrefix; for an npm or Git install, the one the user set).
18382
+ const npm = PACKAGED_RUNTIME ? packagedNpmPrefix(PACKAGED_RUNTIME) : effectiveNpmPrefix();
18383
+ const prefix = npm?.prefix || null;
18384
+ let manifest = null;
18385
+ if (PACKAGED_RUNTIME) { try { manifest = JSON.parse(readFileSync(PACKAGED_RUNTIME.manifestPath, 'utf8')); } catch {} }
18386
+ let browserExecutable = null;
18387
+ try { browserExecutable = chromium.executablePath(); } catch {}
18388
+ let osEntries = [];
18389
+ try { osEntries = (await osPath.refresh()).entries || []; } catch {}
18390
+ res.json(await collectInstallDetails({
18391
+ packageRoot: PACKAGE_ROOT,
18392
+ packaged: PACKAGED_RUNTIME,
18393
+ manifest,
18394
+ osPath: osEntries,
18395
+ npmPrefix: prefix,
18396
+ npmPrefixSource: !prefix ? null : npm.source === 'builtin' || npm.source === 'default' ? 'synabun' : 'user',
18397
+ npmPrefixFrom: prefix && npm.source !== 'builtin' && npm.source !== 'default' ? npm.from || null : null,
18398
+ npmGlobalBin: prefix ? npmGlobalBinFor(prefix) : null,
18399
+ browserExecutable,
18400
+ }));
18401
+ } catch (err) {
18402
+ console.error('GET /api/setup/install-details error:', err.message);
18403
+ res.status(500).json({ error: err.message });
18404
+ }
18405
+ });
18406
+
17870
18407
  // GET /api/setup/onboarding — Check what's configured (onboarding wizard)
17871
18408
  app.get('/api/setup/onboarding', async (req, res) => {
17872
18409
  try {
@@ -17937,9 +18474,11 @@ app.post('/api/setup/build', async (req, res) => {
17937
18474
  res.setHeader('Connection', 'keep-alive');
17938
18475
  res.flushHeaders();
17939
18476
 
18477
+ // What npm wrote, as the wizard shows it: secrets out first (lib/log-redact.js), then the last 500 characters.
17940
18478
  function send(event, data) {
17941
- res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
18479
+ res.write(`event: ${event}\ndata: ${JSON.stringify(redactDeep(data))}\n\n`);
17942
18480
  }
18481
+ const tail = (text) => redactSecrets(String(text || '')).slice(-500);
17943
18482
 
17944
18483
  const dirs = [
17945
18484
  { name: 'Neural Interface', path: resolve(PACKAGE_ROOT, 'neural-interface') },
@@ -17950,28 +18489,34 @@ app.post('/api/setup/build', async (req, res) => {
17950
18489
  const mcpDist = resolve(PACKAGE_ROOT, 'mcp-server', 'dist', 'index.js');
17951
18490
  const mcpNeedsBuild = !existsSync(mcpDist);
17952
18491
 
17953
- // Phase 1: npm install for each directory
17954
- for (const dir of dirs) {
17955
- const hasModules = existsSync(resolve(dir.path, 'node_modules'));
17956
- // If MCP server needs building, reinstall with dev deps even if node_modules exists
17957
- const isMcp = dir.name === 'MCP Server';
17958
- const needsDevDeps = isMcp && mcpNeedsBuild && !existsSync(resolve(dir.path, 'node_modules', 'typescript'));
17959
- if (hasModules && !needsDevDeps) {
17960
- send('install', { dir: dir.name, status: 'skipped', output: 'node_modules already present' });
17961
- continue;
17962
- }
17963
-
17964
- send('install', { dir: dir.name, status: 'start' });
18492
+ // Phase 1: the dependencies. `npm i -g synabun` installed them at the package root (lib/runtime-dependencies.js
18493
+ // checks that Node finds them from both parts); only what is missing is installed, once, at the package root,
18494
+ // and never inside a packaged application. A fresh checkout also needs the TypeScript that builds the MCP server.
18495
+ const report = checkRuntimeDependencies({ packageRoot: PACKAGE_ROOT });
18496
+ const compilerMissing = mcpNeedsBuild && !mcpCompiler(PACKAGE_ROOT);
18497
+ if (report.ok && !compilerMissing) {
18498
+ for (const dir of dirs) send('install', { dir: dir.name, status: 'skipped', output: 'dependencies already installed' });
18499
+ } else if (PACKAGED_RUNTIME) {
18500
+ for (const dir of dirs) send('install', { dir: dir.name, status: 'error', output: 'This application is incomplete: reinstall SynaBun.' });
18501
+ send('complete', { ok: false, error: 'The application is incomplete' });
18502
+ return res.end();
18503
+ } else {
18504
+ for (const dir of dirs) send('install', { dir: dir.name, status: 'start' });
17965
18505
  try {
17966
- const { stdout, stderr } = await execAsync('npm install', {
17967
- cwd: dir.path,
17968
- timeout: 180_000,
17969
- env: { ...process.env, NODE_ENV: 'development' },
18506
+ // The npm that started SynaBun, else the user's own on PATH, else the one next to this Node (npmInvocation).
18507
+ const npm = npmCommand(npmInvocation(), fallbackInstallArgs({ includeDev: mcpNeedsBuild }));
18508
+ const { stdout } = await execFileAsync(npm.file, npm.args, {
18509
+ cwd: PACKAGE_ROOT,
18510
+ timeout: 600_000,
18511
+ maxBuffer: 16 * 1024 * 1024,
18512
+ shell: npm.shell,
18513
+ windowsHide: true,
18514
+ env: withPackagedPathLast(fallbackInstallEnv({ ...process.env, NODE_ENV: 'development' })),
17970
18515
  });
17971
- send('install', { dir: dir.name, status: 'done', output: (stdout || '').slice(-500) });
18516
+ for (const dir of dirs) send('install', { dir: dir.name, status: 'done', output: tail(stdout) });
17972
18517
  } catch (err) {
17973
- send('install', { dir: dir.name, status: 'error', output: (err.stderr || err.message || '').slice(-500) });
17974
- send('complete', { ok: false, error: `npm install failed for ${dir.name}` });
18518
+ for (const dir of dirs) send('install', { dir: dir.name, status: 'error', output: tail(err.stderr || err.message) });
18519
+ send('complete', { ok: false, error: 'npm install failed in the package root' });
17975
18520
  return res.end();
17976
18521
  }
17977
18522
  }
@@ -17988,13 +18533,18 @@ app.post('/api/setup/build', async (req, res) => {
17988
18533
  } else {
17989
18534
  send('build', { status: 'start' });
17990
18535
  try {
17991
- const { stdout, stderr } = await execAsync('npm run build', {
18536
+ // The TypeScript Node finds from mcp-server/ (the root's devDependency in a fresh checkout), by this Node.
18537
+ const tsc = mcpCompiler(PACKAGE_ROOT);
18538
+ if (!tsc) throw new Error('TypeScript is not installed: npm install in the package root installs it');
18539
+ const { stdout } = await execFileAsync(process.execPath, [tsc, '-p', resolve(PACKAGE_ROOT, 'mcp-server')], {
17992
18540
  cwd: resolve(PACKAGE_ROOT, 'mcp-server'),
17993
- timeout: 60_000,
18541
+ timeout: 120_000,
18542
+ maxBuffer: 16 * 1024 * 1024,
18543
+ windowsHide: true,
17994
18544
  });
17995
- send('build', { status: 'done', output: (stdout || '').slice(-500) });
18545
+ send('build', { status: 'done', output: tail(stdout) });
17996
18546
  } catch (err) {
17997
- send('build', { status: 'error', output: (err.stderr || err.message || '').slice(-500) });
18547
+ send('build', { status: 'error', output: tail(err.stderr || err.message) });
17998
18548
  send('complete', { ok: false, error: 'MCP server build failed' });
17999
18549
  return res.end();
18000
18550
  }
@@ -18562,7 +19112,7 @@ function injectClaudeMemoryPerms() {
18562
19112
 
18563
19113
  function injectCodexMemoryPerms() {
18564
19114
  try {
18565
- const configPath = join(getHomePath(), '.codex', 'config.toml');
19115
+ const configPath = join(CODEX_DEFAULT_HOME, 'config.toml');
18566
19116
  if (!existsSync(configPath)) return { scope: 'none', paths: [] };
18567
19117
  let content = readFileSync(configPath, 'utf-8');
18568
19118
  if (!tomlHasSection(content, 'mcp_servers.SynaBun')) return { scope: 'none', paths: [] };
@@ -20074,7 +20624,7 @@ app.post('/api/session-indexing/mirror', async (req, res) => {
20074
20624
  try {
20075
20625
  await mirrorExistingChunks((event) => {
20076
20626
  try { broadcastSync(event); } catch {}
20077
- });
20627
+ }, { ensureCategory: () => ensureFeatureCategory('conversations') });
20078
20628
  } catch (err) {
20079
20629
  console.error('Mirror error:', err);
20080
20630
  try { broadcastSync({ type: 'mirror:error', error: err.message }); } catch {}
@@ -20213,16 +20763,15 @@ app.post('/api/claude-code/integrations', (req, res) => {
20213
20763
  writeClaudeSettings(filePath, settings);
20214
20764
  }
20215
20765
 
20216
- // Save to registered projects list
20766
+ // Save to registered projects list, and create the new project's default category tree (parent + children)
20767
+ // then, once: a project already registered gets nothing, so a default the user deleted stays deleted
20217
20768
  const projectLabel = label || basename(normalized);
20218
20769
  if (!registered) {
20219
20770
  projects.push({ path: normalized, label: projectLabel });
20220
20771
  saveHookProjects(projects);
20772
+ try { ensureProjectCategories([{ path: normalized, label: projectLabel }]); } catch {}
20221
20773
  }
20222
20774
 
20223
- // Create default category tree for the project (parent + children)
20224
- try { ensureProjectCategories(); } catch {}
20225
-
20226
20775
  // Probe for git "dubious ownership" — signals the UI to show an explicit trust prompt.
20227
20776
  // Only relevant on Windows when the project dir is owned by a different SID than the
20228
20777
  // account running Node (external drives, copied repos, etc.). No-op on Mac/Linux.
@@ -20729,9 +21278,9 @@ function tomlRemoveAllToolSections(content, prefix) {
20729
21278
  * - If disabled → remove that section
20730
21279
  * Preserves all non-tool config (model, projects, mcp_servers.SynaBun base, notices).
20731
21280
  */
20732
- function syncCodexToolPermissions(allowedKeys) {
21281
+ function syncCodexToolPermissions(allowedKeys, codexHome = CODEX_DEFAULT_HOME) {
20733
21282
  try {
20734
- const configPath = join(getHomePath(), '.codex', 'config.toml');
21283
+ const configPath = join(codexHome, 'config.toml');
20735
21284
  if (!existsSync(configPath)) return;
20736
21285
  healCodexConfig(configPath);
20737
21286
  const health = inspectCodexConfig(configPath);
@@ -20875,7 +21424,7 @@ app.delete('/api/setup/gemini/mcp', (req, res) => {
20875
21424
 
20876
21425
  app.get('/api/setup/codex/mcp', (req, res) => {
20877
21426
  try {
20878
- const configPath = join(getHomePath(), '.codex', 'config.toml');
21427
+ const configPath = join(CODEX_DEFAULT_HOME, 'config.toml');
20879
21428
  const toml = buildCodexMcpToml();
20880
21429
  const health = inspectCodexConfig(configPath);
20881
21430
  res.json({ ok: true, ...health, toml });
@@ -20886,7 +21435,7 @@ app.get('/api/setup/codex/mcp', (req, res) => {
20886
21435
 
20887
21436
  app.post('/api/setup/codex/mcp', (req, res) => {
20888
21437
  try {
20889
- const dir = join(getHomePath(), '.codex');
21438
+ const dir = CODEX_DEFAULT_HOME;
20890
21439
  const configPath = join(dir, 'config.toml');
20891
21440
  mkdirSync(dir, { recursive: true });
20892
21441
  ensureMcpProfileDefault('codex-browser');
@@ -20901,7 +21450,7 @@ app.post('/api/setup/codex/mcp', (req, res) => {
20901
21450
 
20902
21451
  app.delete('/api/setup/codex/mcp', (req, res) => {
20903
21452
  try {
20904
- const configPath = join(getHomePath(), '.codex', 'config.toml');
21453
+ const configPath = join(CODEX_DEFAULT_HOME, 'config.toml');
20905
21454
  if (!existsSync(configPath)) return res.json({ ok: true, rules: syncRulesWithMcp('codex', 'remove') });
20906
21455
  const health = removeCodexMcpConfig(configPath);
20907
21456
  res.json({ ok: true, ...health, message: 'SynaBun MCP removed from Codex CLI.', rules: syncRulesWithMcp('codex', 'remove') });
@@ -20936,7 +21485,7 @@ app.get('/api/setup/status', (req, res) => {
20936
21485
  // Codex
20937
21486
  let codex = { connected: false, valid: false, repairable: false, issues: [] };
20938
21487
  try {
20939
- codex = inspectCodexConfig(join(home, '.codex', 'config.toml'));
21488
+ codex = inspectCodexConfig(join(CODEX_DEFAULT_HOME, 'config.toml'));
20940
21489
  } catch {}
20941
21490
  codex.cliCommand = buildCodexMcpCliCommand();
20942
21491
 
@@ -22211,6 +22760,7 @@ app.get('/api/health', (req, res) => {
22211
22760
  const count = countMemories();
22212
22761
  res.json({
22213
22762
  ok: true, storage: 'sqlite', memories: count, projectDir: PACKAGE_ROOT, install: INSTALL_KIND, startLauncher: getStartLauncherState(), entry: START_ENTRY,
22763
+ version: SERVER_VERSION, dataHomeId: DATA_HOME_ID, installEntry: INSTALL_ENTRY,
22214
22764
  // Which server this is, and whether it is on its way out for a restart: a
22215
22765
  // page showing the restart tells this one from the one that replaces it.
22216
22766
  bootId: SERVER_BOOT_ID,
@@ -22587,14 +23137,15 @@ const SYNABUN_TOOL_GROUPS = {
22587
23137
  sync: { label: 'Sync', tools: 1, alwaysOn: true },
22588
23138
  loop: { label: 'Loop', tools: 1, alwaysOn: true },
22589
23139
  profile: { label: 'Profile', tools: 1, alwaysOn: true },
23140
+ choice: { label: 'Choice', tools: 1, alwaysOn: true },
22590
23141
  git: { label: 'Git', tools: 1 },
22591
23142
  image: { label: 'Images', tools: 1 },
22592
23143
  whiteboard: { label: 'Whiteboard', tools: 5 },
22593
23144
  card: { label: 'Cards', tools: 5 },
22594
23145
  tictactoe: { label: 'TicTacToe', tools: 1 },
22595
- browser: { label: 'Browser', tools: 18 },
22596
- browser_twitter: { label: 'Twitter/X', tools: 1 },
22597
- browser_facebook: { label: 'Facebook', tools: 3 },
23146
+ browser: { label: 'Browser', tools: 21 },
23147
+ browser_twitter: { label: 'Twitter/X', tools: 2 },
23148
+ browser_facebook: { label: 'Facebook', tools: 4 },
22598
23149
  browser_tiktok: { label: 'TikTok', tools: 4 },
22599
23150
  browser_whatsapp: { label: 'WhatsApp', tools: 2 },
22600
23151
  browser_instagram: { label: 'Instagram', tools: 5 },
@@ -23754,12 +24305,10 @@ function saveCliConfig(config) {
23754
24305
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
23755
24306
  writeFileSync(CLI_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
23756
24307
  _cliConfigCache = null;
23757
- // Clear cached binaries so getClaudeBin()/getCodexBin() re-resolve with the
23758
- // new config. Codex was previously left stale, so a corrected Codex path in
23759
- // Settings did nothing until the server was restarted.
23760
- _claudeBinPath = null;
23761
- _codexBinPath = null;
23762
- _nativeCodexBinPath = null;
24308
+ // A path saved in Settings counts at once, for the version check and every
24309
+ // launcher alike (lib/cli-resolver.js), with no restart.
24310
+ cliResolver.invalidate();
24311
+ _toolVersionCache = { ..._toolVersionCache, checkedAt: null };
23763
24312
  }
23764
24313
 
23765
24314
  // Interactive shell tabs start as login shells, like Terminal.app / iTerm2.
@@ -23781,14 +24330,16 @@ function getTerminalProfile(profileId, opts = {}) {
23781
24330
 
23782
24331
  let cmd = entry.command.trim();
23783
24332
  if (!cmd) return SHELL_PROFILE;
24333
+ const found = cliResolver.launcher(profileId);
24334
+ if (!configuredCommandLine(profileId)) cmd = terminalLaunchCommand(found, DEFAULT_SHELL) || cmd;
23784
24335
 
23785
24336
  // Append resume flag for supported CLIs (mutually exclusive with model)
23786
24337
  if (opts.resume && profileId === 'claude-code') {
23787
- cmd = `${cmd} --resume ${opts.resume}`;
24338
+ cmd = `${cmd} --resume ${terminalArgument(opts.resume, DEFAULT_SHELL)}`;
23788
24339
  } else if (opts.resume && profileId === 'codex') {
23789
- cmd = `${cmd} resume ${opts.resume}`;
24340
+ cmd = `${cmd} resume ${terminalArgument(opts.resume, DEFAULT_SHELL)}`;
23790
24341
  } else if (opts.resume && profileId === 'opencode') {
23791
- cmd = `${cmd} -s ${opts.resume}`;
24342
+ cmd = `${cmd} -s ${terminalArgument(opts.resume, DEFAULT_SHELL)}`;
23792
24343
  }
23793
24344
  // Append model flag for CLIs that support it
23794
24345
  else if (opts.model) {
@@ -23802,7 +24353,7 @@ function getTerminalProfile(profileId, opts = {}) {
23802
24353
  // suffix `[1m]` contains `[ ]`, which zsh parses as a glob character class —
23803
24354
  // unquoted it aborts with "no matches found" and the CLI never launches.
23804
24355
  // Mirror the IS_WIN-aware quoting used for --mcp-config below.
23805
- const quotedModel = IS_WIN ? `"${cleanModel}"` : shellSingleQuote(cleanModel);
24356
+ const quotedModel = terminalArgument(cleanModel, DEFAULT_SHELL);
23806
24357
  const modelMap = {
23807
24358
  'claude-code': (m) => `${cmd} --model ${m}`,
23808
24359
  'codex': (m) => `${cmd} --model ${m}`,
@@ -23824,6 +24375,11 @@ function getTerminalProfile(profileId, opts = {}) {
23824
24375
  cmd = `${cmd} -c model_reasoning_effort="${codexEffort}"`;
23825
24376
  }
23826
24377
  }
24378
+ // Every Codex terminal: the Windows sandbox the Admin sandbox setting allows. A bare value
24379
+ // (Codex reads one that is not TOML as a string), so no shell can strip a quote from it.
24380
+ if (profileId === 'codex') {
24381
+ cmd = `${cmd} -c ${terminalArgument(`windows.sandbox=${codexWindowsSandbox(adminSandboxOn())}`, DEFAULT_SHELL)}`;
24382
+ }
23827
24383
 
23828
24384
  // Loop-only: bypass permission prompts for unattended Claude Code loops.
23829
24385
  // The loop runs in a PTY with no human to answer prompts, so accessing
@@ -23841,7 +24397,7 @@ function getTerminalProfile(profileId, opts = {}) {
23841
24397
  // collisions invisibly. Trade-off: project-scope .mcp.json servers are not
23842
24398
  // loaded inside loops (user-scope servers ARE — they're merged into the file).
23843
24399
  if (opts.mcpConfigPath && profileId === 'claude-code') {
23844
- const quoted = IS_WIN ? `"${opts.mcpConfigPath}"` : `'${opts.mcpConfigPath.replace(/'/g, `'\\''`)}'`;
24400
+ const quoted = terminalArgument(opts.mcpConfigPath, DEFAULT_SHELL);
23845
24401
  cmd = `${cmd} --strict-mcp-config --mcp-config ${quoted}`;
23846
24402
  }
23847
24403
 
@@ -23854,20 +24410,25 @@ function getTerminalProfile(profileId, opts = {}) {
23854
24410
  };
23855
24411
  }
23856
24412
 
24413
+ // Read for every spawn, including a tab restored/split by the client and
24414
+ // provider sign-in terminals. The desktop app uses this same server/PTY host.
24415
+ async function freshTerminalPath(base = process.env) {
24416
+ const os = await osPath.refresh({ force: true });
24417
+ const launchers = ['claude-code', 'codex', 'opencode', 'gemini'].map(tool => cliResolver.launcher(tool));
24418
+ // A packaged application's runtime and npm's global bin (rememberPackagedPath above), after the user's own.
24419
+ const { last: synabunDirs, extra: extraDirs } = rememberedPackagedPath();
24420
+ return { ...composeTerminalPath({ env: base, os, launchers, synabunDirs, extraDirs }), os, launchers };
24421
+ }
24422
+
23857
24423
  function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
23858
24424
  if (terminalHost.ready && !terminalHost.ptyAvailable) {
23859
24425
  throw new Error('Terminal not available: node-pty failed to load. Run: cd neural-interface && npm run postinstall');
23860
24426
  }
23861
24427
  const sessionId = opts.sessionId || randomBytes(16).toString('hex');
23862
- const profileCfg = getTerminalProfile(profile, opts);
24428
+ let profileCfg = opts.launch || getTerminalProfile(profile, opts);
23863
24429
  const initialCols = cols || 120;
23864
24430
  const initialRows = rows || 30;
23865
24431
 
23866
- // Native-terminal environment: SynaBun's own TERM_PROGRAM identity,
23867
- // truecolor, a UTF-8 locale, and none of the launching terminal's or IDE's
23868
- // session variables (VS Code, tmux, iTerm2, a parent Claude Code session…).
23869
- const env = buildTerminalEnv({ profileEnv: profileCfg.env, extraEnv: opts.extraEnv });
23870
-
23871
24432
  const isLoopOwned = !!(opts.extraEnv && opts.extraEnv.SYNABUN_TERMINAL_SESSION === sessionId);
23872
24433
  if (isLoopOwned) {
23873
24434
  loopLog(sessionId, 'pty:resolve', 'profile resolved', {
@@ -23886,6 +24447,7 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
23886
24447
  profile, cwd,
23887
24448
  createdAt: Date.now(),
23888
24449
  graceTimer: null,
24450
+ onProviderAuthClosed: opts.onClosed,
23889
24451
  claudeSessionId: opts.resume || null, // known Claude session ID (from resume or detect)
23890
24452
  };
23891
24453
  terminalSessions.set(sessionId, session);
@@ -23942,6 +24504,7 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
23942
24504
  // The host already delivered the final output (to clients and the tap)
23943
24505
  // before reporting the exit.
23944
24506
  session.pty.exited = true;
24507
+ opts.onExit?.({ exitCode });
23945
24508
 
23946
24509
  // Clear grace timer if set
23947
24510
  if (session.graceTimer) { clearTimeout(session.graceTimer); session.graceTimer = null; }
@@ -24020,7 +24583,6 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
24020
24583
  file: profileCfg.shell,
24021
24584
  args: profileCfg.args,
24022
24585
  cwd: cwd || process.env.USERPROFILE || process.env.HOME || process.cwd(),
24023
- env,
24024
24586
  cols: initialCols,
24025
24587
  rows: initialRows,
24026
24588
  name: 'xterm-256color',
@@ -24036,6 +24598,12 @@ function createTerminalSession(profile, cols, rows, cwd, opts = {}) {
24036
24598
  onResized: ({ cols: c, rows: r }) => { session.pty.cols = c; session.pty.rows = r; },
24037
24599
  onClients: ({ total }) => { session.clients.size = total; },
24038
24600
  onExit,
24601
+ }, async () => {
24602
+ const fresh = await freshTerminalPath(opts.baseEnv || process.env);
24603
+ profileCfg = opts.launch || getTerminalProfile(profile, opts);
24604
+ // On Windows, the Git Bash a claude started in this tab gets (lib/git-bash/): any tab can start one.
24605
+ const env = claudeChildEnv(buildTerminalEnv({ base: fresh.env, profileEnv: profileCfg.env, extraEnv: opts.extraEnv }));
24606
+ return { file: profileCfg.shell, args: profileCfg.args, env };
24039
24607
  });
24040
24608
 
24041
24609
  return sessionId;
@@ -24665,8 +25233,17 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24665
25233
  providerFactories: {
24666
25234
  'claude-code': (state) => {
24667
25235
  const config = _readClaudeSkinConfig();
25236
+ // The account decides the credential (lib/claude-accounts.js launchFor()): inherited credentials out,
25237
+ // its directory and, in API-key mode, its key in. Built here, never stored: the loop state file only
25238
+ // names the account. A run whose account is gone does not start under another identity.
25239
+ const launch = _panelClaudeAccounts().launchFor(state.claudeAccountId || 'default');
25240
+ if (!launch.ok) throw new Error(`${launch.message} The run did not start.`);
24668
25241
  return createClaudeNativeLoopAdapter({
24669
25242
  ...state,
25243
+ extraEnv: { ...(state.extraEnv || {}), ...launch.env },
25244
+ unsetEnv: launch.unset,
25245
+ // On Windows, the Git Bash the worker's Claude Code gets (lib/git-bash/), never stored in the loop state.
25246
+ childEnv: claudeChildEnv,
24670
25247
  model: state.contextMode === '1m' && state.model
24671
25248
  ? `${state.model}[1m]`
24672
25249
  : state.model && state.model.includes(':') ? toCliModelName(state.model) : state.model,
@@ -24686,10 +25263,20 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24686
25263
  // path, so repair the shared config before the SDK can parse it. Without
24687
25264
  // this, legacy inline + table SynaBun env definitions surface as a
24688
25265
  // configWarning and the unattended turn never becomes active.
24689
- ensureCanonicalCodexConfig({ strict: true });
25266
+ // The account decides the credential (lib/codex-accounts.js launchFor()): inherited OpenAI/Codex
25267
+ // credentials out, its CODEX_HOME in and, in API-key mode, the key SynaBun holds as CODEX_API_KEY
25268
+ // (which `codex exec` prefers over auth.json). Built here, never stored: the loop state file only
25269
+ // names the account. A run whose account is gone does not start under another identity.
25270
+ const launch = _codexAccounts().launchFor(state.codexAccountId || 'default');
25271
+ if (!launch.ok) throw new Error(`${launch.message} The run did not start.`);
25272
+ ensureCanonicalCodexConfig({ strict: true, codexHome: launch.home });
24690
25273
  return createCodexNativeLoopAdapter({
24691
25274
  ...state,
24692
- codexHome: state.codexHome || getCodexAccountHome(state.codexAccountId || 'default'),
25275
+ extraEnv: { ...(state.extraEnv || {}), ...codexExecEnv(launch) },
25276
+ unsetEnv: launch.unset,
25277
+ codexHome: launch.home,
25278
+ // The Admin sandbox setting as the run starts: it keeps that Windows sandbox while it lives.
25279
+ adminSandbox: adminSandboxOn(),
24693
25280
  // Native schedules use the same trusted global CLI as the interactive
24694
25281
  // sidepanel; SynaBun carries no Codex for the SDK to find by itself.
24695
25282
  codexPath: getNativeCodexBin(),
@@ -24755,7 +25342,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24755
25342
  import('../mcp-server/dist/tools/remember.js'), import('../mcp-server/dist/services/categories.js'),
24756
25343
  import('./lib/assistant-catalog.js'), import('./lib/assistant-config.js'), import('./lib/assistant-router.js'),
24757
25344
  ]);
24758
- const { budgetLimits, createModelPricing } = await import('./lib/assistant-budget.js');
25345
+ const { createModelPricing } = await import('./lib/assistant-budget.js');
25346
+ const { createBudgetPlanner } = await import('./lib/assistant-budget-planner.js');
24759
25347
  const { createAssistantClarifier } = await import('./lib/assistant-clarify.js');
24760
25348
  const { createAttachmentStore } = await import('./lib/assistant-attachments.js');
24761
25349
  const assistantConfigPath = resolve(LOOP_DATA_DIR, 'assistant-config.json');
@@ -24763,7 +25351,25 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24763
25351
  // One store for assistant-config.json: routing knobs re-read on change.
24764
25352
  const assistantConfigStore = createAssistantConfigStore({ path: assistantConfigPath, log: (tag, message) => assistantLog(tag, message) });
24765
25353
  const assistantConfig = assistantConfigStore.read();
24766
- const claudeAccounts = createClaudeAccounts({ dataHome: DATA_HOME, homeDir: getHomePath(), log: (tag, message) => assistantLog(tag, message) });
25354
+ // The one Claude accounts instance (the sidepanel's): one account state for the brain picker, the catalog,
25355
+ // worker dispatch and the panel. `createClaudeAccounts` stays imported for older callers of this block.
25356
+ void createClaudeAccounts;
25357
+ const claudeAccounts = _panelClaudeAccounts();
25358
+ const { createAccountUsage } = await import('./lib/account-usage.js');
25359
+ const accountUsage = createAccountUsage({ accounts: { 'claude-code': claudeAccounts, codex: _codexAccounts() }, readCodex: async (id, opts) => {
25360
+ for (const connection of _codexLiveConnections) {
25361
+ if (opts.signal?.aborted) return null;
25362
+ if (connection.accountId() !== id) continue;
25363
+ try { const value = await connection.readRateLimits(); if (value && connection.accountId() === id) return value; } catch {}
25364
+ }
25365
+ if (opts.signal?.aborted) return null;
25366
+ return codexAccountAuth().rateLimits(id, opts);
25367
+ } });
25368
+ _assistantAccountUsage = accountUsage;
25369
+ claudeAccounts.subscribe(({ accountId }) => accountUsage.invalidate('claude-code', accountId));
25370
+ _codexAccounts().subscribe(({ accountId }) => accountUsage.invalidate('codex', accountId));
25371
+ // A Claude Code worker whose dispatch names no account runs under this one (Assistant → accounts).
25372
+ _assistantClaudeWorkerAccount = () => assistantConfigStore.workers().claudeAccount;
24767
25373
  const memoryStoredForRun = (runId) => {
24768
25374
  try { return !!assistantGetDb().prepare('SELECT 1 FROM memory_metadata WHERE source_ref = ? LIMIT 1').get(String(runId)); } catch { return false; }
24769
25375
  };
@@ -24797,8 +25403,10 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24797
25403
  import('../mcp-server/dist/services/typesafe.js'), import('../mcp-server/dist/services/typesafe-config.js'),
24798
25404
  import('./lib/assistant-jev.js'),
24799
25405
  ]);
25406
+ // The budget split's questions (surface budget-split): a dist without them leaves the split on its fixed rule.
25407
+ const budgetJudgments = await import('../mcp-server/dist/services/budget-judgments.js').catch(() => null);
24800
25408
  assistantJev = createAssistantJev({
24801
- judgePrompt, judgeWorkerOutcome,
25409
+ judgePrompt, judgeWorkerOutcome, judgeBudgetSplit: budgetJudgments?.judgeBudgetSplit || null,
24802
25410
  surfaceConfig: typesafeKnobs.surfaceConfig,
24803
25411
  enabled: typesafeClient.typesafeEnabled,
24804
25412
  retryAfterMs: typesafeClient.typesafeRetryAfterMs,
@@ -24845,7 +25453,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24845
25453
  const assistantCatalog = createAssistantCatalog({
24846
25454
  fetchJson: catalogFetch,
24847
25455
  codexPrice: (model) => assistantPricing.codexPrice(model),
24848
- listAccounts: () => ({ codex: listCodexAccountsForClient(), 'claude-code': claudeAccounts.listForClient() }),
25456
+ listAccounts: () => accountUsage.list(),
24849
25457
  readFilter: () => assistantConfigStore.routing().catalogFilter || {},
24850
25458
  readHidden: () => assistantHiddenModels.all(),
24851
25459
  // A hidden alias ("haiku") hides the model it named when it was hidden: each build settles that.
@@ -24931,6 +25539,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24931
25539
  accountEnv: () => claudeAccounts.envFor?.('default') || {},
24932
25540
  cwd: DATA_HOME,
24933
25541
  env: process.env,
25542
+ childEnv: claudeChildEnv,
24934
25543
  log: (tag, message) => assistantLog(tag, message),
24935
25544
  }),
24936
25545
  usage: assistantUsage,
@@ -24990,6 +25599,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
24990
25599
  accountEnv: () => claudeAccounts.envFor?.('default') || {},
24991
25600
  cwd: DATA_HOME,
24992
25601
  env: process.env,
25602
+ childEnv: claudeChildEnv,
24993
25603
  label: 'greeting writer',
24994
25604
  logTag: 'greeting:writer-error',
24995
25605
  log: greetingLog,
@@ -25022,7 +25632,7 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25022
25632
  app.use('/api/assistant/hidden-models', createAssistantHiddenModelsRouter({
25023
25633
  hiddenModels: assistantHiddenModels, catalog: assistantCatalog, opencodeStore: opencodeHiddenModels, isGuestRequest, broadcastSync,
25024
25634
  }));
25025
- const buildCatalog = (opts = {}) => assistantCatalog.get(opts);
25635
+ const buildCatalog = async (opts = {}) => { await accountUsage.refresh(); return { ...(await assistantCatalog.get(opts)), accounts: accountUsage.list() }; };
25026
25636
  // Clarification: a materially ambiguous request gets 1-3 questions before it
25027
25637
  // is routed; the user's words and answers become the brief every worker of
25028
25638
  // that request receives. Knobs: assistant-config.json `clarify` (read live).
@@ -25041,10 +25651,41 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25041
25651
  },
25042
25652
  log: (tag, message) => assistantLog(tag, message),
25043
25653
  });
25654
+ // The session budget (lib/assistant-budget-planner.js): one setting for every session, divided
25655
+ // across the providers per session by Jev (surface budget-split) or a fixed rule, at the first
25656
+ // route and at checkpoints. The dispatcher, the runtime and the router read it; it reaches them lazily.
25657
+ const assistantBudget = createBudgetPlanner({
25658
+ setting: () => assistantConfigStore.budget(),
25659
+ spendOf: (sessionId) => _assistantDispatcher?.providerSpend?.(sessionId) || { providers: {}, unpricedRuns: 0, liveRuns: [] },
25660
+ brainOf: (sessionId) => _assistantRuntime?.budgetSession?.(sessionId) || null,
25661
+ isOpen: (sessionId) => _assistantRuntime?.budgetSession?.(sessionId)?.open !== false,
25662
+ catalog: () => assistantCatalog.peek?.() || null,
25663
+ refreshCatalog: () => assistantCatalog.full(),
25664
+ accounts: () => accountUsage.list(),
25665
+ routing: () => assistantConfigStore.routing(),
25666
+ routeMode: (sessionId) => { try { return assistantRouter.modeFor(sessionId); } catch { return null; } },
25667
+ workers: () => assistantConfigStore.workers(),
25668
+ pricing: assistantPricing,
25669
+ // This session's spend by provider from the usage ledger (every model row it booked).
25670
+ recentSpend: (sessionId) => {
25671
+ try {
25672
+ const out = {};
25673
+ for (const row of assistantUsage.sessionView(sessionId)?.models || []) if (row?.provider) out[row.provider] = (out[row.provider] || 0) + (Number(row.costUsd) || 0);
25674
+ return out;
25675
+ } catch { return null; }
25676
+ },
25677
+ judge: assistantJev,
25678
+ emit: (sessionId, view, meta) => { try { _assistantRuntime?.onBudgetSplit?.(sessionId, view, meta); } catch {} },
25679
+ statePath: resolve(LOOP_DATA_DIR, 'assistant-budget-splits.json'),
25680
+ log: (tag, message) => assistantLog(tag, message),
25681
+ });
25044
25682
  // DeepSeek-Harness-style routing: the brain proposes, the router enforces the
25045
25683
  // session's route mode. Sinks reach the runtime/dispatcher lazily.
25046
25684
  const assistantRouter = createAssistantRouter({
25685
+ accountUsage,
25686
+ budget: assistantBudget,
25047
25687
  catalog: assistantCatalog,
25688
+ listAccounts: () => accountUsage.list(),
25048
25689
  configStore: assistantConfigStore,
25049
25690
  getSession: (id) => _assistantRuntime?.routerSession?.(id) || null,
25050
25691
  sinks: {
@@ -25124,20 +25765,25 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25124
25765
  assistantDesktop = null;
25125
25766
  }
25126
25767
  _assistantDispatcher = createAssistantDispatcher({
25768
+ accountUsage,
25127
25769
  getRuntime: () => _nativeLoopRuntime,
25128
25770
  dataDir: LOOP_DATA_DIR,
25129
25771
  loopDir: LOOP_DIR,
25130
25772
  broadcastSync: (message) => { broadcastSync(message); try { _assistantRuntime?.observeBroadcast?.(message); } catch {} },
25131
25773
  log: (runId, tag, message, meta) => { try { loopLog(runId, tag, message, meta); } catch {} },
25132
25774
  PACKAGE_ROOT,
25133
- findCodexAccount, getCodexAccount, CODEX_DEFAULT_HOME, claudeAccounts,
25775
+ findCodexAccount, getCodexAccount, CODEX_DEFAULT_HOME, claudeAccounts, codexAccounts: _codexAccounts(),
25776
+ claudeWorkerAccount: () => assistantConfigStore.workers().claudeAccount,
25777
+ codexWorkerAccount: () => assistantConfigStore.workers().codexAccount,
25134
25778
  isValidMcpProfileValue, normalizeMcpProfileName, readActiveMcpProfile,
25135
25779
  acquireLoopBrowserAndTab, releaseSharedBrowserTab, reconcileKeepAwake,
25136
25780
  memory: assistantMemory, detectProject: assistantDetectProject,
25137
- // Limits and budget caps are read live from assistant-config.json (the Budget tab): no restart.
25781
+ // Limits are read live from assistant-config.json `limits`; the money is the session budget (the planner).
25138
25782
  limits: {},
25139
- readLimits: () => ({ ...(assistantConfigStore.read().limits || {}), ...budgetLimits(assistantConfigStore.budget()) }),
25783
+ readLimits: () => ({ ...(assistantConfigStore.read().limits || {}) }),
25784
+ budget: assistantBudget,
25140
25785
  brainSpend: (sessionId) => _assistantRuntime?.brainSpend?.(sessionId) || 0,
25786
+ brainSpendByProvider: (sessionId) => _assistantRuntime?.brainSpendByProvider?.(sessionId) || null,
25141
25787
  currentTask: (sessionId) => _assistantRuntime?.currentTask?.(sessionId) || null,
25142
25788
  pricing: assistantPricing,
25143
25789
  usage: assistantUsage,
@@ -25148,9 +25794,11 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25148
25794
  testDensity: (sessionId) => _assistantRuntime?.testDensityFor?.(sessionId) ?? null,
25149
25795
  });
25150
25796
  _assistantRuntime = createAssistantRuntime({
25797
+ accountUsage,
25151
25798
  dispatcher: _assistantDispatcher,
25152
25799
  phoneAuthority: _phoneAuthority.verify, // the verifier only
25153
- claudeAccounts,
25800
+ claudeAccounts, codexAccounts: _codexAccounts(),
25801
+ codexHomeForAccount: (accountId) => _codexAccounts().homeFor(accountId),
25154
25802
  ClaudeSession: claudeBridgeModule.ClaudeSession,
25155
25803
  sdkVersion: claudeBridgeModule.SDK_VERSION,
25156
25804
  handleCodexSkinWebSocket, ensureIsolatedServe, stopIsolatedServe, setupOpencodeSidepanelConfig,
@@ -25167,11 +25815,14 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25167
25815
  gateUrl: `http://127.0.0.1:${PORT}/api/assistant/route-gate/check`,
25168
25816
  codexBin: () => getCodexBin(),
25169
25817
  pricing: assistantPricing,
25818
+ budget: assistantBudget,
25170
25819
  usage: assistantUsage,
25171
25820
  clarifier: assistantClarifier,
25172
25821
  userContext: assistantUserContext,
25173
25822
  // The greeting a conversation opened with (told to the brain once) and when the person was last here.
25174
25823
  greeting: _greeting,
25824
+ // The Admin sandbox setting: every session view shows it (features.adminSandbox gates the panel's control).
25825
+ adminSandbox: _agentSandbox,
25175
25826
  });
25176
25827
  assistantDesktop?.attach?.({ runtime: _assistantRuntime, dispatcher: _assistantDispatcher });
25177
25828
  // Warm the catalog so brain capabilities (vision, tier) are known on first attach,
@@ -25189,39 +25840,30 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25189
25840
  if (unverified && attempt < 3) setTimeout(() => { repairRouteEfforts(attempt + 1).catch(() => {}); }, 60_000).unref?.();
25190
25841
  };
25191
25842
  repairRouteEfforts().catch(() => {});
25192
- const codexAccounts = {
25193
- listForClient: listCodexAccountsForClient,
25194
- rename: (id, label) => {
25195
- const data = loadCodexAccounts();
25196
- const acct = data.accounts.find((a) => a.id === id);
25197
- if (!acct) throw Object.assign(new Error('Unknown Codex account'), { code: 'ACCOUNT_NOT_FOUND', status: 404 });
25198
- const next = String(label || '').trim().slice(0, 60);
25199
- if (next) acct.label = next;
25200
- saveCodexAccounts(data);
25201
- return acct;
25202
- },
25203
- remove: (id, { inUse = () => null } = {}) => {
25204
- if (id === 'default') throw Object.assign(new Error('The Default account cannot be removed'), { code: 'DEFAULT_ACCOUNT', status: 409 });
25205
- const data = loadCodexAccounts();
25206
- const idx = data.accounts.findIndex((a) => a.id === id);
25207
- if (idx === -1) throw Object.assign(new Error('Unknown Codex account'), { code: 'ACCOUNT_NOT_FOUND', status: 404 });
25208
- const reason = inUse(id);
25209
- if (reason) throw Object.assign(new Error(reason), { code: 'ACCOUNT_IN_USE', status: 409 });
25210
- const [removed] = data.accounts.splice(idx, 1);
25211
- saveCodexAccounts(data);
25212
- try {
25213
- if (removed.home && removed.home !== CODEX_DEFAULT_HOME && removed.home.startsWith(CODEX_ACCOUNTS_ROOT)) rmSync(removed.home, { recursive: true, force: true });
25214
- } catch (e) { console.warn('[codex-accounts] Failed to delete home:', e.message); }
25215
- return { removed: id };
25216
- },
25217
- };
25843
+ // The one Codex accounts instance (the sidepanel's, lib/codex-accounts.js): the brain picker, the
25844
+ // catalog, worker dispatch and the panel show one account state.
25845
+ const codexAccounts = _codexAccounts();
25846
+ // A Codex worker whose dispatch names no account runs under this one (Assistant → accounts).
25847
+ _assistantCodexWorkerAccount = () => assistantConfigStore.workers().codexAccount;
25218
25848
  const createLoginTerminal = async ({ env = {}, cwd = null, profile = 'claude-code' } = {}) => {
25219
25849
  const terminalSessionId = createTerminalSession(profile, 120, 30, cwd || getHomePath(), { extraEnv: env });
25220
25850
  broadcastSync({ type: 'terminal:session-created', sessionId: terminalSessionId, profile });
25221
25851
  return { terminalSessionId, profile };
25222
25852
  };
25223
25853
  app.use('/api/assistant', createAssistantApi({
25854
+ accountUsage,
25224
25855
  dispatcher: _assistantDispatcher, runtime: _assistantRuntime, claudeAccounts, codexAccounts, buildCatalog, createLoginTerminal,
25856
+ // Claude accounts are the sidepanel's: sign-in is Claude Code's own `auth login` in a SynaBun terminal
25857
+ // under the account's config directory, a mode change ends the account's idle sessions first, and an
25858
+ // account a panel tab, the brain, a run or the worker choice uses cannot be removed.
25859
+ claudeSignIn: (accountId) => providerPanelTerminalAuth.start('claude', accountId),
25860
+ claudeSetMode: (accountId, mode) => panelClaudeAuth().setMode(accountId, mode),
25861
+ claudeAccountInUse,
25862
+ // Codex accounts are the sidepanel's too: sign-in is `codex login` in a SynaBun terminal under the
25863
+ // account's CODEX_HOME, a mode change restarts its idle panel processes, and an account something uses stays.
25864
+ codexSignIn: (accountId) => providerPanelTerminalAuth.start('codex', accountId),
25865
+ codexSetMode: (accountId, mode) => codexAccountAuth().setMode(accountId, mode),
25866
+ codexAccountInUse: (accountId) => codexAccountInUse(accountId),
25225
25867
  broadcastSync, isGuestRequest, log: (tag, message) => assistantLog(tag, message),
25226
25868
  assistantRouter, catalog: assistantCatalog, configStore: assistantConfigStore, taskClasses: TASK_CLASS_META,
25227
25869
  pricing: assistantPricing,
@@ -25233,6 +25875,12 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25233
25875
  userContext: assistantUserContext,
25234
25876
  userContextError: () => assistantUserContextError,
25235
25877
  defaultProject: () => assistantDetectProject(assistantConfigStore.read().defaultBrain?.cwd || PACKAGE_ROOT) || 'global',
25878
+ // whatsapp_send_file (POST /api/assistant/whatsapp/send-file): the WhatsApp service, built after this
25879
+ // router (startWhatsAppLink), and the port for that route's localhost Host check.
25880
+ whatsapp: () => _whatsapp,
25881
+ port: () => Number(PORT),
25882
+ // GET / PUT /api/assistant/admin-sandbox (the PUT answers UI_ONLY to agents).
25883
+ adminSandbox: _agentSandbox,
25236
25884
  }));
25237
25885
  const shutdownAssistant = () => {
25238
25886
  try { assistantRouter.shutdown(); } catch {}
@@ -25241,6 +25889,8 @@ _nativeLoopRuntime = new NativeLoopRuntime({
25241
25889
  try { _assistantRuntime?.shutdown?.(); } catch {}
25242
25890
  try { _assistantDispatcher?.shutdown?.(); } catch {}
25243
25891
  try { _greeting?.shutdown?.(); } catch {}
25892
+ // The sessions' budget splits, written now (the planner debounces its writes).
25893
+ try { assistantBudget.flush(); } catch {}
25244
25894
  // Usage rows whose append failed get their last try (synchronous: an 'exit' handler runs it too).
25245
25895
  try { assistantUsage.flush(); } catch {}
25246
25896
  };
@@ -25472,6 +26122,7 @@ function buildExecMcpEnvOverrides(loopState) {
25472
26122
  function prepareExecWrapperFile(terminalSessionId, loopState, taskFile, sentinel) {
25473
26123
  const config = loadCliConfig();
25474
26124
  const profile = loopState.profile;
26125
+ if (profile === 'codex') ensureCanonicalCodexConfig({ strict: true });
25475
26126
  const mcpEnvOverrides = buildExecMcpEnvOverrides(loopState);
25476
26127
 
25477
26128
  // Surface the exact pins being injected per exec iteration so a stripped/missing
@@ -25492,6 +26143,8 @@ function prepareExecWrapperFile(terminalSessionId, loopState, taskFile, sentinel
25492
26143
  model: loopState.model,
25493
26144
  effort: loopState.effort,
25494
26145
  mcpEnvOverrides,
26146
+ // The Admin sandbox setting the loop started with (a loop file from before it: the current one).
26147
+ codexConfigArgs: codexWindowsSandboxArgs(typeof loopState.adminSandbox === 'boolean' ? loopState.adminSandbox : adminSandboxOn()),
25495
26148
  });
25496
26149
  const wrapperFile = join(
25497
26150
  os.tmpdir(),
@@ -26212,6 +26865,39 @@ function handleSessionMonitorWebSocket(ws) {
26212
26865
  ws.on('error', () => sessionMonitorClients.delete(ws));
26213
26866
  }
26214
26867
 
26868
+ const providerPanelTerminalAuth = createProviderPanelTerminalAuth({
26869
+ resolveSpec: (provider, accountId) => providerLoginSpec({
26870
+ provider, accountId, claudeAccounts: _panelClaudeAccounts(), codexAccount: findCodexAccount,
26871
+ claudeExecutable: getClaudeSdkExecutable, codexExecutable: getCodexBin,
26872
+ env: withPathEnv(process.env, getAugmentedPath()),
26873
+ codexConfigArgs: codexSandboxArgs(),
26874
+ }),
26875
+ createSession: createTerminalSession,
26876
+ whenSpawned: id => terminalHost.whenSpawned(id),
26877
+ killSession: id => { try { terminalSessions.get(id)?.pty.kill(); } catch {} },
26878
+ // A sign-in that ended: ask the CLI what the account now holds. A subscription it confirms is what the
26879
+ // account uses from now on (every window hears it through claude:accounts-changed / codex:accounts-changed).
26880
+ // A Codex app-server reads its login only when it starts: the account's idle ones restart.
26881
+ onSettled: async (provider, accountId, status) => {
26882
+ if (provider === 'codex') {
26883
+ try {
26884
+ const accounts = _codexAccounts();
26885
+ await accounts.refreshStatus([accountId], { force: true });
26886
+ if (status === 'exited' && accounts.describe(accountId)?.subscription?.signedIn === true) accounts.noteSubscriptionSignIn(accountId);
26887
+ codexAccountStoreChanged(accountId);
26888
+ } catch {}
26889
+ return;
26890
+ }
26891
+ if (provider !== 'claude') return;
26892
+ try {
26893
+ const accounts = _panelClaudeAccounts();
26894
+ await accounts.refreshStatus([accountId], { force: true });
26895
+ if (status === 'exited' && accounts.describe(accountId)?.subscription?.signedIn === true) accounts.noteSubscriptionSignIn(accountId);
26896
+ } catch {}
26897
+ },
26898
+ });
26899
+ registerProviderPanelTerminalAuthRoutes(app, { guard: panelAuthOwnerGuard, auth: providerPanelTerminalAuth });
26900
+
26215
26901
  // ── Terminal REST endpoints ──
26216
26902
 
26217
26903
  app.get('/api/terminal/sessions', (req, res) => {
@@ -26259,7 +26945,8 @@ app.post('/api/terminal/sessions', async (req, res) => {
26259
26945
  } catch (err) {
26260
26946
  let msg = err.message;
26261
26947
  if (process.platform !== 'win32' && (msg.includes('posix_spawn') || msg.includes('EACCES'))) {
26262
- msg = `Terminal failed: spawn-helper lacks execute permission. Try: chmod +x neural-interface/node_modules/node-pty/prebuilds/${process.platform}-${process.arch}/spawn-helper`;
26948
+ const ptyDir = findInstalled(__dirname, 'node-pty', { stopAt: PACKAGE_ROOT })?.path || join(__dirname, 'node_modules', 'node-pty');
26949
+ msg = `Terminal failed: spawn-helper lacks execute permission. Try: chmod +x "${join(ptyDir, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper')}"`;
26263
26950
  }
26264
26951
  return res.status(500).json({ error: msg });
26265
26952
  }
@@ -26272,6 +26959,7 @@ app.delete('/api/terminal/sessions/:id', (req, res) => {
26272
26959
  const session = terminalSessions.get(req.params.id);
26273
26960
  if (!session) return res.status(404).json({ error: 'Session not found' });
26274
26961
  const wasClaudeCode = session.profile === 'claude-code';
26962
+ session.onProviderAuthClosed?.();
26275
26963
  session.pty.kill();
26276
26964
  // Clean up temp files from image paste
26277
26965
  if (session.tempFiles) {
@@ -26284,7 +26972,7 @@ app.delete('/api/terminal/sessions/:id', (req, res) => {
26284
26972
  });
26285
26973
 
26286
26974
  app.get('/api/terminal/profiles', (req, res) => {
26287
- const profileIds = ['claude-code', 'codex', 'gemini', 'shell'];
26975
+ const profileIds = ['claude-code', 'codex', 'gemini', 'opencode', 'shell'];
26288
26976
  const profiles = profileIds.map(id => {
26289
26977
  const p = getTerminalProfile(id);
26290
26978
  return { id, shell: p.shell, args: p.args };
@@ -26343,8 +27031,10 @@ let _gitResolved = false;
26343
27031
 
26344
27032
  // Resolve git's absolute path at first use. On Windows, `where git` lists all matches;
26345
27033
  // prefer .exe so spawnSync works without cmd.exe quoting issues. On Unix, use `command -v`.
27034
+ // A Windows PC without git: the git.exe of SynaBun's copy of Git for Windows (lib/git-bash/), asked each time,
27035
+ // since a copy that is still being fetched at the first call arrives later.
26346
27036
  function resolveGitPath() {
26347
- if (_gitResolved) return _gitExePath;
27037
+ if (_gitResolved) return _gitExePath || gitBash.gitExecutable();
26348
27038
  _gitResolved = true;
26349
27039
  try {
26350
27040
  const cmd = IS_WINDOWS ? 'where git' : 'command -v git';
@@ -26361,7 +27051,7 @@ function resolveGitPath() {
26361
27051
  console.warn('[git] resolution failed:', err?.message || String(err));
26362
27052
  _gitExePath = null;
26363
27053
  }
26364
- return _gitExePath;
27054
+ return _gitExePath || gitBash.gitExecutable();
26365
27055
  }
26366
27056
 
26367
27057
  // Cross-platform git invocation. Uses resolved absolute path when possible (spawnable
@@ -26820,16 +27510,16 @@ app.post('/api/git/commit', editorOwnerGuard, (req, res) => {
26820
27510
  for (const f of files) {
26821
27511
  if (typeof f !== 'string' || f.includes('..')) return res.status(400).json({ error: 'Invalid file path' });
26822
27512
  }
26823
- const result = spawnSync('git', ['add', '--', ...files], { cwd: dir, stdio: 'pipe', timeout: 10000 });
27513
+ const result = spawnSync('git', ['add', '--', ...files], { cwd: dir, env: withGitFallback(process.env), stdio: 'pipe', timeout: 10000 });
26824
27514
  if (result.status !== 0) return res.status(500).json({ error: result.stderr?.toString?.()?.trim() || 'Stage failed' });
26825
27515
  } else {
26826
27516
  // Stage all changes
26827
- const result = spawnSync('git', ['add', '-A'], { cwd: dir, stdio: 'pipe', timeout: 10000 });
27517
+ const result = spawnSync('git', ['add', '-A'], { cwd: dir, env: withGitFallback(process.env), stdio: 'pipe', timeout: 10000 });
26828
27518
  if (result.status !== 0) return res.status(500).json({ error: result.stderr?.toString?.()?.trim() || 'Stage failed' });
26829
27519
  }
26830
27520
 
26831
27521
  // Commit
26832
- const result = spawnSync('git', ['commit', '-m', message.trim()], { cwd: dir, stdio: 'pipe', timeout: 15000 });
27522
+ const result = spawnSync('git', ['commit', '-m', message.trim()], { cwd: dir, env: withGitFallback(process.env), stdio: 'pipe', timeout: 15000 });
26833
27523
  if (result.status !== 0) {
26834
27524
  const stderr = result.stderr?.toString?.()?.trim() || '';
26835
27525
  const stdout = result.stdout?.toString?.()?.trim() || '';
@@ -26985,14 +27675,10 @@ app.post('/api/git/generate-message', editorOwnerGuard, async (req, res) => {
26985
27675
  return res.json({ ok: true, message: '', source: 'heuristic', summary: 'No changes to commit' });
26986
27676
  }
26987
27677
 
26988
- // Try Claude CLI
26989
- let claudeAvailable = false;
26990
- try {
26991
- execSync(process.platform === 'win32' ? 'where claude' : 'which claude', { stdio: 'pipe', timeout: 3000 });
26992
- claudeAvailable = true;
26993
- } catch {}
27678
+ // Try Claude CLI: the one the panels use (lib/cli-resolver.js), started without a shell.
27679
+ const claudeLaunch = cliResolver.launcher('claude-code');
26994
27680
 
26995
- if (claudeAvailable) {
27681
+ if (claudeLaunch.spec && claudeLaunch.state !== 'broken') {
26996
27682
  try {
26997
27683
  const prompt = [
26998
27684
  'Generate a single-line git commit message for these changes.',
@@ -27007,14 +27693,16 @@ app.post('/api/git/generate-message', editorOwnerGuard, async (req, res) => {
27007
27693
  untrackedList ? '\n## New untracked files\n' + untrackedList.split('\n').slice(0, 20).join('\n') : '',
27008
27694
  ].join('\n');
27009
27695
 
27010
- const { spawnSync: spawnS } = require('child_process');
27011
- const result = spawnS('claude', ['-p', '--model', 'sonnet'], {
27696
+ const launch = spawnArgsFor(claudeLaunch.spec, ['-p', '--model', 'sonnet']);
27697
+ const result = spawnSync(launch.command, launch.args, {
27698
+ ...launch.options,
27012
27699
  input: prompt,
27013
27700
  cwd: dir,
27014
27701
  timeout: 30000,
27015
27702
  maxBuffer: 64 * 1024,
27016
27703
  stdio: ['pipe', 'pipe', 'pipe'],
27017
- shell: true,
27704
+ env: withPathEnv(process.env, getAugmentedPath()),
27705
+ windowsHide: true,
27018
27706
  });
27019
27707
 
27020
27708
  const output = (result.stdout || '').toString().trim();
@@ -27104,27 +27792,25 @@ app.put('/api/cli/config', (req, res) => {
27104
27792
  }
27105
27793
  });
27106
27794
 
27107
- app.post('/api/cli/detect/:profileId', (req, res) => {
27795
+ // Settings > Terminal > Detect: what is installed, whatever Settings says now
27796
+ // (the button fills the field with it), found the way every launcher finds it
27797
+ // (lib/cli-resolver.js), with the OS PATH read afresh.
27798
+ app.post('/api/cli/detect/:profileId', async (req, res) => {
27108
27799
  try {
27109
27800
  const { profileId } = req.params;
27110
- const defaults = { 'claude-code': 'claude', 'codex': 'codex', 'gemini': 'gemini', 'opencode': 'opencode' };
27111
- const cmdName = defaults[profileId];
27112
- if (!cmdName) return res.status(400).json({ ok: false, error: 'Unknown profile' });
27113
-
27114
- const detectCmd = IS_WIN ? `where ${cmdName}` : `which ${cmdName}`;
27115
- try {
27116
- const result = execSync(detectCmd, {
27117
- encoding: 'utf-8',
27118
- timeout: 5000,
27119
- windowsHide: true,
27120
- stdio: ['pipe', 'pipe', 'pipe'],
27121
- env: { ...process.env, PATH: getAugmentedPath() },
27122
- }).trim();
27123
- const foundPath = result.split(/\r?\n/)[0].trim();
27124
- res.json({ ok: true, found: true, path: foundPath });
27125
- } catch {
27126
- res.json({ ok: true, found: false, path: null });
27127
- }
27801
+ if (!['claude-code', 'codex', 'gemini', 'opencode'].includes(profileId)) return res.status(400).json({ ok: false, error: 'Unknown profile' });
27802
+ const status = await cliResolver.resolve(profileId, { force: true, configured: null });
27803
+ res.json({
27804
+ ok: true,
27805
+ found: status.state !== 'missing',
27806
+ path: status.path,
27807
+ version: status.version,
27808
+ state: status.state,
27809
+ foundBy: status.foundBy,
27810
+ pathStatus: cliPathManager.inspect(status),
27811
+ installSource: status.installSource,
27812
+ error: status.error,
27813
+ });
27128
27814
  } catch (err) {
27129
27815
  res.status(500).json({ ok: false, error: err.message });
27130
27816
  }
@@ -27297,9 +27983,15 @@ function spawnSidecarProcess(linkId, sessionId, message) {
27297
27983
  const command = (cliConfig[profile]?.command || '').trim();
27298
27984
  if (!command) throw new Error(`No CLI command configured for ${profile}`);
27299
27985
 
27300
- let spawnCmd = command;
27301
- let spawnArgs = [...sidecarConfig.args];
27302
- let useShell = IS_WIN;
27986
+ // Named by its plain name in Settings: the CLI the panels use (lib/cli-resolver.js), a program started
27987
+ // without a shell; otherwise the Settings value as written.
27988
+ const found = CLI_DEFAULTS[profile] && command === CLI_DEFAULTS[profile].command ? cliResolver.launcher(profile) : null;
27989
+ const direct = found?.spec?.kind === 'direct' ? found.launchPath : null;
27990
+ let spawnCmd = direct || command;
27991
+ // Codex: the Windows sandbox the Admin sandbox setting allows, before its flag (a bare value: no quote for a shell to lose).
27992
+ const leadArgs = profile === 'codex' ? ['-c', `windows.sandbox=${codexWindowsSandbox(adminSandboxOn())}`] : [];
27993
+ let spawnArgs = [...leadArgs, ...sidecarConfig.args];
27994
+ let useShell = IS_WIN && !direct;
27303
27995
  let tmpFile = null;
27304
27996
 
27305
27997
  if (sidecarConfig.stdin) {
@@ -27310,10 +28002,11 @@ function spawnSidecarProcess(linkId, sessionId, message) {
27310
28002
  // Use PowerShell which correctly passes $msg as a single argument value.
27311
28003
  tmpFile = join(os.tmpdir(), `synabun-relay-${randomBytes(4).toString('hex')}.txt`);
27312
28004
  writeFileSync(tmpFile, message, 'utf-8');
27313
- const flag = spawnArgs[0]; // e.g. '-p', '-q'
28005
+ const flag = sidecarConfig.args[0]; // e.g. '-p', '-q'
28006
+ const lead = leadArgs.map((word) => ` '${word}'`).join('');
27314
28007
  spawnCmd = 'powershell.exe';
27315
28008
  spawnArgs = ['-NoProfile', '-NonInteractive', '-Command',
27316
- `$msg = Get-Content -Raw '${tmpFile}'; & '${command}' ${flag} $msg`];
28009
+ `$msg = Get-Content -Raw '${tmpFile}'; & '${String(direct || command).replace(/'/g, "''")}'${lead} ${flag} $msg`];
27317
28010
  useShell = false;
27318
28011
  } else {
27319
28012
  // Unix: pass directly as the flag's value arg (no shell quoting issues)
@@ -27324,7 +28017,7 @@ function spawnSidecarProcess(linkId, sessionId, message) {
27324
28017
 
27325
28018
  const child = spawn(spawnCmd, spawnArgs, {
27326
28019
  cwd: session.cwd || process.cwd(),
27327
- env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
28020
+ env: { ...withPathEnv(process.env, getAugmentedPath()), FORCE_COLOR: '0', NO_COLOR: '1' },
27328
28021
  stdio: ['pipe', 'pipe', 'pipe'],
27329
28022
  shell: useShell,
27330
28023
  timeout: LINK_SIDECAR_TIMEOUT_MS,
@@ -32868,6 +33561,17 @@ const httpServer = app.listen(PORT, async () => {
32868
33561
  // first session runs on it (checkClaudeCliSkew in lib/claude-model-catalog.js).
32869
33562
  setTimeout(() => { checkClaudeCliSkew(getClaudeBin()).catch(() => {}); }, 1000);
32870
33563
 
33564
+ // Windows: the Git Bash every Claude Code gets (lib/git-bash/). Looked for now (the settings of the user's own
33565
+ // claude follow it); a PC with none, and an app that carries none, gets SynaBun's copy, fetched in the background.
33566
+ setTimeout(() => {
33567
+ try {
33568
+ gitBash.refresh();
33569
+ if (gitBash.status().state === 'missing') gitBash.ensure({ reason: 'first run' });
33570
+ // What an earlier run left in the data home (a stopped attempt's work folder, an older version), off the event loop.
33571
+ gitBash.housekeep().catch(() => {});
33572
+ } catch (err) { console.warn('[git-bash] start check failed:', err?.message || err); }
33573
+ }, 2500);
33574
+
32871
33575
  // Warm the session_cache + session_fts tables so the hybrid search has data
32872
33576
  // on first keystroke. Each pass is synchronous and holds the main thread, so
32873
33577
  // they run one per tick (requests are answered in between), and each only
@@ -33815,6 +34519,7 @@ process.on('exit', () => {
33815
34519
  try { terminalHost.killNow(); } catch {}
33816
34520
  try { _whatsapp?.killNow(); } catch {}
33817
34521
  try { tunnel.killNow(); } catch {}
34522
+ try { gitBash.cancel(); } catch {}
33818
34523
  try { stopOpencodeServer(); } catch {}
33819
34524
  try { editorCompletionServe.stop('exit'); } catch {}
33820
34525
  try { _claudeBridge?.shutdownAllBridges?.(); } catch {}